use std::env; use std::fs; use std::path::{Path, PathBuf}; use std::str::FromStr; use anyhow::{anyhow, Context, Result}; use directories::BaseDirs; use serde::{Deserialize, Serialize}; use crate::cli::{Cli, ColorMode, JobsArg, LlmMode, ProviderChoice, ReportFormat}; #[derive(Clone, Debug)] pub struct Settings { pub input: PathBuf, pub output: PathBuf, pub provider: ProviderChoice, pub api_key_omdb: Option, pub api_key_tmdb: Option, pub cache_path: PathBuf, pub cache_ttl_days: u32, pub refresh_cache: bool, pub report_format: ReportFormat, pub report_path: Option, pub sidecar_notes: bool, pub sidecars: bool, pub overwrite: bool, pub suffix: bool, pub min_score: u8, pub include_id: bool, pub quality_tags: QualityTags, pub color: ColorMode, pub llm: LlmSettings, pub jobs: usize, pub net_jobs: usize, pub no_lookup: bool, pub dry_run: bool, pub dry_run_summary: bool, pub move_files: bool, pub rename_in_place: bool, pub interactive: bool, pub verbose: bool, pub explain: bool, pub omdb_base_url: String, pub tmdb_base_url: String, } #[derive(Clone, Debug)] pub struct QualityTags { pub resolution: bool, pub codec: bool, pub source: bool, } impl Default for QualityTags { fn default() -> Self { Self { resolution: true, codec: false, source: false, } } } impl QualityTags { pub fn to_list(&self) -> Vec { let mut tags = Vec::new(); if self.resolution { tags.push("resolution".to_string()); } if self.codec { tags.push("codec".to_string()); } if self.source { tags.push("source".to_string()); } if tags.is_empty() { tags.push("none".to_string()); } tags } } #[derive(Clone, Debug)] pub struct LlmSettings { pub mode: LlmMode, pub endpoint: String, pub model: Option, pub timeout_seconds: u64, pub max_tokens: Option, } #[derive(Debug, Serialize)] struct PrintableSettings { input: String, output: String, provider: ProviderChoice, api_key_omdb: Option, api_key_tmdb: Option, cache_path: String, cache_ttl_days: u32, refresh_cache: bool, report_format: ReportFormat, report_path: Option, sidecar_notes: bool, sidecars: bool, dry_run_summary: bool, overwrite: bool, suffix: bool, min_score: u8, include_id: bool, quality_tags: Vec, color: ColorMode, jobs: usize, net_jobs: usize, no_lookup: bool, dry_run: bool, move_files: bool, rename_in_place: bool, interactive: bool, verbose: bool, explain: bool, omdb_base_url: String, tmdb_base_url: String, llm: PrintableLlm, } #[derive(Debug, Serialize)] struct PrintableLlm { mode: LlmMode, endpoint: String, model: Option, timeout_seconds: u64, max_tokens: Option, } impl Default for LlmSettings { fn default() -> Self { Self { mode: LlmMode::Off, endpoint: "http://localhost:11434".to_string(), model: None, timeout_seconds: 30, max_tokens: None, } } } #[derive(Debug, Deserialize, Default)] struct FileConfig { provider: Option, api_key_omdb: Option, api_key_tmdb: Option, cache_path: Option, cache_ttl_days: Option, refresh_cache: Option, report_format: Option, sidecar_notes: Option, sidecars: Option, overwrite: Option, suffix: Option, min_score: Option, include_id: Option, quality_tags: Option, color: Option, jobs: Option, net_jobs: Option, llm: Option, omdb_base_url: Option, tmdb_base_url: Option, no_lookup: Option, dry_run_summary: Option, explain: Option, } #[derive(Debug, Deserialize, Default)] struct FileLlmConfig { mode: Option, endpoint: Option, model: Option, timeout_seconds: Option, max_tokens: Option, } #[derive(Debug, Deserialize)] #[serde(untagged)] enum QualityTagsValue { List(Vec), Single(String), } #[derive(Debug, Deserialize)] #[serde(untagged)] enum JobValue { String(String), Number(u64), } pub fn build_settings(cli: &Cli) -> Result { let config_path = resolve_config_path(cli.config.as_deref())?; if let Err(err) = ensure_default_config(&config_path) { eprintln!( "Warning: failed to create default config at {}: {}", config_path.display(), err ); } let file_config = load_config_file(&config_path)?; let input = cli .input .clone() .ok_or_else(|| anyhow!("--input is required unless --completions is used"))?; let output = resolve_output(cli, &input)?; let mut settings = Settings { input, output, provider: ProviderChoice::Auto, api_key_omdb: None, api_key_tmdb: None, cache_path: default_cache_path()?, cache_ttl_days: 30, refresh_cache: false, report_format: ReportFormat::Text, report_path: resolve_report_path(cli)?, sidecar_notes: false, sidecars: false, overwrite: false, suffix: false, min_score: 80, include_id: false, quality_tags: QualityTags::default(), color: ColorMode::Auto, llm: LlmSettings::default(), jobs: default_jobs(), net_jobs: default_net_jobs(default_jobs()), no_lookup: false, dry_run: cli.dry_run, dry_run_summary: false, move_files: cli.move_files, rename_in_place: cli.rename_in_place, interactive: cli.interactive, verbose: cli.verbose, explain: false, omdb_base_url: "https://www.omdbapi.com".to_string(), tmdb_base_url: "https://api.themoviedb.org/3".to_string(), }; apply_file_config(&mut settings, &file_config)?; apply_env_overrides(&mut settings)?; apply_cli_overrides(&mut settings, cli)?; validate_settings(&mut settings)?; Ok(settings) } pub fn init_default_config() -> Result { let config_path = resolve_config_path(None)?; ensure_default_config(&config_path)?; Ok(config_path) } fn resolve_output(cli: &Cli, input: &Path) -> Result { match (cli.rename_in_place, cli.output.as_ref()) { (true, None) => Ok(input.to_path_buf()), (true, Some(out)) => { if out != input { Err(anyhow!( "--rename-in-place requires output to be omitted or the same as input" )) } else { Ok(out.clone()) } } (false, Some(out)) => { if out == input { Err(anyhow!( "output directory must be different from input unless --rename-in-place is set" )) } else { Ok(out.clone()) } } (false, None) => Err(anyhow!("--output is required unless --rename-in-place is set")), } } fn resolve_config_path(cli_path: Option<&Path>) -> Result { if let Some(path) = cli_path { return Ok(path.to_path_buf()); } let dirs = BaseDirs::new().ok_or_else(|| anyhow!("unable to resolve XDG config directory"))?; Ok(dirs.config_dir().join("mov-renamarr").join("config.toml")) } fn default_cache_path() -> Result { let dirs = BaseDirs::new().ok_or_else(|| anyhow!("unable to resolve XDG cache directory"))?; Ok(dirs.cache_dir().join("mov-renamarr").join("cache.db")) } fn load_config_file(path: &Path) -> Result { if !path.exists() { return Ok(FileConfig::default()); } let raw = fs::read_to_string(path) .with_context(|| format!("failed to read config file: {}", path.display()))?; let cfg: FileConfig = toml::from_str(&raw) .with_context(|| format!("failed to parse config TOML: {}", path.display()))?; Ok(cfg) } fn ensure_default_config(path: &Path) -> Result { if path.exists() { return Ok(false); } if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create config dir: {}", parent.display()))?; } fs::write(path, default_config_template()) .with_context(|| format!("failed to write default config: {}", path.display()))?; eprintln!( "Created default config at {}. You can edit it to set API keys and preferences.", path.display() ); Ok(true) } fn default_config_template() -> String { [ "# Mov Renamarr configuration (TOML)", "# Edit this file to set API keys and defaults.", "# Values here override built-in defaults and can be overridden by env/CLI.", "", "# Provider selection:", "# - auto: pick based on available API keys (prefers TMDb if both set).", "# - tmdb / omdb: force a single provider.", "# - both: query both and choose best match.", "provider = \"auto\"", "", "# API keys (set at least one).", "# TMDb accepts either v3 API key or v4 Read Access Token (Bearer).", "# api_key_tmdb = \"YOUR_TMDB_KEY_OR_READ_ACCESS_TOKEN\"", "# api_key_omdb = \"YOUR_OMDB_KEY\"", "", "# Cache settings", "# cache_path lets you override the default XDG cache location.", "# cache_path = \"/home/user/.cache/mov-renamarr/cache.db\"", "# cache_ttl_days controls how long cached API results are reused.", "cache_ttl_days = 30", "# refresh_cache forces new lookups on next run.", "refresh_cache = false", "", "# Output and reporting", "# report_format: text (default), json, or csv.", "report_format = \"text\"", "# sidecar_notes writes a per-file note when a file is skipped/failed.", "sidecar_notes = false", "# sidecars copies/moves subtitle/nfo/etc files with the movie file.", "sidecars = false", "# dry_run_summary suppresses per-file output (use with --dry-run for large batches).", "dry_run_summary = false", "# overwrite replaces existing files; suffix adds \" (1)\", \" (2)\", etc.", "overwrite = false", "suffix = false", "# Disable external lookups (use filename/LLM only).", "# When true, provider selection is ignored.", "no_lookup = false", "# explain prints top candidate matches when a file is skipped.", "explain = false", "# min_score is 0-100 (match confidence threshold).", "min_score = 80", "# include_id adds tmdb-XXXX or imdb-ttXXXX in the filename.", "include_id = false", "", "# Quality tags: list or comma-separated string.", "# Supported tags: resolution, codec, source, all, none.", "quality_tags = [\"resolution\"]", "", "# Console colors: auto, always, never", "color = \"auto\"", "", "# Concurrency: auto or a number", "# jobs controls file processing threads.", "# net_jobs controls concurrent API calls.", "jobs = \"auto\"", "net_jobs = \"auto\"", "", "# Optional: override provider base URLs (useful for testing).", "# tmdb_base_url = \"https://api.themoviedb.org/3\"", "# omdb_base_url = \"https://www.omdbapi.com\"", "", "[llm]", "# LLM usage:", "# - off: no LLM usage", "# - parse: LLM can replace filename parsing hints", "# - assist: LLM adds alternate hints but still verifies via providers", "# Ollama expected at endpoint.", "mode = \"off\"", "endpoint = \"http://localhost:11434\"", "model = \"Qwen2.5:latest\"", "# For higher accuracy (more RAM/VRAM): \"Qwen2.5:14b\"", "# timeout_seconds limits LLM request time.", "timeout_seconds = 30", "# max_tokens caps response length.", "# max_tokens = 256", "", ] .join("\n") } fn apply_file_config(settings: &mut Settings, file: &FileConfig) -> Result<()> { if let Some(provider) = &file.provider { settings.provider = provider.clone(); } if let Some(key) = &file.api_key_omdb { settings.api_key_omdb = Some(key.clone()); } if let Some(key) = &file.api_key_tmdb { settings.api_key_tmdb = Some(key.clone()); } if let Some(path) = &file.cache_path { settings.cache_path = path.clone(); } if let Some(ttl) = file.cache_ttl_days { settings.cache_ttl_days = ttl; } if let Some(refresh) = file.refresh_cache { settings.refresh_cache = refresh; } if let Some(format) = &file.report_format { settings.report_format = format.clone(); } if let Some(sidecar_notes) = file.sidecar_notes { settings.sidecar_notes = sidecar_notes; } if let Some(sidecars) = file.sidecars { settings.sidecars = sidecars; } if let Some(dry_run_summary) = file.dry_run_summary { settings.dry_run_summary = dry_run_summary; } if let Some(overwrite) = file.overwrite { settings.overwrite = overwrite; } if let Some(suffix) = file.suffix { settings.suffix = suffix; } if let Some(min_score) = file.min_score { settings.min_score = min_score; } if let Some(include_id) = file.include_id { settings.include_id = include_id; } if let Some(tags) = &file.quality_tags { let values = match tags { QualityTagsValue::List(list) => list.clone(), QualityTagsValue::Single(value) => split_list(value), }; settings.quality_tags = parse_quality_tags(&values)?; } if let Some(color) = &file.color { settings.color = color.clone(); } if let Some(raw) = &file.jobs { settings.jobs = parse_jobs_setting_value(raw, default_jobs())?; } if let Some(raw) = &file.net_jobs { settings.net_jobs = parse_jobs_setting_value(raw, default_net_jobs(settings.jobs))?; } if let Some(no_lookup) = file.no_lookup { settings.no_lookup = no_lookup; } if let Some(explain) = file.explain { settings.explain = explain; } if let Some(llm) = &file.llm { apply_file_llm(settings, llm); } if let Some(url) = &file.omdb_base_url { settings.omdb_base_url = url.clone(); } if let Some(url) = &file.tmdb_base_url { settings.tmdb_base_url = url.clone(); } Ok(()) } fn apply_file_llm(settings: &mut Settings, llm: &FileLlmConfig) { if let Some(mode) = &llm.mode { settings.llm.mode = mode.clone(); } if let Some(endpoint) = &llm.endpoint { settings.llm.endpoint = endpoint.clone(); } if let Some(model) = &llm.model { settings.llm.model = Some(model.clone()); } if let Some(timeout) = llm.timeout_seconds { settings.llm.timeout_seconds = timeout; } if let Some(max_tokens) = llm.max_tokens { settings.llm.max_tokens = Some(max_tokens); } } fn apply_env_overrides(settings: &mut Settings) -> Result<()> { apply_env_string("MOV_RENAMARR_PROVIDER", |value| { if let Ok(provider) = ProviderChoice::from_str(&value.to_ascii_lowercase()) { settings.provider = provider; } }); apply_env_string("MOV_RENAMARR_OMDB_API_KEY", |value| { settings.api_key_omdb = Some(value); }); apply_env_string("MOV_RENAMARR_TMDB_API_KEY", |value| { settings.api_key_tmdb = Some(value); }); apply_env_string("MOV_RENAMARR_CACHE", |value| { settings.cache_path = PathBuf::from(value); }); apply_env_string("MOV_RENAMARR_REPORT_FORMAT", |value| { if let Ok(format) = ReportFormat::from_str(&value.to_ascii_lowercase()) { settings.report_format = format; } }); apply_env_string("MOV_RENAMARR_JOBS", |value| { if let Ok(jobs) = parse_jobs_setting(&value, default_jobs()) { settings.jobs = jobs; } }); apply_env_string("MOV_RENAMARR_NET_JOBS", |value| { if let Ok(jobs) = parse_jobs_setting(&value, default_net_jobs(settings.jobs)) { settings.net_jobs = jobs; } }); apply_env_string("MOV_RENAMARR_MIN_SCORE", |value| { if let Ok(min_score) = value.parse::() { settings.min_score = min_score; } }); apply_env_bool("MOV_RENAMARR_INCLUDE_ID", |value| settings.include_id = value); apply_env_bool("MOV_RENAMARR_SIDECARS", |value| settings.sidecars = value); apply_env_bool("MOV_RENAMARR_SIDECAR_NOTES", |value| settings.sidecar_notes = value); apply_env_bool("MOV_RENAMARR_DRY_RUN_SUMMARY", |value| settings.dry_run_summary = value); apply_env_bool("MOV_RENAMARR_OVERWRITE", |value| settings.overwrite = value); apply_env_bool("MOV_RENAMARR_SUFFIX", |value| settings.suffix = value); apply_env_bool("MOV_RENAMARR_NO_LOOKUP", |value| settings.no_lookup = value); apply_env_bool("MOV_RENAMARR_EXPLAIN", |value| settings.explain = value); apply_env_string("MOV_RENAMARR_QUALITY_TAGS", |value| { if let Ok(tags) = parse_quality_tags(&split_list(&value)) { settings.quality_tags = tags; } }); apply_env_string("MOV_RENAMARR_COLOR", |value| { if let Ok(mode) = ColorMode::from_str(&value.to_ascii_lowercase()) { settings.color = mode; } }); apply_env_string("MOV_RENAMARR_LLM_MODE", |value| { if let Ok(mode) = LlmMode::from_str(&value.to_ascii_lowercase()) { settings.llm.mode = mode; } }); apply_env_string("MOV_RENAMARR_LLM_ENDPOINT", |value| settings.llm.endpoint = value); apply_env_string("MOV_RENAMARR_LLM_MODEL", |value| settings.llm.model = Some(value)); apply_env_string("MOV_RENAMARR_LLM_TIMEOUT", |value| { if let Ok(timeout) = value.parse::() { settings.llm.timeout_seconds = timeout; } }); apply_env_string("MOV_RENAMARR_LLM_MAX_TOKENS", |value| { if let Ok(max_tokens) = value.parse::() { settings.llm.max_tokens = Some(max_tokens); } }); apply_env_string("MOV_RENAMARR_OMDB_BASE_URL", |value| settings.omdb_base_url = value); apply_env_string("MOV_RENAMARR_TMDB_BASE_URL", |value| settings.tmdb_base_url = value); Ok(()) } fn apply_env_string(key: &str, mut setter: F) { if let Ok(value) = env::var(key) { if !value.trim().is_empty() { setter(value); } } } fn apply_env_bool(key: &str, mut setter: F) { if let Ok(value) = env::var(key) { if let Ok(parsed) = parse_bool(&value) { setter(parsed); } } } fn parse_bool(value: &str) -> Result { match value.trim().to_ascii_lowercase().as_str() { "1" | "true" | "yes" | "on" => Ok(true), "0" | "false" | "no" | "off" => Ok(false), _ => Err(anyhow!("invalid boolean value: {value}")), } } fn apply_cli_overrides(settings: &mut Settings, cli: &Cli) -> Result<()> { if let Some(provider) = &cli.provider { settings.provider = provider.clone(); } if let Some(key) = &cli.api_key_omdb { settings.api_key_omdb = Some(key.clone()); } if let Some(key) = &cli.api_key_tmdb { settings.api_key_tmdb = Some(key.clone()); } if let Some(path) = &cli.cache { settings.cache_path = path.clone(); } if cli.refresh_cache { settings.refresh_cache = true; } if let Some(format) = &cli.report_format { settings.report_format = format.clone(); } if cli.sidecar_notes { settings.sidecar_notes = true; } if cli.sidecars { settings.sidecars = true; } if cli.dry_run_summary { settings.dry_run_summary = true; } if cli.overwrite { settings.overwrite = true; } if cli.suffix { settings.suffix = true; } if let Some(min_score) = cli.min_score { settings.min_score = min_score; } if cli.include_id { settings.include_id = true; } if let Some(tags) = &cli.quality_tags { settings.quality_tags = parse_quality_tags(&split_list(tags))?; } if let Some(color) = &cli.color { settings.color = color.clone(); } if let Some(jobs) = &cli.jobs { settings.jobs = resolve_jobs_arg(jobs, default_jobs()); } if let Some(net_jobs) = &cli.net_jobs { settings.net_jobs = resolve_jobs_arg(net_jobs, default_net_jobs(settings.jobs)); } if cli.no_lookup { settings.no_lookup = true; } if cli.explain { settings.explain = true; } if let Some(mode) = &cli.llm_mode { settings.llm.mode = mode.clone(); } if let Some(endpoint) = &cli.llm_endpoint { settings.llm.endpoint = endpoint.clone(); } if let Some(model) = &cli.llm_model { settings.llm.model = Some(model.clone()); } if let Some(timeout) = cli.llm_timeout { settings.llm.timeout_seconds = timeout; } if let Some(max_tokens) = cli.llm_max_tokens { settings.llm.max_tokens = Some(max_tokens); } if cli.verbose { settings.verbose = true; } Ok(()) } fn validate_settings(settings: &mut Settings) -> Result<()> { if settings.overwrite && settings.suffix { return Err(anyhow!("--overwrite and --suffix cannot both be set")); } if settings.min_score > 100 { return Err(anyhow!("min-score must be between 0 and 100")); } if settings.dry_run_summary && !settings.dry_run { return Err(anyhow!("--dry-run-summary requires --dry-run")); } if settings.net_jobs == 0 { settings.net_jobs = 1; } if settings.net_jobs > settings.jobs { settings.net_jobs = settings.jobs; } Ok(()) } pub fn default_jobs() -> usize { let cores = num_cpus::get(); let half = std::cmp::max(1, cores / 2); let limit = std::cmp::min(4, half); if limit == 0 { 1 } else { limit } } pub fn default_net_jobs(jobs: usize) -> usize { std::cmp::max(1, std::cmp::min(2, jobs)) } pub fn format_settings(settings: &Settings) -> Result { let printable = PrintableSettings::from(settings); toml::to_string_pretty(&printable).context("failed to serialize settings") } impl From<&Settings> for PrintableSettings { fn from(settings: &Settings) -> Self { Self { input: settings.input.display().to_string(), output: settings.output.display().to_string(), provider: settings.provider.clone(), api_key_omdb: settings.api_key_omdb.clone(), api_key_tmdb: settings.api_key_tmdb.clone(), cache_path: settings.cache_path.display().to_string(), cache_ttl_days: settings.cache_ttl_days, refresh_cache: settings.refresh_cache, report_format: settings.report_format.clone(), report_path: settings.report_path.as_ref().map(|p| p.display().to_string()), sidecar_notes: settings.sidecar_notes, sidecars: settings.sidecars, dry_run_summary: settings.dry_run_summary, overwrite: settings.overwrite, suffix: settings.suffix, min_score: settings.min_score, include_id: settings.include_id, quality_tags: settings.quality_tags.to_list(), color: settings.color.clone(), jobs: settings.jobs, net_jobs: settings.net_jobs, no_lookup: settings.no_lookup, dry_run: settings.dry_run, move_files: settings.move_files, rename_in_place: settings.rename_in_place, interactive: settings.interactive, verbose: settings.verbose, explain: settings.explain, omdb_base_url: settings.omdb_base_url.clone(), tmdb_base_url: settings.tmdb_base_url.clone(), llm: PrintableLlm::from(&settings.llm), } } } impl From<&LlmSettings> for PrintableLlm { fn from(settings: &LlmSettings) -> Self { Self { mode: settings.mode.clone(), endpoint: settings.endpoint.clone(), model: settings.model.clone(), timeout_seconds: settings.timeout_seconds, max_tokens: settings.max_tokens, } } } fn parse_jobs_setting(raw: &str, fallback: usize) -> Result { if raw.eq_ignore_ascii_case("auto") { return Ok(fallback); } let parsed: usize = raw.parse().context("invalid jobs value")?; if parsed == 0 { return Err(anyhow!("jobs must be >= 1")); } Ok(parsed) } fn parse_jobs_setting_value(raw: &JobValue, fallback: usize) -> Result { match raw { JobValue::String(value) => parse_jobs_setting(value, fallback), JobValue::Number(value) => { if *value == 0 { return Err(anyhow!("jobs must be >= 1")); } Ok(*value as usize) } } } fn resolve_jobs_arg(arg: &JobsArg, fallback: usize) -> usize { match arg { JobsArg::Auto => fallback, JobsArg::Fixed(value) => *value, } } fn parse_quality_tags(values: &[String]) -> Result { let mut tags = QualityTags::default(); tags.resolution = false; for value in values { let token = value.trim().to_ascii_lowercase(); match token.as_str() { "resolution" => tags.resolution = true, "codec" => tags.codec = true, "source" => tags.source = true, "all" => { tags.resolution = true; tags.codec = true; tags.source = true; } "none" => { tags.resolution = false; tags.codec = false; tags.source = false; } _ if token.is_empty() => {} _ => return Err(anyhow!("unknown quality tag: {token}")), } } Ok(tags) } fn split_list(raw: &str) -> Vec { raw.split([',', ';', ' ']) .filter(|token| !token.trim().is_empty()) .map(|token| token.trim().to_string()) .collect() } fn resolve_report_path(cli: &Cli) -> Result> { match &cli.report { None => Ok(None), Some(path) => { if path.as_os_str() == "__DEFAULT__" { let filename = default_report_filename(); Ok(Some(PathBuf::from(filename))) } else { Ok(Some(path.clone())) } } } } fn default_report_filename() -> String { let now = chrono::Local::now(); let timestamp = now.format("%Y%m%d-%H%M%S").to_string(); format!("mov-renamarr-report-{timestamp}.txt") } // Needed for ValueEnum parsing from env string impl FromStr for ProviderChoice { type Err = anyhow::Error; fn from_str(s: &str) -> Result { match s { "auto" => Ok(ProviderChoice::Auto), "omdb" => Ok(ProviderChoice::Omdb), "tmdb" => Ok(ProviderChoice::Tmdb), "both" => Ok(ProviderChoice::Both), _ => Err(anyhow!("invalid provider choice")), } } } impl FromStr for ReportFormat { type Err = anyhow::Error; fn from_str(s: &str) -> Result { match s { "text" => Ok(ReportFormat::Text), "json" => Ok(ReportFormat::Json), "csv" => Ok(ReportFormat::Csv), _ => Err(anyhow!("invalid report format")), } } } impl FromStr for ColorMode { type Err = anyhow::Error; fn from_str(s: &str) -> Result { match s { "auto" => Ok(ColorMode::Auto), "always" => Ok(ColorMode::Always), "never" => Ok(ColorMode::Never), _ => Err(anyhow!("invalid color mode")), } } } impl FromStr for LlmMode { type Err = anyhow::Error; fn from_str(s: &str) -> Result { match s { "off" => Ok(LlmMode::Off), "parse" => Ok(LlmMode::Parse), "assist" => Ok(LlmMode::Assist), _ => Err(anyhow!("invalid LLM mode")), } } }