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}