Skip to main content

netspeed_cli/
config.rs

1use crate::cli::CliArgs;
2use directories::ProjectDirs;
3use serde::Deserialize;
4use std::fs;
5use std::path::PathBuf;
6
7#[derive(Debug, Default, Deserialize)]
8pub struct ConfigFile {
9    pub no_download: Option<bool>,
10    pub no_upload: Option<bool>,
11    pub single: Option<bool>,
12    pub bytes: Option<bool>,
13    pub simple: Option<bool>,
14    pub csv: Option<bool>,
15    pub csv_delimiter: Option<char>,
16    pub csv_header: Option<bool>,
17    pub json: Option<bool>,
18    pub timeout: Option<u64>,
19}
20
21#[allow(clippy::struct_excessive_bools)]
22pub struct Config {
23    pub no_download: bool,
24    pub no_upload: bool,
25    pub single: bool,
26    pub bytes: bool,
27    pub simple: bool,
28    pub csv: bool,
29    pub csv_delimiter: char,
30    pub csv_header: bool,
31    pub json: bool,
32    pub list: bool,
33    pub server_ids: Vec<String>,
34    pub exclude_ids: Vec<String>,
35    pub source: Option<String>,
36    pub timeout: u64,
37    pub quiet: bool,
38}
39
40impl Config {
41    #[must_use]
42    pub fn from_args(args: &CliArgs) -> Self {
43        let file_config = load_config_file().unwrap_or_default();
44
45        // Merge strategy: CLI flags and config file are combined with OR semantics.
46        // Since clap defaults `bool` to `false`, we cannot distinguish "user didn't
47        // pass the flag" from "user explicitly passed `--no-flag`".
48        //
49        // Practical effect: if config file has `no_download = true`, downloads will
50        // be skipped unless the user passes `--no-download=false` (if supported)
51        // or removes the config line. The config file acts as a persistent default.
52        //
53        // For timeout: CLI value is used only if explicitly set (non-default).
54        // Otherwise, file config is checked, falling back to the compiled default (10).
55        let merge_bool = |cli: bool, file: Option<bool>| cli || file.unwrap_or(false);
56        let merge_u64 = |cli: u64, file: Option<u64>, default: u64| {
57            // If CLI is at default value, check file; otherwise use CLI
58            if cli == default {
59                file.unwrap_or(default)
60            } else {
61                cli
62            }
63        };
64
65        Self {
66            no_download: merge_bool(args.no_download, file_config.no_download),
67            no_upload: merge_bool(args.no_upload, file_config.no_upload),
68            single: merge_bool(args.single, file_config.single),
69            bytes: merge_bool(args.bytes, file_config.bytes),
70            simple: merge_bool(args.simple, file_config.simple),
71            csv: merge_bool(args.csv, file_config.csv),
72            csv_delimiter: if args.csv_delimiter == ',' {
73                file_config.csv_delimiter.unwrap_or(',')
74            } else {
75                args.csv_delimiter
76            },
77            csv_header: merge_bool(args.csv_header, file_config.csv_header),
78            json: merge_bool(args.json, file_config.json),
79            list: args.list,
80            server_ids: args.server.clone(),
81            exclude_ids: args.exclude.clone(),
82            source: args.source.clone(),
83            timeout: merge_u64(args.timeout, file_config.timeout, 10),
84            quiet: merge_bool(args.quiet, None),
85        }
86    }
87}
88
89fn get_config_path() -> Option<PathBuf> {
90    ProjectDirs::from("dev", "vibe", "netspeed-cli").map(|proj_dirs| {
91        let config_dir = proj_dirs.config_dir();
92        fs::create_dir_all(config_dir).ok();
93        config_dir.join("config.toml")
94    })
95}
96
97fn load_config_file() -> Option<ConfigFile> {
98    let path = get_config_path()?;
99    if !path.exists() {
100        return None;
101    }
102
103    let content = fs::read_to_string(path).ok()?;
104    let mut config: ConfigFile = toml::from_str(&content).ok()?;
105
106    // Validate timeout if present
107    if let Some(timeout) = config.timeout {
108        if timeout == 0 || timeout > 300 {
109            // Silently ignore invalid timeout — fall back to default
110            config.timeout = None;
111        }
112    }
113
114    Some(config)
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use clap::Parser;
121
122    #[test]
123    fn test_config_from_args_defaults() {
124        let args = CliArgs::parse_from(["netspeed-cli"]);
125        let config = Config::from_args(&args);
126
127        assert!(!config.no_download);
128        assert!(!config.no_upload);
129        assert!(!config.single);
130        assert!(!config.bytes);
131        assert!(!config.simple);
132        assert!(!config.csv);
133        assert!(!config.json);
134        assert!(!config.list);
135        assert!(!config.quiet);
136        assert_eq!(config.timeout, 10);
137        assert_eq!(config.csv_delimiter, ',');
138        assert!(!config.csv_header);
139        assert!(config.server_ids.is_empty());
140        assert!(config.exclude_ids.is_empty());
141    }
142
143    #[test]
144    fn test_config_from_args_no_download() {
145        let args = CliArgs::parse_from(["netspeed-cli", "--no-download"]);
146        let config = Config::from_args(&args);
147        assert!(config.no_download);
148        assert!(!config.no_upload);
149    }
150
151    #[test]
152    fn test_config_file_deserialization() {
153        let toml_content = r#"
154            no_download = true
155            no_upload = false
156            single = true
157            bytes = true
158            simple = false
159            csv = false
160            csv_delimiter = ';'
161            csv_header = true
162            json = true
163            timeout = 30
164        "#;
165
166        let config: ConfigFile = toml::from_str(toml_content).unwrap();
167        assert_eq!(config.no_download, Some(true));
168        assert_eq!(config.no_upload, Some(false));
169        assert_eq!(config.single, Some(true));
170        assert_eq!(config.bytes, Some(true));
171        assert_eq!(config.simple, Some(false));
172        assert_eq!(config.csv, Some(false));
173        assert_eq!(config.csv_delimiter, Some(';'));
174        assert_eq!(config.csv_header, Some(true));
175        assert_eq!(config.json, Some(true));
176        assert_eq!(config.timeout, Some(30));
177    }
178
179    #[test]
180    fn test_config_file_partial() {
181        let toml_content = r#"
182            no_download = true
183            timeout = 20
184        "#;
185
186        let config: ConfigFile = toml::from_str(toml_content).unwrap();
187        assert_eq!(config.no_download, Some(true));
188        assert!(config.no_upload.is_none());
189        assert!(config.single.is_none());
190        assert_eq!(config.timeout, Some(20));
191        assert!(config.csv_delimiter.is_none());
192    }
193
194    #[test]
195    fn test_config_from_args_overrides_file() {
196        // Test that CLI flags override file config when explicitly set
197        let args = CliArgs::parse_from(["netspeed-cli", "--no-download"]);
198        let config = Config::from_args(&args);
199        assert!(config.no_download);
200    }
201
202    #[test]
203    fn test_config_merge_bool_file_true_cli_false() {
204        // When CLI flag is false (default) and file config is true, result should be false
205        // because merge_bool = cli || file.unwrap_or(false)
206        // Actually merge_bool returns true only if CLI is true OR file is Some(true)
207        // Let's verify the actual behavior
208        let toml_content = r#"
209            no_download = true
210        "#;
211        let file_config: ConfigFile = toml::from_str(toml_content).unwrap();
212
213        // CLI args with no_download=false (default)
214        let args = CliArgs::parse_from(["netspeed-cli"]);
215        let file_config_loaded = Some(file_config);
216
217        // Manual merge check
218        let cli_val = args.no_download; // false
219        let file_val = file_config_loaded.and_then(|c| c.no_download); // Some(true)
220        let merged = cli_val || file_val.unwrap_or(false);
221        // Since CLI is false and file is Some(true), result depends on merge logic
222        // The current merge is: cli || file.unwrap_or(false) = false || true = true
223        assert!(merged);
224    }
225}