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/// 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    /// Named sampling profiles, selectable per request as `{model}:{profile}`.
61    ///
62    /// Global rather than per-model: one `coding` profile applies to every
63    /// model, and its sparse fields fall through to that model's own
64    /// `inference_defaults` for anything it does not set. See
65    /// [`crate::domain::inference_profile`].
66    #[serde(default)]
67    pub inference_profiles: Option<Vec<InferenceProfile>>,
68
69    // ── Setup wizard ────────────────────────────────────────────────
70    /// Whether the first-run setup wizard has been completed.
71    pub setup_completed: Option<bool>,
72
73    /// Custom prompt template for generating chat titles.
74    pub title_generation_prompt: Option<String>,
75
76    // ── Network binding ─────────────────────────────────────────────
77    /// Override the bind host for `gglib web`.
78    ///
79    /// `None` → use the compiled-in default (`127.0.0.1`). The `--host` flag
80    /// takes precedence for a single run without changing this value.
81    pub bind_host: Option<String>,
82
83    /// Whether `gglib web` binds all LAN interfaces and broadcasts over mDNS.
84    ///
85    /// `None`/`Some(false)` → localhost-only. The `--share-lan` flag can turn
86    /// this on for a single run, but cannot turn it off — clear it here.
87    pub share_lan: Option<bool>,
88
89    // ── Sampling authority ──────────────────────────────────────────
90    /// Whether a client's own sampling parameters (`temperature`, `top_p`,
91    /// `top_k`, `presence_penalty`, `repeat_penalty`, `min_p`) are honoured
92    /// by the proxy at all.
93    ///
94    /// `None`/`Some(false)` → the client's sampling opinions are dropped from
95    /// the resolution hierarchy entirely; the request falls straight through
96    /// to the profile / per-model / global / floor layers as if the client
97    /// had sent none of them. `max_tokens` is unaffected either way — it is
98    /// a budget, not a taste, and a client that names one still gets it
99    /// honoured; ignoring it would silently truncate that client's own
100    /// turns.
101    ///
102    /// Defaults to distrust because most clients that talk to this proxy
103    /// send fixed sampling values with no user-facing control behind them —
104    /// boilerplate the client always sends, not a deliberate choice by
105    /// whoever is using it (VS Code Copilot's LLM Gateway hardcodes
106    /// `temperature: 0` on every request, for one). Letting that boilerplate
107    /// silently outrank a model's own tuned defaults and this server's
108    /// global settings defeats the point of configuring either. Set `true`
109    /// for a client that does expose real sampling controls to its user
110    /// (`OpenWebUI`'s sliders, for instance).
111    pub trust_client_sampling: Option<bool>,
112}
113
114impl Settings {
115    /// Create settings with sensible defaults.
116    #[must_use]
117    pub const fn with_defaults() -> Self {
118        Self {
119            default_download_path: None,
120            default_context_size: Some(DEFAULT_CONTEXT_SIZE),
121            proxy_port: Some(DEFAULT_PROXY_PORT),
122            llama_base_port: Some(DEFAULT_LLAMA_BASE_PORT),
123            max_download_queue_size: Some(10),
124            show_memory_fit_indicators: Some(true),
125            #[allow(clippy::cast_possible_truncation)] // compile-time constants, always < u32::MAX
126            max_tool_iterations: Some(crate::domain::agent::DEFAULT_MAX_ITERATIONS as u32),
127            #[allow(clippy::cast_possible_truncation)]
128            max_stagnation_steps: Some(crate::domain::agent::DEFAULT_MAX_STAGNATION_STEPS as u32),
129            default_model_id: None,
130            inference_defaults: None,
131            inference_profiles: None,
132            setup_completed: None,
133            title_generation_prompt: None,
134            bind_host: None,
135            share_lan: None,
136            trust_client_sampling: None,
137        }
138    }
139
140    /// Get the effective proxy port (with default fallback).
141    #[must_use]
142    pub const fn effective_proxy_port(&self) -> u16 {
143        match self.proxy_port {
144            Some(port) => port,
145            None => DEFAULT_PROXY_PORT,
146        }
147    }
148
149    /// Get the effective llama-server base port (with default fallback).
150    #[must_use]
151    pub const fn effective_llama_base_port(&self) -> u16 {
152        match self.llama_base_port {
153            Some(port) => port,
154            None => DEFAULT_LLAMA_BASE_PORT,
155        }
156    }
157
158    /// Merge another settings into this one, only updating fields that are Some.
159    pub fn merge(&mut self, other: &SettingsUpdate) {
160        if let Some(ref path) = other.default_download_path {
161            self.default_download_path.clone_from(path);
162        }
163        if let Some(ref ctx_size) = other.default_context_size {
164            self.default_context_size = *ctx_size;
165        }
166        if let Some(ref port) = other.proxy_port {
167            self.proxy_port = *port;
168        }
169        if let Some(ref port) = other.llama_base_port {
170            self.llama_base_port = *port;
171        }
172        if let Some(ref queue_size) = other.max_download_queue_size {
173            self.max_download_queue_size = *queue_size;
174        }
175        if let Some(ref show_fit) = other.show_memory_fit_indicators {
176            self.show_memory_fit_indicators = *show_fit;
177        }
178        if let Some(ref iters) = other.max_tool_iterations {
179            self.max_tool_iterations = *iters;
180        }
181        if let Some(ref steps) = other.max_stagnation_steps {
182            self.max_stagnation_steps = *steps;
183        }
184        if let Some(ref model_id) = other.default_model_id {
185            self.default_model_id = *model_id;
186        }
187        if let Some(ref inference_defaults) = other.inference_defaults {
188            self.inference_defaults.clone_from(inference_defaults);
189        }
190        if let Some(ref inference_profiles) = other.inference_profiles {
191            self.inference_profiles.clone_from(inference_profiles);
192        }
193        if let Some(ref v) = other.setup_completed {
194            self.setup_completed = *v;
195        }
196        if let Some(ref v) = other.title_generation_prompt {
197            self.title_generation_prompt.clone_from(v);
198        }
199        if let Some(ref v) = other.bind_host {
200            self.bind_host.clone_from(v);
201        }
202        if let Some(ref v) = other.share_lan {
203            self.share_lan = *v;
204        }
205        if let Some(ref v) = other.trust_client_sampling {
206            self.trust_client_sampling = *v;
207        }
208    }
209}
210
211/// Partial settings update.
212///
213/// Each field is `Option<Option<T>>`:
214/// - `None` = don't change this field
215/// - `Some(None)` = set field to None/null
216/// - `Some(Some(value))` = set field to value
217#[derive(Debug, Clone, Default, Serialize, Deserialize)]
218pub struct SettingsUpdate {
219    pub default_download_path: Option<Option<String>>,
220    pub default_context_size: Option<Option<u64>>,
221    pub proxy_port: Option<Option<u16>>,
222    pub llama_base_port: Option<Option<u16>>,
223    pub max_download_queue_size: Option<Option<u32>>,
224    pub show_memory_fit_indicators: Option<Option<bool>>,
225    pub max_tool_iterations: Option<Option<u32>>,
226    pub max_stagnation_steps: Option<Option<u32>>,
227    pub default_model_id: Option<Option<i64>>,
228    pub inference_defaults: Option<Option<InferenceConfig>>,
229    pub inference_profiles: Option<Option<Vec<InferenceProfile>>>,
230    pub setup_completed: Option<Option<bool>>,
231    pub title_generation_prompt: Option<Option<String>>,
232    pub bind_host: Option<Option<String>>,
233    pub share_lan: Option<Option<bool>>,
234    pub trust_client_sampling: Option<Option<bool>>,
235}
236
237/// Settings validation error.
238#[derive(Debug, Clone, thiserror::Error)]
239pub enum SettingsError {
240    #[error("Context size must be between 512 and 1,000,000, got {0}")]
241    InvalidContextSize(u64),
242
243    #[error("Port should be >= 1024 (privileged ports require root), got {0}")]
244    InvalidPort(u16),
245
246    #[error("Max download queue size must be between 1 and 50, got {0}")]
247    InvalidQueueSize(u32),
248
249    #[error("Download path cannot be empty")]
250    EmptyDownloadPath,
251
252    #[error("Invalid inference parameter: {0}")]
253    InvalidInferenceConfig(String),
254
255    #[error("Invalid inference profile: {0}")]
256    InvalidInferenceProfile(String),
257
258    #[error("Bind host must be an IP address (e.g. 127.0.0.1 or 0.0.0.0), got '{0}'")]
259    InvalidBindHost(String),
260}
261
262/// Validate settings values.
263pub fn validate_settings(settings: &Settings) -> Result<(), SettingsError> {
264    // Validate context size
265    if let Some(ctx_size) = settings.default_context_size
266        && !(512..=1_000_000).contains(&ctx_size)
267    {
268        return Err(SettingsError::InvalidContextSize(ctx_size));
269    }
270
271    // Validate proxy port
272    if let Some(port) = settings.proxy_port
273        && port < 1024
274    {
275        return Err(SettingsError::InvalidPort(port));
276    }
277
278    // Validate llama-server base port
279    if let Some(port) = settings.llama_base_port
280        && port < 1024
281    {
282        return Err(SettingsError::InvalidPort(port));
283    }
284
285    // Validate max download queue size
286    if let Some(queue_size) = settings.max_download_queue_size
287        && !(1..=50).contains(&queue_size)
288    {
289        return Err(SettingsError::InvalidQueueSize(queue_size));
290    }
291
292    // Validate download path if specified
293    if settings
294        .default_download_path
295        .as_ref()
296        .is_some_and(|p| p.trim().is_empty())
297    {
298        return Err(SettingsError::EmptyDownloadPath);
299    }
300
301    // Validate the bind host if specified. Requiring a literal IP (rather than
302    // accepting a name) keeps the value unambiguous for both the TCP bind and
303    // the mDNS address record.
304    if let Some(ref host) = settings.bind_host
305        && host.parse::<std::net::IpAddr>().is_err()
306    {
307        return Err(SettingsError::InvalidBindHost(host.clone()));
308    }
309
310    // Validate inference defaults if specified
311    if let Some(ref inference_config) = settings.inference_defaults {
312        validate_inference_config(inference_config)
313            .map_err(SettingsError::InvalidInferenceConfig)?;
314    }
315
316    // Validate inference profiles if specified
317    if let Some(ref profiles) = settings.inference_profiles {
318        validate_inference_profiles(profiles).map_err(SettingsError::InvalidInferenceProfile)?;
319    }
320
321    Ok(())
322}
323
324/// Validate a set of inference profiles.
325///
326/// Checks each profile's name against [`crate::domain::validate_name`], rejects
327/// duplicate names (they would make `{model}:{profile}` ambiguous), and reuses
328/// [`validate_inference_config`] for the numeric ranges so profile parameters
329/// and global defaults can never drift apart on what counts as valid.
330///
331/// # Errors
332///
333/// Returns a human-readable description of the first problem found.
334pub fn validate_inference_profiles(profiles: &[InferenceProfile]) -> Result<(), String> {
335    let mut seen: Vec<&str> = Vec::with_capacity(profiles.len());
336
337    for profile in profiles {
338        profile.validate().map_err(|e| e.to_string())?;
339
340        if seen.contains(&profile.name.as_str()) {
341            return Err(format!("duplicate profile name '{}'", profile.name));
342        }
343        seen.push(&profile.name);
344
345        validate_inference_config(&profile.config)
346            .map_err(|e| format!("profile '{}': {e}", profile.name))?;
347    }
348
349    Ok(())
350}
351
352/// Validate inference configuration parameters.
353///
354/// Checks that all specified parameters are within valid ranges.
355pub fn validate_inference_config(config: &InferenceConfig) -> Result<(), String> {
356    // Validate temperature (0.0 - 2.0)
357    if let Some(temp) = config.temperature
358        && !(0.0..=2.0).contains(&temp)
359    {
360        return Err(format!(
361            "Temperature must be between 0.0 and 2.0, got {temp}"
362        ));
363    }
364
365    // Validate top_p (0.0 - 1.0)
366    if let Some(top_p) = config.top_p
367        && !(0.0..=1.0).contains(&top_p)
368    {
369        return Err(format!("Top P must be between 0.0 and 1.0, got {top_p}"));
370    }
371
372    // Validate top_k (must be positive)
373    if let Some(top_k) = config.top_k
374        && top_k <= 0
375    {
376        return Err(format!("Top K must be positive, got {top_k}"));
377    }
378
379    // Validate max_tokens (must be positive)
380    if let Some(max_tokens) = config.max_tokens
381        && max_tokens == 0
382    {
383        return Err("Max tokens must be positive".to_string());
384    }
385
386    // Validate repeat_penalty (must be positive)
387    if let Some(repeat_penalty) = config.repeat_penalty
388        && repeat_penalty <= 0.0
389    {
390        return Err(format!(
391            "Repeat penalty must be positive, got {repeat_penalty}"
392        ));
393    }
394
395    // Validate presence_penalty (0.0 - 2.0)
396    if let Some(pp) = config.presence_penalty
397        && !(0.0..=2.0).contains(&pp)
398    {
399        return Err(format!(
400            "Presence penalty must be between 0.0 and 2.0, got {pp}"
401        ));
402    }
403
404    // Validate min_p (0.0 - 1.0)
405    if let Some(mp) = config.min_p
406        && !(0.0..=1.0).contains(&mp)
407    {
408        return Err(format!("Min P must be between 0.0 and 1.0, got {mp}"));
409    }
410
411    Ok(())
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    #[test]
419    fn test_default_settings() {
420        let settings = Settings::with_defaults();
421        assert_eq!(settings.default_context_size, Some(4096));
422        assert_eq!(settings.proxy_port, Some(DEFAULT_PROXY_PORT));
423        assert_eq!(settings.llama_base_port, Some(DEFAULT_LLAMA_BASE_PORT));
424        assert_eq!(settings.default_download_path, None);
425        assert_eq!(settings.max_download_queue_size, Some(10));
426        assert_eq!(settings.show_memory_fit_indicators, Some(true));
427    }
428
429    #[test]
430    fn test_validate_settings_valid() {
431        let settings = Settings::with_defaults();
432        assert!(validate_settings(&settings).is_ok());
433    }
434
435    #[test]
436    fn test_validate_context_size_too_small() {
437        let settings = Settings {
438            default_context_size: Some(100),
439            ..Default::default()
440        };
441        assert!(matches!(
442            validate_settings(&settings),
443            Err(SettingsError::InvalidContextSize(100))
444        ));
445    }
446
447    #[test]
448    fn test_validate_context_size_too_large() {
449        let settings = Settings {
450            default_context_size: Some(2_000_000),
451            ..Default::default()
452        };
453        assert!(matches!(
454            validate_settings(&settings),
455            Err(SettingsError::InvalidContextSize(2_000_000))
456        ));
457    }
458
459    #[test]
460    fn test_validate_port_too_low() {
461        let settings = Settings {
462            proxy_port: Some(80),
463            ..Default::default()
464        };
465        assert!(matches!(
466            validate_settings(&settings),
467            Err(SettingsError::InvalidPort(80))
468        ));
469    }
470
471    #[test]
472    fn test_validate_empty_path() {
473        let settings = Settings {
474            default_download_path: Some(String::new()),
475            ..Default::default()
476        };
477        assert!(matches!(
478            validate_settings(&settings),
479            Err(SettingsError::EmptyDownloadPath)
480        ));
481    }
482
483    #[test]
484    fn test_validate_inference_config_valid() {
485        let config = InferenceConfig {
486            temperature: Some(0.7),
487            top_p: Some(0.9),
488            top_k: Some(40),
489            max_tokens: Some(2048),
490            repeat_penalty: Some(1.1),
491            presence_penalty: Some(0.0),
492            min_p: Some(0.0),
493        };
494        assert!(validate_inference_config(&config).is_ok());
495    }
496
497    #[test]
498    fn test_validate_inference_config_temperature_out_of_range() {
499        let config = InferenceConfig {
500            temperature: Some(2.5),
501            ..Default::default()
502        };
503        assert!(validate_inference_config(&config).is_err());
504
505        let config = InferenceConfig {
506            temperature: Some(-0.1),
507            ..Default::default()
508        };
509        assert!(validate_inference_config(&config).is_err());
510    }
511
512    #[test]
513    fn test_validate_inference_config_top_p_out_of_range() {
514        let config = InferenceConfig {
515            top_p: Some(1.5),
516            ..Default::default()
517        };
518        assert!(validate_inference_config(&config).is_err());
519
520        let config = InferenceConfig {
521            top_p: Some(-0.1),
522            ..Default::default()
523        };
524        assert!(validate_inference_config(&config).is_err());
525    }
526
527    #[test]
528    fn test_validate_inference_config_negative_values() {
529        let config = InferenceConfig {
530            top_k: Some(-1),
531            ..Default::default()
532        };
533        assert!(validate_inference_config(&config).is_err());
534
535        let config = InferenceConfig {
536            repeat_penalty: Some(0.0),
537            ..Default::default()
538        };
539        assert!(validate_inference_config(&config).is_err());
540    }
541
542    #[test]
543    fn test_settings_with_valid_inference_defaults() {
544        let settings = Settings {
545            inference_defaults: Some(InferenceConfig {
546                temperature: Some(0.8),
547                top_p: Some(0.95),
548                ..Default::default()
549            }),
550            ..Settings::with_defaults()
551        };
552        assert!(validate_settings(&settings).is_ok());
553    }
554
555    #[test]
556    fn test_settings_with_invalid_inference_defaults() {
557        let settings = Settings {
558            inference_defaults: Some(InferenceConfig {
559                temperature: Some(3.0), // Invalid
560                ..Default::default()
561            }),
562            ..Settings::with_defaults()
563        };
564        assert!(validate_settings(&settings).is_err());
565    }
566
567    #[test]
568    fn test_validate_queue_size_too_small() {
569        let settings = Settings {
570            max_download_queue_size: Some(0),
571            ..Default::default()
572        };
573        assert!(matches!(
574            validate_settings(&settings),
575            Err(SettingsError::InvalidQueueSize(0))
576        ));
577    }
578
579    #[test]
580    fn test_validate_queue_size_too_large() {
581        let settings = Settings {
582            max_download_queue_size: Some(100),
583            ..Default::default()
584        };
585        assert!(matches!(
586            validate_settings(&settings),
587            Err(SettingsError::InvalidQueueSize(100))
588        ));
589    }
590
591    #[test]
592    fn test_merge_settings() {
593        let mut settings = Settings::with_defaults();
594        let update = SettingsUpdate {
595            default_context_size: Some(Some(8192)),
596            proxy_port: Some(None), // Clear proxy port
597            ..Default::default()
598        };
599        settings.merge(&update);
600
601        assert_eq!(settings.default_context_size, Some(8192));
602        assert_eq!(settings.proxy_port, None);
603        assert_eq!(settings.llama_base_port, Some(DEFAULT_LLAMA_BASE_PORT)); // Unchanged
604    }
605
606    #[test]
607    fn test_trust_client_sampling_defaults_to_none_and_merges_like_any_bool_setting() {
608        let defaults = Settings::with_defaults();
609        assert_eq!(defaults.trust_client_sampling, None);
610
611        let mut settings = Settings::with_defaults();
612        settings.merge(&SettingsUpdate {
613            trust_client_sampling: Some(Some(true)),
614            ..Default::default()
615        });
616        assert_eq!(settings.trust_client_sampling, Some(true));
617
618        settings.merge(&SettingsUpdate {
619            trust_client_sampling: Some(None),
620            ..Default::default()
621        });
622        assert_eq!(settings.trust_client_sampling, None);
623    }
624
625    #[test]
626    fn test_effective_ports() {
627        let settings = Settings::with_defaults();
628        assert_eq!(settings.effective_proxy_port(), DEFAULT_PROXY_PORT);
629        assert_eq!(
630            settings.effective_llama_base_port(),
631            DEFAULT_LLAMA_BASE_PORT
632        );
633
634        let settings_none = Settings::default();
635        assert_eq!(settings_none.effective_proxy_port(), DEFAULT_PROXY_PORT);
636        assert_eq!(
637            settings_none.effective_llama_base_port(),
638            DEFAULT_LLAMA_BASE_PORT
639        );
640    }
641
642    // ── Inference profiles ──────────────────────────────────────────────
643
644    fn profile(name: &str, temperature: f32) -> InferenceProfile {
645        InferenceProfile {
646            name: name.to_owned(),
647            description: None,
648            config: InferenceConfig {
649                temperature: Some(temperature),
650                ..Default::default()
651            },
652            list_in_models: false,
653        }
654    }
655
656    #[test]
657    fn test_builtin_templates_pass_settings_validation() {
658        let settings = Settings {
659            inference_profiles: Some(crate::domain::builtin_templates()),
660            ..Settings::with_defaults()
661        };
662        assert!(validate_settings(&settings).is_ok());
663    }
664
665    #[test]
666    fn test_validate_profiles_rejects_duplicate_names() {
667        let err = validate_inference_profiles(&[profile("coding", 0.2), profile("coding", 0.9)])
668            .expect_err("duplicates must be rejected");
669        assert!(err.contains("duplicate"), "unexpected message: {err}");
670        assert!(err.contains("coding"), "message should name the profile");
671    }
672
673    #[test]
674    fn test_validate_profiles_rejects_invalid_name() {
675        let err = validate_inference_profiles(&[profile("Not_A_Slug", 0.5)])
676            .expect_err("invalid slug must be rejected");
677        assert!(err.contains("Not_A_Slug"), "unexpected message: {err}");
678    }
679
680    /// Profile parameters go through the same range checks as global
681    /// defaults, and the failure names which profile was at fault.
682    #[test]
683    fn test_validate_profiles_reuses_inference_config_ranges() {
684        let err = validate_inference_profiles(&[profile("coding", 5.0)])
685            .expect_err("out-of-range temperature must be rejected");
686        assert!(err.contains("coding"), "message should name the profile");
687        assert!(err.contains("Temperature"), "unexpected message: {err}");
688    }
689
690    #[test]
691    fn test_settings_with_invalid_profile_fails_validation() {
692        let settings = Settings {
693            inference_profiles: Some(vec![profile("coding", 5.0)]),
694            ..Settings::with_defaults()
695        };
696        assert!(matches!(
697            validate_settings(&settings),
698            Err(SettingsError::InvalidInferenceProfile(_))
699        ));
700    }
701
702    #[test]
703    fn test_merge_replaces_and_clears_profiles() {
704        let mut settings = Settings {
705            inference_profiles: Some(vec![profile("coding", 0.2)]),
706            ..Settings::with_defaults()
707        };
708
709        settings.merge(&SettingsUpdate {
710            inference_profiles: Some(Some(vec![profile("chat", 0.7)])),
711            ..Default::default()
712        });
713        let profiles = settings.inference_profiles.as_ref().expect("still set");
714        assert_eq!(profiles.len(), 1);
715        assert_eq!(profiles[0].name, "chat");
716
717        settings.merge(&SettingsUpdate {
718            inference_profiles: Some(None),
719            ..Default::default()
720        });
721        assert_eq!(settings.inference_profiles, None);
722
723        // An absent field must leave the current value alone.
724        settings.inference_profiles = Some(vec![profile("coding", 0.2)]);
725        settings.merge(&SettingsUpdate::default());
726        assert!(settings.inference_profiles.is_some());
727    }
728
729    /// The repository stores one KV row per serde field and rebuilds
730    /// `Settings` from whatever rows exist, so an older row set (no profiles
731    /// row) must still deserialize.
732    #[test]
733    fn test_profiles_round_trip_through_json_and_default_when_absent() {
734        let settings = Settings {
735            inference_profiles: Some(vec![profile("coding", 0.2)]),
736            ..Settings::with_defaults()
737        };
738        let value = serde_json::to_value(&settings).expect("serializes");
739        assert!(value.get("inference_profiles").is_some());
740
741        let restored: Settings = serde_json::from_value(value).expect("round-trips");
742        assert_eq!(restored.inference_profiles, settings.inference_profiles);
743
744        let absent: Settings = serde_json::from_str("{}").expect("deserializes without the field");
745        assert_eq!(absent.inference_profiles, None);
746    }
747}