config.rs (5197B)
1 use crate::error::{AppError, Result}; 2 use crate::league::League; 3 use clap::{CommandFactory, Parser}; 4 use std::fs; 5 use std::path::{Path, PathBuf}; 6 7 #[derive(Parser, Debug)] 8 #[command(name = "scores")] 9 #[command(about = "Display football scores and standings from football-data.org")] 10 #[command( 11 after_help = "LEAGUES:\n epl Premier League\n efl Championship\n laliga La Liga\n ucl Champions League" 12 )] 13 pub struct Cli { 14 /// API token from football-data.org (or set ~/.config/scores) 15 #[arg(long, short = 't')] 16 pub token: Option<String>, 17 18 /// Show league standings/table 19 #[arg(long, short = 's')] 20 pub standings: bool, 21 22 /// List all matches across every competition (default) 23 #[arg(long, short = 'a')] 24 pub all: bool, 25 26 /// Filter by team name or abbreviation 27 #[arg(long, short = 'f')] 28 pub filter: Option<String>, 29 30 /// League to display 31 #[arg(long, short = 'l', value_name = "LEAGUE")] 32 pub league: Option<String>, 33 } 34 35 impl Cli { 36 pub fn parse_cli() -> Self { 37 Self::parse() 38 } 39 } 40 41 pub struct Config { 42 pub api_token: String, 43 pub league: Option<League>, 44 pub show_all: bool, 45 pub show_standings: bool, 46 pub team_filter: Option<String>, 47 } 48 49 impl Config { 50 pub fn from_cli(cli: &Cli) -> Result<Self> { 51 let sources = TokenSources::default(); 52 let token_from_file = sources 53 .config_path 54 .as_deref() 55 .and_then(read_token_from_file) 56 .is_some(); 57 58 let Some(api_token) = resolve_token(cli.token.as_deref(), &sources) else { 59 Cli::command().print_help().unwrap(); 60 std::process::exit(0); 61 }; 62 63 let league = match &cli.league { 64 Some(arg) => { 65 Some(League::from_arg(arg).ok_or_else(|| AppError::InvalidLeague(arg.clone()))?) 66 } 67 None => None, 68 }; 69 70 Ok(Self { 71 api_token, 72 league, 73 show_all: cli.all || token_from_file, 74 show_standings: cli.standings, 75 team_filter: cli.filter.clone(), 76 }) 77 } 78 } 79 80 pub struct TokenSources { 81 pub config_path: Option<PathBuf>, 82 } 83 84 impl Default for TokenSources { 85 fn default() -> Self { 86 Self { 87 config_path: config_file_path(), 88 } 89 } 90 } 91 92 fn resolve_token(cli_token: Option<&str>, sources: &TokenSources) -> Option<String> { 93 if let Some(t) = cli_token { 94 return Some(t.to_string()); 95 } 96 if let Some(path) = &sources.config_path 97 && let Some(t) = read_token_from_file(path) 98 { 99 return Some(t); 100 } 101 None 102 } 103 104 fn read_token_from_file(path: &Path) -> Option<String> { 105 let token = fs::read_to_string(path).ok()?; 106 let token = token.trim().to_string(); 107 if token.is_empty() { None } else { Some(token) } 108 } 109 110 fn config_file_path() -> Option<PathBuf> { 111 let home = std::env::var("HOME").ok()?; 112 Some(PathBuf::from(home).join(".config").join("scores")) 113 } 114 115 #[cfg(test)] 116 mod tests { 117 use super::*; 118 119 fn no_sources() -> TokenSources { 120 TokenSources { config_path: None } 121 } 122 123 #[test] 124 fn test_config_from_cli_valid() { 125 let cli = Cli { 126 token: Some("test-token".to_string()), 127 standings: false, 128 all: true, 129 filter: Some("arsenal".to_string()), 130 league: Some("epl".to_string()), 131 }; 132 133 let config = Config::from_cli(&cli).unwrap(); 134 assert_eq!(config.api_token, "test-token"); 135 assert_eq!(config.league, Some(League::PremierLeague)); 136 assert!(config.show_all); 137 assert!(!config.show_standings); 138 assert_eq!(config.team_filter, Some("arsenal".to_string())); 139 } 140 141 #[test] 142 fn test_config_no_league_defaults_to_all() { 143 let cli = Cli { 144 token: Some("test-token".to_string()), 145 standings: false, 146 all: false, 147 filter: None, 148 league: None, 149 }; 150 151 let config = Config::from_cli(&cli).unwrap(); 152 assert_eq!(config.league, None); 153 } 154 155 #[test] 156 fn test_config_missing_token() { 157 assert_eq!(resolve_token(None, &no_sources()), None); 158 } 159 160 #[test] 161 fn test_config_invalid_league() { 162 let cli = Cli { 163 token: Some("test-token".to_string()), 164 standings: false, 165 all: false, 166 filter: None, 167 league: Some("bundesliga".to_string()), 168 }; 169 170 let result = Config::from_cli(&cli); 171 assert!(matches!(result, Err(AppError::InvalidLeague(_)))); 172 } 173 174 #[test] 175 fn test_config_reads_token_from_file() { 176 let dir = std::env::temp_dir().join("scores-test-home-12345"); 177 let config_dir = dir.join(".config"); 178 fs::create_dir_all(&config_dir).unwrap(); 179 let path = config_dir.join("scores"); 180 fs::write(&path, "file-token-abc").unwrap(); 181 182 let sources = TokenSources { 183 config_path: Some(path.clone()), 184 }; 185 let token = resolve_token(None, &sources); 186 assert_eq!(token.as_deref(), Some("file-token-abc")); 187 188 fs::remove_dir_all(&dir).ok(); 189 } 190 }