subx_cli/config/
validation.rs

1//! Low-level validation functions for individual configuration values.
2//!
3//! This module provides primitive validation functions that are used by
4//! higher-level validation systems. These functions focus on validating
5//! individual values and types without knowledge of the overall configuration
6//! structure.
7//!
8//! # Architecture
9//!
10//! This module is the foundation of the validation system:
11//! - [`crate::config::validation`] (this module) - Low-level validation functions
12//! - [`crate::config::validator`] - High-level configuration section validators
13//! - [`crate::config::field_validator`] - Key-value validation for configuration service
14
15use crate::error::{SubXError, SubXResult};
16use std::path::Path;
17use url::Url;
18
19/// Validate a string value against a list of allowed values.
20pub fn validate_enum(value: &str, allowed: &[&str]) -> SubXResult<()> {
21    if allowed.contains(&value) {
22        Ok(())
23    } else {
24        Err(SubXError::config(format!(
25            "Invalid value '{}'. Allowed values: {}",
26            value,
27            allowed.join(", ")
28        )))
29    }
30}
31
32/// Validate a float value within a specified range.
33pub fn validate_float_range(value: &str, min: f32, max: f32) -> SubXResult<f32> {
34    let parsed = value
35        .parse::<f32>()
36        .map_err(|_| SubXError::config(format!("Invalid float value: {value}")))?;
37    if parsed < min || parsed > max {
38        return Err(SubXError::config(format!(
39            "Value {parsed} is out of range [{min}, {max}]"
40        )));
41    }
42    Ok(parsed)
43}
44
45/// Validate an unsigned integer within a specified range.
46pub fn validate_uint_range(value: &str, min: u32, max: u32) -> SubXResult<u32> {
47    let parsed = value
48        .parse::<u32>()
49        .map_err(|_| SubXError::config(format!("Invalid integer value: {value}")))?;
50    if parsed < min || parsed > max {
51        return Err(SubXError::config(format!(
52            "Value {parsed} is out of range [{min}, {max}]"
53        )));
54    }
55    Ok(parsed)
56}
57
58/// Validate a u64 value within a specified range.
59pub fn validate_u64_range(value: &str, min: u64, max: u64) -> SubXResult<u64> {
60    let parsed = value
61        .parse::<u64>()
62        .map_err(|_| SubXError::config(format!("Invalid u64 value: {value}")))?;
63    if parsed < min || parsed > max {
64        return Err(SubXError::config(format!(
65            "Value {parsed} is out of range [{min}, {max}]"
66        )));
67    }
68    Ok(parsed)
69}
70
71/// Validate a usize value within a specified range.
72pub fn validate_usize_range(value: &str, min: usize, max: usize) -> SubXResult<usize> {
73    let parsed = value
74        .parse::<usize>()
75        .map_err(|_| SubXError::config(format!("Invalid usize value: {value}")))?;
76    if parsed < min || parsed > max {
77        return Err(SubXError::config(format!(
78            "Value {parsed} is out of range [{min}, {max}]"
79        )));
80    }
81    Ok(parsed)
82}
83
84/// Validate API key format.
85pub fn validate_api_key(value: &str) -> SubXResult<()> {
86    if value.is_empty() {
87        return Err(SubXError::config("API key cannot be empty".to_string()));
88    }
89    if value.len() < 10 {
90        return Err(SubXError::config("API key is too short".to_string()));
91    }
92    Ok(())
93}
94
95/// Validate URL format.
96pub fn validate_url(value: &str) -> SubXResult<()> {
97    if !value.starts_with("http://") && !value.starts_with("https://") {
98        return Err(SubXError::config(format!(
99            "Invalid URL format: {value}. Must start with http:// or https://"
100        )));
101    }
102    Ok(())
103}
104
105/// Parse boolean value from string.
106pub fn parse_bool(value: &str) -> SubXResult<bool> {
107    match value.to_lowercase().as_str() {
108        "true" | "1" | "yes" | "on" | "enabled" => Ok(true),
109        "false" | "0" | "no" | "off" | "disabled" => Ok(false),
110        _ => Err(SubXError::config(format!("Invalid boolean value: {value}"))),
111    }
112}
113
114/// Validate that a string is a valid URL.
115///
116/// # Arguments
117/// * `value` - The string to validate as URL
118///
119/// # Errors
120/// Returns error if the string is not a valid URL format.
121pub fn validate_url_format(value: &str) -> SubXResult<()> {
122    if value.trim().is_empty() {
123        return Ok(()); // Empty URLs are often optional
124    }
125
126    Url::parse(value).map_err(|_| SubXError::config(format!("Invalid URL format: {value}")))?;
127    Ok(())
128}
129
130/// Validate that a number is positive.
131///
132/// # Arguments
133/// * `value` - The number to validate
134///
135/// # Errors
136/// Returns error if the number is not positive.
137pub fn validate_positive_number<T>(value: T) -> SubXResult<()>
138where
139    T: PartialOrd + Default + std::fmt::Display + Copy,
140{
141    if value <= T::default() {
142        return Err(SubXError::config(format!(
143            "Value must be positive, got: {value}"
144        )));
145    }
146    Ok(())
147}
148
149/// Validate that a number is within a specified range.
150///
151/// # Arguments
152/// * `value` - The value to validate
153/// * `min` - Minimum allowed value (inclusive)
154/// * `max` - Maximum allowed value (inclusive)
155///
156/// # Errors
157/// Returns error if the value is outside the specified range.
158pub fn validate_range<T>(value: T, min: T, max: T) -> SubXResult<()>
159where
160    T: PartialOrd + std::fmt::Display + Copy,
161{
162    if value < min || value > max {
163        return Err(SubXError::config(format!(
164            "Value {value} is outside allowed range [{min}, {max}]"
165        )));
166    }
167    Ok(())
168}
169
170/// Validate that a string is not empty after trimming.
171///
172/// # Arguments
173/// * `value` - The string to validate
174/// * `field_name` - Name of the field for error messages
175///
176/// # Errors
177/// Returns error if the string is empty or contains only whitespace.
178pub fn validate_non_empty_string(value: &str, field_name: &str) -> SubXResult<()> {
179    if value.trim().is_empty() {
180        return Err(SubXError::config(format!("{field_name} cannot be empty")));
181    }
182    Ok(())
183}
184
185/// Validate that a path exists and is accessible.
186///
187/// # Arguments
188/// * `value` - The path string to validate
189/// * `must_exist` - Whether the path must already exist
190///
191/// # Errors
192/// Returns error if path is invalid or doesn't exist when required.
193pub fn validate_file_path(value: &str, must_exist: bool) -> SubXResult<()> {
194    if value.trim().is_empty() {
195        return Err(SubXError::config("File path cannot be empty"));
196    }
197
198    let path = Path::new(value);
199    if must_exist && !path.exists() {
200        return Err(SubXError::config(format!("Path does not exist: {value}")));
201    }
202
203    Ok(())
204}
205
206/// Validate temperature value for AI models.
207///
208/// # Arguments
209/// * `temperature` - The temperature value to validate
210///
211/// # Errors
212/// Returns error if temperature is outside the valid range (0.0-2.0).
213pub fn validate_temperature(temperature: f32) -> SubXResult<()> {
214    validate_range(temperature, 0.0, 2.0)
215        .map_err(|_| SubXError::config("AI temperature must be between 0.0 and 2.0"))
216}
217
218/// Validate AI model name format.
219///
220/// # Arguments
221/// * `model` - The model name to validate
222///
223/// # Errors
224/// Returns error if model name is invalid.
225pub fn validate_ai_model(model: &str) -> SubXResult<()> {
226    validate_non_empty_string(model, "AI model")?;
227
228    // Basic format validation - could be extended
229    if model.len() > 100 {
230        return Err(SubXError::config(
231            "AI model name is too long (max 100 characters)",
232        ));
233    }
234
235    Ok(())
236}
237
238/// Validate that a value is a power of two.
239///
240/// # Arguments
241/// * `value` - The value to check
242///
243/// # Errors
244/// Returns error if the value is not a power of two.
245pub fn validate_power_of_two(value: usize) -> SubXResult<()> {
246    if value == 0 || !value.is_power_of_two() {
247        return Err(SubXError::config(format!(
248            "Value {value} must be a power of two"
249        )));
250    }
251    Ok(())
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn test_validate_url_format() {
260        assert!(validate_url_format("https://api.openai.com").is_ok());
261        assert!(validate_url_format("").is_ok()); // Empty is OK for optional fields
262        assert!(validate_url_format("invalid-url").is_err());
263        assert!(validate_url_format("ftp://example.com").is_ok()); // Different protocol is OK
264    }
265
266    #[test]
267    fn test_validate_positive_number() {
268        assert!(validate_positive_number(1.0).is_ok());
269        assert!(validate_positive_number(0.0).is_err());
270        assert!(validate_positive_number(-1.0).is_err());
271        assert!(validate_positive_number(0.1).is_ok());
272    }
273
274    #[test]
275    fn test_validate_range() {
276        assert!(validate_range(1.5, 0.0, 2.0).is_ok());
277        assert!(validate_range(0.0, 0.0, 2.0).is_ok()); // Boundary values OK
278        assert!(validate_range(2.0, 0.0, 2.0).is_ok());
279        assert!(validate_range(-0.1, 0.0, 2.0).is_err());
280        assert!(validate_range(2.1, 0.0, 2.0).is_err());
281    }
282
283    #[test]
284    fn test_validate_non_empty_string() {
285        assert!(validate_non_empty_string("test", "field").is_ok());
286        assert!(validate_non_empty_string("", "field").is_err());
287        assert!(validate_non_empty_string("   ", "field").is_err()); // Whitespace only
288        assert!(validate_non_empty_string(" test ", "field").is_ok());
289    }
290
291    #[test]
292    fn test_validate_temperature() {
293        assert!(validate_temperature(0.8).is_ok());
294        assert!(validate_temperature(0.0).is_ok());
295        assert!(validate_temperature(2.0).is_ok());
296        assert!(validate_temperature(-0.1).is_err());
297        assert!(validate_temperature(2.1).is_err());
298    }
299
300    #[test]
301    fn test_validate_ai_model() {
302        assert!(validate_ai_model("gpt-4").is_ok());
303        assert!(validate_ai_model("").is_err());
304        assert!(validate_ai_model(&"a".repeat(101)).is_err()); // Too long
305        assert!(validate_ai_model(&"a".repeat(100)).is_ok()); // Max length OK
306    }
307
308    #[test]
309    fn test_validate_power_of_two() {
310        assert!(validate_power_of_two(1).is_ok());
311        assert!(validate_power_of_two(2).is_ok());
312        assert!(validate_power_of_two(4).is_ok());
313        assert!(validate_power_of_two(256).is_ok());
314        assert!(validate_power_of_two(1024).is_ok());
315        assert!(validate_power_of_two(0).is_err());
316        assert!(validate_power_of_two(3).is_err());
317        assert!(validate_power_of_two(5).is_err());
318    }
319
320    #[test]
321    fn test_validate_enum() {
322        let allowed = &["openai", "anthropic", "openrouter"];
323        assert!(validate_enum("openai", allowed).is_ok());
324        assert!(validate_enum("anthropic", allowed).is_ok());
325        assert!(validate_enum("openrouter", allowed).is_ok());
326        assert!(validate_enum("invalid", allowed).is_err());
327    }
328
329    #[test]
330    fn test_validate_float_range() {
331        assert!(validate_float_range("1.5", 0.0, 2.0).is_ok());
332        assert!(validate_float_range("0.0", 0.0, 2.0).is_ok());
333        assert!(validate_float_range("2.0", 0.0, 2.0).is_ok());
334        assert!(validate_float_range("-0.1", 0.0, 2.0).is_err());
335        assert!(validate_float_range("2.1", 0.0, 2.0).is_err());
336        assert!(validate_float_range("invalid", 0.0, 2.0).is_err());
337    }
338
339    #[test]
340    fn test_parse_bool() {
341        assert_eq!(parse_bool("true").unwrap(), true);
342        assert_eq!(parse_bool("false").unwrap(), false);
343        assert_eq!(parse_bool("1").unwrap(), true);
344        assert_eq!(parse_bool("0").unwrap(), false);
345        assert_eq!(parse_bool("yes").unwrap(), true);
346        assert_eq!(parse_bool("no").unwrap(), false);
347        assert_eq!(parse_bool("enabled").unwrap(), true);
348        assert_eq!(parse_bool("disabled").unwrap(), false);
349        assert!(parse_bool("invalid").is_err());
350    }
351}