Skip to main content

oxi_store/
settings_validation.rs

1//! 설정 검증 모듈.
2//!
3//! 애플리케이션 시작 시 모든 설정 값을 검증하여
4//! 런타임 패닉을 사전에 방지한다.
5
6use crate::settings::Settings;
7
8/// 검증 결과
9#[derive(Debug)]
10pub struct ValidationReport {
11    pub errors: Vec<ValidationError>,
12    pub warnings: Vec<ValidationWarning>,
13}
14
15#[derive(Debug, Clone)]
16pub struct ValidationError {
17    pub field: String,
18    pub message: String,
19}
20
21#[derive(Debug, Clone)]
22pub struct ValidationWarning {
23    pub field: String,
24    pub message: String,
25}
26
27impl ValidationReport {
28    /// 에러가 없으면 `true`를 반환한다.
29    pub fn is_valid(&self) -> bool {
30        self.errors.is_empty()
31    }
32}
33
34impl Settings {
35    /// 현재 설정의 유효성을 검증한다.
36    ///
37    /// 에러는 즉시 프로그램 종료 사유가 되며, 경고는 로그에 남기만 한다.
38    pub fn validate(&self) -> ValidationReport {
39        let mut report = ValidationReport {
40            errors: Vec::new(),
41            warnings: Vec::new(),
42        };
43
44        // 1. default_temperature — 0.0~2.0 범위
45        if let Some(temp) = self.default_temperature {
46            if !(0.0..=2.0).contains(&temp) {
47                report.errors.push(ValidationError {
48                    field: "default_temperature".to_string(),
49                    message: format!(
50                        "Temperature must be between 0.0 and 2.0 (current: {})",
51                        temp
52                    ),
53                });
54            }
55        }
56        // 레거시 temperature(f32) 필드도 검증
57        if let Some(temp) = self.temperature {
58            if !(0.0..=2.0).contains(&temp) {
59                report.errors.push(ValidationError {
60                    field: "temperature".to_string(),
61                    message: format!(
62                        "Temperature must be between 0.0 and 2.0 (current: {})",
63                        temp
64                    ),
65                });
66            }
67        }
68
69        // 2. max_response_tokens — 최소 1, 128000 초과 시 경고
70        if let Some(tokens) = self.max_response_tokens {
71            if tokens == 0 {
72                report.errors.push(ValidationError {
73                    field: "max_response_tokens".to_string(),
74                    message: "Must be at least 1 (current: 0)".to_string(),
75                });
76            } else if tokens > 128_000 {
77                report.warnings.push(ValidationWarning {
78                    field: "max_response_tokens".to_string(),
79                    message: format!(
80                        "Value exceeds 128,000. Most models may not support this (current: {})",
81                        tokens
82                    ),
83                });
84            }
85        }
86        // 레거시 max_tokens(u32)도 동일 검증
87        if let Some(tokens) = self.max_tokens {
88            if tokens == 0 {
89                report.errors.push(ValidationError {
90                    field: "max_tokens".to_string(),
91                    message: "Must be at least 1 (current: 0)".to_string(),
92                });
93            } else if tokens as usize > 128_000 {
94                report.warnings.push(ValidationWarning {
95                    field: "max_tokens".to_string(),
96                    message: format!(
97                        "Value exceeds 128,000. Most models may not support this (current: {})",
98                        tokens
99                    ),
100                });
101            }
102        }
103
104        // 3. tool_timeout_seconds — 최소 1
105        if self.tool_timeout_seconds == 0 {
106            report.errors.push(ValidationError {
107                field: "tool_timeout_seconds".to_string(),
108                message: "Must be at least 1 second (current: 0)".to_string(),
109            });
110        }
111
112        // 4. thinking_level — 열거형이므로 직렬화/역직렬화 단계에서 이미 검증됨.
113        //    하지만 env 등을 통해 우회 입력된 값도 있으니 안전망으로 확인.
114        //    (ThinkingLevel은 이미 enum이므로 유효하지 않은 값은 역직렬화에서 거부됨)
115
116        // 5. default_model — model name only (no provider prefix expected)
117        // No validation needed: model name may or may not contain '/' depending on user input.
118
119        // 6. session_history_size — 최소 1
120        if self.session_history_size == 0 {
121            report.errors.push(ValidationError {
122                field: "session_history_size".to_string(),
123                message: "Must be at least 1 (current: 0)".to_string(),
124            });
125        }
126
127        report
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::settings::ThinkingLevel;
135
136    /// 기본 설정은 에러·경고 모두 없어야 한다.
137    #[test]
138    fn test_default_settings_are_valid() {
139        let settings = Settings::default();
140        let report = settings.validate();
141        assert!(report.is_valid(), "default settings should be valid");
142        assert!(
143            report.warnings.is_empty(),
144            "default settings should have no warnings"
145        );
146    }
147
148    // ── default_temperature ──────────────────────────────────────────
149
150    #[test]
151    fn test_temperature_in_range() {
152        let mut settings = Settings::default();
153        settings.default_temperature = Some(1.5);
154        let report = settings.validate();
155        assert!(report.is_valid());
156    }
157
158    #[test]
159    fn test_temperature_at_boundaries() {
160        let mut settings = Settings::default();
161        settings.default_temperature = Some(0.0);
162        assert!(settings.validate().is_valid());
163
164        settings.default_temperature = Some(2.0);
165        assert!(settings.validate().is_valid());
166    }
167
168    #[test]
169    fn test_temperature_below_range() {
170        let mut settings = Settings::default();
171        settings.default_temperature = Some(-0.1);
172        let report = settings.validate();
173        assert!(!report.is_valid());
174        assert_eq!(report.errors.len(), 1);
175        assert_eq!(report.errors[0].field, "default_temperature");
176    }
177
178    #[test]
179    fn test_temperature_above_range() {
180        let mut settings = Settings::default();
181        settings.default_temperature = Some(2.5);
182        let report = settings.validate();
183        assert!(!report.is_valid());
184        assert_eq!(report.errors.len(), 1);
185        assert_eq!(report.errors[0].field, "default_temperature");
186    }
187
188    // ── legacy temperature (f32) ─────────────────────────────────────
189
190    #[test]
191    fn test_legacy_temperature_out_of_range() {
192        let mut settings = Settings::default();
193        settings.temperature = Some(3.0);
194        let report = settings.validate();
195        assert!(!report.is_valid());
196        assert_eq!(report.errors[0].field, "temperature");
197    }
198
199    // ── max_response_tokens ──────────────────────────────────────────
200
201    #[test]
202    fn test_max_response_tokens_zero_is_error() {
203        let mut settings = Settings::default();
204        settings.max_response_tokens = Some(0);
205        let report = settings.validate();
206        assert!(!report.is_valid());
207        assert_eq!(report.errors[0].field, "max_response_tokens");
208    }
209
210    #[test]
211    fn test_max_response_tokens_above_128k_is_warning() {
212        let mut settings = Settings::default();
213        settings.max_response_tokens = Some(200_000);
214        let report = settings.validate();
215        assert!(report.is_valid(), "warning should not block");
216        assert_eq!(report.warnings.len(), 1);
217        assert_eq!(report.warnings[0].field, "max_response_tokens");
218    }
219
220    #[test]
221    fn test_max_response_tokens_normal() {
222        let mut settings = Settings::default();
223        settings.max_response_tokens = Some(4096);
224        let report = settings.validate();
225        assert!(report.is_valid());
226        assert!(report.warnings.is_empty());
227    }
228
229    // ── legacy max_tokens (u32) ──────────────────────────────────────
230
231    #[test]
232    fn test_max_tokens_zero_is_error() {
233        let mut settings = Settings::default();
234        settings.max_tokens = Some(0);
235        let report = settings.validate();
236        assert!(!report.is_valid());
237        assert_eq!(report.errors[0].field, "max_tokens");
238    }
239
240    #[test]
241    fn test_max_tokens_above_128k_is_warning() {
242        let mut settings = Settings::default();
243        settings.max_tokens = Some(200_000);
244        let report = settings.validate();
245        assert!(report.is_valid());
246        assert_eq!(report.warnings.len(), 1);
247    }
248
249    // ── tool_timeout_seconds ─────────────────────────────────────────
250
251    #[test]
252    fn test_tool_timeout_zero_is_error() {
253        let mut settings = Settings::default();
254        settings.tool_timeout_seconds = 0;
255        let report = settings.validate();
256        assert!(!report.is_valid());
257        assert_eq!(report.errors[0].field, "tool_timeout_seconds");
258    }
259
260    #[test]
261    fn test_tool_timeout_positive_is_ok() {
262        let mut settings = Settings::default();
263        settings.tool_timeout_seconds = 60;
264        assert!(settings.validate().is_valid());
265    }
266
267    // ── default_model (now model-only, no slash validation) ──────
268
269    #[test]
270    fn test_model_without_slash_is_ok() {
271        let mut settings = Settings::default();
272        settings.default_model = Some("claude-3".to_string());
273        let report = settings.validate();
274        assert!(report.is_valid());
275        assert!(report.warnings.is_empty());
276    }
277
278    #[test]
279    fn test_model_none_produces_no_warning() {
280        let settings = Settings::default();
281        let report = settings.validate();
282        assert!(report.warnings.is_empty());
283    }
284
285    // ── session_history_size ─────────────────────────────────────────
286
287    #[test]
288    fn test_session_history_size_zero_is_error() {
289        let mut settings = Settings::default();
290        settings.session_history_size = 0;
291        let report = settings.validate();
292        assert!(!report.is_valid());
293        assert_eq!(report.errors[0].field, "session_history_size");
294    }
295
296    #[test]
297    fn test_session_history_size_positive_is_ok() {
298        let mut settings = Settings::default();
299        settings.session_history_size = 50;
300        assert!(settings.validate().is_valid());
301    }
302
303    // ── multiple issues ──────────────────────────────────────────────
304
305    #[test]
306    fn test_multiple_errors_and_warnings() {
307        let mut settings = Settings::default();
308        settings.default_temperature = Some(5.0);
309        settings.tool_timeout_seconds = 0;
310        settings.default_model = Some("badmodel".to_string());
311        settings.session_history_size = 0;
312
313        let report = settings.validate();
314        assert!(!report.is_valid());
315        // 3 errors: temperature, tool_timeout, session_history_size
316        assert_eq!(report.errors.len(), 3);
317        // No warnings (default_model no longer warns for missing slash)
318        assert_eq!(report.warnings.len(), 0);
319    }
320
321    // ── ValidationReport helpers ─────────────────────────────────────
322
323    #[test]
324    fn test_report_is_valid_with_warnings_only() {
325        let report = ValidationReport {
326            errors: vec![],
327            warnings: vec![ValidationWarning {
328                field: "x".to_string(),
329                message: "soft warning".to_string(),
330            }],
331        };
332        assert!(report.is_valid());
333    }
334
335    #[test]
336    fn test_report_is_invalid_with_errors() {
337        let report = ValidationReport {
338            errors: vec![ValidationError {
339                field: "x".to_string(),
340                message: "hard error".to_string(),
341            }],
342            warnings: vec![],
343        };
344        assert!(!report.is_valid());
345    }
346}