Skip to main content

netspeed_cli/config/
validate.rs

1//! Config validation types and functions.
2//!
3//! [`ValidationResult`] uses a builder pattern — start with [`ValidationResult::ok()`]
4//! then chain [`with_error`](ValidationResult::with_error) /
5//! [`with_warning`](ValidationResult::with_warning) calls.
6
7use directories::ProjectDirs;
8use std::fs;
9use std::path::PathBuf;
10
11use super::File;
12
13/// Validation result with error details.
14///
15/// # Example
16///
17/// ```
18/// use netspeed_cli::config::ValidationResult;
19///
20/// let result = ValidationResult::ok()
21///     .with_warning("deprecated option")
22///     .with_error("invalid profile");
23///
24/// assert!(!result.valid);
25/// assert_eq!(result.errors.len(), 1);
26/// assert_eq!(result.warnings.len(), 1);
27/// ```
28#[derive(Debug, Clone)]
29pub struct ValidationResult {
30    /// Whether validation passed (no errors). Warnings do not affect this.
31    pub valid: bool,
32    /// Error messages (any error sets [`valid`](ValidationResult::valid) to `false`).
33    pub errors: Vec<String>,
34    /// Warning messages (do not affect [`valid`](ValidationResult::valid)).
35    pub warnings: Vec<String>,
36}
37
38impl ValidationResult {
39    /// Create a successful validation result.
40    ///
41    /// # Example
42    ///
43    /// ```
44    /// use netspeed_cli::config::ValidationResult;
45    ///
46    /// let result = ValidationResult::ok();
47    /// assert!(result.valid);
48    /// assert!(result.errors.is_empty());
49    /// assert!(result.warnings.is_empty());
50    /// ```
51    #[must_use]
52    pub fn ok() -> Self {
53        Self {
54            valid: true,
55            errors: Vec::new(),
56            warnings: Vec::new(),
57        }
58    }
59
60    /// Add a warning to the result.
61    ///
62    /// Warnings do **not** change [`valid`](ValidationResult::valid).
63    ///
64    /// # Example
65    ///
66    /// ```
67    /// use netspeed_cli::config::ValidationResult;
68    ///
69    /// let result = ValidationResult::ok().with_warning("'simple' is deprecated");
70    /// assert!(result.valid);
71    /// assert_eq!(result.warnings.len(), 1);
72    /// assert!(result.warnings[0].contains("deprecated"));
73    /// ```
74    #[must_use]
75    pub fn with_warning(mut self, warning: impl Into<String>) -> Self {
76        self.warnings.push(warning.into());
77        self
78    }
79
80    /// Create a validation failure.
81    ///
82    /// # Example
83    ///
84    /// ```
85    /// use netspeed_cli::config::ValidationResult;
86    ///
87    /// let result = ValidationResult::error("invalid profile 'foo'");
88    /// assert!(!result.valid);
89    /// assert_eq!(result.errors.len(), 1);
90    /// assert!(result.errors[0].contains("foo"));
91    /// assert!(result.warnings.is_empty());
92    /// ```
93    #[must_use]
94    pub fn error(msg: impl Into<String>) -> Self {
95        Self {
96            valid: false,
97            errors: vec![msg.into()],
98            warnings: Vec::new(),
99        }
100    }
101
102    /// Add an error to the result, flipping [`valid`](ValidationResult::valid) to `false`.
103    ///
104    /// # Example
105    ///
106    /// ```
107    /// use netspeed_cli::config::ValidationResult;
108    ///
109    /// let result = ValidationResult::ok().with_error("bad theme");
110    /// assert!(!result.valid);
111    /// assert_eq!(result.errors.len(), 1);
112    /// ```
113    #[must_use]
114    pub fn with_error(mut self, error: impl Into<String>) -> Self {
115        self.errors.push(error.into());
116        self.valid = false;
117        self
118    }
119
120    /// Merge another validation result into this one.
121    ///
122    /// # Example
123    ///
124    /// ```
125    /// use netspeed_cli::config::ValidationResult;
126    ///
127    /// let a = ValidationResult::ok().with_warning("warn-a");
128    /// let b = ValidationResult::error("bad profile");
129    /// let merged = a.merge(b);
130    /// assert!(!merged.valid);
131    /// assert_eq!(merged.errors.len(), 1);
132    /// assert_eq!(merged.warnings.len(), 1);
133    /// ```
134    #[must_use]
135    pub fn merge(mut self, other: ValidationResult) -> Self {
136        if !other.valid {
137            self.valid = false;
138        }
139        self.errors.extend(other.errors);
140        self.warnings.extend(other.warnings);
141        self
142    }
143}
144
145/// Validate CSV delimiter character.
146fn validate_csv_delimiter_config(delimiter: char) -> Result<(), String> {
147    if !",;|\t".contains(delimiter) {
148        return Err(format!(
149            "Invalid CSV delimiter '{}'. Must be one of: comma, semicolon, pipe, or tab",
150            delimiter
151        ));
152    }
153    Ok(())
154}
155
156/// Validate the entire file config structure.
157pub fn validate_config(file_config: &File) -> ValidationResult {
158    let mut result = ValidationResult::ok();
159
160    if let Some(ref profile) = file_config.profile {
161        if let Err(e) = crate::profiles::UserProfile::validate(profile) {
162            result = result.with_error(e);
163        }
164    }
165
166    if let Some(ref theme) = file_config.theme {
167        if let Err(e) = crate::theme::Theme::validate(theme) {
168            result = result.with_error(e);
169        }
170    }
171
172    if let Some(delimiter) = file_config.csv_delimiter {
173        if let Err(e) = validate_csv_delimiter_config(delimiter) {
174            result = result.with_error(e);
175        }
176    }
177
178    if file_config.simple.unwrap_or(false) {
179        result = result.with_warning(
180            "'simple' option is deprecated. Use '--format simple' instead.".to_string(),
181        );
182    }
183    if file_config.csv.unwrap_or(false) {
184        result = result
185            .with_warning("'csv' option is deprecated. Use '--format csv' instead.".to_string());
186    }
187    if file_config.json.unwrap_or(false) {
188        result = result
189            .with_warning("'json' option is deprecated. Use '--format json' instead.".to_string());
190    }
191
192    result
193}
194
195/// Get the configuration file path (also used by orchestrator for --show-config-path).
196#[must_use]
197pub fn get_config_path_internal() -> Option<PathBuf> {
198    ProjectDirs::from("dev", "vibe", "netspeed-cli").map(|proj_dirs| {
199        let config_dir = proj_dirs.config_dir();
200        if let Err(e) = fs::create_dir_all(config_dir) {
201            eprintln!("Warning: Failed to create config directory: {e}");
202        }
203        config_dir.join("config.toml")
204    })
205}
206
207/// Load the configuration file from the standard config path.
208///
209/// Returns `None` if no config file exists or if loading fails.
210pub fn load_config_file() -> Option<File> {
211    let path = get_config_path_internal()?;
212    if !path.exists() {
213        return None;
214    }
215
216    let content = match fs::read_to_string(&path) {
217        Ok(c) => c,
218        Err(e) => {
219            eprintln!(
220                "Warning: Failed to read config file {}: {e}",
221                path.display()
222            );
223            return None;
224        }
225    };
226    let mut config: File = match toml::from_str(&content) {
227        Ok(c) => c,
228        Err(e) => {
229            eprintln!("Warning: Failed to parse config: {e}");
230            return None;
231        }
232    };
233
234    if let Some(timeout) = config.timeout {
235        if timeout == 0 || timeout > 300 {
236            eprintln!(
237                "Warning: Invalid config timeout ({timeout}s, must be 1-300). Using default."
238            );
239            config.timeout = None;
240        }
241    }
242
243    Some(config)
244}