Skip to main content

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, InferenceProfile};
9
10/// Default port for the OpenAI-compatible proxy server.
11pub const DEFAULT_PROXY_PORT: u16 = 8080;
12
13/// Fixed loopback port for the gglib daemon's management API.
14///
15/// Deliberately a compile-time constant rather than a setting: the daemon is
16/// the one process every client (CLI, desktop app, browser dashboard) must be
17/// able to find without configuration, and a configurable port would reopen
18/// the "two daemons on different ports" split-brain this constant closes.
19pub const DAEMON_PORT: u16 = 9887;
20
21/// Default base port for llama-server instance allocation.
22pub const DEFAULT_LLAMA_BASE_PORT: u16 = 9000;
23
24/// Default context size for models when not specified by the user.
25pub const DEFAULT_CONTEXT_SIZE: u64 = 4096;
26
27/// Application settings structure.
28///
29/// All fields are optional to support partial updates and graceful defaults.
30#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
31#[serde(default)]
32pub struct Settings {
33    /// Default directory for downloading models.
34    pub default_download_path: Option<String>,
35
36    /// Default context size for models (e.g., 4096, 8192).
37    pub default_context_size: Option<u64>,
38
39    /// Port for the OpenAI-compatible proxy server.
40    pub proxy_port: Option<u16>,
41
42    /// Base port for llama-server instance allocation (first port in range).
43    /// Note: The OpenAI-compatible proxy listens on `proxy_port`.
44    pub llama_base_port: Option<u16>,
45
46    /// Maximum number of downloads that can be queued (1-50).
47    pub max_download_queue_size: Option<u32>,
48
49    /// Whether to show memory fit indicators in `HuggingFace` browser.
50    pub show_memory_fit_indicators: Option<bool>,
51
52    /// Maximum iterations for tool calling agentic loop.
53    pub max_tool_iterations: Option<u32>,
54
55    /// Maximum stagnation steps before stopping agent loop.
56    pub max_stagnation_steps: Option<u32>,
57
58    /// Default model ID for commands that support a default model.
59    pub default_model_id: Option<i64>,
60
61    /// Global inference parameter defaults.
62    ///
63    /// Applied when neither request nor per-model defaults are specified.
64    /// If not set, hardcoded defaults are used as final fallback.
65    #[serde(default)]
66    pub inference_defaults: Option<InferenceConfig>,
67
68    /// Named sampling profiles, selectable per request as `{model}:{profile}`.
69    ///
70    /// Global rather than per-model: one `coding` profile applies to every
71    /// model, and its sparse fields fall through to that model's own
72    /// `inference_defaults` for anything it does not set. See
73    /// [`crate::domain::inference_profile`].
74    #[serde(default)]
75    pub inference_profiles: Option<Vec<InferenceProfile>>,
76
77    // ── Setup wizard ────────────────────────────────────────────────
78    /// Whether the first-run setup wizard has been completed.
79    pub setup_completed: Option<bool>,
80
81    /// Custom prompt template for generating chat titles.
82    pub title_generation_prompt: Option<String>,
83
84    // ── Network binding ─────────────────────────────────────────────
85    /// Override the bind host for `gglib web`.
86    ///
87    /// `None` → use the compiled-in default (`127.0.0.1`). The `--host` flag
88    /// takes precedence for a single run without changing this value.
89    pub bind_host: Option<String>,
90
91    /// Whether `gglib web` binds all LAN interfaces and broadcasts over mDNS.
92    ///
93    /// `None`/`Some(false)` → localhost-only. The `--share-lan` flag can turn
94    /// this on for a single run, but cannot turn it off — clear it here.
95    pub share_lan: Option<bool>,
96
97    /// Bearer token required on the proxy's `/v1/*` and `/mcp` routes.
98    ///
99    /// `None` leaves the endpoint unauthenticated, which is the historical
100    /// behaviour and remains the default for a loopback bind. The proxy mints
101    /// one here automatically the first time it binds a non-loopback host, so
102    /// an endpoint that reaches a network is never left open by omission.
103    ///
104    /// `--api-key` and `GGLIB_API_KEY` override this for a single run without
105    /// changing it. The desktop app reads it from here — that is how the GUI
106    /// dashboard authenticates against the proxy it started.
107    pub proxy_api_key: Option<String>,
108
109    // ── Sampling authority ──────────────────────────────────────────
110    /// Whether a client's own sampling parameters (`temperature`, `top_p`,
111    /// `top_k`, `presence_penalty`, `repeat_penalty`, `min_p`) are honoured
112    /// by the proxy at all.
113    ///
114    /// `None`/`Some(false)` → the client's sampling opinions are dropped from
115    /// the resolution hierarchy entirely; the request falls straight through
116    /// to the profile / per-model / global / floor layers as if the client
117    /// had sent none of them. `max_tokens` is unaffected either way — it is
118    /// a budget, not a taste, and a client that names one still gets it
119    /// honoured; ignoring it would silently truncate that client's own
120    /// turns.
121    ///
122    /// Defaults to distrust because most clients that talk to this proxy
123    /// send fixed sampling values with no user-facing control behind them —
124    /// boilerplate the client always sends, not a deliberate choice by
125    /// whoever is using it (VS Code Copilot's LLM Gateway hardcodes
126    /// `temperature: 0` on every request, for one). Letting that boilerplate
127    /// silently outrank a model's own tuned defaults and this server's
128    /// global settings defeats the point of configuring either. Set `true`
129    /// for a client that does expose real sampling controls to its user
130    /// (`OpenWebUI`'s sliders, for instance).
131    pub trust_client_sampling: Option<bool>,
132
133    // ── Always-on proxy (desktop app) ───────────────────────────────
134    /// Whether the desktop app starts the OpenAI-compatible proxy as soon as
135    /// it launches, rather than waiting for the user to switch it on.
136    ///
137    /// This is what makes the proxy a background service rather than a
138    /// feature you remember to enable: combined with [`Self::start_at_login`]
139    /// and [`Self::close_to_tray`], the endpoint is simply always there for
140    /// clients like VS Code Copilot, with no terminal held open.
141    ///
142    /// Read by the desktop app only. `gglib proxy` and `gglib serve` are
143    /// explicit foreground commands — starting a second proxy underneath them
144    /// would contend for the same port.
145    pub proxy_autostart: Option<bool>,
146
147    /// Whether closing the desktop app's window hides it to the system tray
148    /// instead of quitting.
149    ///
150    /// `None`/`Some(false)` → closing the window shuts the app down, stopping
151    /// the proxy and any running llama-server with it (the historical
152    /// behaviour). `Some(true)` → the window hides and the app keeps serving;
153    /// quitting is then an explicit action from the tray menu.
154    pub close_to_tray: Option<bool>,
155
156    /// Whether the desktop app registers itself to launch on login.
157    ///
158    /// Backed by the OS autostart mechanism for each platform (macOS login
159    /// item, Windows `Run` key, XDG autostart entry on Linux). Toggling this
160    /// registers or unregisters immediately rather than at next launch, so the
161    /// stored value and the OS state cannot drift apart.
162    pub start_at_login: Option<bool>,
163}
164
165impl Settings {
166    /// Create settings with sensible defaults.
167    #[must_use]
168    pub const fn with_defaults() -> Self {
169        Self {
170            default_download_path: None,
171            default_context_size: Some(DEFAULT_CONTEXT_SIZE),
172            proxy_port: Some(DEFAULT_PROXY_PORT),
173            llama_base_port: Some(DEFAULT_LLAMA_BASE_PORT),
174            max_download_queue_size: Some(10),
175            show_memory_fit_indicators: Some(true),
176            #[allow(clippy::cast_possible_truncation)] // compile-time constants, always < u32::MAX
177            max_tool_iterations: Some(crate::domain::agent::DEFAULT_MAX_ITERATIONS as u32),
178            #[allow(clippy::cast_possible_truncation)]
179            max_stagnation_steps: Some(crate::domain::agent::DEFAULT_MAX_STAGNATION_STEPS as u32),
180            default_model_id: None,
181            inference_defaults: None,
182            inference_profiles: None,
183            setup_completed: None,
184            title_generation_prompt: None,
185            bind_host: None,
186            share_lan: None,
187            proxy_api_key: None,
188            trust_client_sampling: None,
189            proxy_autostart: None,
190            close_to_tray: None,
191            start_at_login: None,
192        }
193    }
194
195    /// Get the effective proxy port (with default fallback).
196    #[must_use]
197    pub const fn effective_proxy_port(&self) -> u16 {
198        match self.proxy_port {
199            Some(port) => port,
200            None => DEFAULT_PROXY_PORT,
201        }
202    }
203
204    /// Get the effective llama-server base port (with default fallback).
205    #[must_use]
206    pub const fn effective_llama_base_port(&self) -> u16 {
207        match self.llama_base_port {
208            Some(port) => port,
209            None => DEFAULT_LLAMA_BASE_PORT,
210        }
211    }
212
213    /// Merge another settings into this one, only updating fields that are Some.
214    pub fn merge(&mut self, other: &SettingsUpdate) {
215        if let Some(ref path) = other.default_download_path {
216            self.default_download_path.clone_from(path);
217        }
218        if let Some(ref ctx_size) = other.default_context_size {
219            self.default_context_size = *ctx_size;
220        }
221        if let Some(ref port) = other.proxy_port {
222            self.proxy_port = *port;
223        }
224        if let Some(ref port) = other.llama_base_port {
225            self.llama_base_port = *port;
226        }
227        if let Some(ref queue_size) = other.max_download_queue_size {
228            self.max_download_queue_size = *queue_size;
229        }
230        if let Some(ref show_fit) = other.show_memory_fit_indicators {
231            self.show_memory_fit_indicators = *show_fit;
232        }
233        if let Some(ref iters) = other.max_tool_iterations {
234            self.max_tool_iterations = *iters;
235        }
236        if let Some(ref steps) = other.max_stagnation_steps {
237            self.max_stagnation_steps = *steps;
238        }
239        if let Some(ref model_id) = other.default_model_id {
240            self.default_model_id = *model_id;
241        }
242        if let Some(ref inference_defaults) = other.inference_defaults {
243            self.inference_defaults.clone_from(inference_defaults);
244        }
245        if let Some(ref inference_profiles) = other.inference_profiles {
246            self.inference_profiles.clone_from(inference_profiles);
247        }
248        if let Some(ref v) = other.setup_completed {
249            self.setup_completed = *v;
250        }
251        if let Some(ref v) = other.title_generation_prompt {
252            self.title_generation_prompt.clone_from(v);
253        }
254        if let Some(ref v) = other.bind_host {
255            self.bind_host.clone_from(v);
256        }
257        if let Some(ref v) = other.share_lan {
258            self.share_lan = *v;
259        }
260        if let Some(ref v) = other.proxy_api_key {
261            self.proxy_api_key.clone_from(v);
262        }
263        if let Some(ref v) = other.trust_client_sampling {
264            self.trust_client_sampling = *v;
265        }
266        if let Some(ref v) = other.proxy_autostart {
267            self.proxy_autostart = *v;
268        }
269        if let Some(ref v) = other.close_to_tray {
270            self.close_to_tray = *v;
271        }
272        if let Some(ref v) = other.start_at_login {
273            self.start_at_login = *v;
274        }
275    }
276}
277
278/// Partial settings update.
279///
280/// Each field is `Option<Option<T>>`:
281/// - `None` = don't change this field
282/// - `Some(None)` = set field to None/null
283/// - `Some(Some(value))` = set field to value
284#[derive(Debug, Clone, Default, Serialize, Deserialize)]
285pub struct SettingsUpdate {
286    pub default_download_path: Option<Option<String>>,
287    pub default_context_size: Option<Option<u64>>,
288    pub proxy_port: Option<Option<u16>>,
289    pub llama_base_port: Option<Option<u16>>,
290    pub max_download_queue_size: Option<Option<u32>>,
291    pub show_memory_fit_indicators: Option<Option<bool>>,
292    pub max_tool_iterations: Option<Option<u32>>,
293    pub max_stagnation_steps: Option<Option<u32>>,
294    pub default_model_id: Option<Option<i64>>,
295    pub inference_defaults: Option<Option<InferenceConfig>>,
296    pub inference_profiles: Option<Option<Vec<InferenceProfile>>>,
297    pub setup_completed: Option<Option<bool>>,
298    pub title_generation_prompt: Option<Option<String>>,
299    pub bind_host: Option<Option<String>>,
300    pub share_lan: Option<Option<bool>>,
301    pub proxy_api_key: Option<Option<String>>,
302    pub trust_client_sampling: Option<Option<bool>>,
303    pub proxy_autostart: Option<Option<bool>>,
304    pub close_to_tray: Option<Option<bool>>,
305    pub start_at_login: Option<Option<bool>>,
306}
307
308/// Settings validation error.
309#[derive(Debug, Clone, thiserror::Error)]
310pub enum SettingsError {
311    #[error("Context size must be between 512 and 1,000,000, got {0}")]
312    InvalidContextSize(u64),
313
314    #[error("Port should be >= 1024 (privileged ports require root), got {0}")]
315    InvalidPort(u16),
316
317    #[error("Max download queue size must be between 1 and 50, got {0}")]
318    InvalidQueueSize(u32),
319
320    #[error("Download path cannot be empty")]
321    EmptyDownloadPath,
322
323    #[error("Invalid inference parameter: {0}")]
324    InvalidInferenceConfig(String),
325
326    #[error("Invalid inference profile: {0}")]
327    InvalidInferenceProfile(String),
328
329    #[error("Bind host must be an IP address (e.g. 127.0.0.1 or 0.0.0.0), got '{0}'")]
330    InvalidBindHost(String),
331
332    #[error("Proxy API key cannot be blank — clear it instead to disable authentication")]
333    BlankProxyApiKey,
334}
335
336/// Validate settings values.
337pub fn validate_settings(settings: &Settings) -> Result<(), SettingsError> {
338    // Validate context size
339    if let Some(ctx_size) = settings.default_context_size
340        && !(512..=1_000_000).contains(&ctx_size)
341    {
342        return Err(SettingsError::InvalidContextSize(ctx_size));
343    }
344
345    // Validate proxy port
346    if let Some(port) = settings.proxy_port
347        && port < 1024
348    {
349        return Err(SettingsError::InvalidPort(port));
350    }
351
352    // Validate llama-server base port
353    if let Some(port) = settings.llama_base_port
354        && port < 1024
355    {
356        return Err(SettingsError::InvalidPort(port));
357    }
358
359    // Validate max download queue size
360    if let Some(queue_size) = settings.max_download_queue_size
361        && !(1..=50).contains(&queue_size)
362    {
363        return Err(SettingsError::InvalidQueueSize(queue_size));
364    }
365
366    // Validate download path if specified
367    if settings
368        .default_download_path
369        .as_ref()
370        .is_some_and(|p| p.trim().is_empty())
371    {
372        return Err(SettingsError::EmptyDownloadPath);
373    }
374
375    // Validate the bind host if specified. Requiring a literal IP (rather than
376    // accepting a name) keeps the value unambiguous for both the TCP bind and
377    // the mDNS address record.
378    if let Some(ref host) = settings.bind_host
379        && host.parse::<std::net::IpAddr>().is_err()
380    {
381        return Err(SettingsError::InvalidBindHost(host.clone()));
382    }
383
384    // A stored blank would read as "authentication is on" while accepting
385    // `Bearer ` from anyone. Clearing the field is the way to turn it off.
386    if settings
387        .proxy_api_key
388        .as_ref()
389        .is_some_and(|key| key.trim().is_empty())
390    {
391        return Err(SettingsError::BlankProxyApiKey);
392    }
393
394    // Validate inference defaults if specified
395    if let Some(ref inference_config) = settings.inference_defaults {
396        validate_inference_config(inference_config)
397            .map_err(SettingsError::InvalidInferenceConfig)?;
398    }
399
400    // Validate inference profiles if specified
401    if let Some(ref profiles) = settings.inference_profiles {
402        validate_inference_profiles(profiles).map_err(SettingsError::InvalidInferenceProfile)?;
403    }
404
405    Ok(())
406}
407
408/// Validate a set of inference profiles.
409///
410/// Checks each profile's name against [`crate::domain::validate_name`], rejects
411/// duplicate names (they would make `{model}:{profile}` ambiguous), and reuses
412/// [`validate_inference_config`] for the numeric ranges so profile parameters
413/// and global defaults can never drift apart on what counts as valid.
414///
415/// # Errors
416///
417/// Returns a human-readable description of the first problem found.
418pub fn validate_inference_profiles(profiles: &[InferenceProfile]) -> Result<(), String> {
419    let mut seen: Vec<&str> = Vec::with_capacity(profiles.len());
420
421    for profile in profiles {
422        profile.validate().map_err(|e| e.to_string())?;
423
424        if seen.contains(&profile.name.as_str()) {
425            return Err(format!("duplicate profile name '{}'", profile.name));
426        }
427        seen.push(&profile.name);
428
429        validate_inference_config(&profile.config)
430            .map_err(|e| format!("profile '{}': {e}", profile.name))?;
431    }
432
433    Ok(())
434}
435
436/// Validate inference configuration parameters.
437///
438/// Checks that all specified parameters are within valid ranges.
439pub fn validate_inference_config(config: &InferenceConfig) -> Result<(), String> {
440    // Validate temperature (0.0 - 2.0)
441    if let Some(temp) = config.temperature
442        && !(0.0..=2.0).contains(&temp)
443    {
444        return Err(format!(
445            "Temperature must be between 0.0 and 2.0, got {temp}"
446        ));
447    }
448
449    // Validate top_p (0.0 - 1.0)
450    if let Some(top_p) = config.top_p
451        && !(0.0..=1.0).contains(&top_p)
452    {
453        return Err(format!("Top P must be between 0.0 and 1.0, got {top_p}"));
454    }
455
456    // Validate top_k (must be positive)
457    if let Some(top_k) = config.top_k
458        && top_k <= 0
459    {
460        return Err(format!("Top K must be positive, got {top_k}"));
461    }
462
463    // Validate max_tokens (must be positive)
464    if let Some(max_tokens) = config.max_tokens
465        && max_tokens == 0
466    {
467        return Err("Max tokens must be positive".to_string());
468    }
469
470    // Validate repeat_penalty (must be positive)
471    if let Some(repeat_penalty) = config.repeat_penalty
472        && repeat_penalty <= 0.0
473    {
474        return Err(format!(
475            "Repeat penalty must be positive, got {repeat_penalty}"
476        ));
477    }
478
479    // Validate presence_penalty (0.0 - 2.0)
480    if let Some(pp) = config.presence_penalty
481        && !(0.0..=2.0).contains(&pp)
482    {
483        return Err(format!(
484            "Presence penalty must be between 0.0 and 2.0, got {pp}"
485        ));
486    }
487
488    // Validate min_p (0.0 - 1.0)
489    if let Some(mp) = config.min_p
490        && !(0.0..=1.0).contains(&mp)
491    {
492        return Err(format!("Min P must be between 0.0 and 1.0, got {mp}"));
493    }
494
495    Ok(())
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    #[test]
503    fn test_default_settings() {
504        let settings = Settings::with_defaults();
505        assert_eq!(settings.default_context_size, Some(4096));
506        assert_eq!(settings.proxy_port, Some(DEFAULT_PROXY_PORT));
507        assert_eq!(settings.llama_base_port, Some(DEFAULT_LLAMA_BASE_PORT));
508        assert_eq!(settings.default_download_path, None);
509        assert_eq!(settings.max_download_queue_size, Some(10));
510        assert_eq!(settings.show_memory_fit_indicators, Some(true));
511    }
512
513    #[test]
514    fn test_validate_settings_valid() {
515        let settings = Settings::with_defaults();
516        assert!(validate_settings(&settings).is_ok());
517    }
518
519    #[test]
520    fn test_validate_context_size_too_small() {
521        let settings = Settings {
522            default_context_size: Some(100),
523            ..Default::default()
524        };
525        assert!(matches!(
526            validate_settings(&settings),
527            Err(SettingsError::InvalidContextSize(100))
528        ));
529    }
530
531    #[test]
532    fn test_validate_context_size_too_large() {
533        let settings = Settings {
534            default_context_size: Some(2_000_000),
535            ..Default::default()
536        };
537        assert!(matches!(
538            validate_settings(&settings),
539            Err(SettingsError::InvalidContextSize(2_000_000))
540        ));
541    }
542
543    #[test]
544    fn test_validate_port_too_low() {
545        let settings = Settings {
546            proxy_port: Some(80),
547            ..Default::default()
548        };
549        assert!(matches!(
550            validate_settings(&settings),
551            Err(SettingsError::InvalidPort(80))
552        ));
553    }
554
555    #[test]
556    fn test_validate_empty_path() {
557        let settings = Settings {
558            default_download_path: Some(String::new()),
559            ..Default::default()
560        };
561        assert!(matches!(
562            validate_settings(&settings),
563            Err(SettingsError::EmptyDownloadPath)
564        ));
565    }
566
567    #[test]
568    fn test_validate_inference_config_valid() {
569        let config = InferenceConfig {
570            temperature: Some(0.7),
571            top_p: Some(0.9),
572            top_k: Some(40),
573            max_tokens: Some(2048),
574            repeat_penalty: Some(1.1),
575            presence_penalty: Some(0.0),
576            min_p: Some(0.0),
577        };
578        assert!(validate_inference_config(&config).is_ok());
579    }
580
581    #[test]
582    fn test_validate_inference_config_temperature_out_of_range() {
583        let config = InferenceConfig {
584            temperature: Some(2.5),
585            ..Default::default()
586        };
587        assert!(validate_inference_config(&config).is_err());
588
589        let config = InferenceConfig {
590            temperature: Some(-0.1),
591            ..Default::default()
592        };
593        assert!(validate_inference_config(&config).is_err());
594    }
595
596    #[test]
597    fn test_validate_inference_config_top_p_out_of_range() {
598        let config = InferenceConfig {
599            top_p: Some(1.5),
600            ..Default::default()
601        };
602        assert!(validate_inference_config(&config).is_err());
603
604        let config = InferenceConfig {
605            top_p: Some(-0.1),
606            ..Default::default()
607        };
608        assert!(validate_inference_config(&config).is_err());
609    }
610
611    #[test]
612    fn test_validate_inference_config_negative_values() {
613        let config = InferenceConfig {
614            top_k: Some(-1),
615            ..Default::default()
616        };
617        assert!(validate_inference_config(&config).is_err());
618
619        let config = InferenceConfig {
620            repeat_penalty: Some(0.0),
621            ..Default::default()
622        };
623        assert!(validate_inference_config(&config).is_err());
624    }
625
626    #[test]
627    fn test_settings_with_valid_inference_defaults() {
628        let settings = Settings {
629            inference_defaults: Some(InferenceConfig {
630                temperature: Some(0.8),
631                top_p: Some(0.95),
632                ..Default::default()
633            }),
634            ..Settings::with_defaults()
635        };
636        assert!(validate_settings(&settings).is_ok());
637    }
638
639    #[test]
640    fn test_settings_with_invalid_inference_defaults() {
641        let settings = Settings {
642            inference_defaults: Some(InferenceConfig {
643                temperature: Some(3.0), // Invalid
644                ..Default::default()
645            }),
646            ..Settings::with_defaults()
647        };
648        assert!(validate_settings(&settings).is_err());
649    }
650
651    #[test]
652    fn test_validate_queue_size_too_small() {
653        let settings = Settings {
654            max_download_queue_size: Some(0),
655            ..Default::default()
656        };
657        assert!(matches!(
658            validate_settings(&settings),
659            Err(SettingsError::InvalidQueueSize(0))
660        ));
661    }
662
663    #[test]
664    fn test_validate_queue_size_too_large() {
665        let settings = Settings {
666            max_download_queue_size: Some(100),
667            ..Default::default()
668        };
669        assert!(matches!(
670            validate_settings(&settings),
671            Err(SettingsError::InvalidQueueSize(100))
672        ));
673    }
674
675    #[test]
676    fn test_merge_settings() {
677        let mut settings = Settings::with_defaults();
678        let update = SettingsUpdate {
679            default_context_size: Some(Some(8192)),
680            proxy_port: Some(None), // Clear proxy port
681            ..Default::default()
682        };
683        settings.merge(&update);
684
685        assert_eq!(settings.default_context_size, Some(8192));
686        assert_eq!(settings.proxy_port, None);
687        assert_eq!(settings.llama_base_port, Some(DEFAULT_LLAMA_BASE_PORT)); // Unchanged
688    }
689
690    #[test]
691    fn test_trust_client_sampling_defaults_to_none_and_merges_like_any_bool_setting() {
692        let defaults = Settings::with_defaults();
693        assert_eq!(defaults.trust_client_sampling, None);
694
695        let mut settings = Settings::with_defaults();
696        settings.merge(&SettingsUpdate {
697            trust_client_sampling: Some(Some(true)),
698            ..Default::default()
699        });
700        assert_eq!(settings.trust_client_sampling, Some(true));
701
702        settings.merge(&SettingsUpdate {
703            trust_client_sampling: Some(None),
704            ..Default::default()
705        });
706        assert_eq!(settings.trust_client_sampling, None);
707    }
708
709    #[test]
710    fn test_effective_ports() {
711        let settings = Settings::with_defaults();
712        assert_eq!(settings.effective_proxy_port(), DEFAULT_PROXY_PORT);
713        assert_eq!(
714            settings.effective_llama_base_port(),
715            DEFAULT_LLAMA_BASE_PORT
716        );
717
718        let settings_none = Settings::default();
719        assert_eq!(settings_none.effective_proxy_port(), DEFAULT_PROXY_PORT);
720        assert_eq!(
721            settings_none.effective_llama_base_port(),
722            DEFAULT_LLAMA_BASE_PORT
723        );
724    }
725
726    // ── Inference profiles ──────────────────────────────────────────────
727
728    fn profile(name: &str, temperature: f32) -> InferenceProfile {
729        InferenceProfile {
730            name: name.to_owned(),
731            description: None,
732            config: InferenceConfig {
733                temperature: Some(temperature),
734                ..Default::default()
735            },
736            list_in_models: false,
737        }
738    }
739
740    #[test]
741    fn test_builtin_templates_pass_settings_validation() {
742        let settings = Settings {
743            inference_profiles: Some(crate::domain::builtin_templates()),
744            ..Settings::with_defaults()
745        };
746        assert!(validate_settings(&settings).is_ok());
747    }
748
749    #[test]
750    fn test_validate_profiles_rejects_duplicate_names() {
751        let err = validate_inference_profiles(&[profile("coding", 0.2), profile("coding", 0.9)])
752            .expect_err("duplicates must be rejected");
753        assert!(err.contains("duplicate"), "unexpected message: {err}");
754        assert!(err.contains("coding"), "message should name the profile");
755    }
756
757    #[test]
758    fn test_validate_profiles_rejects_invalid_name() {
759        let err = validate_inference_profiles(&[profile("Not_A_Slug", 0.5)])
760            .expect_err("invalid slug must be rejected");
761        assert!(err.contains("Not_A_Slug"), "unexpected message: {err}");
762    }
763
764    /// Profile parameters go through the same range checks as global
765    /// defaults, and the failure names which profile was at fault.
766    #[test]
767    fn test_validate_profiles_reuses_inference_config_ranges() {
768        let err = validate_inference_profiles(&[profile("coding", 5.0)])
769            .expect_err("out-of-range temperature must be rejected");
770        assert!(err.contains("coding"), "message should name the profile");
771        assert!(err.contains("Temperature"), "unexpected message: {err}");
772    }
773
774    #[test]
775    fn test_settings_with_invalid_profile_fails_validation() {
776        let settings = Settings {
777            inference_profiles: Some(vec![profile("coding", 5.0)]),
778            ..Settings::with_defaults()
779        };
780        assert!(matches!(
781            validate_settings(&settings),
782            Err(SettingsError::InvalidInferenceProfile(_))
783        ));
784    }
785
786    #[test]
787    fn test_merge_replaces_and_clears_profiles() {
788        let mut settings = Settings {
789            inference_profiles: Some(vec![profile("coding", 0.2)]),
790            ..Settings::with_defaults()
791        };
792
793        settings.merge(&SettingsUpdate {
794            inference_profiles: Some(Some(vec![profile("chat", 0.7)])),
795            ..Default::default()
796        });
797        let profiles = settings.inference_profiles.as_ref().expect("still set");
798        assert_eq!(profiles.len(), 1);
799        assert_eq!(profiles[0].name, "chat");
800
801        settings.merge(&SettingsUpdate {
802            inference_profiles: Some(None),
803            ..Default::default()
804        });
805        assert_eq!(settings.inference_profiles, None);
806
807        // An absent field must leave the current value alone.
808        settings.inference_profiles = Some(vec![profile("coding", 0.2)]);
809        settings.merge(&SettingsUpdate::default());
810        assert!(settings.inference_profiles.is_some());
811    }
812
813    /// The repository stores one KV row per serde field and rebuilds
814    /// `Settings` from whatever rows exist, so an older row set (no profiles
815    /// row) must still deserialize.
816    #[test]
817    fn test_profiles_round_trip_through_json_and_default_when_absent() {
818        let settings = Settings {
819            inference_profiles: Some(vec![profile("coding", 0.2)]),
820            ..Settings::with_defaults()
821        };
822        let value = serde_json::to_value(&settings).expect("serializes");
823        assert!(value.get("inference_profiles").is_some());
824
825        let restored: Settings = serde_json::from_value(value).expect("round-trips");
826        assert_eq!(restored.inference_profiles, settings.inference_profiles);
827
828        let absent: Settings = serde_json::from_str("{}").expect("deserializes without the field");
829        assert_eq!(absent.inference_profiles, None);
830    }
831}