Skip to main content

ragrig_gpui/rag/
config.rs

1//! Editable Ragrig configuration for the settings page and profiles.
2
3use std::{
4    collections::{BTreeMap, BTreeSet},
5    fs,
6    path::{Path, PathBuf},
7};
8
9use ragrig::{
10    ChatConfig, EmbedConfig, GenerationParams, MemoryConfig, MemoryStrategyKind, ParseConfig,
11    PdfParserBackend, RagrigConfig,
12    agents::{DEFAULT_LLAMACPP_MODEL, resolve_llamacpp_base_url},
13    types::{EmbeddingProvider, Provider},
14};
15
16/// The GROBID URL used when no server URL has been configured.
17pub const DEFAULT_GROBID_URL: &str = "http://localhost:8070";
18
19/// The default managed GROBID image: all CRF and Deep Learning models.
20pub const DEFAULT_GROBID_IMAGE: &str = "grobid/grobid:0.9.1-full";
21
22/// The default managed Ollama image.
23pub const DEFAULT_OLLAMA_IMAGE: &str = "ollama/ollama:latest";
24
25/// The default managed llama.cpp server image.
26pub const DEFAULT_LLAMACPP_IMAGE: &str = "ghcr.io/ggml-org/llama.cpp:server";
27
28/// The container name Scico creates when it manages GROBID itself.
29pub const DEFAULT_GROBID_CONTAINER: &str = "ragrig-grobid";
30
31/// The container name Scico creates when it manages Ollama itself.
32pub const DEFAULT_OLLAMA_CONTAINER: &str = "ragrig-ollama";
33
34/// The container name Scico creates when it manages llama.cpp itself.
35pub const DEFAULT_LLAMACPP_CONTAINER: &str = "ragrig-llamacpp";
36
37/// Chat backend offered by the settings page.
38#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
39pub enum ChatProvider {
40    /// Local Ollama server.
41    #[default]
42    Ollama,
43    /// Local llama.cpp `llama-server`; optional key from `LLAMACPP_API_KEY`.
44    LlamaCpp,
45    /// DeepSeek cloud API; the key comes from `DEEPSEEK_API_KEY`.
46    DeepSeek,
47    /// OpenAI API; the key comes from `OPENAI_API_KEY`.
48    OpenAi,
49    /// Anthropic Claude API; the key comes from `ANTHROPIC_API_KEY`.
50    Claude,
51    /// Mistral API; the key comes from `MISTRAL_API_KEY`.
52    Mistral,
53    /// OpenRouter API; the key comes from `OPENROUTER_API_KEY`.
54    OpenRouter,
55}
56
57impl ChatProvider {
58    /// Human-readable name.
59    pub fn label(self) -> &'static str {
60        match self {
61            Self::Ollama => "Ollama",
62            Self::LlamaCpp => "llama.cpp",
63            Self::DeepSeek => "DeepSeek",
64            Self::OpenAi => "OpenAI",
65            Self::Claude => "Claude",
66            Self::Mistral => "Mistral",
67            Self::OpenRouter => "OpenRouter",
68        }
69    }
70
71    /// The environment variable holding this provider's API key.
72    ///
73    /// `None` for the local Ollama server.  `llama.cpp` authenticates
74    /// optionally, so its variable is only needed for a secured server.
75    pub fn env_var(self) -> Option<&'static str> {
76        match self {
77            Self::Ollama => None,
78            Self::LlamaCpp => Some("LLAMACPP_API_KEY"),
79            Self::DeepSeek => Some("DEEPSEEK_API_KEY"),
80            Self::OpenAi => Some("OPENAI_API_KEY"),
81            Self::Claude => Some("ANTHROPIC_API_KEY"),
82            Self::Mistral => Some("MISTRAL_API_KEY"),
83            Self::OpenRouter => Some("OPENROUTER_API_KEY"),
84        }
85    }
86
87    /// The model used when the settings hold no explicit choice.
88    pub fn default_model(self) -> &'static str {
89        match self {
90            Self::Ollama => "qwen3.5:9b",
91            Self::LlamaCpp => DEFAULT_LLAMACPP_MODEL,
92            Self::DeepSeek => "deepseek-v4-pro",
93            Self::OpenAi => "gpt-4o",
94            Self::Claude => "claude-sonnet-4-6",
95            Self::Mistral => "mistral-large-latest",
96            Self::OpenRouter => "openrouter/auto",
97        }
98    }
99
100    /// Options for a dropdown.
101    pub fn options() -> &'static [(Self, &'static str)] {
102        &[
103            (Self::Ollama, "Ollama"),
104            (Self::LlamaCpp, "llama.cpp"),
105            (Self::DeepSeek, "DeepSeek"),
106            (Self::OpenAi, "OpenAI"),
107            (Self::Claude, "Claude"),
108            (Self::Mistral, "Mistral"),
109            (Self::OpenRouter, "OpenRouter"),
110        ]
111    }
112
113    /// Find the provider with the given dropdown label.
114    pub fn from_label(label: &str) -> Option<Self> {
115        Self::options()
116            .iter()
117            .find(|(_, known)| *known == label)
118            .map(|(provider, _)| *provider)
119    }
120}
121
122impl From<ChatProvider> for Provider {
123    fn from(provider: ChatProvider) -> Self {
124        match provider {
125            ChatProvider::Ollama => Self::Ollama,
126            ChatProvider::LlamaCpp => Self::LlamaCpp,
127            ChatProvider::DeepSeek => Self::Deepseek,
128            ChatProvider::OpenAi => Self::OpenAi,
129            ChatProvider::Claude => Self::Claude,
130            ChatProvider::Mistral => Self::Mistral,
131            ChatProvider::OpenRouter => Self::OpenRouter,
132        }
133    }
134}
135
136impl From<Provider> for ChatProvider {
137    fn from(provider: Provider) -> Self {
138        match provider {
139            Provider::Deepseek => Self::DeepSeek,
140            Provider::LlamaCpp => Self::LlamaCpp,
141            Provider::OpenAi => Self::OpenAi,
142            Provider::Claude => Self::Claude,
143            Provider::Mistral => Self::Mistral,
144            Provider::OpenRouter => Self::OpenRouter,
145            _ => Self::Ollama,
146        }
147    }
148}
149
150/// PDF backend offered by the settings page.
151#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
152pub enum PdfBackend {
153    /// Legacy flat-text extraction.
154    #[default]
155    Extract,
156    /// Markdown-native extraction.
157    Unpdf,
158    /// Structured, layout-aware extraction.
159    Sink,
160    /// Binary scavenger that never panics.
161    Internal,
162}
163
164impl PdfBackend {
165    /// Human-readable name.
166    pub fn label(self) -> &'static str {
167        match self {
168            Self::Extract => "pdf-extract",
169            Self::Unpdf => "unpdf",
170            Self::Sink => "pdfsink",
171            Self::Internal => "sloppy-pdf",
172        }
173    }
174
175    /// Options for a dropdown.
176    pub fn options() -> &'static [(Self, &'static str)] {
177        &[
178            (Self::Extract, "pdf-extract (flat text)"),
179            (Self::Unpdf, "unpdf (Markdown)"),
180            (Self::Sink, "pdfsink (layout)"),
181            (Self::Internal, "sloppy-pdf (scavenger)"),
182        ]
183    }
184}
185
186/// Memory strategy offered by the settings page.
187#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
188pub enum MemoryChoice {
189    /// Rewrite the query with a small model before retrieval.
190    #[default]
191    Rewrite,
192    /// Pass the transcript through without rewriting.
193    Transcript,
194    /// Blend the most recent saved session into the query.
195    Log,
196    /// Summarise past sessions with an LLM.
197    Summary,
198    /// No transcript, no rewriting.
199    Off,
200}
201
202impl MemoryChoice {
203    /// Human-readable name.
204    pub fn label(self) -> &'static str {
205        match self {
206            Self::Rewrite => "Rewrite",
207            Self::Transcript => "Transcript",
208            Self::Log => "Log",
209            Self::Summary => "Summary",
210            Self::Off => "Off",
211        }
212    }
213
214    /// Options for a dropdown.
215    pub fn options() -> &'static [(Self, &'static str)] {
216        &[
217            (Self::Rewrite, "Rewrite (small model)"),
218            (Self::Transcript, "Transcript only"),
219            (Self::Log, "Log (last session)"),
220            (Self::Summary, "Summary (LLM)"),
221            (Self::Off, "Off"),
222        ]
223    }
224
225    pub(crate) fn uses_model(self) -> bool {
226        matches!(self, Self::Rewrite | Self::Log | Self::Summary)
227    }
228}
229
230/// Where an external service comes from.
231#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
232pub enum ServiceSource {
233    /// Use a server that is already running (local or remote).
234    #[default]
235    Existing,
236    /// Start and stop a container the user created by name.
237    ExistingContainer,
238    /// Let the app pull and run the official Docker image.
239    Managed,
240}
241
242/// Backwards-compatible name for the GROBID source.
243pub type GrobidSource = ServiceSource;
244
245impl ServiceSource {
246    /// Human-readable name, also used as the persisted value.
247    pub fn label(self) -> &'static str {
248        match self {
249            Self::Existing => "Existing server",
250            Self::ExistingContainer => "Existing container",
251            Self::Managed => "Docker container",
252        }
253    }
254
255    /// Options for a dropdown.
256    pub fn options() -> &'static [(Self, &'static str)] {
257        &[
258            (Self::Existing, "Existing server"),
259            (Self::ExistingContainer, "Existing container"),
260            (Self::Managed, "Docker container"),
261        ]
262    }
263
264    /// Parse the label stored in a settings file.
265    pub fn from_label(label: &str) -> Option<Self> {
266        Self::options()
267            .iter()
268            .find(|(_, option)| *option == label || option.eq_ignore_ascii_case(label))
269            .map(|(source, _)| *source)
270    }
271}
272
273/// Everything needed to build one [`ragrig::RagAgent`].
274#[derive(Clone, Debug)]
275pub struct RagSetup {
276    /// Ragrig's own configuration tree.
277    pub config: RagrigConfig,
278    /// Bibliography the worker reads and writes.
279    ///
280    /// `None` uses the conventional `<workspace>/.ragrig/references.bib`.
281    /// Hosts that keep their library elsewhere set this so `@key`
282    /// citations, the reference rows, and GROBID all agree on one file.
283    pub bibliography: Option<PathBuf>,
284    /// Prompt text applied after building; takes precedence over
285    /// `config.chat.system_prompt_path`.
286    pub system_prompt: Option<String>,
287    /// Whether answers stream token by token.
288    pub stream: bool,
289    /// Whether `@key` mentions in a question attach the paper as one-shot
290    /// context. On by default.
291    pub resolve_citations: bool,
292    /// Whether hybrid retrieval (BM25 + embeddings) is enabled.
293    ///
294    /// Off builds a no-op embedder: questions still use explicit attachments
295    /// and the bibliographic library, but no vector search runs and no
296    /// embedding model is required. On by default.
297    pub hybrid: bool,
298    /// Where the vector store, sessions, and profiles live.
299    ///
300    /// `None` uses `<config.workspace>/.ragrig`. A host with a dedicated
301    /// project folder (for example `.ragrig` inside the document workspace)
302    /// sets this explicitly.
303    pub data_dir: Option<PathBuf>,
304}
305
306impl RagSetup {
307    /// Wrap a config without a prompt override.
308    pub fn new(config: RagrigConfig) -> Self {
309        Self {
310            config,
311            bibliography: None,
312            system_prompt: None,
313            stream: true,
314            resolve_citations: true,
315            hybrid: true,
316            data_dir: None,
317        }
318    }
319
320    /// Wrap a config and apply `prompt` before the agent is built.
321    pub fn with_system_prompt(config: RagrigConfig, prompt: impl Into<String>) -> Self {
322        Self {
323            config,
324            bibliography: None,
325            system_prompt: Some(prompt.into()),
326            stream: true,
327            resolve_citations: true,
328            hybrid: true,
329            data_dir: None,
330        }
331    }
332
333    /// Use `path` for the bibliography instead of the conventional file.
334    pub fn with_bibliography(mut self, path: impl Into<PathBuf>) -> Self {
335        self.bibliography = Some(path.into());
336        self
337    }
338
339    /// Turn hybrid retrieval (embeddings) on or off.
340    pub fn with_hybrid(mut self, hybrid: bool) -> Self {
341        self.hybrid = hybrid;
342        self
343    }
344
345    /// Use `dir` for the vector store, sessions, and profiles.
346    pub fn with_data_dir(mut self, dir: impl Into<PathBuf>) -> Self {
347        self.data_dir = Some(dir.into());
348        self
349    }
350
351    /// Whether conversation memory is on (`false` for the `Off` strategy).
352    pub fn memory_enabled(&self) -> bool {
353        !matches!(self.config.memory.strategy, MemoryStrategyKind::Off)
354    }
355}
356
357impl From<RagrigConfig> for RagSetup {
358    fn from(config: RagrigConfig) -> Self {
359        Self::new(config)
360    }
361}
362
363/// Editable configuration shown by the settings page.
364///
365/// This mirrors the Ragrig fields a chat surface exposes. Secrets never enter
366/// this struct: cloud API keys are read from the environment by the worker.
367#[derive(Clone, Debug, PartialEq)]
368pub struct RagSettings {
369    /// Workspace folder holding the store and sessions.
370    pub workspace: String,
371    /// Bibliography the library and `@key` resolution use.
372    ///
373    /// Empty means the conventional `<workspace>/.ragrig/references.bib`.
374    pub bibliography: String,
375    /// Active chat backend.
376    pub provider: ChatProvider,
377    /// Chat model per provider.
378    ///
379    /// Every provider keeps its own model, so switching backends does not
380    /// carry a model name into an incompatible API. Missing entries use
381    /// [`ChatProvider::default_model`] when the configuration is built.
382    pub models: BTreeMap<ChatProvider, String>,
383    /// Base URL of the local llama.cpp server, including `/v1`.
384    ///
385    /// Used when [`Self::provider`] is [`ChatProvider::LlamaCpp`]; other
386    /// providers keep it so switching back restores the server.
387    pub llamacpp_url: String,
388    /// Sampling temperature.
389    pub temperature: f64,
390    /// Maximum generated tokens.
391    pub max_tokens: usize,
392    /// Chat context window in tokens.
393    pub context_tokens: usize,
394    /// Inline system prompt, used when `system_prompt_path` is empty.
395    pub system_prompt: String,
396    /// Markdown file that overrides the inline prompt; empty uses
397    /// `system_prompt`.
398    pub system_prompt_path: String,
399    /// The prompt the host compiles in.
400    ///
401    /// Hosts set this after loading their settings; **Use built-in** and
402    /// profile defaults restore it. It is not written to profiles and is
403    /// ignored by dirty tracking.
404    pub built_in_prompt: String,
405    /// Stream tokens into the transcript.
406    pub stream: bool,
407    /// Ollama embedding model.
408    pub embed_model: String,
409    /// Hybrid retrieval (BM25 + embeddings). Off runs without an embedding
410    /// model and skips vector search.
411    pub hybrid: bool,
412    /// Chunks retrieved per query.
413    pub top_k: usize,
414    /// Minimum similarity for a chunk to enter the prompt.
415    pub similarity_threshold: f64,
416    /// Target chunk size in tokens.
417    pub chunk_size: usize,
418    /// Token overlap between chunks.
419    pub chunk_overlap: usize,
420    /// PDF parser selected as primary.
421    pub pdf_backend: PdfBackend,
422    /// Keep the sloppy PDF fallback registered.
423    pub sloppy_pdf: bool,
424    /// Conversation memory strategy.
425    pub memory: MemoryChoice,
426    /// Small model used for rewriting and summaries.
427    pub memory_model: String,
428    /// Where the GROBID server comes from.
429    pub grobid_source: GrobidSource,
430    /// Base URL of the GROBID server.
431    pub grobid_url: String,
432    /// Docker image used when [`Self::grobid_source`] is [`ServiceSource::Managed`].
433    pub grobid_image: String,
434    /// Start the managed GROBID container when the app opens.
435    pub grobid_auto_start: bool,
436    /// Container name used for GROBID when the source is an existing container.
437    pub grobid_container: String,
438    /// Where the Ollama server comes from.
439    pub ollama_source: ServiceSource,
440    /// Docker image used when [`Self::ollama_source`] is [`ServiceSource::Managed`].
441    pub ollama_image: String,
442    /// Start the managed Ollama container when the app opens.
443    pub ollama_auto_start: bool,
444    /// Container name used for Ollama when the source is an existing container.
445    pub ollama_container: String,
446    /// Where the llama.cpp server comes from.
447    pub llamacpp_source: ServiceSource,
448    /// Docker image used when [`Self::llamacpp_source`] is [`ServiceSource::Managed`].
449    pub llamacpp_image: String,
450    /// Container name used for llama.cpp when the source is an existing container.
451    pub llamacpp_container: String,
452    /// Model the managed llama.cpp container loads.
453    ///
454    /// A Hugging Face repository (`owner/repo[:quant]`) is downloaded by
455    /// `llama-server`; an absolute path to a `.gguf` file is mounted from the
456    /// host instead.
457    pub llamacpp_model: String,
458    /// Extra `llama-server` arguments for the managed container.
459    pub llamacpp_args: String,
460    /// Start the managed llama.cpp container when the app opens.
461    pub llamacpp_auto_start: bool,
462    /// Rename PDFs to their metadata name during reference extraction.
463    pub rename_pdfs: bool,
464    /// Complete extracted references against OpenAlex.
465    pub openalex: bool,
466    /// Attach `@key` papers mentioned in a question as one-shot context.
467    pub resolve_citations: bool,
468    /// Named folder corpora in `name=path` form.
469    pub corpus_dirs: Vec<String>,
470    /// Profile name typed by the user.
471    pub profile_name: String,
472    /// Profiles found in the workspace.
473    pub profiles: Vec<String>,
474    /// Last settings action result, shown under the Apply row.
475    pub status: Option<String>,
476}
477
478impl Default for RagSettings {
479    fn default() -> Self {
480        let config = RagrigConfig::default();
481        let mut settings = Self::from_config(&config);
482        settings.workspace = default_workspace().to_string_lossy().into_owned();
483        settings
484    }
485}
486
487/// One editable RAG setting.
488///
489/// The settings page uses this to mark exactly the fields that differ from the
490/// configuration last applied to the worker.
491#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
492pub enum RagField {
493    /// Workspace folder.
494    Workspace,
495    /// Bibliography path.
496    Bibliography,
497    /// Chat backend.
498    Provider,
499    /// Model of the active chat backend.
500    Model,
501    /// Base URL of the local llama.cpp server.
502    LlamaCppUrl,
503    /// Sampling temperature.
504    Temperature,
505    /// Maximum generated tokens.
506    MaxTokens,
507    /// Chat context window.
508    ContextTokens,
509    /// Streaming responses.
510    Stream,
511    /// System prompt.
512    SystemPrompt,
513    /// Embedding model.
514    EmbedModel,
515    /// Hybrid retrieval.
516    Hybrid,
517    /// Retrieved chunks.
518    TopK,
519    /// Minimum similarity.
520    SimilarityThreshold,
521    /// Named folder corpora.
522    CorpusDirs,
523    /// PDF parser backend.
524    PdfBackend,
525    /// Sloppy PDF fallback.
526    SloppyPdf,
527    /// Chunk size.
528    ChunkSize,
529    /// Chunk overlap.
530    ChunkOverlap,
531    /// Memory strategy.
532    Memory,
533    /// Memory model.
534    MemoryModel,
535    /// Where the GROBID server comes from.
536    GrobidSource,
537    /// GROBID server URL.
538    GrobidUrl,
539    /// Docker image for the managed GROBID container.
540    GrobidImage,
541    /// Start the managed GROBID container when the app opens.
542    GrobidAutoStart,
543    /// Container name for an existing GROBID container.
544    GrobidContainer,
545    /// Where the Ollama server comes from.
546    OllamaSource,
547    /// Docker image for the managed Ollama container.
548    OllamaImage,
549    /// Start the managed Ollama container when the app opens.
550    OllamaAutoStart,
551    /// Container name for an existing Ollama container.
552    OllamaContainer,
553    /// Where the llama.cpp server comes from.
554    LlamaCppSource,
555    /// Docker image for the managed llama.cpp container.
556    LlamaCppImage,
557    /// Container name for an existing llama.cpp container.
558    LlamaCppContainer,
559    /// Model loaded by the managed llama.cpp container.
560    LlamaCppModel,
561    /// Extra arguments for the managed llama.cpp container.
562    LlamaCppArgs,
563    /// Start the managed llama.cpp container when the app opens.
564    LlamaCppAutoStart,
565    /// Rename processed PDFs.
566    RenamePdfs,
567    /// OpenAlex completion.
568    OpenAlex,
569    /// `@key` citation resolution.
570    ResolveCitations,
571}
572
573impl RagField {
574    /// The group this field is shown in.
575    pub fn section(self) -> RagSection {
576        match self {
577            Self::Workspace | Self::Bibliography => RagSection::Workspace,
578            Self::Provider
579            | Self::Model
580            | Self::LlamaCppUrl
581            | Self::Temperature
582            | Self::MaxTokens
583            | Self::ContextTokens
584            | Self::Stream
585            | Self::SystemPrompt => RagSection::Chat,
586            Self::EmbedModel
587            | Self::Hybrid
588            | Self::TopK
589            | Self::SimilarityThreshold
590            | Self::CorpusDirs => RagSection::Retrieval,
591            Self::PdfBackend | Self::SloppyPdf | Self::ChunkSize | Self::ChunkOverlap => {
592                RagSection::Parsing
593            }
594            Self::Memory | Self::MemoryModel => RagSection::Memory,
595            Self::GrobidSource
596            | Self::GrobidUrl
597            | Self::GrobidImage
598            | Self::GrobidAutoStart
599            | Self::GrobidContainer
600            | Self::OllamaSource
601            | Self::OllamaImage
602            | Self::OllamaAutoStart
603            | Self::OllamaContainer
604            | Self::LlamaCppSource
605            | Self::LlamaCppImage
606            | Self::LlamaCppContainer
607            | Self::LlamaCppModel
608            | Self::LlamaCppArgs
609            | Self::LlamaCppAutoStart => RagSection::Services,
610            Self::RenamePdfs | Self::OpenAlex | Self::ResolveCitations => RagSection::References,
611        }
612    }
613}
614
615/// A group of settings shown on one page.
616#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
617pub enum RagSection {
618    /// Workspace folder and bibliography.
619    Workspace,
620    /// Provider, models, generation, and prompt.
621    Chat,
622    /// Embedding and retrieval.
623    Retrieval,
624    /// Parser and chunking.
625    Parsing,
626    /// Memory strategy and model.
627    Memory,
628    /// Renaming, OpenAlex, and citations.
629    References,
630    /// External services such as GROBID, Ollama, and llama.cpp.
631    Services,
632}
633
634impl RagSection {
635    /// Every section, in page order.
636    pub const ALL: [Self; 7] = [
637        Self::Workspace,
638        Self::Chat,
639        Self::Retrieval,
640        Self::Parsing,
641        Self::Memory,
642        Self::References,
643        Self::Services,
644    ];
645
646    /// The human-readable section name.
647    pub fn label(self) -> &'static str {
648        match self {
649            Self::Workspace => "Workspace",
650            Self::Chat => "Chat",
651            Self::Retrieval => "Retrieval",
652            Self::Parsing => "Parsing and chunking",
653            Self::Memory => "Memory",
654            Self::References => "References",
655            Self::Services => "External services",
656        }
657    }
658}
659
660/// The editable fields that differ from the applied configuration.
661#[derive(Clone, Debug, Default, PartialEq, Eq)]
662pub struct RagChanges {
663    fields: BTreeSet<RagField>,
664}
665
666impl RagChanges {
667    /// Whether no setting changed.
668    pub fn is_empty(&self) -> bool {
669        self.fields.is_empty()
670    }
671
672    /// Whether any setting changed.
673    pub fn any(&self) -> bool {
674        !self.is_empty()
675    }
676
677    /// Whether one field changed.
678    pub fn contains(&self, field: RagField) -> bool {
679        self.fields.contains(&field)
680    }
681
682    /// Whether any field in a section changed.
683    pub fn section(&self, section: RagSection) -> bool {
684        self.fields.iter().any(|field| field.section() == section)
685    }
686
687    /// How many fields in a section changed.
688    pub fn count_in(&self, section: RagSection) -> usize {
689        self.fields
690            .iter()
691            .filter(|field| field.section() == section)
692            .count()
693    }
694
695    /// The number of changed fields.
696    pub fn len(&self) -> usize {
697        self.fields.len()
698    }
699
700    /// Iterate over the changed fields in declaration order.
701    pub fn fields(&self) -> impl Iterator<Item = RagField> + '_ {
702        self.fields.iter().copied()
703    }
704
705    fn mark_if(&mut self, changed: bool, field: RagField) {
706        if changed {
707            self.fields.insert(field);
708        }
709    }
710}
711
712impl RagSettings {
713    /// The model configured for the active provider.
714    ///
715    /// The value is returned as stored, so an edited field can be empty while
716    /// the user types. [`Self::effective_model`] applies the provider default.
717    pub fn model(&self) -> &str {
718        self.model_for(self.provider)
719    }
720
721    /// The model configured for `provider`, as stored.
722    pub fn model_for(&self, provider: ChatProvider) -> &str {
723        self.models
724            .get(&provider)
725            .map(String::as_str)
726            .unwrap_or_default()
727    }
728
729    /// The model configured for the active provider, or its default.
730    pub fn effective_model(&self) -> &str {
731        self.effective_model_for(self.provider)
732    }
733
734    /// The model configured for `provider`, or its default when unset.
735    pub fn effective_model_for(&self, provider: ChatProvider) -> &str {
736        let model = self.model_for(provider).trim();
737        if model.is_empty() {
738            provider.default_model()
739        } else {
740            model
741        }
742    }
743
744    /// Set the model of the active provider.
745    pub fn set_model(&mut self, model: impl Into<String>) {
746        self.set_model_for(self.provider, model);
747    }
748
749    /// Set the model of `provider`.
750    pub fn set_model_for(&mut self, provider: ChatProvider, model: impl Into<String>) {
751        self.models.insert(provider, model.into());
752    }
753
754    fn default_models() -> BTreeMap<ChatProvider, String> {
755        ChatProvider::options()
756            .iter()
757            .map(|(provider, _)| (*provider, provider.default_model().to_owned()))
758            .collect()
759    }
760
761    /// Compare the editable configuration fields with the configuration that is
762    /// currently running. Profile bookkeeping is ignored.
763    pub fn dirty_since(&self, applied: &Self) -> RagChanges {
764        let mut changes = RagChanges::default();
765        changes.mark_if(self.workspace != applied.workspace, RagField::Workspace);
766        changes.mark_if(
767            self.bibliography != applied.bibliography,
768            RagField::Bibliography,
769        );
770        changes.mark_if(self.provider != applied.provider, RagField::Provider);
771        changes.mark_if(self.models != applied.models, RagField::Model);
772        changes.mark_if(
773            self.llamacpp_url != applied.llamacpp_url,
774            RagField::LlamaCppUrl,
775        );
776        changes.mark_if(
777            self.temperature != applied.temperature,
778            RagField::Temperature,
779        );
780        changes.mark_if(self.max_tokens != applied.max_tokens, RagField::MaxTokens);
781        changes.mark_if(
782            self.context_tokens != applied.context_tokens,
783            RagField::ContextTokens,
784        );
785        changes.mark_if(self.stream != applied.stream, RagField::Stream);
786        changes.mark_if(
787            self.system_prompt != applied.system_prompt
788                || self.system_prompt_path != applied.system_prompt_path,
789            RagField::SystemPrompt,
790        );
791        changes.mark_if(
792            self.embed_model != applied.embed_model,
793            RagField::EmbedModel,
794        );
795        changes.mark_if(self.hybrid != applied.hybrid, RagField::Hybrid);
796        changes.mark_if(self.top_k != applied.top_k, RagField::TopK);
797        changes.mark_if(
798            self.similarity_threshold != applied.similarity_threshold,
799            RagField::SimilarityThreshold,
800        );
801        changes.mark_if(
802            self.corpus_dirs != applied.corpus_dirs,
803            RagField::CorpusDirs,
804        );
805        changes.mark_if(
806            self.pdf_backend != applied.pdf_backend,
807            RagField::PdfBackend,
808        );
809        changes.mark_if(self.sloppy_pdf != applied.sloppy_pdf, RagField::SloppyPdf);
810        changes.mark_if(self.chunk_size != applied.chunk_size, RagField::ChunkSize);
811        changes.mark_if(
812            self.chunk_overlap != applied.chunk_overlap,
813            RagField::ChunkOverlap,
814        );
815        changes.mark_if(self.memory != applied.memory, RagField::Memory);
816        changes.mark_if(
817            self.memory_model != applied.memory_model,
818            RagField::MemoryModel,
819        );
820        changes.mark_if(
821            self.grobid_source != applied.grobid_source,
822            RagField::GrobidSource,
823        );
824        changes.mark_if(self.grobid_url != applied.grobid_url, RagField::GrobidUrl);
825        changes.mark_if(
826            self.grobid_image != applied.grobid_image,
827            RagField::GrobidImage,
828        );
829        changes.mark_if(
830            self.grobid_auto_start != applied.grobid_auto_start,
831            RagField::GrobidAutoStart,
832        );
833        changes.mark_if(
834            self.grobid_container != applied.grobid_container,
835            RagField::GrobidContainer,
836        );
837        changes.mark_if(
838            self.ollama_source != applied.ollama_source,
839            RagField::OllamaSource,
840        );
841        changes.mark_if(
842            self.ollama_image != applied.ollama_image,
843            RagField::OllamaImage,
844        );
845        changes.mark_if(
846            self.ollama_auto_start != applied.ollama_auto_start,
847            RagField::OllamaAutoStart,
848        );
849        changes.mark_if(
850            self.ollama_container != applied.ollama_container,
851            RagField::OllamaContainer,
852        );
853        changes.mark_if(
854            self.llamacpp_source != applied.llamacpp_source,
855            RagField::LlamaCppSource,
856        );
857        changes.mark_if(
858            self.llamacpp_image != applied.llamacpp_image,
859            RagField::LlamaCppImage,
860        );
861        changes.mark_if(
862            self.llamacpp_container != applied.llamacpp_container,
863            RagField::LlamaCppContainer,
864        );
865        changes.mark_if(
866            self.llamacpp_model != applied.llamacpp_model,
867            RagField::LlamaCppModel,
868        );
869        changes.mark_if(
870            self.llamacpp_args != applied.llamacpp_args,
871            RagField::LlamaCppArgs,
872        );
873        changes.mark_if(
874            self.llamacpp_auto_start != applied.llamacpp_auto_start,
875            RagField::LlamaCppAutoStart,
876        );
877        changes.mark_if(
878            self.rename_pdfs != applied.rename_pdfs,
879            RagField::RenamePdfs,
880        );
881        changes.mark_if(self.openalex != applied.openalex, RagField::OpenAlex);
882        changes.mark_if(
883            self.resolve_citations != applied.resolve_citations,
884            RagField::ResolveCitations,
885        );
886        changes
887    }
888
889    /// Reset the editable fields to the defaults of the last chosen profile.
890    ///
891    /// With no profile selected, the compiled defaults are used while keeping
892    /// the workspace, bibliography, and profile bookkeeping so the library
893    /// location is not lost.
894    pub fn reset_to_profile_defaults(&mut self) -> Result<(), String> {
895        let name = self.profile_name.trim().to_owned();
896        if !name.is_empty() && self.profiles.iter().any(|profile| profile == &name) {
897            return self.load_profile(&name);
898        }
899
900        let mut defaults = Self::default();
901        defaults.inherit_host_state(self);
902        defaults.system_prompt_path.clear();
903        defaults.system_prompt = defaults.built_in_prompt.clone();
904        defaults.refresh_profiles();
905        defaults.status = Some("Reset to built-in defaults — press Apply".to_owned());
906        *self = defaults;
907        Ok(())
908    }
909}
910
911impl RagSettings {
912    /// Read the editable fields from a Ragrig configuration.
913    pub fn from_config(config: &RagrigConfig) -> Self {
914        let provider = ChatProvider::from(config.chat.provider.clone());
915        let mut models = Self::default_models();
916        if !config.chat.deepseek_model.trim().is_empty() {
917            models.insert(ChatProvider::DeepSeek, config.chat.deepseek_model.clone());
918        }
919        if !config.chat.model.trim().is_empty() {
920            match provider {
921                // `model` is ignored for DeepSeek; it carries the Ollama
922                // choice so switching back keeps it.
923                ChatProvider::DeepSeek => {
924                    models.insert(ChatProvider::Ollama, config.chat.model.clone());
925                }
926                ChatProvider::Ollama => {
927                    models.insert(ChatProvider::Ollama, config.chat.model.clone());
928                }
929                // Upstream treats the Ollama default as "provider default"
930                // when another backend is selected.
931                _ if config.chat.model == ChatProvider::Ollama.default_model() => {}
932                _ => {
933                    models.insert(provider, config.chat.model.clone());
934                }
935            }
936        }
937        let pdf_backend = match config.parse.pdf_parser {
938            PdfParserBackend::Unpdf => PdfBackend::Unpdf,
939            PdfParserBackend::Sink => PdfBackend::Sink,
940            PdfParserBackend::Internal => PdfBackend::Internal,
941            _ => PdfBackend::Extract,
942        };
943        let memory = match config.memory.strategy {
944            MemoryStrategyKind::Transcript => MemoryChoice::Transcript,
945            MemoryStrategyKind::Log => MemoryChoice::Log,
946            MemoryStrategyKind::Summary => MemoryChoice::Summary,
947            MemoryStrategyKind::Off => MemoryChoice::Off,
948            _ => MemoryChoice::Rewrite,
949        };
950        let system_prompt_path = config
951            .chat
952            .system_prompt_path
953            .as_ref()
954            .map(|path| {
955                if path.is_absolute() {
956                    path.clone()
957                } else {
958                    config.workspace.join(path)
959                }
960            })
961            .map(|path| path.display().to_string())
962            .unwrap_or_default();
963        let system_prompt = if system_prompt_path.is_empty() {
964            default_prompt()
965        } else {
966            fs::read_to_string(&system_prompt_path)
967                .ok()
968                .filter(|text| !text.trim().is_empty())
969                .unwrap_or_else(default_prompt)
970        };
971
972        let llamacpp_url = resolve_llamacpp_base_url(config.chat.llamacpp_base_url.as_deref());
973
974        Self {
975            workspace: config.workspace.to_string_lossy().into_owned(),
976            bibliography: String::new(),
977            provider,
978            models,
979            llamacpp_url,
980            temperature: config.chat.params.temperature.unwrap_or(0.2),
981            max_tokens: config.chat.params.max_tokens.unwrap_or(2048),
982            context_tokens: config.chat.context_tokens,
983            system_prompt,
984            system_prompt_path,
985            built_in_prompt: default_prompt(),
986            stream: true,
987            embed_model: config.embed.model.clone(),
988            hybrid: true,
989            top_k: config.embed.top_k,
990            similarity_threshold: config.embed.similarity_threshold,
991            chunk_size: config.parse.chunk_size,
992            chunk_overlap: config.parse.chunk_overlap,
993            pdf_backend,
994            sloppy_pdf: config.parse.sloppy_pdf,
995            memory,
996            memory_model: config.memory.model.clone(),
997            grobid_source: GrobidSource::Existing,
998            grobid_url: DEFAULT_GROBID_URL.to_owned(),
999            grobid_image: DEFAULT_GROBID_IMAGE.to_owned(),
1000            grobid_auto_start: false,
1001            grobid_container: DEFAULT_GROBID_CONTAINER.to_owned(),
1002            ollama_source: ServiceSource::Existing,
1003            ollama_image: DEFAULT_OLLAMA_IMAGE.to_owned(),
1004            ollama_auto_start: false,
1005            ollama_container: DEFAULT_OLLAMA_CONTAINER.to_owned(),
1006            llamacpp_source: ServiceSource::Existing,
1007            llamacpp_image: DEFAULT_LLAMACPP_IMAGE.to_owned(),
1008            llamacpp_container: DEFAULT_LLAMACPP_CONTAINER.to_owned(),
1009            llamacpp_model: String::new(),
1010            llamacpp_args: String::new(),
1011            llamacpp_auto_start: false,
1012            rename_pdfs: false,
1013            openalex: false,
1014            resolve_citations: true,
1015            corpus_dirs: config.corpus_dirs.clone(),
1016            profile_name: String::new(),
1017            profiles: Vec::new(),
1018            status: None,
1019        }
1020    }
1021
1022    /// Read `~/.ragrig/config.json` and `<workspace>/.ragrig/config.json` as
1023    /// partial configurations and merge them over the built-in defaults.
1024    ///
1025    /// The merge is per setting: a key missing from a higher layer keeps the
1026    /// value from the layer below, so a workspace only states what it
1027    /// changes. Returns `None` when neither file exists.
1028    pub fn from_layers(workspace: &Path) -> Option<Self> {
1029        let global = home_ragrig_dir().map(|dir| dir.join("config.json"));
1030        Self::from_layer_paths(
1031            global.as_deref(),
1032            &workspace.join(".ragrig/config.json"),
1033            None,
1034        )
1035    }
1036
1037    /// Like [`Self::from_layers`], but treats the layer files as partial
1038    /// overlays on `base`.
1039    ///
1040    /// Keys the layers name win; every other setting — generation limits,
1041    /// prompts, per-provider models, and the GROBID/Ollama/llama.cpp choices
1042    /// that live only in the host settings — survives. This prevents a
1043    /// workspace layer that only sets, say, the chat model from resetting the
1044    /// rest of the configuration.
1045    pub fn from_layers_over(workspace: &Path, base: &Self) -> Option<Self> {
1046        let global = home_ragrig_dir().map(|dir| dir.join("config.json"));
1047        Self::from_layer_paths(
1048            global.as_deref(),
1049            &workspace.join(".ragrig/config.json"),
1050            Some(base),
1051        )
1052    }
1053
1054    fn from_layer_paths(
1055        global: Option<&Path>,
1056        workspace: &Path,
1057        base: Option<&Self>,
1058    ) -> Option<Self> {
1059        let mut layers = serde_json::json!({});
1060        let mut found = false;
1061        for path in global.into_iter().chain(std::iter::once(workspace)) {
1062            let Ok(text) = fs::read_to_string(path) else {
1063                continue;
1064            };
1065            let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
1066                continue;
1067            };
1068            merge_json(&mut layers, value);
1069            found = true;
1070        }
1071        if !found {
1072            return None;
1073        }
1074        let layer_hybrid = layers.get("hybrid").and_then(serde_json::Value::as_bool);
1075        // Start from the host's current configuration, not from the library
1076        // defaults: a layer that only names one key must not reset the rest.
1077        let mut merged = match base {
1078            Some(base) => serde_json::to_value(base.build_config()).ok()?,
1079            None => serde_json::to_value(RagrigConfig::default()).ok()?,
1080        };
1081        merge_json(&mut merged, layers.clone());
1082        let mut config: RagrigConfig = serde_json::from_value(merged).ok()?;
1083        // The layer file lives in `<root>/.ragrig/`; relative prompts and
1084        // bibliographies resolve against the workspace root, not the cwd.
1085        if let Some(root) = workspace.parent().and_then(Path::parent) {
1086            config.workspace = root.to_path_buf();
1087        }
1088        let mut settings = Self::from_config(&config);
1089        if let Some(base) = base {
1090            settings.inherit_host_state(base);
1091            settings.workspace = config.workspace.to_string_lossy().into_owned();
1092            settings.inherit_models(
1093                base,
1094                layers
1095                    .pointer("/chat/model")
1096                    .and_then(serde_json::Value::as_str),
1097                layers
1098                    .pointer("/chat/deepseek_model")
1099                    .and_then(serde_json::Value::as_str),
1100            );
1101            match layers.pointer("/chat/system_prompt_path") {
1102                None => {
1103                    settings.system_prompt_path = base.system_prompt_path.clone();
1104                    settings.system_prompt = base.system_prompt.clone();
1105                }
1106                Some(serde_json::Value::String(path)) if !path.trim().is_empty() => {}
1107                Some(_) => {
1108                    settings.system_prompt_path.clear();
1109                    settings.system_prompt = base.built_in_prompt.clone();
1110                }
1111            }
1112        }
1113        if let Some(hybrid) = layer_hybrid {
1114            settings.hybrid = hybrid;
1115        }
1116        Some(settings)
1117    }
1118
1119    /// Carry the settings that live only in the host: the app settings file,
1120    /// a status-bar switch, or the library location.
1121    ///
1122    /// Ragrig profiles and layer files cannot express these, so loading one
1123    /// must never reset them.
1124    fn inherit_host_state(&mut self, base: &Self) {
1125        self.workspace = base.workspace.clone();
1126        self.bibliography = base.bibliography.clone();
1127        self.stream = base.stream;
1128        self.hybrid = base.hybrid;
1129        self.rename_pdfs = base.rename_pdfs;
1130        self.openalex = base.openalex;
1131        self.resolve_citations = base.resolve_citations;
1132        self.grobid_source = base.grobid_source;
1133        self.grobid_url = base.grobid_url.clone();
1134        self.grobid_image = base.grobid_image.clone();
1135        self.grobid_auto_start = base.grobid_auto_start;
1136        self.grobid_container = base.grobid_container.clone();
1137        self.ollama_source = base.ollama_source;
1138        self.ollama_image = base.ollama_image.clone();
1139        self.ollama_auto_start = base.ollama_auto_start;
1140        self.ollama_container = base.ollama_container.clone();
1141        self.llamacpp_source = base.llamacpp_source;
1142        self.llamacpp_image = base.llamacpp_image.clone();
1143        self.llamacpp_container = base.llamacpp_container.clone();
1144        self.llamacpp_model = base.llamacpp_model.clone();
1145        self.llamacpp_args = base.llamacpp_args.clone();
1146        self.llamacpp_auto_start = base.llamacpp_auto_start;
1147        self.built_in_prompt = base.built_in_prompt.clone();
1148        self.profile_name = base.profile_name.clone();
1149        self.profiles = base.profiles.clone();
1150    }
1151
1152    /// Keep the per-provider model choices a profile or layer does not name.
1153    ///
1154    /// A [`RagrigConfig`] holds one active `chat.model`, so rebuilding
1155    /// from one would reset every other provider to its default.
1156    fn inherit_models(&mut self, base: &Self, model: Option<&str>, deepseek_model: Option<&str>) {
1157        let mut models = base.models.clone();
1158        if let Some(model) = model.map(str::trim).filter(|model| !model.is_empty()) {
1159            let provider = if self.provider == ChatProvider::DeepSeek {
1160                ChatProvider::Ollama
1161            } else {
1162                self.provider
1163            };
1164            models.insert(provider, model.to_owned());
1165        }
1166        if let Some(model) = deepseek_model
1167            .map(str::trim)
1168            .filter(|model| !model.is_empty())
1169        {
1170            models.insert(ChatProvider::DeepSeek, model.to_owned());
1171        }
1172        self.models = models;
1173    }
1174
1175    /// Validate and convert to a Ragrig configuration.
1176    pub fn to_config(&self) -> Result<RagrigConfig, String> {
1177        self.validate()?;
1178        Ok(self.build_config())
1179    }
1180
1181    /// Build a configuration without validating it.
1182    ///
1183    /// Layer merging starts from this shape, so an incomplete edit (an empty
1184    /// model while typing, for example) must not make the whole layer
1185    /// unreadable.
1186    fn build_config(&self) -> RagrigConfig {
1187        let workspace = PathBuf::from(self.workspace.trim());
1188        let model = match self.provider {
1189            // `model` is ignored for DeepSeek; keep the Ollama choice so a
1190            // profile switched back to Ollama retains it.
1191            ChatProvider::DeepSeek => self.effective_model_for(ChatProvider::Ollama),
1192            _ => self.effective_model(),
1193        };
1194        let chat = ChatConfig {
1195            provider: self.provider.into(),
1196            model: model.to_owned(),
1197            deepseek_model: self.effective_model_for(ChatProvider::DeepSeek).to_owned(),
1198            // Secrets only ever come from the environment.
1199            deepseek_api_key: None,
1200            params: GenerationParams {
1201                temperature: Some(self.temperature),
1202                top_p: None,
1203                max_tokens: Some(self.max_tokens),
1204                seed: None,
1205            },
1206            context_tokens: self.context_tokens,
1207            llamacpp_base_url: Some(self.llamacpp_url.trim().to_owned()),
1208            // Secrets only ever come from the environment.
1209            llamacpp_api_key: None,
1210            system_prompt_path: None,
1211            ..ChatConfig::default()
1212        };
1213
1214        let embed = EmbedConfig {
1215            provider: EmbeddingProvider::Ollama,
1216            model: self.embed_model.trim().to_owned(),
1217            top_k: self.top_k,
1218            similarity_threshold: self.similarity_threshold,
1219            ..EmbedConfig::default()
1220        };
1221
1222        let parse = ParseConfig {
1223            pdf_parser: match self.pdf_backend {
1224                PdfBackend::Extract => PdfParserBackend::Extract,
1225                PdfBackend::Unpdf => PdfParserBackend::Unpdf,
1226                PdfBackend::Sink => PdfParserBackend::Sink,
1227                PdfBackend::Internal => PdfParserBackend::Internal,
1228            },
1229            sloppy_pdf: self.sloppy_pdf,
1230            chunk_size: self.chunk_size,
1231            chunk_overlap: self.chunk_overlap,
1232        };
1233
1234        let memory = MemoryConfig {
1235            model: self.memory_model.trim().to_owned(),
1236            rewrite_prompt_path: None,
1237            strategy: match self.memory {
1238                MemoryChoice::Rewrite => MemoryStrategyKind::Rewrite,
1239                MemoryChoice::Transcript => MemoryStrategyKind::Transcript,
1240                MemoryChoice::Log => MemoryStrategyKind::Log,
1241                MemoryChoice::Summary => MemoryStrategyKind::Summary,
1242                MemoryChoice::Off => MemoryStrategyKind::Off,
1243            },
1244        };
1245
1246        RagrigConfig {
1247            workspace,
1248            chat,
1249            embed,
1250            parse,
1251            memory,
1252            corpus_dirs: self
1253                .corpus_dirs
1254                .iter()
1255                .map(|entry| entry.trim().to_owned())
1256                .filter(|entry| !entry.is_empty())
1257                .collect(),
1258            corpus_urls: Vec::new(),
1259            semantic_scholar_api_key: None,
1260        }
1261    }
1262
1263    /// The custom prompt file, resolved against the workspace.
1264    pub fn prompt_path(&self) -> Option<PathBuf> {
1265        let path = self.system_prompt_path.trim();
1266        if path.is_empty() {
1267            return None;
1268        }
1269        let path = Path::new(path);
1270        Some(if path.is_absolute() {
1271            path.to_path_buf()
1272        } else {
1273            Path::new(self.workspace.trim()).join(path)
1274        })
1275    }
1276
1277    /// The prompt in effect: the selected file, or the inline prompt.
1278    pub fn effective_prompt(&self) -> Result<String, String> {
1279        match self.prompt_path() {
1280            Some(path) => {
1281                let text = fs::read_to_string(&path).map_err(|error| {
1282                    format!("cannot read system prompt '{}': {error}", path.display())
1283                })?;
1284                if text.trim().is_empty() {
1285                    return Err(format!("system prompt '{}' is empty", path.display()));
1286                }
1287                Ok(text)
1288            }
1289            None => Ok(self.system_prompt.clone()),
1290        }
1291    }
1292
1293    /// Clear the prompt file and restore the host's compiled-in prompt.
1294    pub fn use_built_in_prompt(&mut self) {
1295        self.system_prompt_path.clear();
1296        self.system_prompt = self.built_in_prompt.clone();
1297    }
1298
1299    /// Validate and produce a build setup, including the prompt override.
1300    pub fn to_setup(&self) -> Result<RagSetup, String> {
1301        let config = self.to_config()?;
1302        let prompt = self.effective_prompt()?;
1303        let prompt = prompt.trim();
1304        let mut setup = if prompt.is_empty() {
1305            RagSetup::new(config)
1306        } else {
1307            RagSetup::with_system_prompt(config, prompt)
1308        };
1309        setup.stream = self.stream;
1310        setup.resolve_citations = self.resolve_citations;
1311        setup.hybrid = self.hybrid;
1312        if !self.bibliography.trim().is_empty() {
1313            setup.bibliography = Some(PathBuf::from(self.bibliography.trim()));
1314        }
1315        Ok(setup)
1316    }
1317
1318    /// Check the editable values without touching the filesystem.
1319    pub fn validate(&self) -> Result<(), String> {
1320        if self.workspace.trim().is_empty() {
1321            return Err("choose a workspace folder".to_owned());
1322        }
1323        if self.model().trim().is_empty() {
1324            return Err(format!("choose a {} chat model", self.provider.label()));
1325        }
1326        if self.provider == ChatProvider::LlamaCpp && self.llamacpp_url.trim().is_empty() {
1327            return Err("enter the llama.cpp server URL".to_owned());
1328        }
1329        // API keys are read from the environment when the worker builds the
1330        // agent. A missing key must not block editing settings or saving a
1331        // profile; the worker reports it through `AgentFailed`.
1332        if !self.temperature.is_finite() || !(0.0..=2.0).contains(&self.temperature) {
1333            return Err("temperature must be between 0 and 2".to_owned());
1334        }
1335        if self.max_tokens == 0 {
1336            return Err("max tokens must be at least 1".to_owned());
1337        }
1338        if self.context_tokens < 256 {
1339            return Err("context tokens must be at least 256".to_owned());
1340        }
1341        if self.hybrid {
1342            if self.embed_model.trim().is_empty() {
1343                return Err("choose an embedding model".to_owned());
1344            }
1345            if self.top_k == 0 {
1346                return Err("top-k must be at least 1".to_owned());
1347            }
1348            if !self.similarity_threshold.is_finite()
1349                || !(0.0..=1.0).contains(&self.similarity_threshold)
1350            {
1351                return Err("similarity threshold must be between 0 and 1".to_owned());
1352            }
1353        }
1354        if self.chunk_size == 0 {
1355            return Err("chunk size must be at least 1".to_owned());
1356        }
1357        if self.chunk_overlap >= self.chunk_size {
1358            return Err("chunk overlap must be smaller than the chunk size".to_owned());
1359        }
1360        if self.memory.uses_model() && self.memory_model.trim().is_empty() {
1361            return Err("choose a memory model or turn memory off".to_owned());
1362        }
1363        if self.grobid_url.trim().is_empty() {
1364            return Err("enter the GROBID server URL".to_owned());
1365        }
1366        if self.grobid_source == GrobidSource::Managed && self.grobid_image.trim().is_empty() {
1367            return Err("enter the Docker image for the managed GROBID container".to_owned());
1368        }
1369        if self.grobid_source == ServiceSource::ExistingContainer
1370            && self.grobid_container.trim().is_empty()
1371        {
1372            return Err("enter the GROBID container name".to_owned());
1373        }
1374        if self.ollama_source == ServiceSource::Managed && self.ollama_image.trim().is_empty() {
1375            return Err("enter the Docker image for the managed Ollama container".to_owned());
1376        }
1377        if self.ollama_source == ServiceSource::ExistingContainer
1378            && self.ollama_container.trim().is_empty()
1379        {
1380            return Err("enter the Ollama container name".to_owned());
1381        }
1382        if self.llamacpp_source == ServiceSource::Managed {
1383            if self.llamacpp_image.trim().is_empty() {
1384                return Err("enter the Docker image for the managed llama.cpp container".to_owned());
1385            }
1386            if self.llamacpp_model.trim().is_empty() {
1387                return Err(
1388                    "enter a Hugging Face model or a .gguf file for the managed llama.cpp container"
1389                        .to_owned(),
1390                );
1391            }
1392        }
1393        if self.llamacpp_source == ServiceSource::ExistingContainer
1394            && self.llamacpp_container.trim().is_empty()
1395        {
1396            return Err("enter the llama.cpp container name".to_owned());
1397        }
1398        for entry in &self.corpus_dirs {
1399            let entry = entry.trim();
1400            if entry.is_empty() {
1401                continue;
1402            }
1403            let Some((name, path)) = entry.split_once('=') else {
1404                return Err(format!("corpus '{entry}' must use name=path"));
1405            };
1406            if name.trim().is_empty() || path.trim().is_empty() {
1407                return Err(format!("corpus '{entry}' needs a name and a path"));
1408            }
1409        }
1410        Ok(())
1411    }
1412
1413    /// Re-read the profile list from the workspace and `~/.ragrig`.
1414    ///
1415    /// Workspace profiles shadow global profiles with the same name.
1416    pub fn refresh_profiles(&mut self) {
1417        let workspace = Path::new(self.workspace.trim());
1418        let mut workspace_failed = None;
1419        let mut profiles = match RagrigConfig::list_profiles(workspace) {
1420            Ok(profiles) => profiles,
1421            Err(error) => {
1422                workspace_failed = Some(error);
1423                Vec::new()
1424            }
1425        };
1426        if let Some(home) = home_ragrig_dir() {
1427            for name in RagrigConfig::list_profiles(&home).unwrap_or_default() {
1428                if !profiles.contains(&name) {
1429                    profiles.push(name);
1430                }
1431            }
1432        }
1433        profiles.sort();
1434        if let Some(error) = workspace_failed {
1435            self.status = Some(format!("cannot list workspace profiles: {error:#}"));
1436        }
1437        self.profiles = profiles;
1438        if !self.profile_name.trim().is_empty()
1439            && !self
1440                .profiles
1441                .iter()
1442                .any(|name| name == self.profile_name.trim())
1443        {
1444            self.profiles.insert(0, self.profile_name.trim().to_owned());
1445        }
1446    }
1447
1448    /// Save these settings as a named profile in the workspace.
1449    pub fn save_profile(&mut self) -> Result<(), String> {
1450        let name = self.profile_name.trim().to_owned();
1451        validate_profile_name(&name)?;
1452        let mut config = self.to_config()?;
1453        let workspace = Path::new(self.workspace.trim());
1454        let prompt = self.effective_prompt()?;
1455        let prompt = prompt.trim();
1456        if prompt.is_empty()
1457            || (self.system_prompt_path.trim().is_empty() && prompt == self.built_in_prompt.trim())
1458        {
1459            config.chat.system_prompt_path = None;
1460        } else {
1461            let dir = RagrigConfig::profiles_dir(workspace);
1462            fs::create_dir_all(&dir)
1463                .map_err(|error| format!("cannot create profiles folder: {error}"))?;
1464            let relative = PathBuf::from(format!(".ragrig/profiles/{name}.prompt.md"));
1465            fs::write(workspace.join(&relative), prompt).map_err(|error| {
1466                format!("cannot write the prompt for profile '{name}': {error}")
1467            })?;
1468            config.chat.system_prompt_path = Some(relative);
1469        }
1470        config
1471            .save_to_profile(workspace, &name)
1472            .map_err(|error| format!("cannot save profile '{name}': {error:#}"))?;
1473        self.refresh_profiles();
1474        self.status = Some(format!("Saved profile '{name}'"));
1475        Ok(())
1476    }
1477
1478    /// Load a named profile from the workspace, then from `~/.ragrig`.
1479    pub fn load_profile(&mut self, name: &str) -> Result<(), String> {
1480        validate_profile_name(name)?;
1481        let workspace = Path::new(self.workspace.trim());
1482        let mut config = match RagrigConfig::load_from_profile(workspace, name) {
1483            Ok(config) => config,
1484            Err(workspace_error) => match home_ragrig_dir() {
1485                Some(home) => RagrigConfig::load_from_profile(&home, name)
1486                    .map_err(|_| format!("cannot load profile '{name}': {workspace_error:#}"))?,
1487                None => {
1488                    return Err(format!("cannot load profile '{name}': {workspace_error:#}"));
1489                }
1490            },
1491        };
1492        // A global profile resolves relative prompt paths against the actual
1493        // workspace, never against the home directory.
1494        config.workspace = workspace.to_path_buf();
1495        let mut loaded = Self::from_config(&config);
1496        // Profiles carry Ragrig's configuration fields. Everything the host
1497        // owns — library location, streaming, service containers, the prompt
1498        // the host compiles in — must survive a profile switch.
1499        loaded.inherit_host_state(self);
1500        loaded.inherit_models(
1501            self,
1502            Some(&config.chat.model),
1503            Some(&config.chat.deepseek_model),
1504        );
1505        loaded.workspace = workspace.to_string_lossy().into_owned();
1506        loaded.profile_name = name.to_owned();
1507        if loaded.system_prompt_path.is_empty() {
1508            loaded.system_prompt = loaded.built_in_prompt.clone();
1509        }
1510        loaded.status = Some(format!("Loaded profile '{name}' — press Apply"));
1511        *self = loaded;
1512        self.refresh_profiles();
1513        Ok(())
1514    }
1515
1516    /// Remember a folder corpus so later applies keep it.
1517    pub fn add_corpus(&mut self, name: &str, folder: &Path) {
1518        let entry = format!("{name}={}", folder.display());
1519        self.corpus_dirs.retain(|existing| {
1520            existing
1521                .split_once('=')
1522                .is_none_or(|(existing_name, _)| existing_name != name)
1523        });
1524        self.corpus_dirs.push(entry);
1525    }
1526}
1527
1528fn default_workspace() -> PathBuf {
1529    std::env::var_os("RAGRIG_WORKSPACE")
1530        .map(PathBuf::from)
1531        .or_else(|| std::env::current_dir().ok())
1532        .unwrap_or_else(|| PathBuf::from("."))
1533}
1534
1535/// The user-wide Ragrig directory.
1536///
1537/// `RAGRIG_HOME` overrides the default `~/.ragrig` (useful for tests and for
1538/// keeping one profile library on a shared drive). Profiles here are visible
1539/// from every workspace; a workspace profile with the same name wins. A
1540/// `config.json` in this directory is the global layer between the built-in
1541/// defaults and a workspace's `.ragrig/config.json`.
1542pub fn home_ragrig_dir() -> Option<PathBuf> {
1543    std::env::var_os("RAGRIG_HOME")
1544        .map(PathBuf::from)
1545        .or_else(|| dirs::home_dir().map(|home| home.join(".ragrig")))
1546}
1547
1548/// Merge `overlay` into `base` per key; objects recurse, everything else
1549/// replaces.
1550fn merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
1551    match (base, overlay) {
1552        (serde_json::Value::Object(base), serde_json::Value::Object(overlay)) => {
1553            for (key, value) in overlay {
1554                match base.get_mut(&key) {
1555                    Some(existing) => merge_json(existing, value),
1556                    None => {
1557                        base.insert(key, value);
1558                    }
1559                }
1560            }
1561        }
1562        (base, overlay) => *base = overlay,
1563    }
1564}
1565
1566fn default_prompt() -> String {
1567    "Answer from the attached context and the indexed corpus. Cite each paper by its @Key. \
1568     If the context does not support an answer, say what is missing instead of guessing."
1569        .to_owned()
1570}
1571
1572fn validate_profile_name(name: &str) -> Result<(), String> {
1573    if name.is_empty() {
1574        return Err("enter a profile name first".to_owned());
1575    }
1576    if !name
1577        .chars()
1578        .all(|character| character.is_ascii_alphanumeric() || character == '-' || character == '_')
1579    {
1580        return Err("profile names may use letters, digits, '-' and '_' only".to_owned());
1581    }
1582    Ok(())
1583}
1584
1585#[cfg(test)]
1586mod tests {
1587    use super::*;
1588
1589    fn fixture() -> RagSettings {
1590        RagSettings {
1591            workspace: std::env::temp_dir().display().to_string(),
1592            ..RagSettings::default()
1593        }
1594    }
1595
1596    #[test]
1597    fn defaults_convert_to_a_valid_setup() {
1598        let setup = fixture().to_setup().expect("valid defaults");
1599        assert_eq!(setup.config.parse.chunk_size, 1024);
1600        assert_eq!(setup.config.parse.chunk_overlap, 128);
1601        assert!(setup.system_prompt.is_some());
1602        assert!(setup.resolve_citations);
1603        assert!(setup.hybrid);
1604        assert!(setup.memory_enabled());
1605
1606        let mut settings = fixture();
1607        settings.resolve_citations = false;
1608        assert!(!settings.to_setup().expect("valid setup").resolve_citations);
1609    }
1610
1611    #[test]
1612    fn every_provider_has_a_default_model() {
1613        let chat = ChatConfig::default();
1614        assert_eq!(ChatProvider::Ollama.default_model(), chat.model);
1615        assert_eq!(ChatProvider::DeepSeek.default_model(), chat.deepseek_model);
1616        for (provider, _) in ChatProvider::options() {
1617            assert!(
1618                !provider.default_model().trim().is_empty(),
1619                "{provider:?} needs a default model"
1620            );
1621        }
1622    }
1623
1624    #[test]
1625    fn cloud_defaults_match_the_upstream_specs() {
1626        use ragrig::ChatAgentSpec;
1627
1628        for (backend, provider) in [
1629            ("openai", ChatProvider::OpenAi),
1630            ("claude", ChatProvider::Claude),
1631            ("mistral", ChatProvider::Mistral),
1632            ("openrouter", ChatProvider::OpenRouter),
1633        ] {
1634            let spec = ChatAgentSpec::parse(backend, None, None, None).expect("known backend");
1635            let model = match spec {
1636                ChatAgentSpec::OpenAi { model, .. }
1637                | ChatAgentSpec::Claude { model, .. }
1638                | ChatAgentSpec::Mistral { model, .. }
1639                | ChatAgentSpec::OpenRouter { model, .. } => model,
1640                _ => panic!("unexpected spec for {backend}"),
1641            };
1642            assert_eq!(provider.default_model(), model, "{backend} default drifted");
1643        }
1644    }
1645
1646    #[test]
1647    fn llamacpp_defaults_match_the_upstream_spec() {
1648        use ragrig::ChatAgentSpec;
1649
1650        let spec = ChatAgentSpec::parse("llamacpp", None, None, None).expect("known backend");
1651        let model = match spec {
1652            ChatAgentSpec::LlamaCpp { model, .. } => model,
1653            _ => panic!("unexpected spec for llamacpp"),
1654        };
1655        assert_eq!(ChatProvider::LlamaCpp.default_model(), model);
1656        if std::env::var("LLAMACPP_API_BASE_URL").is_err() {
1657            assert_eq!(
1658                RagSettings::default().llamacpp_url,
1659                ragrig::agents::DEFAULT_LLAMACPP_BASE_URL
1660            );
1661        }
1662    }
1663
1664    #[test]
1665    fn the_llamacpp_url_reaches_the_chat_config() {
1666        let mut settings = fixture();
1667        settings.provider = ChatProvider::LlamaCpp;
1668        settings.llamacpp_url = "http://gpu-box:9000/v1".to_owned();
1669
1670        let config = settings.to_config().expect("valid configuration");
1671        assert_eq!(config.chat.provider, Provider::LlamaCpp);
1672        assert_eq!(
1673            config.chat.llamacpp_base_url.as_deref(),
1674            Some("http://gpu-box:9000/v1")
1675        );
1676        assert!(
1677            config.chat.llamacpp_api_key.is_none(),
1678            "keys stay in the environment"
1679        );
1680
1681        let restored = RagSettings::from_config(&config);
1682        assert_eq!(restored.provider, ChatProvider::LlamaCpp);
1683        assert_eq!(restored.llamacpp_url, "http://gpu-box:9000/v1");
1684    }
1685
1686    #[test]
1687    fn a_blank_llamacpp_url_is_rejected() {
1688        let mut settings = fixture();
1689        settings.provider = ChatProvider::LlamaCpp;
1690        settings.llamacpp_url.clear();
1691        assert_eq!(
1692            settings.validate(),
1693            Err("enter the llama.cpp server URL".to_owned())
1694        );
1695    }
1696
1697    #[test]
1698    fn each_provider_keeps_its_own_model() {
1699        let mut settings = fixture();
1700        settings.set_model_for(ChatProvider::Ollama, "llama3");
1701        settings.set_model_for(ChatProvider::OpenAi, "gpt-4o-mini");
1702
1703        settings.provider = ChatProvider::OpenAi;
1704        assert_eq!(settings.model(), "gpt-4o-mini");
1705
1706        settings.provider = ChatProvider::Claude;
1707        assert_eq!(settings.model(), ChatProvider::Claude.default_model());
1708
1709        settings.provider = ChatProvider::Ollama;
1710        assert_eq!(settings.model(), "llama3");
1711    }
1712
1713    #[test]
1714    fn cloud_providers_map_to_upstream_backends_and_key_variables() {
1715        let providers = [
1716            (
1717                ChatProvider::DeepSeek,
1718                Provider::Deepseek,
1719                "DEEPSEEK_API_KEY",
1720            ),
1721            (
1722                ChatProvider::LlamaCpp,
1723                Provider::LlamaCpp,
1724                "LLAMACPP_API_KEY",
1725            ),
1726            (ChatProvider::OpenAi, Provider::OpenAi, "OPENAI_API_KEY"),
1727            (ChatProvider::Claude, Provider::Claude, "ANTHROPIC_API_KEY"),
1728            (ChatProvider::Mistral, Provider::Mistral, "MISTRAL_API_KEY"),
1729            (
1730                ChatProvider::OpenRouter,
1731                Provider::OpenRouter,
1732                "OPENROUTER_API_KEY",
1733            ),
1734        ];
1735        for (provider, backend, key) in providers {
1736            assert_eq!(Provider::from(provider), backend);
1737            assert_eq!(ChatProvider::from(backend), provider);
1738            assert_eq!(provider.env_var(), Some(key));
1739            assert_eq!(ChatProvider::from_label(provider.label()), Some(provider));
1740        }
1741        assert_eq!(Provider::from(ChatProvider::Ollama), Provider::Ollama);
1742        assert_eq!(ChatProvider::Ollama.env_var(), None);
1743    }
1744
1745    #[test]
1746    fn a_missing_api_key_does_not_block_the_settings_form() {
1747        let mut settings = fixture();
1748        // The worker reports a missing key when it builds the agent. The form
1749        // must still validate and convert so Apply and profile saves work.
1750        for provider in [
1751            ChatProvider::DeepSeek,
1752            ChatProvider::LlamaCpp,
1753            ChatProvider::OpenAi,
1754            ChatProvider::Claude,
1755            ChatProvider::Mistral,
1756            ChatProvider::OpenRouter,
1757        ] {
1758            settings.provider = provider;
1759            settings.set_model(provider.default_model());
1760            assert!(
1761                settings.to_config().is_ok(),
1762                "{provider:?} must validate without its key"
1763            );
1764        }
1765
1766        let workspace = unique_temp_dir("ragrig-gpui-cloud-profile");
1767        settings.workspace = workspace.display().to_string();
1768        settings.profile_name = "cloud".to_owned();
1769        settings
1770            .save_profile()
1771            .expect("a profile saves without a key in the environment");
1772        assert!(workspace.join(".ragrig/profiles/cloud.json").is_file());
1773
1774        let _ = std::fs::remove_dir_all(workspace);
1775    }
1776
1777    #[test]
1778    fn the_selected_model_reaches_the_chat_config() {
1779        let mut settings = fixture();
1780        settings.set_model_for(ChatProvider::Ollama, "llama3");
1781        let config = settings.to_config().expect("valid configuration");
1782        assert_eq!(config.chat.provider, Provider::Ollama);
1783        assert_eq!(config.chat.model, "llama3");
1784
1785        let mut config = RagrigConfig::default();
1786        config.chat.provider = Provider::OpenAi;
1787        config.chat.model = "gpt-4o-mini".to_owned();
1788        let settings = RagSettings::from_config(&config);
1789        assert_eq!(settings.provider, ChatProvider::OpenAi);
1790        assert_eq!(settings.model(), "gpt-4o-mini");
1791        assert_eq!(
1792            settings.model_for(ChatProvider::Ollama),
1793            ChatProvider::Ollama.default_model(),
1794            "inactive providers keep their own default"
1795        );
1796
1797        let mut config = RagrigConfig::default();
1798        config.chat.provider = Provider::OpenAi;
1799        let settings = RagSettings::from_config(&config);
1800        assert_eq!(
1801            settings.model(),
1802            ChatProvider::OpenAi.default_model(),
1803            "the Ollama default means the selected provider's default"
1804        );
1805
1806        let mut config = RagrigConfig::default();
1807        config.chat.provider = Provider::Deepseek;
1808        config.chat.model = "llama3".to_owned();
1809        config.chat.deepseek_model = "deepseek-chat".to_owned();
1810        let settings = RagSettings::from_config(&config);
1811        assert_eq!(settings.model_for(ChatProvider::Ollama), "llama3");
1812        assert_eq!(settings.model_for(ChatProvider::DeepSeek), "deepseek-chat");
1813    }
1814
1815    #[test]
1816    fn memory_and_retrieval_switches_reach_the_setup() {
1817        let mut settings = fixture();
1818        settings.memory = MemoryChoice::Off;
1819        settings.hybrid = false;
1820
1821        let setup = settings.to_setup().expect("valid setup");
1822        assert!(!setup.memory_enabled());
1823        assert!(!setup.hybrid);
1824    }
1825
1826    #[test]
1827    fn hybrid_off_does_not_require_an_embedding_model() {
1828        let mut settings = fixture();
1829        settings.hybrid = false;
1830        settings.embed_model.clear();
1831
1832        assert!(settings.to_setup().is_ok());
1833
1834        settings.hybrid = true;
1835        assert!(settings.to_setup().is_err());
1836    }
1837
1838    #[test]
1839    fn layered_config_merges_one_setting_at_a_time() {
1840        let root = unique_temp_dir("ragrig-gpui-layers");
1841        let global = root.join("home/.ragrig/config.json");
1842        let workspace = root.join("paper/.ragrig/config.json");
1843        std::fs::create_dir_all(global.parent().expect("global parent")).expect("global dir");
1844        std::fs::create_dir_all(workspace.parent().expect("workspace parent"))
1845            .expect("workspace dir");
1846        std::fs::write(
1847            &global,
1848            r#"{"chat":{"model":"global-chat"},"embed":{"top_k":12},"parse":{"chunk_size":512}}"#,
1849        )
1850        .expect("global layer");
1851        std::fs::write(
1852            &workspace,
1853            r#"{"chat":{"model":"local-chat"},"hybrid":false,"parse":{"chunk_overlap":32}}"#,
1854        )
1855        .expect("workspace layer");
1856
1857        let settings = RagSettings::from_layer_paths(Some(&global), &workspace, None)
1858            .expect("layered settings");
1859        assert_eq!(
1860            settings.model_for(ChatProvider::Ollama),
1861            "local-chat",
1862            "workspace wins"
1863        );
1864        assert_eq!(settings.top_k, 12, "global wins over built-in");
1865        assert_eq!(
1866            settings.chunk_size, 512,
1867            "global survives the workspace layer"
1868        );
1869        assert_eq!(settings.chunk_overlap, 32, "workspace fills a missing key");
1870        assert!(!settings.hybrid, "the workspace hybrid extension is read");
1871        assert_eq!(
1872            settings.provider,
1873            RagSettings::default().provider,
1874            "untouched keys keep the built-in default"
1875        );
1876
1877        let _ = std::fs::remove_dir_all(root);
1878    }
1879
1880    #[test]
1881    fn runtime_switches_survive_a_layer_without_them() {
1882        let root = unique_temp_dir("ragrig-gpui-layer-switches");
1883        let global = root.join("home/.ragrig/config.json");
1884        let workspace = root.join("paper/.ragrig/config.json");
1885        std::fs::create_dir_all(global.parent().expect("global parent")).expect("global dir");
1886        std::fs::create_dir_all(workspace.parent().expect("workspace parent"))
1887            .expect("workspace dir");
1888        std::fs::write(&global, r#"{"chat":{"model":"layer-chat"}}"#).expect("layer");
1889        std::fs::write(&workspace, r#"{"parse":{"chunk_size":256}}"#).expect("layer");
1890
1891        let mut base = fixture();
1892        base.memory = MemoryChoice::Off;
1893        base.hybrid = false;
1894        let settings = RagSettings::from_layer_paths(Some(&global), &workspace, Some(&base))
1895            .expect("layered settings");
1896        assert_eq!(settings.model_for(ChatProvider::Ollama), "layer-chat");
1897        assert_eq!(settings.chunk_size, 256);
1898        assert_eq!(
1899            settings.memory,
1900            MemoryChoice::Off,
1901            "runtime memory survives"
1902        );
1903        assert!(!settings.hybrid, "runtime retrieval switch survives");
1904
1905        let _ = std::fs::remove_dir_all(root);
1906    }
1907
1908    #[test]
1909    fn layers_keep_every_host_choice() {
1910        let root = unique_temp_dir("ragrig-gpui-layer-host-state");
1911        let global = root.join("home/.ragrig/config.json");
1912        let workspace = root.join("paper/.ragrig/config.json");
1913        std::fs::create_dir_all(global.parent().expect("global parent")).expect("global dir");
1914        std::fs::create_dir_all(workspace.parent().expect("workspace parent"))
1915            .expect("workspace dir");
1916        std::fs::write(&global, r#"{"memory":{"strategy":"summary"}}"#).expect("layer");
1917        std::fs::write(
1918            &workspace,
1919            r#"{"memory":{"strategy":"off"},"hybrid":false}"#,
1920        )
1921        .expect("layer");
1922
1923        let mut base = fixture();
1924        base.context_tokens = 16_384;
1925        base.max_tokens = 4096;
1926        base.temperature = 0.7;
1927        base.stream = false;
1928        base.bibliography = "/tmp/library.bib".to_owned();
1929        base.rename_pdfs = true;
1930        base.openalex = true;
1931        base.resolve_citations = false;
1932        base.system_prompt = "Host prompt".to_owned();
1933        base.system_prompt_path = "/tmp/host-prompt.md".to_owned();
1934        base.grobid_source = GrobidSource::Managed;
1935        base.grobid_image = "custom/grobid:test".to_owned();
1936        base.grobid_auto_start = true;
1937        base.grobid_container = "my-grobid".to_owned();
1938        base.ollama_source = ServiceSource::ExistingContainer;
1939        base.ollama_image = "custom/ollama:test".to_owned();
1940        base.ollama_auto_start = true;
1941        base.ollama_container = "ollama.sycl".to_owned();
1942        base.llamacpp_source = ServiceSource::ExistingContainer;
1943        base.llamacpp_image = "custom/llamacpp:test".to_owned();
1944        base.llamacpp_container = "llama.sycl".to_owned();
1945        base.llamacpp_url = "http://gpu-box:9999/v1".to_owned();
1946        base.llamacpp_model = "owner/model:Q4_K_M".to_owned();
1947        base.llamacpp_args = "--n-gpu-layers all".to_owned();
1948        base.llamacpp_auto_start = true;
1949
1950        let settings = RagSettings::from_layer_paths(Some(&global), &workspace, Some(&base))
1951            .expect("layered settings");
1952
1953        assert_eq!(settings.context_tokens, 16_384, "context size survives");
1954        assert_eq!(settings.max_tokens, 4096, "max tokens survive");
1955        assert_eq!(settings.temperature, 0.7);
1956        assert!(!settings.stream, "the host stream switch survives");
1957        assert_eq!(settings.bibliography, "/tmp/library.bib");
1958        assert!(settings.rename_pdfs);
1959        assert!(settings.openalex);
1960        assert!(!settings.resolve_citations);
1961        assert_eq!(settings.system_prompt, "Host prompt");
1962        assert_eq!(settings.system_prompt_path, "/tmp/host-prompt.md");
1963        assert_eq!(settings.memory, MemoryChoice::Off, "the workspace wins");
1964        assert!(!settings.hybrid, "the layer extension is read");
1965
1966        assert_eq!(settings.grobid_source, GrobidSource::Managed);
1967        assert_eq!(settings.grobid_image, "custom/grobid:test");
1968        assert!(settings.grobid_auto_start);
1969        assert_eq!(settings.grobid_container, "my-grobid");
1970        assert_eq!(settings.ollama_source, ServiceSource::ExistingContainer);
1971        assert_eq!(settings.ollama_image, "custom/ollama:test");
1972        assert!(settings.ollama_auto_start);
1973        assert_eq!(settings.ollama_container, "ollama.sycl");
1974        assert_eq!(settings.llamacpp_source, ServiceSource::ExistingContainer);
1975        assert_eq!(settings.llamacpp_image, "custom/llamacpp:test");
1976        assert_eq!(settings.llamacpp_container, "llama.sycl");
1977        assert_eq!(settings.llamacpp_url, "http://gpu-box:9999/v1");
1978        assert_eq!(settings.llamacpp_model, "owner/model:Q4_K_M");
1979        assert_eq!(settings.llamacpp_args, "--n-gpu-layers all");
1980        assert!(settings.llamacpp_auto_start);
1981
1982        let _ = std::fs::remove_dir_all(root);
1983    }
1984
1985    #[test]
1986    fn layers_keep_models_of_inactive_providers() {
1987        let root = unique_temp_dir("ragrig-gpui-layer-models");
1988        let workspace = root.join("paper/.ragrig/config.json");
1989        std::fs::create_dir_all(workspace.parent().expect("workspace parent"))
1990            .expect("workspace dir");
1991        std::fs::write(&workspace, r#"{"parse":{"chunk_size":256}}"#).expect("layer");
1992
1993        let mut base = fixture();
1994        base.provider = ChatProvider::OpenAi;
1995        base.set_model_for(ChatProvider::OpenAi, "gpt-4o-mini");
1996        base.set_model_for(ChatProvider::Ollama, "host-ollama");
1997        base.set_model_for(ChatProvider::DeepSeek, "host-deepseek");
1998
1999        let settings =
2000            RagSettings::from_layer_paths(None, &workspace, Some(&base)).expect("layered settings");
2001        assert_eq!(settings.provider, ChatProvider::OpenAi);
2002        assert_eq!(settings.model_for(ChatProvider::OpenAi), "gpt-4o-mini");
2003        assert_eq!(
2004            settings.model_for(ChatProvider::Ollama),
2005            "host-ollama",
2006            "the inactive Ollama model survives"
2007        );
2008        assert_eq!(
2009            settings.model_for(ChatProvider::DeepSeek),
2010            "host-deepseek",
2011            "the inactive DeepSeek model survives"
2012        );
2013
2014        std::fs::write(
2015            &workspace,
2016            r#"{"chat":{"model":"layer-model","deepseek_model":"layer-deepseek"}}"#,
2017        )
2018        .expect("layer");
2019        let settings =
2020            RagSettings::from_layer_paths(None, &workspace, Some(&base)).expect("layered settings");
2021        assert_eq!(
2022            settings.model_for(ChatProvider::OpenAi),
2023            "layer-model",
2024            "the layer replaces the active model"
2025        );
2026        assert_eq!(settings.model_for(ChatProvider::DeepSeek), "layer-deepseek");
2027        assert_eq!(
2028            settings.model_for(ChatProvider::Ollama),
2029            "host-ollama",
2030            "the untouched model survives"
2031        );
2032
2033        let _ = std::fs::remove_dir_all(root);
2034    }
2035
2036    #[test]
2037    fn a_layer_prompt_overrides_the_host_prompt() {
2038        let root = unique_temp_dir("ragrig-gpui-layer-prompt");
2039        let workspace = root.join("paper/.ragrig/config.json");
2040        std::fs::create_dir_all(workspace.parent().expect("workspace parent"))
2041            .expect("workspace dir");
2042        std::fs::write(root.join("paper/Layer.md"), "Answer as the workspace does.")
2043            .expect("prompt");
2044        std::fs::write(&workspace, r#"{"chat":{"system_prompt_path":"Layer.md"}}"#).expect("layer");
2045
2046        let mut base = fixture();
2047        base.workspace = root.join("paper").display().to_string();
2048        base.system_prompt = "Host prompt".to_owned();
2049        base.built_in_prompt = "Host built-in".to_owned();
2050
2051        let settings =
2052            RagSettings::from_layer_paths(None, &workspace, Some(&base)).expect("layered settings");
2053        assert!(
2054            settings.system_prompt_path.ends_with("paper/Layer.md"),
2055            "the layer prompt path is resolved against the workspace: {}",
2056            settings.system_prompt_path
2057        );
2058        assert_eq!(settings.system_prompt, "Answer as the workspace does.");
2059
2060        std::fs::write(&workspace, r#"{"chat":{"system_prompt_path":null}}"#).expect("layer");
2061        let settings =
2062            RagSettings::from_layer_paths(None, &workspace, Some(&base)).expect("layered settings");
2063        assert_eq!(settings.system_prompt_path, "");
2064        assert_eq!(settings.system_prompt, "Host built-in");
2065
2066        let _ = std::fs::remove_dir_all(root);
2067    }
2068
2069    #[test]
2070    fn a_prompt_file_overrides_the_inline_prompt() {
2071        let workspace = unique_temp_dir("ragrig-gpui-prompt-file");
2072        std::fs::write(workspace.join("Chat.md"), "# Chat\n\nUse the corpus.")
2073            .expect("prompt file");
2074        let mut settings = fixture();
2075        settings.workspace = workspace.display().to_string();
2076        settings.system_prompt = "inline".to_owned();
2077        settings.system_prompt_path = "Chat.md".to_owned();
2078
2079        assert_eq!(
2080            settings.to_setup().expect("setup").system_prompt.as_deref(),
2081            Some("# Chat\n\nUse the corpus.")
2082        );
2083        let applied = RagSettings {
2084            system_prompt_path: String::new(),
2085            system_prompt: "inline".to_owned(),
2086            ..settings.clone()
2087        };
2088        assert!(
2089            settings
2090                .dirty_since(&applied)
2091                .contains(RagField::SystemPrompt)
2092        );
2093
2094        let _ = std::fs::remove_dir_all(workspace);
2095    }
2096
2097    #[test]
2098    fn a_missing_prompt_file_is_reported() {
2099        let mut settings = fixture();
2100        settings.system_prompt_path = "/definitely/not/here/Chat.md".to_owned();
2101        let error = settings.to_setup().expect_err("missing prompt");
2102        assert!(error.contains("cannot read system prompt"), "{error}");
2103    }
2104
2105    #[test]
2106    fn using_the_built_in_prompt_clears_the_file() {
2107        let mut settings = fixture();
2108        settings.built_in_prompt = "Host prompt".to_owned();
2109        settings.system_prompt = "File prompt".to_owned();
2110        settings.system_prompt_path = "Chat.md".to_owned();
2111
2112        settings.use_built_in_prompt();
2113
2114        assert_eq!(settings.system_prompt, "Host prompt");
2115        assert!(settings.system_prompt_path.is_empty());
2116    }
2117
2118    #[test]
2119    fn profile_defaults_restore_the_built_in_prompt() {
2120        let mut settings = fixture();
2121        settings.built_in_prompt = "Host prompt".to_owned();
2122        settings.system_prompt = "Custom".to_owned();
2123        settings.system_prompt_path = "custom.md".to_owned();
2124
2125        settings.reset_to_profile_defaults().expect("reset");
2126
2127        assert_eq!(settings.system_prompt, "Host prompt");
2128        assert!(settings.system_prompt_path.is_empty());
2129    }
2130
2131    #[test]
2132    fn a_configured_bibliography_reaches_the_setup() {
2133        let mut settings = fixture();
2134        assert!(
2135            settings
2136                .to_setup()
2137                .expect("valid defaults")
2138                .bibliography
2139                .is_none()
2140        );
2141
2142        settings.bibliography = "  /tmp/library.bib ".to_owned();
2143        assert_eq!(
2144            settings.to_setup().expect("valid setup").bibliography,
2145            Some(std::path::PathBuf::from("/tmp/library.bib"))
2146        );
2147    }
2148
2149    #[test]
2150    fn invalid_values_are_rejected() {
2151        let mut settings = fixture();
2152        settings.chunk_overlap = settings.chunk_size;
2153        assert_eq!(
2154            settings.validate(),
2155            Err("chunk overlap must be smaller than the chunk size".to_owned())
2156        );
2157
2158        settings = fixture();
2159        settings.top_k = 0;
2160        assert_eq!(
2161            settings.validate(),
2162            Err("top-k must be at least 1".to_owned())
2163        );
2164
2165        settings = fixture();
2166        settings.similarity_threshold = 1.5;
2167        assert_eq!(
2168            settings.validate(),
2169            Err("similarity threshold must be between 0 and 1".to_owned())
2170        );
2171
2172        settings = fixture();
2173        settings.corpus_dirs = vec!["broken".to_owned()];
2174        assert_eq!(
2175            settings.validate(),
2176            Err("corpus 'broken' must use name=path".to_owned())
2177        );
2178    }
2179
2180    #[test]
2181    fn profiles_round_trip_prompt_and_api_key_slot() {
2182        let workspace = unique_temp_dir("ragrig-gpui-profile");
2183        let mut settings = fixture();
2184        settings.workspace = workspace.display().to_string();
2185        settings.set_model("qwen3.5:9b");
2186        settings.system_prompt = "Cite primary sources only.".to_owned();
2187        settings.profile_name = "lab".to_owned();
2188        settings.status = None;
2189
2190        settings.save_profile().expect("save profile");
2191        let path = workspace.join(".ragrig/profiles/lab.json");
2192        let json = std::fs::read_to_string(&path).expect("profile file");
2193        assert!(!json.contains("sk-"));
2194        assert!(json.contains("\"deepseek_api_key\": null"));
2195        assert!(json.contains("lab.prompt.md"));
2196        assert_eq!(
2197            std::fs::read_to_string(workspace.join(".ragrig/profiles/lab.prompt.md"))
2198                .expect("prompt file"),
2199            "Cite primary sources only."
2200        );
2201
2202        settings.workspace = workspace.display().to_string();
2203        settings.refresh_profiles();
2204        settings.profile_name = "lab".to_owned();
2205        settings.system_prompt = "overwritten".to_owned();
2206        settings.load_profile("lab").expect("load profile");
2207        assert_eq!(settings.model_for(ChatProvider::Ollama), "qwen3.5:9b");
2208        assert_eq!(settings.system_prompt, "Cite primary sources only.");
2209
2210        settings.profile_name = "../escape".to_owned();
2211        assert!(settings.save_profile().is_err());
2212
2213        let _ = std::fs::remove_dir_all(workspace);
2214    }
2215
2216    #[test]
2217    fn every_profile_field_round_trips() {
2218        type Mutation = fn(&mut RagSettings);
2219        let mutations: &[(&str, Mutation)] = &[
2220            ("provider", |settings| {
2221                settings.provider = ChatProvider::DeepSeek
2222            }),
2223            ("model", |settings| settings.set_model("llama3.1:8b")),
2224            ("deepseek model", |settings| {
2225                settings.set_model_for(ChatProvider::DeepSeek, "deepseek-chat")
2226            }),
2227            ("llamacpp url", |settings| {
2228                settings.llamacpp_url = "http://gpu-box:9000/v1".to_owned()
2229            }),
2230            ("temperature", |settings| settings.temperature = 0.9),
2231            ("max tokens", |settings| settings.max_tokens = 4096),
2232            ("context tokens", |settings| {
2233                settings.context_tokens = 16_384
2234            }),
2235            ("embed model", |settings| {
2236                settings.embed_model = "mxbai-embed-large".to_owned()
2237            }),
2238            ("top k", |settings| settings.top_k = 12),
2239            ("similarity threshold", |settings| {
2240                settings.similarity_threshold = 0.25
2241            }),
2242            ("chunk size", |settings| settings.chunk_size = 2048),
2243            ("chunk overlap", |settings| settings.chunk_overlap = 64),
2244            ("pdf backend", |settings| {
2245                settings.pdf_backend = PdfBackend::Internal
2246            }),
2247            ("sloppy pdf", |settings| settings.sloppy_pdf = true),
2248            ("memory strategy", |settings| {
2249                settings.memory = MemoryChoice::Summary
2250            }),
2251            ("memory model", |settings| {
2252                settings.memory_model = "qwen3:1.7b".to_owned()
2253            }),
2254            ("corpus dirs", |settings| {
2255                settings.corpus_dirs = vec!["papers=/docs/papers".to_owned()]
2256            }),
2257        ];
2258
2259        for (label, mutate) in mutations {
2260            let workspace = unique_temp_dir("ragrig-gpui-profile-field");
2261            let mut settings = fixture();
2262            settings.workspace = workspace.display().to_string();
2263            settings.profile_name = "roundtrip".to_owned();
2264            mutate(&mut settings);
2265            settings.save_profile().expect(label);
2266
2267            let mut host = fixture();
2268            host.workspace = workspace.display().to_string();
2269            host.profile_name = "roundtrip".to_owned();
2270            host.load_profile("roundtrip").expect(label);
2271
2272            let saved =
2273                serde_json::to_value(settings.to_config().expect(label)).expect("saved config");
2274            let loaded =
2275                serde_json::to_value(host.to_config().expect(label)).expect("loaded config");
2276            assert_eq!(loaded, saved, "{label} did not survive the profile");
2277
2278            let _ = std::fs::remove_dir_all(workspace);
2279        }
2280    }
2281
2282    #[test]
2283    fn profile_load_keeps_host_only_settings() {
2284        let workspace = unique_temp_dir("ragrig-gpui-profile-host-state");
2285        let mut settings = fixture();
2286        settings.workspace = workspace.display().to_string();
2287        settings.profile_name = "lab".to_owned();
2288        settings.context_tokens = 8192;
2289        settings.save_profile().expect("save profile");
2290
2291        let mut host = fixture();
2292        host.workspace = workspace.display().to_string();
2293        host.bibliography = "/tmp/library.bib".to_owned();
2294        host.stream = false;
2295        host.hybrid = false;
2296        host.rename_pdfs = true;
2297        host.openalex = true;
2298        host.resolve_citations = false;
2299        host.built_in_prompt = "Host prompt".to_owned();
2300        host.system_prompt = "Host prompt".to_owned();
2301        host.ollama_source = ServiceSource::ExistingContainer;
2302        host.ollama_container = "ollama.sycl".to_owned();
2303        host.ollama_image = "custom/ollama:test".to_owned();
2304        host.ollama_auto_start = true;
2305        host.grobid_source = GrobidSource::Managed;
2306        host.grobid_container = "my-grobid".to_owned();
2307        host.llamacpp_source = ServiceSource::ExistingContainer;
2308        host.llamacpp_container = "llama.sycl".to_owned();
2309        host.llamacpp_model = "owner/model:Q4_K_M".to_owned();
2310        host.llamacpp_args = "--n-gpu-layers all".to_owned();
2311        host.llamacpp_auto_start = true;
2312
2313        host.load_profile("lab").expect("load profile");
2314
2315        assert_eq!(host.context_tokens, 8192, "the profile's own value wins");
2316        assert_eq!(host.bibliography, "/tmp/library.bib");
2317        assert!(!host.stream);
2318        assert!(!host.hybrid, "the runtime retrieval switch survives");
2319        assert!(host.rename_pdfs);
2320        assert!(host.openalex);
2321        assert!(!host.resolve_citations);
2322        assert_eq!(host.system_prompt, "Host prompt");
2323        assert_eq!(host.ollama_source, ServiceSource::ExistingContainer);
2324        assert_eq!(host.ollama_container, "ollama.sycl");
2325        assert_eq!(host.ollama_image, "custom/ollama:test");
2326        assert!(host.ollama_auto_start);
2327        assert_eq!(host.grobid_source, GrobidSource::Managed);
2328        assert_eq!(host.grobid_container, "my-grobid");
2329        assert_eq!(host.llamacpp_source, ServiceSource::ExistingContainer);
2330        assert_eq!(host.llamacpp_container, "llama.sycl");
2331        assert_eq!(host.llamacpp_model, "owner/model:Q4_K_M");
2332        assert_eq!(host.llamacpp_args, "--n-gpu-layers all");
2333        assert!(host.llamacpp_auto_start);
2334
2335        let _ = std::fs::remove_dir_all(workspace);
2336    }
2337
2338    #[test]
2339    fn profile_load_keeps_inactive_provider_models() {
2340        let workspace = unique_temp_dir("ragrig-gpui-profile-models");
2341        let mut settings = fixture();
2342        settings.workspace = workspace.display().to_string();
2343        settings.provider = ChatProvider::OpenAi;
2344        settings.set_model_for(ChatProvider::OpenAi, "gpt-4o-mini");
2345        settings.set_model_for(ChatProvider::Ollama, "profile-ollama");
2346        settings.profile_name = "cloud".to_owned();
2347        settings.save_profile().expect("save profile");
2348
2349        let mut host = fixture();
2350        host.workspace = workspace.display().to_string();
2351        host.set_model_for(ChatProvider::Ollama, "host-ollama");
2352        host.load_profile("cloud").expect("load profile");
2353
2354        assert_eq!(host.provider, ChatProvider::OpenAi);
2355        assert_eq!(
2356            host.model_for(ChatProvider::OpenAi),
2357            "gpt-4o-mini",
2358            "the profile's active model is loaded"
2359        );
2360        assert_eq!(
2361            host.model_for(ChatProvider::Ollama),
2362            "host-ollama",
2363            "a model the profile cannot express survives"
2364        );
2365
2366        let _ = std::fs::remove_dir_all(workspace);
2367    }
2368
2369    #[test]
2370    fn profile_defaults_keep_host_only_settings() {
2371        let mut settings = fixture();
2372        settings.top_k = 3;
2373        settings.context_tokens = 32_768;
2374        settings.bibliography = "/tmp/library.bib".to_owned();
2375        settings.stream = false;
2376        settings.hybrid = false;
2377        settings.rename_pdfs = true;
2378        settings.openalex = true;
2379        settings.resolve_citations = false;
2380        settings.ollama_source = ServiceSource::ExistingContainer;
2381        settings.ollama_container = "ollama.sycl".to_owned();
2382        settings.profile_name.clear();
2383
2384        settings.reset_to_profile_defaults().expect("reset");
2385
2386        let defaults = RagSettings::default();
2387        assert_eq!(settings.top_k, defaults.top_k, "profile fields reset");
2388        assert_eq!(settings.context_tokens, defaults.context_tokens);
2389        assert_eq!(settings.bibliography, "/tmp/library.bib");
2390        assert!(!settings.stream);
2391        assert!(!settings.hybrid);
2392        assert!(settings.rename_pdfs);
2393        assert!(settings.openalex);
2394        assert!(!settings.resolve_citations);
2395        assert_eq!(settings.ollama_source, ServiceSource::ExistingContainer);
2396        assert_eq!(settings.ollama_container, "ollama.sycl");
2397    }
2398
2399    #[test]
2400    fn adding_a_corpus_replaces_the_same_name() {
2401        let mut settings = fixture();
2402        settings.add_corpus("papers", Path::new("/docs/a"));
2403        settings.add_corpus("papers", Path::new("/docs/b"));
2404        assert_eq!(settings.corpus_dirs, vec!["papers=/docs/b".to_owned()]);
2405    }
2406
2407    #[test]
2408    fn grobid_defaults_to_an_existing_server_and_the_full_image() {
2409        let settings = RagSettings::default();
2410        assert_eq!(settings.grobid_source, GrobidSource::Existing);
2411        assert_eq!(settings.grobid_url, DEFAULT_GROBID_URL.to_owned());
2412        assert_eq!(settings.grobid_image, DEFAULT_GROBID_IMAGE.to_owned());
2413        assert_eq!(
2414            settings.grobid_container,
2415            DEFAULT_GROBID_CONTAINER.to_owned()
2416        );
2417        assert!(!settings.grobid_auto_start);
2418        assert_eq!(
2419            GrobidSource::from_label("Docker container"),
2420            Some(GrobidSource::Managed)
2421        );
2422        assert_eq!(GrobidSource::from_label("not a source"), None);
2423    }
2424
2425    #[test]
2426    fn local_model_services_default_to_existing_servers() {
2427        let settings = RagSettings::default();
2428        assert_eq!(settings.ollama_source, ServiceSource::Existing);
2429        assert_eq!(settings.ollama_image, DEFAULT_OLLAMA_IMAGE.to_owned());
2430        assert_eq!(
2431            settings.ollama_container,
2432            DEFAULT_OLLAMA_CONTAINER.to_owned()
2433        );
2434        assert!(!settings.ollama_auto_start);
2435        assert_eq!(settings.llamacpp_source, ServiceSource::Existing);
2436        assert_eq!(settings.llamacpp_image, DEFAULT_LLAMACPP_IMAGE.to_owned());
2437        assert_eq!(
2438            settings.llamacpp_container,
2439            DEFAULT_LLAMACPP_CONTAINER.to_owned()
2440        );
2441        assert!(settings.llamacpp_model.is_empty());
2442        assert!(settings.llamacpp_args.is_empty());
2443        assert!(!settings.llamacpp_auto_start);
2444    }
2445
2446    #[test]
2447    fn an_existing_container_source_requires_a_container_name() {
2448        let mut settings = RagSettings {
2449            ollama_source: ServiceSource::ExistingContainer,
2450            ollama_container: "   ".to_owned(),
2451            ..RagSettings::default()
2452        };
2453        assert!(settings.validate().is_err());
2454        settings.ollama_container = "ollama-sycl".to_owned();
2455        assert!(settings.validate().is_ok());
2456        assert_eq!(
2457            ServiceSource::from_label("Existing container"),
2458            Some(ServiceSource::ExistingContainer)
2459        );
2460    }
2461
2462    #[test]
2463    fn a_managed_llamacpp_container_requires_a_model() {
2464        let mut settings = RagSettings {
2465            llamacpp_source: ServiceSource::Managed,
2466            ..RagSettings::default()
2467        };
2468        assert!(settings.validate().is_err());
2469        settings.llamacpp_model = "owner/model:Q4_K_M".to_owned();
2470        assert!(settings.validate().is_ok());
2471    }
2472
2473    #[test]
2474    fn changes_name_changed_fields_and_their_sections() {
2475        let applied = fixture();
2476        let mut settings = applied.clone();
2477        assert!(settings.dirty_since(&applied).is_empty());
2478
2479        settings.max_tokens = applied.max_tokens * 2;
2480        settings.chunk_size = applied.chunk_size * 2;
2481        settings.openalex = !applied.openalex;
2482        let changes = settings.dirty_since(&applied);
2483
2484        assert!(changes.contains(RagField::MaxTokens));
2485        assert!(changes.contains(RagField::ChunkSize));
2486        assert!(changes.contains(RagField::OpenAlex));
2487        assert!(!changes.contains(RagField::Provider));
2488        assert_eq!(changes.len(), 3);
2489        assert!(changes.section(RagSection::Chat));
2490        assert!(changes.section(RagSection::Parsing));
2491        assert!(changes.section(RagSection::References));
2492        assert!(!changes.section(RagSection::Memory));
2493        assert_eq!(changes.count_in(RagSection::Chat), 1);
2494        assert_eq!(changes.count_in(RagSection::Parsing), 1);
2495        assert_eq!(changes.count_in(RagSection::Memory), 0);
2496    }
2497
2498    #[test]
2499    fn every_editable_field_is_tracked() {
2500        type FieldMutation = (RagField, fn(&mut RagSettings));
2501        let applied = fixture();
2502        let mutators: &[FieldMutation] = &[
2503            (RagField::Workspace, |settings| {
2504                settings.workspace.push_str("/elsewhere")
2505            }),
2506            (RagField::Bibliography, |settings| {
2507                settings.bibliography.push_str("/other.bib")
2508            }),
2509            (RagField::Provider, |settings| {
2510                settings.provider = ChatProvider::DeepSeek
2511            }),
2512            (RagField::Model, |settings| {
2513                let model = settings.model().to_owned();
2514                settings.set_model(format!("{model}-alt"));
2515            }),
2516            (RagField::LlamaCppUrl, |settings| {
2517                settings.llamacpp_url.push_str(":9090")
2518            }),
2519            (RagField::Temperature, |settings| {
2520                settings.temperature += 0.1
2521            }),
2522            (RagField::MaxTokens, |settings| settings.max_tokens *= 2),
2523            (RagField::ContextTokens, |settings| {
2524                settings.context_tokens *= 2
2525            }),
2526            (RagField::Stream, |settings| {
2527                settings.stream = !settings.stream
2528            }),
2529            (RagField::SystemPrompt, |settings| {
2530                settings.system_prompt.push_str(" Be terse.")
2531            }),
2532            (RagField::EmbedModel, |settings| {
2533                settings.embed_model.push_str("-alt")
2534            }),
2535            (RagField::Hybrid, |settings| {
2536                settings.hybrid = !settings.hybrid
2537            }),
2538            (RagField::TopK, |settings| settings.top_k += 1),
2539            (RagField::SimilarityThreshold, |settings| {
2540                settings.similarity_threshold += 0.01
2541            }),
2542            (RagField::CorpusDirs, |settings| {
2543                settings.corpus_dirs.push("lab=/docs/lab".to_owned())
2544            }),
2545            (RagField::PdfBackend, |settings| {
2546                settings.pdf_backend = PdfBackend::Sink
2547            }),
2548            (RagField::SloppyPdf, |settings| {
2549                settings.sloppy_pdf = !settings.sloppy_pdf
2550            }),
2551            (RagField::ChunkSize, |settings| settings.chunk_size += 16),
2552            (RagField::ChunkOverlap, |settings| {
2553                settings.chunk_overlap += 8
2554            }),
2555            (RagField::Memory, |settings| {
2556                settings.memory = MemoryChoice::Off
2557            }),
2558            (RagField::MemoryModel, |settings| {
2559                settings.memory_model.push_str("-alt")
2560            }),
2561            (RagField::GrobidSource, |settings| {
2562                settings.grobid_source = GrobidSource::Managed
2563            }),
2564            (RagField::GrobidUrl, |settings| {
2565                settings.grobid_url.push_str("/v2")
2566            }),
2567            (RagField::GrobidImage, |settings| {
2568                settings.grobid_image.push_str("-crf")
2569            }),
2570            (RagField::GrobidAutoStart, |settings| {
2571                settings.grobid_auto_start = !settings.grobid_auto_start
2572            }),
2573            (RagField::GrobidContainer, |settings| {
2574                settings.grobid_container.push_str("-custom")
2575            }),
2576            (RagField::OllamaSource, |settings| {
2577                settings.ollama_source = ServiceSource::Managed
2578            }),
2579            (RagField::OllamaImage, |settings| {
2580                settings.ollama_image.push_str(":rocm")
2581            }),
2582            (RagField::OllamaAutoStart, |settings| {
2583                settings.ollama_auto_start = !settings.ollama_auto_start
2584            }),
2585            (RagField::OllamaContainer, |settings| {
2586                settings.ollama_container.push_str("-custom")
2587            }),
2588            (RagField::LlamaCppSource, |settings| {
2589                settings.llamacpp_source = ServiceSource::Managed
2590            }),
2591            (RagField::LlamaCppImage, |settings| {
2592                settings.llamacpp_image.push_str("-cuda")
2593            }),
2594            (RagField::LlamaCppContainer, |settings| {
2595                settings.llamacpp_container.push_str("-custom")
2596            }),
2597            (RagField::LlamaCppModel, |settings| {
2598                settings.llamacpp_model = "owner/model:Q4_K_M".to_owned()
2599            }),
2600            (RagField::LlamaCppArgs, |settings| {
2601                settings.llamacpp_args = "--n-gpu-layers all".to_owned()
2602            }),
2603            (RagField::LlamaCppAutoStart, |settings| {
2604                settings.llamacpp_auto_start = !settings.llamacpp_auto_start
2605            }),
2606            (RagField::RenamePdfs, |settings| {
2607                settings.rename_pdfs = !settings.rename_pdfs
2608            }),
2609            (RagField::OpenAlex, |settings| {
2610                settings.openalex = !settings.openalex
2611            }),
2612            (RagField::ResolveCitations, |settings| {
2613                settings.resolve_citations = !settings.resolve_citations
2614            }),
2615        ];
2616
2617        for (field, mutate) in mutators {
2618            let mut settings = applied.clone();
2619            mutate(&mut settings);
2620            let changes = settings.dirty_since(&applied);
2621            assert!(
2622                changes.contains(*field),
2623                "{field:?} is not part of the change set"
2624            );
2625            assert_eq!(changes.len(), 1, "{field:?} changed more than one field");
2626            assert!(
2627                changes.section(field.section()),
2628                "{field:?} did not mark its section"
2629            );
2630        }
2631    }
2632
2633    #[test]
2634    fn profile_bookkeeping_is_not_a_change() {
2635        let applied = fixture();
2636        let mut settings = applied.clone();
2637        settings.profile_name = "lab".to_owned();
2638        settings.profiles = vec!["lab".to_owned()];
2639        settings.status = Some("Saved profile 'lab'".to_owned());
2640
2641        assert!(settings.dirty_since(&applied).is_empty());
2642    }
2643
2644    #[test]
2645    fn profile_defaults_reload_the_selected_profile() {
2646        let workspace = unique_temp_dir("ragrig-gpui-defaults");
2647        let mut settings = fixture();
2648        settings.workspace = workspace.display().to_string();
2649        settings.profile_name = "lab".to_owned();
2650        settings.set_model("qwen3.5:9b");
2651        settings.top_k = 42;
2652        settings.save_profile().expect("save profile");
2653
2654        settings.set_model("scratch");
2655        settings.top_k = 3;
2656        settings.reset_to_profile_defaults().expect("reset");
2657
2658        assert_eq!(settings.model_for(ChatProvider::Ollama), "qwen3.5:9b");
2659        assert_eq!(settings.top_k, 42);
2660        assert_eq!(settings.workspace, workspace.display().to_string());
2661
2662        let _ = std::fs::remove_dir_all(workspace);
2663    }
2664
2665    #[test]
2666    fn profile_defaults_without_a_profile_keep_the_library_location() {
2667        let mut settings = fixture();
2668        settings.workspace = "/tmp/scico-workspace".to_owned();
2669        settings.bibliography = "/tmp/scico.bib".to_owned();
2670        settings.profile_name.clear();
2671        settings.top_k = 3;
2672        settings.temperature = 1.7;
2673
2674        settings.reset_to_profile_defaults().expect("reset");
2675
2676        let defaults = RagSettings::default();
2677        assert_eq!(settings.top_k, defaults.top_k);
2678        assert_eq!(settings.temperature, defaults.temperature);
2679        assert_eq!(settings.workspace, "/tmp/scico-workspace");
2680        assert_eq!(settings.bibliography, "/tmp/scico.bib");
2681    }
2682
2683    fn unique_temp_dir(prefix: &str) -> PathBuf {
2684        static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2685        let counter = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2686        let dir = std::env::temp_dir().join(format!("{prefix}-{}-{counter}", std::process::id()));
2687        std::fs::create_dir_all(&dir).expect("create temp dir");
2688        dir
2689    }
2690}