use anyhow::{Context, Result}; use std::env; use std::fs; use std::io::{self, Write}; use std::path::PathBuf; const DEFAULT_API_URL: &str = "http://localhost:8888"; const CONFIG_FILE_NAME: &str = "config"; const CONFIG_DIR_NAME: &str = ".hindsight"; #[derive(Debug)] pub struct Config { pub api_url: String, pub api_key: Option, pub source: ConfigSource, } #[derive(Debug, Clone, PartialEq)] pub enum ConfigSource { LocalFile, Environment, Default, } impl std::fmt::Display for ConfigSource { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ConfigSource::LocalFile => write!(f, "config file"), ConfigSource::Environment => write!(f, "environment variable"), ConfigSource::Default => write!(f, "default"), } } } impl Config { /// Load configuration with the following priority: /// 1. Environment variable (HINDSIGHT_API_URL, HINDSIGHT_API_KEY) - highest priority, for overrides /// 2. Local config file (~/.hindsight/config.toml) /// 3. Default (http://localhost:8888) pub fn load() -> Result { // Load API key from environment (highest priority) let env_api_key = env::var("HINDSIGHT_API_KEY").ok(); // 1. Environment variable takes highest priority (for overrides) if let Ok(api_url) = env::var("HINDSIGHT_API_URL") { return Self::validate_and_create(api_url, env_api_key, ConfigSource::Environment); } // 2. Try local config file if let Some((api_url, file_api_key)) = Self::load_from_file()? { // Environment api_key takes precedence over file api_key let api_key = env_api_key.or(file_api_key); return Self::validate_and_create(api_url, api_key, ConfigSource::LocalFile); } // 3. Fall back to default Self::validate_and_create(DEFAULT_API_URL.to_string(), env_api_key, ConfigSource::Default) } /// Legacy method for backwards compatibility pub fn from_env() -> Result { Self::load() } fn validate_and_create(api_url: String, api_key: Option, source: ConfigSource) -> Result { if !api_url.starts_with("http://") && !api_url.starts_with("https://") { anyhow::bail!( "Invalid API URL: {}. Must start with http:// or https://", api_url ); } Ok(Config { api_url, api_key, source }) } fn config_dir() -> Option { dirs::home_dir().map(|home| home.join(CONFIG_DIR_NAME)) } fn config_file_path() -> Option { Self::config_dir().map(|dir| dir.join(CONFIG_FILE_NAME)) } fn load_from_file() -> Result)>> { let config_path = match Self::config_file_path() { Some(path) => path, None => return Ok(None), }; if !config_path.exists() { return Ok(None); } let content = fs::read_to_string(&config_path) .with_context(|| format!("Failed to read config file: {}", config_path.display()))?; let mut api_url: Option = None; let mut api_key: Option = None; // Simple TOML parsing for api_url and api_key for line in content.lines() { let line = line.trim(); if line.starts_with("api_url") { if let Some(value) = line.split('=').nth(1) { let value = value.trim().trim_matches('"').trim_matches('\''); if !value.is_empty() { api_url = Some(value.to_string()); } } } else if line.starts_with("api_key") { if let Some(value) = line.split('=').nth(1) { let value = value.trim().trim_matches('"').trim_matches('\''); if !value.is_empty() { api_key = Some(value.to_string()); } } } } match api_url { Some(url) => Ok(Some((url, api_key))), None => Ok(None), } } pub fn save_api_url(api_url: &str) -> Result { Self::save_config(api_url, None) } pub fn save_config(api_url: &str, api_key: Option<&str>) -> Result { let config_dir = Self::config_dir() .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?; // Create config directory if it doesn't exist if !config_dir.exists() { fs::create_dir_all(&config_dir) .with_context(|| format!("Failed to create config directory: {}", config_dir.display()))?; } let config_path = config_dir.join(CONFIG_FILE_NAME); let mut content = format!("api_url = \"{}\"\n", api_url); if let Some(key) = api_key { content.push_str(&format!("api_key = \"{}\"\n", key)); } fs::write(&config_path, content) .with_context(|| format!("Failed to write config file: {}", config_path.display()))?; Ok(config_path) } pub fn api_url(&self) -> &str { &self.api_url } } /// Prompt user for API URL interactively pub fn prompt_api_url(current_url: Option<&str>) -> Result { let default = current_url.unwrap_or(DEFAULT_API_URL); print!("Enter API URL [{}]: ", default); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; let input = input.trim(); if input.is_empty() { Ok(default.to_string()) } else { Ok(input.to_string()) } } pub fn generate_doc_id() -> String { let now = chrono::Local::now(); format!("cli_put_{}", now.format("%Y%m%d_%H%M%S")) } /// Parse a simple TOML-like config line and extract value. /// Handles both quoted and unquoted values. pub fn parse_config_value(line: &str, key: &str) -> Option { let line = line.trim(); if !line.starts_with(key) { return None; } line.split('=').nth(1).map(|value| { value.trim().trim_matches('"').trim_matches('\'').to_string() }).filter(|v| !v.is_empty()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_config_source_display() { assert_eq!(format!("{}", ConfigSource::LocalFile), "config file"); assert_eq!(format!("{}", ConfigSource::Environment), "environment variable"); assert_eq!(format!("{}", ConfigSource::Default), "default"); } #[test] fn test_validate_and_create_valid_http() { let config = Config::validate_and_create( "http://localhost:8888".to_string(), None, ConfigSource::Default, ); assert!(config.is_ok()); let config = config.unwrap(); assert_eq!(config.api_url, "http://localhost:8888"); assert_eq!(config.source, ConfigSource::Default); } #[test] fn test_validate_and_create_valid_https() { let config = Config::validate_and_create( "https://api.example.com".to_string(), Some("secret-key".to_string()), ConfigSource::Environment, ); assert!(config.is_ok()); let config = config.unwrap(); assert_eq!(config.api_url, "https://api.example.com"); assert_eq!(config.api_key, Some("secret-key".to_string())); assert_eq!(config.source, ConfigSource::Environment); } #[test] fn test_validate_and_create_invalid_url() { let config = Config::validate_and_create( "localhost:8888".to_string(), None, ConfigSource::Default, ); assert!(config.is_err()); let err = config.unwrap_err().to_string(); assert!(err.contains("Invalid API URL")); assert!(err.contains("Must start with http:// or https://")); } #[test] fn test_validate_and_create_ftp_url() { let config = Config::validate_and_create( "ftp://example.com".to_string(), None, ConfigSource::Default, ); assert!(config.is_err()); } #[test] fn test_generate_doc_id_format() { let doc_id = generate_doc_id(); assert!(doc_id.starts_with("cli_put_")); // Should be cli_put_YYYYMMDD_HHMMSS format assert!(doc_id.len() > 20); // cli_put_ (8) + date (8) + _ (1) + time (6) = 23 } #[test] fn test_generate_doc_id_uniqueness() { let id1 = generate_doc_id(); std::thread::sleep(std::time::Duration::from_secs(1)); let id2 = generate_doc_id(); // IDs generated at different times should be different assert_ne!(id1, id2); } #[test] fn test_parse_config_value_quoted() { assert_eq!( parse_config_value(r#"api_url = "http://localhost:8888""#, "api_url"), Some("http://localhost:8888".to_string()) ); } #[test] fn test_parse_config_value_single_quoted() { assert_eq!( parse_config_value("api_url = 'http://localhost:8888'", "api_url"), Some("http://localhost:8888".to_string()) ); } #[test] fn test_parse_config_value_unquoted() { assert_eq!( parse_config_value("api_url = http://localhost:8888", "api_url"), Some("http://localhost:8888".to_string()) ); } #[test] fn test_parse_config_value_with_spaces() { assert_eq!( parse_config_value(" api_url = \"http://localhost:8888\" ", "api_url"), Some("http://localhost:8888".to_string()) ); } #[test] fn test_parse_config_value_wrong_key() { assert_eq!( parse_config_value("api_key = secret", "api_url"), None ); } #[test] fn test_parse_config_value_empty() { assert_eq!( parse_config_value("api_url = ", "api_url"), None ); assert_eq!( parse_config_value("api_url = \"\"", "api_url"), None ); } #[test] fn test_config_api_url_accessor() { let config = Config { api_url: "http://test:8080".to_string(), api_key: None, source: ConfigSource::Default, }; assert_eq!(config.api_url(), "http://test:8080"); } }