Skip to main content

opencode_cloud_core/config/
validation.rs

1//! Configuration validation with actionable error messages
2//!
3//! Validates the configuration and provides exact commands to fix issues.
4
5use super::schema::{Config, validate_bind_address};
6use console::style;
7
8/// A configuration validation error with an actionable fix command
9#[derive(Debug, Clone)]
10pub struct ValidationError {
11    /// The config field that has an error
12    pub field: String,
13    /// Description of what's wrong
14    pub message: String,
15    /// Exact occ command to fix the issue
16    pub fix_command: String,
17}
18
19/// A configuration validation warning (non-fatal)
20#[derive(Debug, Clone)]
21pub struct ValidationWarning {
22    /// The config field with a potential issue
23    pub field: String,
24    /// Description of the warning
25    pub message: String,
26    /// Suggested occ command to address the warning
27    pub fix_command: String,
28}
29
30/// Validate configuration and return warnings or first error
31///
32/// Returns Ok(warnings) if validation passes (possibly with non-fatal warnings).
33/// Returns Err(error) on the first fatal validation error encountered.
34///
35/// Validation is performed in order, stopping at the first error.
36pub fn validate_config(config: &Config) -> Result<Vec<ValidationWarning>, ValidationError> {
37    let mut warnings = Vec::new();
38
39    // Port validation
40    if config.opencode_web_port < 1024 {
41        return Err(ValidationError {
42            field: "opencode_web_port".to_string(),
43            message: "Port must be >= 1024 (non-privileged)".to_string(),
44            fix_command: "occ config set opencode_web_port 3000".to_string(),
45        });
46    }
47    // Note: No need to check > 65535 - u16 type enforces this limit
48
49    // Bind address validation
50    if let Err(msg) = validate_bind_address(&config.bind_address) {
51        return Err(ValidationError {
52            field: "bind_address".to_string(),
53            message: msg,
54            fix_command: "occ config set bind_address 127.0.0.1".to_string(),
55        });
56    }
57
58    // Boot mode validation
59    if config.boot_mode != "user" && config.boot_mode != "system" {
60        return Err(ValidationError {
61            field: "boot_mode".to_string(),
62            message: "boot_mode must be 'user' or 'system'".to_string(),
63            fix_command: "occ config set boot_mode user".to_string(),
64        });
65    }
66
67    // Rate limit validation
68    if config.rate_limit_attempts == 0 {
69        return Err(ValidationError {
70            field: "rate_limit_attempts".to_string(),
71            message: "rate_limit_attempts must be > 0".to_string(),
72            fix_command: "occ config set rate_limit_attempts 5".to_string(),
73        });
74    }
75
76    if config.rate_limit_window_seconds == 0 {
77        return Err(ValidationError {
78            field: "rate_limit_window_seconds".to_string(),
79            message: "rate_limit_window_seconds must be > 0".to_string(),
80            fix_command: "occ config set rate_limit_window_seconds 60".to_string(),
81        });
82    }
83
84    // Warnings (non-fatal)
85
86    // Network exposure without auth
87    if config.is_network_exposed()
88        && config.users.is_empty()
89        && !config.allow_unauthenticated_network
90    {
91        warnings.push(ValidationWarning {
92            field: "bind_address".to_string(),
93            message: "Network exposed without authentication".to_string(),
94            fix_command: "occ user add".to_string(),
95        });
96    }
97
98    // Legacy auth fields present
99    if let Some(ref username) = config.auth_username {
100        if !username.is_empty() {
101            warnings.push(ValidationWarning {
102                field: "auth_username".to_string(),
103                message: "Legacy auth fields present; consider using 'occ user add' instead"
104                    .to_string(),
105                fix_command: "occ config set auth_username ''".to_string(),
106            });
107        }
108    }
109
110    if let Some(ref password) = config.auth_password {
111        if !password.is_empty() {
112            warnings.push(ValidationWarning {
113                field: "auth_password".to_string(),
114                message: "Legacy auth fields present; consider using 'occ user add' instead"
115                    .to_string(),
116                fix_command: "occ config set auth_password ''".to_string(),
117            });
118        }
119    }
120
121    Ok(warnings)
122}
123
124/// Display a validation error with styled formatting
125pub fn display_validation_error(error: &ValidationError) {
126    eprintln!();
127    eprintln!("{}", style("Error: Configuration error").red().bold());
128    eprintln!();
129    eprintln!("  {}  {}", style("Field:").dim(), error.field);
130    eprintln!("  {}  {}", style("Problem:").dim(), error.message);
131    eprintln!();
132    eprintln!("{}:", style("To fix, run").dim());
133    eprintln!("  {}", style(&error.fix_command).cyan());
134    eprintln!();
135}
136
137/// Display a validation warning with styled formatting
138pub fn display_validation_warning(warning: &ValidationWarning) {
139    eprintln!();
140    eprintln!(
141        "{}",
142        style("Warning: Configuration warning").yellow().bold()
143    );
144    eprintln!();
145    eprintln!("  {}  {}", style("Field:").dim(), warning.field);
146    eprintln!("  {}  {}", style("Issue:").dim(), warning.message);
147    eprintln!();
148    eprintln!("{}:", style("To address, run").dim());
149    eprintln!("  {}", style(&warning.fix_command).cyan());
150    eprintln!();
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn test_valid_config_passes() {
159        let config = Config::default();
160        let result = validate_config(&config);
161        assert!(result.is_ok());
162    }
163
164    #[test]
165    fn test_port_too_low() {
166        let config = Config {
167            opencode_web_port: 80,
168            ..Config::default()
169        };
170        let result = validate_config(&config);
171        assert!(result.is_err());
172        let err = result.unwrap_err();
173        assert_eq!(err.field, "opencode_web_port");
174        assert!(err.message.contains("1024"));
175    }
176
177    // Note: No test for port > 65535 - u16 type enforces this limit at compile time
178
179    #[test]
180    fn test_invalid_bind_address() {
181        let config = Config {
182            bind_address: "not-an-ip".to_string(),
183            ..Config::default()
184        };
185        let result = validate_config(&config);
186        assert!(result.is_err());
187        let err = result.unwrap_err();
188        assert_eq!(err.field, "bind_address");
189    }
190
191    #[test]
192    fn test_invalid_boot_mode() {
193        let config = Config {
194            boot_mode: "invalid".to_string(),
195            ..Config::default()
196        };
197        let result = validate_config(&config);
198        assert!(result.is_err());
199        let err = result.unwrap_err();
200        assert_eq!(err.field, "boot_mode");
201    }
202
203    #[test]
204    fn test_rate_limit_attempts_zero() {
205        let config = Config {
206            rate_limit_attempts: 0,
207            ..Config::default()
208        };
209        let result = validate_config(&config);
210        assert!(result.is_err());
211        let err = result.unwrap_err();
212        assert_eq!(err.field, "rate_limit_attempts");
213    }
214
215    #[test]
216    fn test_rate_limit_window_zero() {
217        let config = Config {
218            rate_limit_window_seconds: 0,
219            ..Config::default()
220        };
221        let result = validate_config(&config);
222        assert!(result.is_err());
223        let err = result.unwrap_err();
224        assert_eq!(err.field, "rate_limit_window_seconds");
225    }
226
227    #[test]
228    fn test_network_exposed_without_auth_warning() {
229        let config = Config {
230            bind_address: "0.0.0.0".to_string(),
231            users: Vec::new(),
232            allow_unauthenticated_network: false,
233            ..Config::default()
234        };
235        let result = validate_config(&config);
236        assert!(result.is_ok());
237        let warnings = result.unwrap();
238        assert!(!warnings.is_empty());
239        assert!(
240            warnings
241                .iter()
242                .any(|w| w.message.contains("Network exposed"))
243        );
244    }
245
246    #[test]
247    fn test_legacy_auth_username_warning() {
248        let config = Config {
249            auth_username: Some("admin".to_string()),
250            ..Config::default()
251        };
252        let result = validate_config(&config);
253        assert!(result.is_ok());
254        let warnings = result.unwrap();
255        assert!(!warnings.is_empty());
256        assert!(warnings.iter().any(|w| w.field == "auth_username"));
257    }
258
259    #[test]
260    fn test_legacy_auth_password_warning() {
261        let config = Config {
262            auth_password: Some("secret".to_string()),
263            ..Config::default()
264        };
265        let result = validate_config(&config);
266        assert!(result.is_ok());
267        let warnings = result.unwrap();
268        assert!(!warnings.is_empty());
269        assert!(warnings.iter().any(|w| w.field == "auth_password"));
270    }
271}