Commit Diff


commit - 610cc027f905579f3cb2e7cbef265ac92a51d97c
commit + 49a2fdca1c53263116ccc4fab70eac448586a09c
blob - d4e0030c044a2c2b4d5e65ad1efa39438722f883
blob + ea2863fba3c25048f0ea4af1b645dc86ec74ce4c
--- src/config.rs
+++ src/config.rs
@@ -1,6 +1,8 @@
 use crate::error::{AppError, Result};
 use crate::league::League;
 use clap::{CommandFactory, Parser};
+use std::fs;
+use std::path::{Path, PathBuf};
 
 #[derive(Parser, Debug)]
 #[command(name = "scores")]
@@ -9,9 +11,9 @@ use clap::{CommandFactory, Parser};
     after_help = "LEAGUES:\n  epl             Premier League\n  efl             Championship\n  laliga          La Liga\n  ucl             Champions League"
 )]
 pub struct Cli {
-    /// API token from football-data.org (required)
+    /// API token from football-data.org (or set ~/.config/scores)
     #[arg(long, short = 't')]
-    pub token: String,
+    pub token: Option<String>,
 
     /// Show league standings/table
     #[arg(long, short = 's')]
@@ -32,18 +34,6 @@ pub struct Cli {
 
 impl Cli {
     pub fn parse_cli() -> Self {
-        let args: Vec<String> = std::env::args().collect();
-        if args.len() == 1 {
-            Self::command().print_help().unwrap();
-            println!();
-            println!("LEAGUES:");
-            println!("  epl             Premier League");
-            println!("  efl             Championship");
-            println!("  laliga          La Liga");
-            println!("  ucl             Champions League");
-            println!();
-            std::process::exit(0);
-        }
         Self::parse()
     }
 }
@@ -58,10 +48,18 @@ pub struct Config {
 
 impl Config {
     pub fn from_cli(cli: &Cli) -> Result<Self> {
-        if cli.token.is_empty() {
-            return Err(AppError::InvalidToken);
-        }
+        let sources = TokenSources::default();
+        let token_from_file = sources
+            .config_path
+            .as_deref()
+            .and_then(read_token_from_file)
+            .is_some();
 
+        let Some(api_token) = resolve_token(cli.token.as_deref(), &sources) else {
+            Cli::command().print_help().unwrap();
+            std::process::exit(0);
+        };
+
         let league = match &cli.league {
             Some(arg) => {
                 Some(League::from_arg(arg).ok_or_else(|| AppError::InvalidLeague(arg.clone()))?)
@@ -70,23 +68,62 @@ impl Config {
         };
 
         Ok(Self {
-            api_token: cli.token.clone(),
+            api_token,
             league,
-            show_all: cli.all,
+            show_all: cli.all || token_from_file,
             show_standings: cli.standings,
             team_filter: cli.filter.clone(),
         })
     }
 }
 
+pub struct TokenSources {
+    pub config_path: Option<PathBuf>,
+}
+
+impl Default for TokenSources {
+    fn default() -> Self {
+        Self {
+            config_path: config_file_path(),
+        }
+    }
+}
+
+fn resolve_token(cli_token: Option<&str>, sources: &TokenSources) -> Option<String> {
+    if let Some(t) = cli_token {
+        return Some(t.to_string());
+    }
+    if let Some(path) = &sources.config_path
+        && let Some(t) = read_token_from_file(path)
+    {
+        return Some(t);
+    }
+    None
+}
+
+fn read_token_from_file(path: &Path) -> Option<String> {
+    let token = fs::read_to_string(path).ok()?;
+    let token = token.trim().to_string();
+    if token.is_empty() { None } else { Some(token) }
+}
+
+fn config_file_path() -> Option<PathBuf> {
+    let home = std::env::var("HOME").ok()?;
+    Some(PathBuf::from(home).join(".config").join("scores"))
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
 
+    fn no_sources() -> TokenSources {
+        TokenSources { config_path: None }
+    }
+
     #[test]
     fn test_config_from_cli_valid() {
         let cli = Cli {
-            token: "test-token".to_string(),
+            token: Some("test-token".to_string()),
             standings: false,
             all: true,
             filter: Some("arsenal".to_string()),
@@ -104,7 +141,7 @@ mod tests {
     #[test]
     fn test_config_no_league_defaults_to_all() {
         let cli = Cli {
-            token: "test-token".to_string(),
+            token: Some("test-token".to_string()),
             standings: false,
             all: false,
             filter: None,
@@ -117,22 +154,13 @@ mod tests {
 
     #[test]
     fn test_config_missing_token() {
-        let cli = Cli {
-            token: String::new(),
-            standings: false,
-            all: false,
-            filter: None,
-            league: Some("epl".to_string()),
-        };
-
-        let result = Config::from_cli(&cli);
-        assert!(matches!(result, Err(AppError::InvalidToken)));
+        assert_eq!(resolve_token(None, &no_sources()), None);
     }
 
     #[test]
     fn test_config_invalid_league() {
         let cli = Cli {
-            token: "test-token".to_string(),
+            token: Some("test-token".to_string()),
             standings: false,
             all: false,
             filter: None,
@@ -142,4 +170,21 @@ mod tests {
         let result = Config::from_cli(&cli);
         assert!(matches!(result, Err(AppError::InvalidLeague(_))));
     }
+
+    #[test]
+    fn test_config_reads_token_from_file() {
+        let dir = std::env::temp_dir().join("scores-test-home-12345");
+        let config_dir = dir.join(".config");
+        fs::create_dir_all(&config_dir).unwrap();
+        let path = config_dir.join("scores");
+        fs::write(&path, "file-token-abc").unwrap();
+
+        let sources = TokenSources {
+            config_path: Some(path.clone()),
+        };
+        let token = resolve_token(None, &sources);
+        assert_eq!(token.as_deref(), Some("file-token-abc"));
+
+        fs::remove_dir_all(&dir).ok();
+    }
 }
blob - 6e1ada732b4f8bd88289ecf0f205eff27e29e953
blob + b754e72e406d46f7ed452ad89319170c88422213
--- src/error.rs
+++ src/error.rs
@@ -8,9 +8,6 @@ pub enum AppError {
     #[error("API returned error status: {0}")]
     ApiStatus(u16),
 
-    #[error("Invalid API token: token is missing or empty")]
-    InvalidToken,
-
     #[error("Invalid league: {0}")]
     InvalidLeague(String),