gglib_core/
settings.rs

1//! Settings domain types and validation.
2//!
3//! This module contains the core settings types used across the application.
4//! These are pure domain types with no infrastructure dependencies.
5
6use serde::{Deserialize, Serialize};
7
8use crate::domain::InferenceConfig;
9
10/// Default port for the OpenAI-compatible proxy server.
11pub const DEFAULT_PROXY_PORT: u16 = 8080;
12
13/// Default base port for llama-server instance allocation.
14pub const DEFAULT_LLAMA_BASE_PORT: u16 = 9000;
15
16/// Default context size for models when not specified by the user.
17pub const DEFAULT_CONTEXT_SIZE: u64 = 4096;
18
19/// Application settings structure.
20///
21/// All fields are optional to support partial updates and graceful defaults.
22#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
23#[serde(default)]
24pub struct Settings {
25    /// Default directory for downloading models.
26    pub default_download_path: Option<String>,
27
28    /// Default context size for models (e.g., 4096, 8192).
29    pub default_context_size: Option<u64>,
30
31    /// Port for the OpenAI-compatible proxy server.
32    pub proxy_port: Option<u16>,
33
34    /// Base port for llama-server instance allocation (first port in range).
35    /// Note: The OpenAI-compatible proxy listens on `proxy_port`.
36    pub llama_base_port: Option<u16>,
37
38    /// Maximum number of downloads that can be queued (1-50).
39    pub max_download_queue_size: Option<u32>,
40
41    /// Whether to show memory fit indicators in `HuggingFace` browser.
42    pub show_memory_fit_indicators: Option<bool>,
43
44    /// Maximum iterations for tool calling agentic loop.
45    pub max_tool_iterations: Option<u32>,
46
47    /// Maximum stagnation steps before stopping agent loop.
48    pub max_stagnation_steps: Option<u32>,
49
50    /// Default model ID for commands that support a default model.
51    pub default_model_id: Option<i64>,
52
53    /// Global inference parameter defaults.
54    ///
55    /// Applied when neither request nor per-model defaults are specified.
56    /// If not set, hardcoded defaults are used as final fallback.
57    #[serde(default)]
58    pub inference_defaults: Option<InferenceConfig>,
59
60    // ── Setup wizard ────────────────────────────────────────────────
61    /// Whether the first-run setup wizard has been completed.
62    pub setup_completed: Option<bool>,
63
64    /// Custom prompt template for generating chat titles.
65    pub title_generation_prompt: Option<String>,
66}
67
68impl Settings {
69    /// Create settings with sensible defaults.
70    #[must_use]
71    pub const fn with_defaults() -> Self {
72        Self {
73            default_download_path: None,
74            default_context_size: Some(DEFAULT_CONTEXT_SIZE),
75            proxy_port: Some(DEFAULT_PROXY_PORT),
76            llama_base_port: Some(DEFAULT_LLAMA_BASE_PORT),
77            max_download_queue_size: Some(10),
78            show_memory_fit_indicators: Some(true),
79            #[allow(clippy::cast_possible_truncation)] // compile-time constants, always < u32::MAX
80            max_tool_iterations: Some(crate::domain::agent::DEFAULT_MAX_ITERATIONS as u32),
81            #[allow(clippy::cast_possible_truncation)]
82            max_stagnation_steps: Some(crate::domain::agent::DEFAULT_MAX_STAGNATION_STEPS as u32),
83            default_model_id: None,
84            inference_defaults: None,
85            setup_completed: None,
86            title_generation_prompt: None,
87        }
88    }
89
90    /// Get the effective proxy port (with default fallback).
91    #[must_use]
92    pub const fn effective_proxy_port(&self) -> u16 {
93        match self.proxy_port {
94            Some(port) => port,
95            None => DEFAULT_PROXY_PORT,
96        }
97    }
98
99    /// Get the effective llama-server base port (with default fallback).
100    #[must_use]
101    pub const fn effective_llama_base_port(&self) -> u16 {
102        match self.llama_base_port {
103            Some(port) => port,
104            None => DEFAULT_LLAMA_BASE_PORT,
105        }
106    }
107
108    /// Merge another settings into this one, only updating fields that are Some.
109    pub fn merge(&mut self, other: &SettingsUpdate) {
110        if let Some(ref path) = other.default_download_path {
111            self.default_download_path.clone_from(path);
112        }
113        if let Some(ref ctx_size) = other.default_context_size {
114            self.default_context_size = *ctx_size;
115        }
116        if let Some(ref port) = other.proxy_port {
117            self.proxy_port = *port;
118        }
119        if let Some(ref port) = other.llama_base_port {
120            self.llama_base_port = *port;
121        }
122        if let Some(ref queue_size) = other.max_download_queue_size {
123            self.max_download_queue_size = *queue_size;
124        }
125        if let Some(ref show_fit) = other.show_memory_fit_indicators {
126            self.show_memory_fit_indicators = *show_fit;
127        }
128        if let Some(ref iters) = other.max_tool_iterations {
129            self.max_tool_iterations = *iters;
130        }
131        if let Some(ref steps) = other.max_stagnation_steps {
132            self.max_stagnation_steps = *steps;
133        }
134        if let Some(ref model_id) = other.default_model_id {
135            self.default_model_id = *model_id;
136        }
137        if let Some(ref inference_defaults) = other.inference_defaults {
138            self.inference_defaults.clone_from(inference_defaults);
139        }
140        if let Some(ref v) = other.setup_completed {
141            self.setup_completed = *v;
142        }
143        if let Some(ref v) = other.title_generation_prompt {
144            self.title_generation_prompt.clone_from(v);
145        }
146    }
147}
148
149/// Partial settings update.
150///
151/// Each field is `Option<Option<T>>`:
152/// - `None` = don't change this field
153/// - `Some(None)` = set field to None/null
154/// - `Some(Some(value))` = set field to value
155#[derive(Debug, Clone, Default, Serialize, Deserialize)]
156pub struct SettingsUpdate {
157    pub default_download_path: Option<Option<String>>,
158    pub default_context_size: Option<Option<u64>>,
159    pub proxy_port: Option<Option<u16>>,
160    pub llama_base_port: Option<Option<u16>>,
161    pub max_download_queue_size: Option<Option<u32>>,
162    pub show_memory_fit_indicators: Option<Option<bool>>,
163    pub max_tool_iterations: Option<Option<u32>>,
164    pub max_stagnation_steps: Option<Option<u32>>,
165    pub default_model_id: Option<Option<i64>>,
166    pub inference_defaults: Option<Option<InferenceConfig>>,
167    pub setup_completed: Option<Option<bool>>,
168    pub title_generation_prompt: Option<Option<String>>,
169}
170
171/// Settings validation error.
172#[derive(Debug, Clone, thiserror::Error)]
173pub enum SettingsError {
174    #[error("Context size must be between 512 and 1,000,000, got {0}")]
175    InvalidContextSize(u64),
176
177    #[error("Port should be >= 1024 (privileged ports require root), got {0}")]
178    InvalidPort(u16),
179
180    #[error("Max download queue size must be between 1 and 50, got {0}")]
181    InvalidQueueSize(u32),
182
183    #[error("Download path cannot be empty")]
184    EmptyDownloadPath,
185
186    #[error("Invalid inference parameter: {0}")]
187    InvalidInferenceConfig(String),
188}
189
190/// Validate settings values.
191pub fn validate_settings(settings: &Settings) -> Result<(), SettingsError> {
192    // Validate context size
193    if let Some(ctx_size) = settings.default_context_size
194        && !(512..=1_000_000).contains(&ctx_size)
195    {
196        return Err(SettingsError::InvalidContextSize(ctx_size));
197    }
198
199    // Validate proxy port
200    if let Some(port) = settings.proxy_port
201        && port < 1024
202    {
203        return Err(SettingsError::InvalidPort(port));
204    }
205
206    // Validate llama-server base port
207    if let Some(port) = settings.llama_base_port
208        && port < 1024
209    {
210        return Err(SettingsError::InvalidPort(port));
211    }
212
213    // Validate max download queue size
214    if let Some(queue_size) = settings.max_download_queue_size
215        && !(1..=50).contains(&queue_size)
216    {
217        return Err(SettingsError::InvalidQueueSize(queue_size));
218    }
219
220    // Validate download path if specified
221    if settings
222        .default_download_path
223        .as_ref()
224        .is_some_and(|p| p.trim().is_empty())
225    {
226        return Err(SettingsError::EmptyDownloadPath);
227    }
228
229    // Validate inference defaults if specified
230    if let Some(ref inference_config) = settings.inference_defaults {
231        validate_inference_config(inference_config)
232            .map_err(SettingsError::InvalidInferenceConfig)?;
233    }
234
235    Ok(())
236}
237
238/// Validate inference configuration parameters.
239///
240/// Checks that all specified parameters are within valid ranges.
241pub fn validate_inference_config(config: &InferenceConfig) -> Result<(), String> {
242    // Validate temperature (0.0 - 2.0)
243    if let Some(temp) = config.temperature
244        && !(0.0..=2.0).contains(&temp)
245    {
246        return Err(format!(
247            "Temperature must be between 0.0 and 2.0, got {temp}"
248        ));
249    }
250
251    // Validate top_p (0.0 - 1.0)
252    if let Some(top_p) = config.top_p
253        && !(0.0..=1.0).contains(&top_p)
254    {
255        return Err(format!("Top P must be between 0.0 and 1.0, got {top_p}"));
256    }
257
258    // Validate top_k (must be positive)
259    if let Some(top_k) = config.top_k
260        && top_k <= 0
261    {
262        return Err(format!("Top K must be positive, got {top_k}"));
263    }
264
265    // Validate max_tokens (must be positive)
266    if let Some(max_tokens) = config.max_tokens
267        && max_tokens == 0
268    {
269        return Err("Max tokens must be positive".to_string());
270    }
271
272    // Validate repeat_penalty (must be positive)
273    if let Some(repeat_penalty) = config.repeat_penalty
274        && repeat_penalty <= 0.0
275    {
276        return Err(format!(
277            "Repeat penalty must be positive, got {repeat_penalty}"
278        ));
279    }
280
281    // Validate presence_penalty (0.0 - 2.0)
282    if let Some(pp) = config.presence_penalty
283        && !(0.0..=2.0).contains(&pp)
284    {
285        return Err(format!(
286            "Presence penalty must be between 0.0 and 2.0, got {pp}"
287        ));
288    }
289
290    // Validate min_p (0.0 - 1.0)
291    if let Some(mp) = config.min_p
292        && !(0.0..=1.0).contains(&mp)
293    {
294        return Err(format!("Min P must be between 0.0 and 1.0, got {mp}"));
295    }
296
297    Ok(())
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn test_default_settings() {
306        let settings = Settings::with_defaults();
307        assert_eq!(settings.default_context_size, Some(4096));
308        assert_eq!(settings.proxy_port, Some(DEFAULT_PROXY_PORT));
309        assert_eq!(settings.llama_base_port, Some(DEFAULT_LLAMA_BASE_PORT));
310        assert_eq!(settings.default_download_path, None);
311        assert_eq!(settings.max_download_queue_size, Some(10));
312        assert_eq!(settings.show_memory_fit_indicators, Some(true));
313    }
314
315    #[test]
316    fn test_validate_settings_valid() {
317        let settings = Settings::with_defaults();
318        assert!(validate_settings(&settings).is_ok());
319    }
320
321    #[test]
322    fn test_validate_context_size_too_small() {
323        let settings = Settings {
324            default_context_size: Some(100),
325            ..Default::default()
326        };
327        assert!(matches!(
328            validate_settings(&settings),
329            Err(SettingsError::InvalidContextSize(100))
330        ));
331    }
332
333    #[test]
334    fn test_validate_context_size_too_large() {
335        let settings = Settings {
336            default_context_size: Some(2_000_000),
337            ..Default::default()
338        };
339        assert!(matches!(
340            validate_settings(&settings),
341            Err(SettingsError::InvalidContextSize(2_000_000))
342        ));
343    }
344
345    #[test]
346    fn test_validate_port_too_low() {
347        let settings = Settings {
348            proxy_port: Some(80),
349            ..Default::default()
350        };
351        assert!(matches!(
352            validate_settings(&settings),
353            Err(SettingsError::InvalidPort(80))
354        ));
355    }
356
357    #[test]
358    fn test_validate_empty_path() {
359        let settings = Settings {
360            default_download_path: Some(String::new()),
361            ..Default::default()
362        };
363        assert!(matches!(
364            validate_settings(&settings),
365            Err(SettingsError::EmptyDownloadPath)
366        ));
367    }
368
369    #[test]
370    fn test_validate_inference_config_valid() {
371        let config = InferenceConfig {
372            temperature: Some(0.7),
373            top_p: Some(0.9),
374            top_k: Some(40),
375            max_tokens: Some(2048),
376            repeat_penalty: Some(1.1),
377            presence_penalty: Some(0.0),
378            min_p: Some(0.0),
379        };
380        assert!(validate_inference_config(&config).is_ok());
381    }
382
383    #[test]
384    fn test_validate_inference_config_temperature_out_of_range() {
385        let config = InferenceConfig {
386            temperature: Some(2.5),
387            ..Default::default()
388        };
389        assert!(validate_inference_config(&config).is_err());
390
391        let config = InferenceConfig {
392            temperature: Some(-0.1),
393            ..Default::default()
394        };
395        assert!(validate_inference_config(&config).is_err());
396    }
397
398    #[test]
399    fn test_validate_inference_config_top_p_out_of_range() {
400        let config = InferenceConfig {
401            top_p: Some(1.5),
402            ..Default::default()
403        };
404        assert!(validate_inference_config(&config).is_err());
405
406        let config = InferenceConfig {
407            top_p: Some(-0.1),
408            ..Default::default()
409        };
410        assert!(validate_inference_config(&config).is_err());
411    }
412
413    #[test]
414    fn test_validate_inference_config_negative_values() {
415        let config = InferenceConfig {
416            top_k: Some(-1),
417            ..Default::default()
418        };
419        assert!(validate_inference_config(&config).is_err());
420
421        let config = InferenceConfig {
422            repeat_penalty: Some(0.0),
423            ..Default::default()
424        };
425        assert!(validate_inference_config(&config).is_err());
426    }
427
428    #[test]
429    fn test_settings_with_valid_inference_defaults() {
430        let settings = Settings {
431            inference_defaults: Some(InferenceConfig {
432                temperature: Some(0.8),
433                top_p: Some(0.95),
434                ..Default::default()
435            }),
436            ..Settings::with_defaults()
437        };
438        assert!(validate_settings(&settings).is_ok());
439    }
440
441    #[test]
442    fn test_settings_with_invalid_inference_defaults() {
443        let settings = Settings {
444            inference_defaults: Some(InferenceConfig {
445                temperature: Some(3.0), // Invalid
446                ..Default::default()
447            }),
448            ..Settings::with_defaults()
449        };
450        assert!(validate_settings(&settings).is_err());
451    }
452
453    #[test]
454    fn test_validate_queue_size_too_small() {
455        let settings = Settings {
456            max_download_queue_size: Some(0),
457            ..Default::default()
458        };
459        assert!(matches!(
460            validate_settings(&settings),
461            Err(SettingsError::InvalidQueueSize(0))
462        ));
463    }
464
465    #[test]
466    fn test_validate_queue_size_too_large() {
467        let settings = Settings {
468            max_download_queue_size: Some(100),
469            ..Default::default()
470        };
471        assert!(matches!(
472            validate_settings(&settings),
473            Err(SettingsError::InvalidQueueSize(100))
474        ));
475    }
476
477    #[test]
478    fn test_merge_settings() {
479        let mut settings = Settings::with_defaults();
480        let update = SettingsUpdate {
481            default_context_size: Some(Some(8192)),
482            proxy_port: Some(None), // Clear proxy port
483            ..Default::default()
484        };
485        settings.merge(&update);
486
487        assert_eq!(settings.default_context_size, Some(8192));
488        assert_eq!(settings.proxy_port, None);
489        assert_eq!(settings.llama_base_port, Some(DEFAULT_LLAMA_BASE_PORT)); // Unchanged
490    }
491
492    #[test]
493    fn test_effective_ports() {
494        let settings = Settings::with_defaults();
495        assert_eq!(settings.effective_proxy_port(), DEFAULT_PROXY_PORT);
496        assert_eq!(
497            settings.effective_llama_base_port(),
498            DEFAULT_LLAMA_BASE_PORT
499        );
500
501        let settings_none = Settings::default();
502        assert_eq!(settings_none.effective_proxy_port(), DEFAULT_PROXY_PORT);
503        assert_eq!(
504            settings_none.effective_llama_base_port(),
505            DEFAULT_LLAMA_BASE_PORT
506        );
507    }
508}