use std::collections::HashMap; use std::fs; #[derive(Debug)] pub struct ScriptedGoal { pub minute: u8, pub team: String, pub player: String, } #[derive(Debug, PartialEq)] pub enum OutputMode { UnicodeTable, AsciiTable, Json, } #[derive(Debug)] pub struct TsrcScript { pub teams: HashMap, pub roster: HashMap>, pub home_var: String, pub away_var: String, pub goals: Option>, pub output_mode: OutputMode, } pub fn parse_tsrc(path: &str) -> Result { let content = fs::read_to_string(path) .map_err(|e| format!("tsrc error: cannot read '{}': {}", path, e))?; let mut teams = HashMap::new(); let mut roster: HashMap> = HashMap::new(); let mut match_found = false; let mut home_var = String::new(); let mut away_var = String::new(); let mut var_lines: HashMap = HashMap::new(); let mut player_entries: HashMap<(String, String), usize> = HashMap::new(); let mut goals: Vec = Vec::new(); let mut goal_entries: HashMap<(u8, String), usize> = HashMap::new(); let mut output_mode: OutputMode = OutputMode::UnicodeTable; for (line_num, raw_line) in content.lines().enumerate() { let n = line_num + 1; let line = raw_line.trim(); if line.is_empty() || line.starts_with("//") || line.starts_with("#!") { continue; } if let Some(rest) = line.strip_prefix("#define ") { if output_mode != OutputMode::UnicodeTable { return Err(format!( "tsrc error: output mode already defined on line {} (duplicate #define)", n )); } output_mode = parse_define(rest, n)?; } else if let Some(rest) = line.strip_prefix("string ") { parse_string_decl(rest, n, &mut teams, &mut var_lines)?; } else if let Some(rest) = line.strip_prefix("players") { parse_player_insert(rest, n, &mut roster, &mut player_entries, &teams)?; } else if let Some(rest) = line.strip_prefix("goal") { let goal = parse_goal(rest, n, &teams, &roster, &mut goal_entries)?; goals.push(goal); } else if let Some(rest) = line.strip_prefix("match") { let rest = rest.trim_end_matches(';'); (home_var, away_var) = parse_match(rest, n, &teams)?; match_found = true; } else { return Err(format!("tsrc error: unrecognized statement on line {}: '{}'", n, line)); } } if !match_found { return Err("tsrc error: no match() directive found".to_string()); } validate_script(&teams, &roster, &home_var, &away_var)?; if !goals.is_empty() { let home_team = teams.get(&home_var).unwrap(); let away_team = teams.get(&away_var).unwrap(); validate_goals(&goals, home_team, away_team, &roster)?; } Ok(TsrcScript { teams, roster, home_var, away_var, goals: if goals.is_empty() { None } else { Some(goals) }, output_mode, }) } fn parse_define(s: &str, n: usize) -> Result { match s.trim() { "JSON" => Ok(OutputMode::Json), "UNICODE_TABLE" => Ok(OutputMode::UnicodeTable), "ASCII_TABLE" => Ok(OutputMode::AsciiTable), other => Err(format!( "tsrc error: unknown #define '{}' on line {} (expected JSON, UNICODE_TABLE, or ASCII_TABLE)", other, n )), } } fn validate_script( teams: &HashMap, roster: &HashMap>, home_var: &str, away_var: &str, ) -> Result<(), String> { let home_team = teams.get(home_var).unwrap(); let away_team = teams.get(away_var).unwrap(); if home_team == away_team { return Err(format!( "tsrc error: match() has the same team for both home and away (\"{}\")", home_team )); } for (team_name, players) in roster { if team_name != home_team && team_name != away_team { return Err(format!( "tsrc error: {} player(s) assigned to \"{}\" which is not a match team (home=\"{}\", away=\"{}\")", players.len(), team_name, home_team, away_team )); } } let home_count = roster.get(home_team).map(|v| v.len()).unwrap_or(0); let away_count = roster.get(away_team).map(|v| v.len()).unwrap_or(0); if home_count == 0 { return Err(format!( "tsrc error: \"{}\" ({}) has no players assigned", home_team, home_var )); } if away_count == 0 { return Err(format!( "tsrc error: \"{}\" ({}) has no players assigned", away_team, away_var )); } Ok(()) } fn parse_string_decl( s: &str, n: usize, teams: &mut HashMap, var_lines: &mut HashMap, ) -> Result<(), String> { let s = s.trim(); let eq_pos = s.find('=') .ok_or_else(|| format!("tsrc error: missing '=' on line {}", n))?; let var_name = s[..eq_pos].trim(); let value_part = s[eq_pos + 1..].trim().trim_end_matches(';'); if !is_valid_ident(var_name) { return Err(format!("tsrc error: invalid variable name '{}' on line {}", var_name, n)); } if let Some(&first_line) = var_lines.get(var_name) { return Err(format!( "tsrc error: variable '{}' redefined on line {} (first seen on line {})", var_name, n, first_line )); } let value = parse_string_literal(value_part, n)?; var_lines.insert(var_name.to_string(), n); teams.insert(var_name.to_string(), value); Ok(()) } fn parse_player_insert( s: &str, n: usize, roster: &mut HashMap>, player_entries: &mut HashMap<(String, String), usize>, teams: &HashMap, ) -> Result<(), String> { let s = s.trim(); let bracket_end = s.find(']') .ok_or_else(|| format!("tsrc error: missing ']' on line {}", n))?; let key_part = &s[1..bracket_end]; let after = &s[bracket_end + 1..]; let player_name = parse_string_literal(key_part.trim(), n)?; let eq_pos = after.find('=') .ok_or_else(|| format!("tsrc error: missing '=' on line {}", n))?; let value_part = after[eq_pos + 1..].trim().trim_end_matches(';'); let team_name = if value_part.starts_with('"') { parse_string_literal(value_part, n)? } else { let var_name = value_part.trim(); if !is_valid_ident(var_name) { return Err(format!( "tsrc error: expected string literal or variable on line {}", n )); } match teams.get(var_name) { Some(v) => v.clone(), None => { return Err(format!( "tsrc error: undefined variable '{}' on line {} (declare it with `string {} = \"...\"` first)", var_name, n, var_name )); } } }; let key = (player_name.clone(), team_name.clone()); if let Some(&first_line) = player_entries.get(&key) { return Err(format!( "tsrc error: duplicate player \"{}\" on team \"{}\" (first seen on line {})", player_name, team_name, first_line )); } player_entries.insert(key, n); roster.entry(team_name).or_default().push(player_name); Ok(()) } fn parse_match(s: &str, n: usize, teams: &HashMap) -> Result<(String, String), String> { let s = s.trim(); if !s.starts_with('(') || !s.ends_with(')') { return Err(format!("tsrc error: match() requires parentheses on line {}", n)); } let inner = &s[1..s.len() - 1]; let comma_pos = inner.find(',') .ok_or_else(|| format!("tsrc error: match() requires two arguments separated by ',' on line {}", n))?; let var1 = inner[..comma_pos].trim(); let var2 = inner[comma_pos + 1..].trim(); if !is_valid_ident(var1) { return Err(format!("tsrc error: invalid variable '{}' in match() on line {}", var1, n)); } if !is_valid_ident(var2) { return Err(format!("tsrc error: invalid variable '{}' in match() on line {}", var2, n)); } if !teams.contains_key(var1) { return Err(format!("tsrc error: undefined variable '{}' in match() on line {}", var1, n)); } if !teams.contains_key(var2) { return Err(format!("tsrc error: undefined variable '{}' in match() on line {}", var2, n)); } Ok((var1.to_string(), var2.to_string())) } fn parse_goal( s: &str, n: usize, teams: &HashMap, roster: &HashMap>, goal_entries: &mut HashMap<(u8, String), usize>, ) -> Result { let s = s.trim().trim_end_matches(';'); if !s.starts_with('(') || !s.ends_with(')') { return Err(format!("tsrc error: goal() requires parentheses on line {}", n)); } let inner = &s[1..s.len() - 1]; let parts: Vec<&str> = inner.splitn(3, ',').collect(); if parts.len() != 3 { return Err(format!( "tsrc error: goal() requires 3 arguments (minute, team, player) on line {}", n )); } let minute: u8 = parts[0].trim().parse().map_err(|_| { format!("tsrc error: goal() minute must be a number on line {}", n) })?; if minute < 1 || minute > 90 { return Err(format!( "tsrc error: goal() minute must be between 1 and 90 on line {}", n )); } let team_part = parts[1].trim(); let team_name = if team_part.starts_with('"') { parse_string_literal(team_part, n)? } else { if !is_valid_ident(team_part) { return Err(format!( "tsrc error: goal() team must be a string literal or variable on line {}", n )); } match teams.get(team_part) { Some(v) => v.clone(), None => { return Err(format!( "tsrc error: undefined variable '{}' in goal() on line {} (declare it with `string {} = \"...\"` first)", team_part, n, team_part )); } } }; let player_name = parse_string_literal(parts[2].trim(), n)?; let key = (minute, player_name.clone()); if let Some(&first_line) = goal_entries.get(&key) { return Err(format!( "tsrc error: duplicate goal on line {} — {} already scored at minute {} (line {})", n, player_name, minute, first_line )); } goal_entries.insert(key, n); if let Some(players) = roster.get(&team_name) { if !players.contains(&player_name) { return Err(format!( "tsrc error: goal() on line {} — player \"{}\" is not on team \"{}\"", n, player_name, team_name )); } } Ok(ScriptedGoal { minute, team: team_name, player: player_name, }) } fn validate_goals( goals: &[ScriptedGoal], home_team: &str, away_team: &str, roster: &HashMap>, ) -> Result<(), String> { for goal in goals { if goal.team != home_team && goal.team != away_team { return Err(format!( "tsrc error: goal() for \"{}\" references team \"{}\" which is not a match team (home=\"{}\", away=\"{}\")", goal.player, goal.team, home_team, away_team )); } if let Some(players) = roster.get(&goal.team) { if !players.contains(&goal.player) { return Err(format!( "tsrc error: goal() — player \"{}\" is not on team \"{}\"", goal.player, goal.team )); } } } Ok(()) } fn parse_string_literal(s: &str, n: usize) -> Result { let s = s.trim(); if s.len() < 2 || !s.starts_with('"') || !s.ends_with('"') { return Err(format!("tsrc error: expected string literal on line {}", n)); } Ok(s[1..s.len() - 1].to_string()) } fn is_valid_ident(s: &str) -> bool { if s.is_empty() { return false; } s.chars().all(|c| c.is_alphanumeric() || c == '_') }