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/// Chat backend offered by the settings page.
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
24pub enum ChatProvider {
25    /// Local Ollama server.
26    #[default]
27    Ollama,
28    /// Local llama.cpp `llama-server`; optional key from `LLAMACPP_API_KEY`.
29    LlamaCpp,
30    /// DeepSeek cloud API; the key comes from `DEEPSEEK_API_KEY`.
31    DeepSeek,
32    /// OpenAI API; the key comes from `OPENAI_API_KEY`.
33    OpenAi,
34    /// Anthropic Claude API; the key comes from `ANTHROPIC_API_KEY`.
35    Claude,
36    /// Mistral API; the key comes from `MISTRAL_API_KEY`.
37    Mistral,
38    /// OpenRouter API; the key comes from `OPENROUTER_API_KEY`.
39    OpenRouter,
40}
41
42impl ChatProvider {
43    /// Human-readable name.
44    pub fn label(self) -> &'static str {
45        match self {
46            Self::Ollama => "Ollama",
47            Self::LlamaCpp => "llama.cpp",
48            Self::DeepSeek => "DeepSeek",
49            Self::OpenAi => "OpenAI",
50            Self::Claude => "Claude",
51            Self::Mistral => "Mistral",
52            Self::OpenRouter => "OpenRouter",
53        }
54    }
55
56    /// The environment variable holding this provider's API key.
57    ///
58    /// `None` for the local Ollama server.  `llama.cpp` authenticates
59    /// optionally, so its variable is only needed for a secured server.
60    pub fn env_var(self) -> Option<&'static str> {
61        match self {
62            Self::Ollama => None,
63            Self::LlamaCpp => Some("LLAMACPP_API_KEY"),
64            Self::DeepSeek => Some("DEEPSEEK_API_KEY"),
65            Self::OpenAi => Some("OPENAI_API_KEY"),
66            Self::Claude => Some("ANTHROPIC_API_KEY"),
67            Self::Mistral => Some("MISTRAL_API_KEY"),
68            Self::OpenRouter => Some("OPENROUTER_API_KEY"),
69        }
70    }
71
72    /// The model used when the settings hold no explicit choice.
73    pub fn default_model(self) -> &'static str {
74        match self {
75            Self::Ollama => "qwen3.5:9b",
76            Self::LlamaCpp => DEFAULT_LLAMACPP_MODEL,
77            Self::DeepSeek => "deepseek-v4-pro",
78            Self::OpenAi => "gpt-4o",
79            Self::Claude => "claude-sonnet-4-6",
80            Self::Mistral => "mistral-large-latest",
81            Self::OpenRouter => "openrouter/auto",
82        }
83    }
84
85    /// Options for a dropdown.
86    pub fn options() -> &'static [(Self, &'static str)] {
87        &[
88            (Self::Ollama, "Ollama"),
89            (Self::LlamaCpp, "llama.cpp"),
90            (Self::DeepSeek, "DeepSeek"),
91            (Self::OpenAi, "OpenAI"),
92            (Self::Claude, "Claude"),
93            (Self::Mistral, "Mistral"),
94            (Self::OpenRouter, "OpenRouter"),
95        ]
96    }
97
98    /// Find the provider with the given dropdown label.
99    pub fn from_label(label: &str) -> Option<Self> {
100        Self::options()
101            .iter()
102            .find(|(_, known)| *known == label)
103            .map(|(provider, _)| *provider)
104    }
105}
106
107impl From<ChatProvider> for Provider {
108    fn from(provider: ChatProvider) -> Self {
109        match provider {
110            ChatProvider::Ollama => Self::Ollama,
111            ChatProvider::LlamaCpp => Self::LlamaCpp,
112            ChatProvider::DeepSeek => Self::Deepseek,
113            ChatProvider::OpenAi => Self::OpenAi,
114            ChatProvider::Claude => Self::Claude,
115            ChatProvider::Mistral => Self::Mistral,
116            ChatProvider::OpenRouter => Self::OpenRouter,
117        }
118    }
119}
120
121impl From<Provider> for ChatProvider {
122    fn from(provider: Provider) -> Self {
123        match provider {
124            Provider::Deepseek => Self::DeepSeek,
125            Provider::LlamaCpp => Self::LlamaCpp,
126            Provider::OpenAi => Self::OpenAi,
127            Provider::Claude => Self::Claude,
128            Provider::Mistral => Self::Mistral,
129            Provider::OpenRouter => Self::OpenRouter,
130            _ => Self::Ollama,
131        }
132    }
133}
134
135/// PDF backend offered by the settings page.
136#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
137pub enum PdfBackend {
138    /// Legacy flat-text extraction.
139    #[default]
140    Extract,
141    /// Markdown-native extraction.
142    Unpdf,
143    /// Structured, layout-aware extraction.
144    Sink,
145    /// Binary scavenger that never panics.
146    Internal,
147}
148
149impl PdfBackend {
150    /// Human-readable name.
151    pub fn label(self) -> &'static str {
152        match self {
153            Self::Extract => "pdf-extract",
154            Self::Unpdf => "unpdf",
155            Self::Sink => "pdfsink",
156            Self::Internal => "sloppy-pdf",
157        }
158    }
159
160    /// Options for a dropdown.
161    pub fn options() -> &'static [(Self, &'static str)] {
162        &[
163            (Self::Extract, "pdf-extract (flat text)"),
164            (Self::Unpdf, "unpdf (Markdown)"),
165            (Self::Sink, "pdfsink (layout)"),
166            (Self::Internal, "sloppy-pdf (scavenger)"),
167        ]
168    }
169}
170
171/// Memory strategy offered by the settings page.
172#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
173pub enum MemoryChoice {
174    /// Rewrite the query with a small model before retrieval.
175    #[default]
176    Rewrite,
177    /// Pass the transcript through without rewriting.
178    Transcript,
179    /// Blend the most recent saved session into the query.
180    Log,
181    /// Summarise past sessions with an LLM.
182    Summary,
183    /// No transcript, no rewriting.
184    Off,
185}
186
187impl MemoryChoice {
188    /// Human-readable name.
189    pub fn label(self) -> &'static str {
190        match self {
191            Self::Rewrite => "Rewrite",
192            Self::Transcript => "Transcript",
193            Self::Log => "Log",
194            Self::Summary => "Summary",
195            Self::Off => "Off",
196        }
197    }
198
199    /// Options for a dropdown.
200    pub fn options() -> &'static [(Self, &'static str)] {
201        &[
202            (Self::Rewrite, "Rewrite (small model)"),
203            (Self::Transcript, "Transcript only"),
204            (Self::Log, "Log (last session)"),
205            (Self::Summary, "Summary (LLM)"),
206            (Self::Off, "Off"),
207        ]
208    }
209
210    fn uses_model(self) -> bool {
211        matches!(self, Self::Rewrite | Self::Log | Self::Summary)
212    }
213}
214
215/// Where the GROBID server comes from.
216#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
217pub enum GrobidSource {
218    /// Use a server that is already running (local or remote).
219    #[default]
220    Existing,
221    /// Let Scico pull and run the official Docker image.
222    Managed,
223}
224
225impl GrobidSource {
226    /// Human-readable name, also used as the persisted value.
227    pub fn label(self) -> &'static str {
228        match self {
229            Self::Existing => "Existing server",
230            Self::Managed => "Docker container",
231        }
232    }
233
234    /// Options for a dropdown.
235    pub fn options() -> &'static [(Self, &'static str)] {
236        &[
237            (Self::Existing, "Existing server"),
238            (Self::Managed, "Docker container"),
239        ]
240    }
241
242    /// Parse the label stored in a settings file.
243    pub fn from_label(label: &str) -> Option<Self> {
244        Self::options()
245            .iter()
246            .find(|(_, option)| *option == label || option.eq_ignore_ascii_case(label))
247            .map(|(source, _)| *source)
248    }
249}
250
251/// Everything needed to build one [`ragrig::RagAgent`].
252#[derive(Clone, Debug)]
253pub struct RagSetup {
254    /// Ragrig's own configuration tree.
255    pub config: RagrigConfig,
256    /// Bibliography the worker reads and writes.
257    ///
258    /// `None` uses the conventional `<workspace>/.ragrig/references.bib`.
259    /// Hosts that keep their library elsewhere set this so `@key`
260    /// citations, the reference rows, and GROBID all agree on one file.
261    pub bibliography: Option<PathBuf>,
262    /// Prompt text applied after building; takes precedence over
263    /// `config.chat.system_prompt_path`.
264    pub system_prompt: Option<String>,
265    /// Whether answers stream token by token.
266    pub stream: bool,
267    /// Whether `@key` mentions in a question attach the paper as one-shot
268    /// context. On by default.
269    pub resolve_citations: bool,
270    /// Whether hybrid retrieval (BM25 + embeddings) is enabled.
271    ///
272    /// Off builds a no-op embedder: questions still use explicit attachments
273    /// and the bibliographic library, but no vector search runs and no
274    /// embedding model is required. On by default.
275    pub hybrid: bool,
276    /// Where the vector store, sessions, and profiles live.
277    ///
278    /// `None` uses `<config.workspace>/.ragrig`. A host with a dedicated
279    /// project folder (for example `.ragrig` inside the document workspace)
280    /// sets this explicitly.
281    pub data_dir: Option<PathBuf>,
282}
283
284impl RagSetup {
285    /// Wrap a config without a prompt override.
286    pub fn new(config: RagrigConfig) -> Self {
287        Self {
288            config,
289            bibliography: None,
290            system_prompt: None,
291            stream: true,
292            resolve_citations: true,
293            hybrid: true,
294            data_dir: None,
295        }
296    }
297
298    /// Wrap a config and apply `prompt` before the agent is built.
299    pub fn with_system_prompt(config: RagrigConfig, prompt: impl Into<String>) -> Self {
300        Self {
301            config,
302            bibliography: None,
303            system_prompt: Some(prompt.into()),
304            stream: true,
305            resolve_citations: true,
306            hybrid: true,
307            data_dir: None,
308        }
309    }
310
311    /// Use `path` for the bibliography instead of the conventional file.
312    pub fn with_bibliography(mut self, path: impl Into<PathBuf>) -> Self {
313        self.bibliography = Some(path.into());
314        self
315    }
316
317    /// Turn hybrid retrieval (embeddings) on or off.
318    pub fn with_hybrid(mut self, hybrid: bool) -> Self {
319        self.hybrid = hybrid;
320        self
321    }
322
323    /// Use `dir` for the vector store, sessions, and profiles.
324    pub fn with_data_dir(mut self, dir: impl Into<PathBuf>) -> Self {
325        self.data_dir = Some(dir.into());
326        self
327    }
328
329    /// Whether conversation memory is on (`false` for the `Off` strategy).
330    pub fn memory_enabled(&self) -> bool {
331        !matches!(self.config.memory.strategy, MemoryStrategyKind::Off)
332    }
333}
334
335impl From<RagrigConfig> for RagSetup {
336    fn from(config: RagrigConfig) -> Self {
337        Self::new(config)
338    }
339}
340
341/// Editable configuration shown by the settings page.
342///
343/// This mirrors the Ragrig fields a chat surface exposes. Secrets never enter
344/// this struct: cloud API keys are read from the environment by the worker.
345#[derive(Clone, Debug, PartialEq)]
346pub struct RagSettings {
347    /// Workspace folder holding the store and sessions.
348    pub workspace: String,
349    /// Bibliography the library and `@key` resolution use.
350    ///
351    /// Empty means the conventional `<workspace>/.ragrig/references.bib`.
352    pub bibliography: String,
353    /// Active chat backend.
354    pub provider: ChatProvider,
355    /// Chat model per provider.
356    ///
357    /// Every provider keeps its own model, so switching backends does not
358    /// carry a model name into an incompatible API. Missing entries use
359    /// [`ChatProvider::default_model`] when the configuration is built.
360    pub models: BTreeMap<ChatProvider, String>,
361    /// Base URL of the local llama.cpp server, including `/v1`.
362    ///
363    /// Used when [`Self::provider`] is [`ChatProvider::LlamaCpp`]; other
364    /// providers keep it so switching back restores the server.
365    pub llamacpp_url: String,
366    /// Sampling temperature.
367    pub temperature: f64,
368    /// Maximum generated tokens.
369    pub max_tokens: usize,
370    /// Chat context window in tokens.
371    pub context_tokens: usize,
372    /// Inline system prompt, used when `system_prompt_path` is empty.
373    pub system_prompt: String,
374    /// Markdown file that overrides the inline prompt; empty uses
375    /// `system_prompt`.
376    pub system_prompt_path: String,
377    /// The prompt the host compiles in.
378    ///
379    /// Hosts set this after loading their settings; **Use built-in** and
380    /// profile defaults restore it. It is not written to profiles and is
381    /// ignored by dirty tracking.
382    pub built_in_prompt: String,
383    /// Stream tokens into the transcript.
384    pub stream: bool,
385    /// Ollama embedding model.
386    pub embed_model: String,
387    /// Hybrid retrieval (BM25 + embeddings). Off runs without an embedding
388    /// model and skips vector search.
389    pub hybrid: bool,
390    /// Chunks retrieved per query.
391    pub top_k: usize,
392    /// Minimum similarity for a chunk to enter the prompt.
393    pub similarity_threshold: f64,
394    /// Target chunk size in tokens.
395    pub chunk_size: usize,
396    /// Token overlap between chunks.
397    pub chunk_overlap: usize,
398    /// PDF parser selected as primary.
399    pub pdf_backend: PdfBackend,
400    /// Keep the sloppy PDF fallback registered.
401    pub sloppy_pdf: bool,
402    /// Conversation memory strategy.
403    pub memory: MemoryChoice,
404    /// Small model used for rewriting and summaries.
405    pub memory_model: String,
406    /// Where the GROBID server comes from.
407    pub grobid_source: GrobidSource,
408    /// Base URL of the GROBID server.
409    pub grobid_url: String,
410    /// Docker image used when [`Self::grobid_source`] is [`GrobidSource::Managed`].
411    pub grobid_image: String,
412    /// Start the managed GROBID container when the app opens.
413    pub grobid_auto_start: bool,
414    /// Rename PDFs to their metadata name during reference extraction.
415    pub rename_pdfs: bool,
416    /// Complete extracted references against OpenAlex.
417    pub openalex: bool,
418    /// Attach `@key` papers mentioned in a question as one-shot context.
419    pub resolve_citations: bool,
420    /// Named folder corpora in `name=path` form.
421    pub corpus_dirs: Vec<String>,
422    /// Profile name typed by the user.
423    pub profile_name: String,
424    /// Profiles found in the workspace.
425    pub profiles: Vec<String>,
426    /// Last settings action result, shown under the Apply row.
427    pub status: Option<String>,
428}
429
430impl Default for RagSettings {
431    fn default() -> Self {
432        let config = RagrigConfig::default();
433        let mut settings = Self::from_config(&config);
434        settings.workspace = default_workspace().to_string_lossy().into_owned();
435        settings
436    }
437}
438
439/// One editable RAG setting.
440///
441/// The settings page uses this to mark exactly the fields that differ from the
442/// configuration last applied to the worker.
443#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
444pub enum RagField {
445    /// Workspace folder.
446    Workspace,
447    /// Bibliography path.
448    Bibliography,
449    /// Chat backend.
450    Provider,
451    /// Model of the active chat backend.
452    Model,
453    /// Base URL of the local llama.cpp server.
454    LlamaCppUrl,
455    /// Sampling temperature.
456    Temperature,
457    /// Maximum generated tokens.
458    MaxTokens,
459    /// Chat context window.
460    ContextTokens,
461    /// Streaming responses.
462    Stream,
463    /// System prompt.
464    SystemPrompt,
465    /// Embedding model.
466    EmbedModel,
467    /// Hybrid retrieval.
468    Hybrid,
469    /// Retrieved chunks.
470    TopK,
471    /// Minimum similarity.
472    SimilarityThreshold,
473    /// Named folder corpora.
474    CorpusDirs,
475    /// PDF parser backend.
476    PdfBackend,
477    /// Sloppy PDF fallback.
478    SloppyPdf,
479    /// Chunk size.
480    ChunkSize,
481    /// Chunk overlap.
482    ChunkOverlap,
483    /// Memory strategy.
484    Memory,
485    /// Memory model.
486    MemoryModel,
487    /// Where the GROBID server comes from.
488    GrobidSource,
489    /// GROBID server URL.
490    GrobidUrl,
491    /// Docker image for the managed GROBID container.
492    GrobidImage,
493    /// Start the managed GROBID container when the app opens.
494    GrobidAutoStart,
495    /// Rename processed PDFs.
496    RenamePdfs,
497    /// OpenAlex completion.
498    OpenAlex,
499    /// `@key` citation resolution.
500    ResolveCitations,
501}
502
503impl RagField {
504    /// The group this field is shown in.
505    pub fn section(self) -> RagSection {
506        match self {
507            Self::Workspace | Self::Bibliography => RagSection::Workspace,
508            Self::Provider
509            | Self::Model
510            | Self::LlamaCppUrl
511            | Self::Temperature
512            | Self::MaxTokens
513            | Self::ContextTokens
514            | Self::Stream
515            | Self::SystemPrompt => RagSection::Chat,
516            Self::EmbedModel
517            | Self::Hybrid
518            | Self::TopK
519            | Self::SimilarityThreshold
520            | Self::CorpusDirs => RagSection::Retrieval,
521            Self::PdfBackend | Self::SloppyPdf | Self::ChunkSize | Self::ChunkOverlap => {
522                RagSection::Parsing
523            }
524            Self::Memory | Self::MemoryModel => RagSection::Memory,
525            Self::GrobidSource | Self::GrobidUrl | Self::GrobidImage | Self::GrobidAutoStart => {
526                RagSection::Services
527            }
528            Self::RenamePdfs | Self::OpenAlex | Self::ResolveCitations => RagSection::References,
529        }
530    }
531}
532
533/// A group of settings shown on one page.
534#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
535pub enum RagSection {
536    /// Workspace folder and bibliography.
537    Workspace,
538    /// Provider, models, generation, and prompt.
539    Chat,
540    /// Embedding and retrieval.
541    Retrieval,
542    /// Parser and chunking.
543    Parsing,
544    /// Memory strategy and model.
545    Memory,
546    /// Renaming, OpenAlex, and citations.
547    References,
548    /// External services such as the GROBID server.
549    Services,
550}
551
552impl RagSection {
553    /// Every section, in page order.
554    pub const ALL: [Self; 7] = [
555        Self::Workspace,
556        Self::Chat,
557        Self::Retrieval,
558        Self::Parsing,
559        Self::Memory,
560        Self::References,
561        Self::Services,
562    ];
563
564    /// The human-readable section name.
565    pub fn label(self) -> &'static str {
566        match self {
567            Self::Workspace => "Workspace",
568            Self::Chat => "Chat",
569            Self::Retrieval => "Retrieval",
570            Self::Parsing => "Parsing and chunking",
571            Self::Memory => "Memory",
572            Self::References => "References",
573            Self::Services => "External services",
574        }
575    }
576}
577
578/// The editable fields that differ from the applied configuration.
579#[derive(Clone, Debug, Default, PartialEq, Eq)]
580pub struct RagChanges {
581    fields: BTreeSet<RagField>,
582}
583
584impl RagChanges {
585    /// Whether no setting changed.
586    pub fn is_empty(&self) -> bool {
587        self.fields.is_empty()
588    }
589
590    /// Whether any setting changed.
591    pub fn any(&self) -> bool {
592        !self.is_empty()
593    }
594
595    /// Whether one field changed.
596    pub fn contains(&self, field: RagField) -> bool {
597        self.fields.contains(&field)
598    }
599
600    /// Whether any field in a section changed.
601    pub fn section(&self, section: RagSection) -> bool {
602        self.fields.iter().any(|field| field.section() == section)
603    }
604
605    /// How many fields in a section changed.
606    pub fn count_in(&self, section: RagSection) -> usize {
607        self.fields
608            .iter()
609            .filter(|field| field.section() == section)
610            .count()
611    }
612
613    /// The number of changed fields.
614    pub fn len(&self) -> usize {
615        self.fields.len()
616    }
617
618    /// Iterate over the changed fields in declaration order.
619    pub fn fields(&self) -> impl Iterator<Item = RagField> + '_ {
620        self.fields.iter().copied()
621    }
622
623    fn mark_if(&mut self, changed: bool, field: RagField) {
624        if changed {
625            self.fields.insert(field);
626        }
627    }
628}
629
630impl RagSettings {
631    /// The model configured for the active provider.
632    ///
633    /// The value is returned as stored, so an edited field can be empty while
634    /// the user types. [`Self::effective_model`] applies the provider default.
635    pub fn model(&self) -> &str {
636        self.model_for(self.provider)
637    }
638
639    /// The model configured for `provider`, as stored.
640    pub fn model_for(&self, provider: ChatProvider) -> &str {
641        self.models
642            .get(&provider)
643            .map(String::as_str)
644            .unwrap_or_default()
645    }
646
647    /// The model configured for the active provider, or its default.
648    pub fn effective_model(&self) -> &str {
649        self.effective_model_for(self.provider)
650    }
651
652    /// The model configured for `provider`, or its default when unset.
653    pub fn effective_model_for(&self, provider: ChatProvider) -> &str {
654        let model = self.model_for(provider).trim();
655        if model.is_empty() {
656            provider.default_model()
657        } else {
658            model
659        }
660    }
661
662    /// Set the model of the active provider.
663    pub fn set_model(&mut self, model: impl Into<String>) {
664        self.set_model_for(self.provider, model);
665    }
666
667    /// Set the model of `provider`.
668    pub fn set_model_for(&mut self, provider: ChatProvider, model: impl Into<String>) {
669        self.models.insert(provider, model.into());
670    }
671
672    fn default_models() -> BTreeMap<ChatProvider, String> {
673        ChatProvider::options()
674            .iter()
675            .map(|(provider, _)| (*provider, provider.default_model().to_owned()))
676            .collect()
677    }
678
679    /// Compare the editable configuration fields with the configuration that is
680    /// currently running. Profile bookkeeping is ignored.
681    pub fn dirty_since(&self, applied: &Self) -> RagChanges {
682        let mut changes = RagChanges::default();
683        changes.mark_if(self.workspace != applied.workspace, RagField::Workspace);
684        changes.mark_if(
685            self.bibliography != applied.bibliography,
686            RagField::Bibliography,
687        );
688        changes.mark_if(self.provider != applied.provider, RagField::Provider);
689        changes.mark_if(self.models != applied.models, RagField::Model);
690        changes.mark_if(
691            self.llamacpp_url != applied.llamacpp_url,
692            RagField::LlamaCppUrl,
693        );
694        changes.mark_if(
695            self.temperature != applied.temperature,
696            RagField::Temperature,
697        );
698        changes.mark_if(self.max_tokens != applied.max_tokens, RagField::MaxTokens);
699        changes.mark_if(
700            self.context_tokens != applied.context_tokens,
701            RagField::ContextTokens,
702        );
703        changes.mark_if(self.stream != applied.stream, RagField::Stream);
704        changes.mark_if(
705            self.system_prompt != applied.system_prompt
706                || self.system_prompt_path != applied.system_prompt_path,
707            RagField::SystemPrompt,
708        );
709        changes.mark_if(
710            self.embed_model != applied.embed_model,
711            RagField::EmbedModel,
712        );
713        changes.mark_if(self.hybrid != applied.hybrid, RagField::Hybrid);
714        changes.mark_if(self.top_k != applied.top_k, RagField::TopK);
715        changes.mark_if(
716            self.similarity_threshold != applied.similarity_threshold,
717            RagField::SimilarityThreshold,
718        );
719        changes.mark_if(
720            self.corpus_dirs != applied.corpus_dirs,
721            RagField::CorpusDirs,
722        );
723        changes.mark_if(
724            self.pdf_backend != applied.pdf_backend,
725            RagField::PdfBackend,
726        );
727        changes.mark_if(self.sloppy_pdf != applied.sloppy_pdf, RagField::SloppyPdf);
728        changes.mark_if(self.chunk_size != applied.chunk_size, RagField::ChunkSize);
729        changes.mark_if(
730            self.chunk_overlap != applied.chunk_overlap,
731            RagField::ChunkOverlap,
732        );
733        changes.mark_if(self.memory != applied.memory, RagField::Memory);
734        changes.mark_if(
735            self.memory_model != applied.memory_model,
736            RagField::MemoryModel,
737        );
738        changes.mark_if(
739            self.grobid_source != applied.grobid_source,
740            RagField::GrobidSource,
741        );
742        changes.mark_if(self.grobid_url != applied.grobid_url, RagField::GrobidUrl);
743        changes.mark_if(
744            self.grobid_image != applied.grobid_image,
745            RagField::GrobidImage,
746        );
747        changes.mark_if(
748            self.grobid_auto_start != applied.grobid_auto_start,
749            RagField::GrobidAutoStart,
750        );
751        changes.mark_if(
752            self.rename_pdfs != applied.rename_pdfs,
753            RagField::RenamePdfs,
754        );
755        changes.mark_if(self.openalex != applied.openalex, RagField::OpenAlex);
756        changes.mark_if(
757            self.resolve_citations != applied.resolve_citations,
758            RagField::ResolveCitations,
759        );
760        changes
761    }
762
763    /// Reset the editable fields to the defaults of the last chosen profile.
764    ///
765    /// With no profile selected, the compiled defaults are used while keeping
766    /// the workspace, bibliography, and profile bookkeeping so the library
767    /// location is not lost.
768    pub fn reset_to_profile_defaults(&mut self) -> Result<(), String> {
769        let name = self.profile_name.trim().to_owned();
770        if !name.is_empty() && self.profiles.iter().any(|profile| profile == &name) {
771            return self.load_profile(&name);
772        }
773
774        let workspace = self.workspace.clone();
775        let bibliography = self.bibliography.clone();
776        let built_in_prompt = self.built_in_prompt.clone();
777        let grobid_source = self.grobid_source;
778        let grobid_url = self.grobid_url.clone();
779        let grobid_image = self.grobid_image.clone();
780        let grobid_auto_start = self.grobid_auto_start;
781        let mut defaults = Self {
782            workspace,
783            bibliography,
784            grobid_source,
785            grobid_url,
786            grobid_image,
787            grobid_auto_start,
788            profile_name: self.profile_name.clone(),
789            built_in_prompt: built_in_prompt.clone(),
790            system_prompt: built_in_prompt,
791            system_prompt_path: String::new(),
792            ..Self::default()
793        };
794        defaults.refresh_profiles();
795        defaults.status = Some("Reset to built-in defaults — press Apply".to_owned());
796        *self = defaults;
797        Ok(())
798    }
799}
800
801impl RagSettings {
802    /// Read the editable fields from a Ragrig configuration.
803    pub fn from_config(config: &RagrigConfig) -> Self {
804        let provider = ChatProvider::from(config.chat.provider.clone());
805        let mut models = Self::default_models();
806        if !config.chat.deepseek_model.trim().is_empty() {
807            models.insert(ChatProvider::DeepSeek, config.chat.deepseek_model.clone());
808        }
809        if !config.chat.model.trim().is_empty() {
810            match provider {
811                // `model` is ignored for DeepSeek; it carries the Ollama
812                // choice so switching back keeps it.
813                ChatProvider::DeepSeek => {
814                    models.insert(ChatProvider::Ollama, config.chat.model.clone());
815                }
816                ChatProvider::Ollama => {
817                    models.insert(ChatProvider::Ollama, config.chat.model.clone());
818                }
819                // Upstream treats the Ollama default as "provider default"
820                // when another backend is selected.
821                _ if config.chat.model == ChatProvider::Ollama.default_model() => {}
822                _ => {
823                    models.insert(provider, config.chat.model.clone());
824                }
825            }
826        }
827        let pdf_backend = match config.parse.pdf_parser {
828            PdfParserBackend::Unpdf => PdfBackend::Unpdf,
829            PdfParserBackend::Sink => PdfBackend::Sink,
830            PdfParserBackend::Internal => PdfBackend::Internal,
831            _ => PdfBackend::Extract,
832        };
833        let memory = match config.memory.strategy {
834            MemoryStrategyKind::Transcript => MemoryChoice::Transcript,
835            MemoryStrategyKind::Log => MemoryChoice::Log,
836            MemoryStrategyKind::Summary => MemoryChoice::Summary,
837            MemoryStrategyKind::Off => MemoryChoice::Off,
838            _ => MemoryChoice::Rewrite,
839        };
840        let system_prompt_path = config
841            .chat
842            .system_prompt_path
843            .as_ref()
844            .map(|path| {
845                if path.is_absolute() {
846                    path.clone()
847                } else {
848                    config.workspace.join(path)
849                }
850            })
851            .map(|path| path.display().to_string())
852            .unwrap_or_default();
853        let system_prompt = if system_prompt_path.is_empty() {
854            default_prompt()
855        } else {
856            fs::read_to_string(&system_prompt_path)
857                .ok()
858                .filter(|text| !text.trim().is_empty())
859                .unwrap_or_else(default_prompt)
860        };
861
862        let llamacpp_url = resolve_llamacpp_base_url(config.chat.llamacpp_base_url.as_deref());
863
864        Self {
865            workspace: config.workspace.to_string_lossy().into_owned(),
866            bibliography: String::new(),
867            provider,
868            models,
869            llamacpp_url,
870            temperature: config.chat.params.temperature.unwrap_or(0.2),
871            max_tokens: config.chat.params.max_tokens.unwrap_or(2048),
872            context_tokens: config.chat.context_tokens,
873            system_prompt,
874            system_prompt_path,
875            built_in_prompt: default_prompt(),
876            stream: true,
877            embed_model: config.embed.model.clone(),
878            hybrid: true,
879            top_k: config.embed.top_k,
880            similarity_threshold: config.embed.similarity_threshold,
881            chunk_size: config.parse.chunk_size,
882            chunk_overlap: config.parse.chunk_overlap,
883            pdf_backend,
884            sloppy_pdf: config.parse.sloppy_pdf,
885            memory,
886            memory_model: config.memory.model.clone(),
887            grobid_source: GrobidSource::Existing,
888            grobid_url: DEFAULT_GROBID_URL.to_owned(),
889            grobid_image: DEFAULT_GROBID_IMAGE.to_owned(),
890            grobid_auto_start: false,
891            rename_pdfs: false,
892            openalex: false,
893            resolve_citations: true,
894            corpus_dirs: config.corpus_dirs.clone(),
895            profile_name: String::new(),
896            profiles: Vec::new(),
897            status: None,
898        }
899    }
900
901    /// Read `~/.ragrig/config.json` and `<workspace>/.ragrig/config.json` as
902    /// partial configurations and merge them over the built-in defaults.
903    ///
904    /// The merge is per setting: a key missing from a higher layer keeps the
905    /// value from the layer below, so a workspace only states what it
906    /// changes. Returns `None` when neither file exists.
907    pub fn from_layers(workspace: &Path) -> Option<Self> {
908        let global = home_ragrig_dir().map(|dir| dir.join("config.json"));
909        Self::from_layer_paths(
910            global.as_deref(),
911            &workspace.join(".ragrig/config.json"),
912            None,
913        )
914    }
915
916    /// Like [`Self::from_layers`], but keeps the runtime memory and hybrid
917    /// switches from `base` when no layer mentions them.
918    ///
919    /// The status-bar switches live in `settings.json` as well as the
920    /// workspace layer; this prevents a layer that only sets, say, the chat
921    /// model from resetting them to their defaults.
922    pub fn from_layers_over(workspace: &Path, base: &Self) -> Option<Self> {
923        let global = home_ragrig_dir().map(|dir| dir.join("config.json"));
924        Self::from_layer_paths(
925            global.as_deref(),
926            &workspace.join(".ragrig/config.json"),
927            Some(base),
928        )
929    }
930
931    fn from_layer_paths(
932        global: Option<&Path>,
933        workspace: &Path,
934        base: Option<&Self>,
935    ) -> Option<Self> {
936        let mut layers = serde_json::json!({});
937        let mut found = false;
938        for path in global.into_iter().chain(std::iter::once(workspace)) {
939            let Ok(text) = fs::read_to_string(path) else {
940                continue;
941            };
942            let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
943                continue;
944            };
945            merge_json(&mut layers, value);
946            found = true;
947        }
948        if !found {
949            return None;
950        }
951        let memory_present = layers.pointer("/memory/strategy").is_some();
952        let hybrid_present = layers.get("hybrid").is_some();
953        let merged_hybrid = layers.get("hybrid").and_then(|value| value.as_bool());
954        let mut merged = serde_json::to_value(RagrigConfig::default()).ok()?;
955        merge_json(&mut merged, layers);
956        let config: RagrigConfig = serde_json::from_value(merged).ok()?;
957        let mut config = config;
958        // The layer file lives in `<root>/.ragrig/`; relative prompts and
959        // bibliographies resolve against the workspace root, not the cwd.
960        if let Some(root) = workspace.parent().and_then(Path::parent) {
961            config.workspace = root.to_path_buf();
962        }
963        let mut settings = Self::from_config(&config);
964        if let Some(hybrid) = merged_hybrid {
965            settings.hybrid = hybrid;
966        }
967        if let Some(base) = base {
968            if !memory_present {
969                settings.memory = base.memory;
970            }
971            if !hybrid_present {
972                settings.hybrid = base.hybrid;
973            }
974        }
975        Some(settings)
976    }
977
978    /// Validate and convert to a Ragrig configuration.
979    pub fn to_config(&self) -> Result<RagrigConfig, String> {
980        self.validate()?;
981
982        let workspace = PathBuf::from(self.workspace.trim());
983        let model = match self.provider {
984            // `model` is ignored for DeepSeek; keep the Ollama choice so a
985            // profile switched back to Ollama retains it.
986            ChatProvider::DeepSeek => self.effective_model_for(ChatProvider::Ollama),
987            _ => self.effective_model(),
988        };
989        let chat = ChatConfig {
990            provider: self.provider.into(),
991            model: model.to_owned(),
992            deepseek_model: self.effective_model_for(ChatProvider::DeepSeek).to_owned(),
993            // Secrets only ever come from the environment.
994            deepseek_api_key: None,
995            params: GenerationParams {
996                temperature: Some(self.temperature),
997                top_p: None,
998                max_tokens: Some(self.max_tokens),
999                seed: None,
1000            },
1001            context_tokens: self.context_tokens,
1002            llamacpp_base_url: Some(self.llamacpp_url.trim().to_owned()),
1003            // Secrets only ever come from the environment.
1004            llamacpp_api_key: None,
1005            system_prompt_path: None,
1006            ..ChatConfig::default()
1007        };
1008
1009        let embed = EmbedConfig {
1010            provider: EmbeddingProvider::Ollama,
1011            model: self.embed_model.trim().to_owned(),
1012            top_k: self.top_k,
1013            similarity_threshold: self.similarity_threshold,
1014            ..EmbedConfig::default()
1015        };
1016
1017        let parse = ParseConfig {
1018            pdf_parser: match self.pdf_backend {
1019                PdfBackend::Extract => PdfParserBackend::Extract,
1020                PdfBackend::Unpdf => PdfParserBackend::Unpdf,
1021                PdfBackend::Sink => PdfParserBackend::Sink,
1022                PdfBackend::Internal => PdfParserBackend::Internal,
1023            },
1024            sloppy_pdf: self.sloppy_pdf,
1025            chunk_size: self.chunk_size,
1026            chunk_overlap: self.chunk_overlap,
1027        };
1028
1029        let memory = MemoryConfig {
1030            model: self.memory_model.trim().to_owned(),
1031            rewrite_prompt_path: None,
1032            strategy: match self.memory {
1033                MemoryChoice::Rewrite => MemoryStrategyKind::Rewrite,
1034                MemoryChoice::Transcript => MemoryStrategyKind::Transcript,
1035                MemoryChoice::Log => MemoryStrategyKind::Log,
1036                MemoryChoice::Summary => MemoryStrategyKind::Summary,
1037                MemoryChoice::Off => MemoryStrategyKind::Off,
1038            },
1039        };
1040
1041        Ok(RagrigConfig {
1042            workspace,
1043            chat,
1044            embed,
1045            parse,
1046            memory,
1047            corpus_dirs: self
1048                .corpus_dirs
1049                .iter()
1050                .map(|entry| entry.trim().to_owned())
1051                .filter(|entry| !entry.is_empty())
1052                .collect(),
1053            corpus_urls: Vec::new(),
1054            semantic_scholar_api_key: None,
1055        })
1056    }
1057
1058    /// The custom prompt file, resolved against the workspace.
1059    pub fn prompt_path(&self) -> Option<PathBuf> {
1060        let path = self.system_prompt_path.trim();
1061        if path.is_empty() {
1062            return None;
1063        }
1064        let path = Path::new(path);
1065        Some(if path.is_absolute() {
1066            path.to_path_buf()
1067        } else {
1068            Path::new(self.workspace.trim()).join(path)
1069        })
1070    }
1071
1072    /// The prompt in effect: the selected file, or the inline prompt.
1073    pub fn effective_prompt(&self) -> Result<String, String> {
1074        match self.prompt_path() {
1075            Some(path) => {
1076                let text = fs::read_to_string(&path).map_err(|error| {
1077                    format!("cannot read system prompt '{}': {error}", path.display())
1078                })?;
1079                if text.trim().is_empty() {
1080                    return Err(format!("system prompt '{}' is empty", path.display()));
1081                }
1082                Ok(text)
1083            }
1084            None => Ok(self.system_prompt.clone()),
1085        }
1086    }
1087
1088    /// Clear the prompt file and restore the host's compiled-in prompt.
1089    pub fn use_built_in_prompt(&mut self) {
1090        self.system_prompt_path.clear();
1091        self.system_prompt = self.built_in_prompt.clone();
1092    }
1093
1094    /// Validate and produce a build setup, including the prompt override.
1095    pub fn to_setup(&self) -> Result<RagSetup, String> {
1096        let config = self.to_config()?;
1097        let prompt = self.effective_prompt()?;
1098        let prompt = prompt.trim();
1099        let mut setup = if prompt.is_empty() {
1100            RagSetup::new(config)
1101        } else {
1102            RagSetup::with_system_prompt(config, prompt)
1103        };
1104        setup.stream = self.stream;
1105        setup.resolve_citations = self.resolve_citations;
1106        setup.hybrid = self.hybrid;
1107        if !self.bibliography.trim().is_empty() {
1108            setup.bibliography = Some(PathBuf::from(self.bibliography.trim()));
1109        }
1110        Ok(setup)
1111    }
1112
1113    /// Check the editable values without touching the filesystem.
1114    pub fn validate(&self) -> Result<(), String> {
1115        if self.workspace.trim().is_empty() {
1116            return Err("choose a workspace folder".to_owned());
1117        }
1118        if self.model().trim().is_empty() {
1119            return Err(format!("choose a {} chat model", self.provider.label()));
1120        }
1121        if self.provider == ChatProvider::LlamaCpp && self.llamacpp_url.trim().is_empty() {
1122            return Err("enter the llama.cpp server URL".to_owned());
1123        }
1124        // API keys are read from the environment when the worker builds the
1125        // agent. A missing key must not block editing settings or saving a
1126        // profile; the worker reports it through `AgentFailed`.
1127        if !self.temperature.is_finite() || !(0.0..=2.0).contains(&self.temperature) {
1128            return Err("temperature must be between 0 and 2".to_owned());
1129        }
1130        if self.max_tokens == 0 {
1131            return Err("max tokens must be at least 1".to_owned());
1132        }
1133        if self.context_tokens < 256 {
1134            return Err("context tokens must be at least 256".to_owned());
1135        }
1136        if self.hybrid {
1137            if self.embed_model.trim().is_empty() {
1138                return Err("choose an embedding model".to_owned());
1139            }
1140            if self.top_k == 0 {
1141                return Err("top-k must be at least 1".to_owned());
1142            }
1143            if !self.similarity_threshold.is_finite()
1144                || !(0.0..=1.0).contains(&self.similarity_threshold)
1145            {
1146                return Err("similarity threshold must be between 0 and 1".to_owned());
1147            }
1148        }
1149        if self.chunk_size == 0 {
1150            return Err("chunk size must be at least 1".to_owned());
1151        }
1152        if self.chunk_overlap >= self.chunk_size {
1153            return Err("chunk overlap must be smaller than the chunk size".to_owned());
1154        }
1155        if self.memory.uses_model() && self.memory_model.trim().is_empty() {
1156            return Err("choose a memory model or turn memory off".to_owned());
1157        }
1158        if self.grobid_url.trim().is_empty() {
1159            return Err("enter the GROBID server URL".to_owned());
1160        }
1161        if self.grobid_source == GrobidSource::Managed && self.grobid_image.trim().is_empty() {
1162            return Err("enter the Docker image for the managed GROBID container".to_owned());
1163        }
1164        for entry in &self.corpus_dirs {
1165            let entry = entry.trim();
1166            if entry.is_empty() {
1167                continue;
1168            }
1169            let Some((name, path)) = entry.split_once('=') else {
1170                return Err(format!("corpus '{entry}' must use name=path"));
1171            };
1172            if name.trim().is_empty() || path.trim().is_empty() {
1173                return Err(format!("corpus '{entry}' needs a name and a path"));
1174            }
1175        }
1176        Ok(())
1177    }
1178
1179    /// Re-read the profile list from the workspace and `~/.ragrig`.
1180    ///
1181    /// Workspace profiles shadow global profiles with the same name.
1182    pub fn refresh_profiles(&mut self) {
1183        let workspace = Path::new(self.workspace.trim());
1184        let mut workspace_failed = None;
1185        let mut profiles = match RagrigConfig::list_profiles(workspace) {
1186            Ok(profiles) => profiles,
1187            Err(error) => {
1188                workspace_failed = Some(error);
1189                Vec::new()
1190            }
1191        };
1192        if let Some(home) = home_ragrig_dir() {
1193            for name in RagrigConfig::list_profiles(&home).unwrap_or_default() {
1194                if !profiles.contains(&name) {
1195                    profiles.push(name);
1196                }
1197            }
1198        }
1199        profiles.sort();
1200        if let Some(error) = workspace_failed {
1201            self.status = Some(format!("cannot list workspace profiles: {error:#}"));
1202        }
1203        self.profiles = profiles;
1204        if !self.profile_name.trim().is_empty()
1205            && !self
1206                .profiles
1207                .iter()
1208                .any(|name| name == self.profile_name.trim())
1209        {
1210            self.profiles.insert(0, self.profile_name.trim().to_owned());
1211        }
1212    }
1213
1214    /// Save these settings as a named profile in the workspace.
1215    pub fn save_profile(&mut self) -> Result<(), String> {
1216        let name = self.profile_name.trim().to_owned();
1217        validate_profile_name(&name)?;
1218        let mut config = self.to_config()?;
1219        let workspace = Path::new(self.workspace.trim());
1220        let prompt = self.effective_prompt()?;
1221        let prompt = prompt.trim();
1222        if prompt.is_empty()
1223            || (self.system_prompt_path.trim().is_empty() && prompt == self.built_in_prompt.trim())
1224        {
1225            config.chat.system_prompt_path = None;
1226        } else {
1227            let dir = RagrigConfig::profiles_dir(workspace);
1228            fs::create_dir_all(&dir)
1229                .map_err(|error| format!("cannot create profiles folder: {error}"))?;
1230            let relative = PathBuf::from(format!(".ragrig/profiles/{name}.prompt.md"));
1231            fs::write(workspace.join(&relative), prompt).map_err(|error| {
1232                format!("cannot write the prompt for profile '{name}': {error}")
1233            })?;
1234            config.chat.system_prompt_path = Some(relative);
1235        }
1236        config
1237            .save_to_profile(workspace, &name)
1238            .map_err(|error| format!("cannot save profile '{name}': {error:#}"))?;
1239        self.refresh_profiles();
1240        self.status = Some(format!("Saved profile '{name}'"));
1241        Ok(())
1242    }
1243
1244    /// Load a named profile from the workspace, then from `~/.ragrig`.
1245    pub fn load_profile(&mut self, name: &str) -> Result<(), String> {
1246        validate_profile_name(name)?;
1247        let workspace = Path::new(self.workspace.trim());
1248        let mut config = match RagrigConfig::load_from_profile(workspace, name) {
1249            Ok(config) => config,
1250            Err(workspace_error) => match home_ragrig_dir() {
1251                Some(home) => RagrigConfig::load_from_profile(&home, name)
1252                    .map_err(|_| format!("cannot load profile '{name}': {workspace_error:#}"))?,
1253                None => {
1254                    return Err(format!("cannot load profile '{name}': {workspace_error:#}"));
1255                }
1256            },
1257        };
1258        // A global profile resolves relative prompt paths against the actual
1259        // workspace, never against the home directory.
1260        config.workspace = workspace.to_path_buf();
1261        let profiles = self.profiles.clone();
1262        let built_in_prompt = self.built_in_prompt.clone();
1263        // Hybrid retrieval and the service endpoint are runtime choices, not
1264        // part of a profile, so keep them across profile switches.
1265        let hybrid = self.hybrid;
1266        let grobid_source = self.grobid_source;
1267        let grobid_url = self.grobid_url.clone();
1268        let grobid_image = self.grobid_image.clone();
1269        let grobid_auto_start = self.grobid_auto_start;
1270        let mut loaded = Self::from_config(&config);
1271        loaded.hybrid = hybrid;
1272        loaded.grobid_source = grobid_source;
1273        loaded.grobid_url = grobid_url;
1274        loaded.grobid_image = grobid_image;
1275        loaded.grobid_auto_start = grobid_auto_start;
1276        loaded.profiles = profiles;
1277        loaded.profile_name = name.to_owned();
1278        loaded.built_in_prompt = built_in_prompt.clone();
1279        if loaded.system_prompt_path.is_empty() {
1280            loaded.system_prompt = built_in_prompt;
1281        }
1282        loaded.status = Some(format!("Loaded profile '{name}' — press Apply"));
1283        *self = loaded;
1284        self.refresh_profiles();
1285        Ok(())
1286    }
1287
1288    /// Remember a folder corpus so later applies keep it.
1289    pub fn add_corpus(&mut self, name: &str, folder: &Path) {
1290        let entry = format!("{name}={}", folder.display());
1291        self.corpus_dirs.retain(|existing| {
1292            existing
1293                .split_once('=')
1294                .is_none_or(|(existing_name, _)| existing_name != name)
1295        });
1296        self.corpus_dirs.push(entry);
1297    }
1298}
1299
1300fn default_workspace() -> PathBuf {
1301    std::env::var_os("RAGRIG_WORKSPACE")
1302        .map(PathBuf::from)
1303        .or_else(|| std::env::current_dir().ok())
1304        .unwrap_or_else(|| PathBuf::from("."))
1305}
1306
1307/// The user-wide Ragrig directory.
1308///
1309/// `RAGRIG_HOME` overrides the default `~/.ragrig` (useful for tests and for
1310/// keeping one profile library on a shared drive). Profiles here are visible
1311/// from every workspace; a workspace profile with the same name wins. A
1312/// `config.json` in this directory is the global layer between the built-in
1313/// defaults and a workspace's `.ragrig/config.json`.
1314pub fn home_ragrig_dir() -> Option<PathBuf> {
1315    std::env::var_os("RAGRIG_HOME")
1316        .map(PathBuf::from)
1317        .or_else(|| dirs::home_dir().map(|home| home.join(".ragrig")))
1318}
1319
1320/// Merge `overlay` into `base` per key; objects recurse, everything else
1321/// replaces.
1322fn merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
1323    match (base, overlay) {
1324        (serde_json::Value::Object(base), serde_json::Value::Object(overlay)) => {
1325            for (key, value) in overlay {
1326                match base.get_mut(&key) {
1327                    Some(existing) => merge_json(existing, value),
1328                    None => {
1329                        base.insert(key, value);
1330                    }
1331                }
1332            }
1333        }
1334        (base, overlay) => *base = overlay,
1335    }
1336}
1337
1338fn default_prompt() -> String {
1339    "Answer from the attached context and the indexed corpus. Cite each paper by its @Key. \
1340     If the context does not support an answer, say what is missing instead of guessing."
1341        .to_owned()
1342}
1343
1344fn validate_profile_name(name: &str) -> Result<(), String> {
1345    if name.is_empty() {
1346        return Err("enter a profile name first".to_owned());
1347    }
1348    if !name
1349        .chars()
1350        .all(|character| character.is_ascii_alphanumeric() || character == '-' || character == '_')
1351    {
1352        return Err("profile names may use letters, digits, '-' and '_' only".to_owned());
1353    }
1354    Ok(())
1355}
1356
1357#[cfg(test)]
1358mod tests {
1359    use super::*;
1360
1361    fn fixture() -> RagSettings {
1362        RagSettings {
1363            workspace: std::env::temp_dir().display().to_string(),
1364            ..RagSettings::default()
1365        }
1366    }
1367
1368    #[test]
1369    fn defaults_convert_to_a_valid_setup() {
1370        let setup = fixture().to_setup().expect("valid defaults");
1371        assert_eq!(setup.config.parse.chunk_size, 1024);
1372        assert_eq!(setup.config.parse.chunk_overlap, 128);
1373        assert!(setup.system_prompt.is_some());
1374        assert!(setup.resolve_citations);
1375        assert!(setup.hybrid);
1376        assert!(setup.memory_enabled());
1377
1378        let mut settings = fixture();
1379        settings.resolve_citations = false;
1380        assert!(!settings.to_setup().expect("valid setup").resolve_citations);
1381    }
1382
1383    #[test]
1384    fn every_provider_has_a_default_model() {
1385        let chat = ChatConfig::default();
1386        assert_eq!(ChatProvider::Ollama.default_model(), chat.model);
1387        assert_eq!(ChatProvider::DeepSeek.default_model(), chat.deepseek_model);
1388        for (provider, _) in ChatProvider::options() {
1389            assert!(
1390                !provider.default_model().trim().is_empty(),
1391                "{provider:?} needs a default model"
1392            );
1393        }
1394    }
1395
1396    #[test]
1397    fn cloud_defaults_match_the_upstream_specs() {
1398        use ragrig::ChatAgentSpec;
1399
1400        for (backend, provider) in [
1401            ("openai", ChatProvider::OpenAi),
1402            ("claude", ChatProvider::Claude),
1403            ("mistral", ChatProvider::Mistral),
1404            ("openrouter", ChatProvider::OpenRouter),
1405        ] {
1406            let spec = ChatAgentSpec::parse(backend, None, None, None).expect("known backend");
1407            let model = match spec {
1408                ChatAgentSpec::OpenAi { model, .. }
1409                | ChatAgentSpec::Claude { model, .. }
1410                | ChatAgentSpec::Mistral { model, .. }
1411                | ChatAgentSpec::OpenRouter { model, .. } => model,
1412                _ => panic!("unexpected spec for {backend}"),
1413            };
1414            assert_eq!(provider.default_model(), model, "{backend} default drifted");
1415        }
1416    }
1417
1418    #[test]
1419    fn llamacpp_defaults_match_the_upstream_spec() {
1420        use ragrig::ChatAgentSpec;
1421
1422        let spec = ChatAgentSpec::parse("llamacpp", None, None, None).expect("known backend");
1423        let model = match spec {
1424            ChatAgentSpec::LlamaCpp { model, .. } => model,
1425            _ => panic!("unexpected spec for llamacpp"),
1426        };
1427        assert_eq!(ChatProvider::LlamaCpp.default_model(), model);
1428        if std::env::var("LLAMACPP_API_BASE_URL").is_err() {
1429            assert_eq!(
1430                RagSettings::default().llamacpp_url,
1431                ragrig::agents::DEFAULT_LLAMACPP_BASE_URL
1432            );
1433        }
1434    }
1435
1436    #[test]
1437    fn the_llamacpp_url_reaches_the_chat_config() {
1438        let mut settings = fixture();
1439        settings.provider = ChatProvider::LlamaCpp;
1440        settings.llamacpp_url = "http://gpu-box:9000/v1".to_owned();
1441
1442        let config = settings.to_config().expect("valid configuration");
1443        assert_eq!(config.chat.provider, Provider::LlamaCpp);
1444        assert_eq!(
1445            config.chat.llamacpp_base_url.as_deref(),
1446            Some("http://gpu-box:9000/v1")
1447        );
1448        assert!(
1449            config.chat.llamacpp_api_key.is_none(),
1450            "keys stay in the environment"
1451        );
1452
1453        let restored = RagSettings::from_config(&config);
1454        assert_eq!(restored.provider, ChatProvider::LlamaCpp);
1455        assert_eq!(restored.llamacpp_url, "http://gpu-box:9000/v1");
1456    }
1457
1458    #[test]
1459    fn a_blank_llamacpp_url_is_rejected() {
1460        let mut settings = fixture();
1461        settings.provider = ChatProvider::LlamaCpp;
1462        settings.llamacpp_url.clear();
1463        assert_eq!(
1464            settings.validate(),
1465            Err("enter the llama.cpp server URL".to_owned())
1466        );
1467    }
1468
1469    #[test]
1470    fn each_provider_keeps_its_own_model() {
1471        let mut settings = fixture();
1472        settings.set_model_for(ChatProvider::Ollama, "llama3");
1473        settings.set_model_for(ChatProvider::OpenAi, "gpt-4o-mini");
1474
1475        settings.provider = ChatProvider::OpenAi;
1476        assert_eq!(settings.model(), "gpt-4o-mini");
1477
1478        settings.provider = ChatProvider::Claude;
1479        assert_eq!(settings.model(), ChatProvider::Claude.default_model());
1480
1481        settings.provider = ChatProvider::Ollama;
1482        assert_eq!(settings.model(), "llama3");
1483    }
1484
1485    #[test]
1486    fn cloud_providers_map_to_upstream_backends_and_key_variables() {
1487        let providers = [
1488            (
1489                ChatProvider::DeepSeek,
1490                Provider::Deepseek,
1491                "DEEPSEEK_API_KEY",
1492            ),
1493            (
1494                ChatProvider::LlamaCpp,
1495                Provider::LlamaCpp,
1496                "LLAMACPP_API_KEY",
1497            ),
1498            (ChatProvider::OpenAi, Provider::OpenAi, "OPENAI_API_KEY"),
1499            (ChatProvider::Claude, Provider::Claude, "ANTHROPIC_API_KEY"),
1500            (ChatProvider::Mistral, Provider::Mistral, "MISTRAL_API_KEY"),
1501            (
1502                ChatProvider::OpenRouter,
1503                Provider::OpenRouter,
1504                "OPENROUTER_API_KEY",
1505            ),
1506        ];
1507        for (provider, backend, key) in providers {
1508            assert_eq!(Provider::from(provider), backend);
1509            assert_eq!(ChatProvider::from(backend), provider);
1510            assert_eq!(provider.env_var(), Some(key));
1511            assert_eq!(ChatProvider::from_label(provider.label()), Some(provider));
1512        }
1513        assert_eq!(Provider::from(ChatProvider::Ollama), Provider::Ollama);
1514        assert_eq!(ChatProvider::Ollama.env_var(), None);
1515    }
1516
1517    #[test]
1518    fn a_missing_api_key_does_not_block_the_settings_form() {
1519        let mut settings = fixture();
1520        // The worker reports a missing key when it builds the agent. The form
1521        // must still validate and convert so Apply and profile saves work.
1522        for provider in [
1523            ChatProvider::DeepSeek,
1524            ChatProvider::LlamaCpp,
1525            ChatProvider::OpenAi,
1526            ChatProvider::Claude,
1527            ChatProvider::Mistral,
1528            ChatProvider::OpenRouter,
1529        ] {
1530            settings.provider = provider;
1531            settings.set_model(provider.default_model());
1532            assert!(
1533                settings.to_config().is_ok(),
1534                "{provider:?} must validate without its key"
1535            );
1536        }
1537
1538        let workspace = unique_temp_dir("ragrig-gpui-cloud-profile");
1539        settings.workspace = workspace.display().to_string();
1540        settings.profile_name = "cloud".to_owned();
1541        settings
1542            .save_profile()
1543            .expect("a profile saves without a key in the environment");
1544        assert!(workspace.join(".ragrig/profiles/cloud.json").is_file());
1545
1546        let _ = std::fs::remove_dir_all(workspace);
1547    }
1548
1549    #[test]
1550    fn the_selected_model_reaches_the_chat_config() {
1551        let mut settings = fixture();
1552        settings.set_model_for(ChatProvider::Ollama, "llama3");
1553        let config = settings.to_config().expect("valid configuration");
1554        assert_eq!(config.chat.provider, Provider::Ollama);
1555        assert_eq!(config.chat.model, "llama3");
1556
1557        let mut config = RagrigConfig::default();
1558        config.chat.provider = Provider::OpenAi;
1559        config.chat.model = "gpt-4o-mini".to_owned();
1560        let settings = RagSettings::from_config(&config);
1561        assert_eq!(settings.provider, ChatProvider::OpenAi);
1562        assert_eq!(settings.model(), "gpt-4o-mini");
1563        assert_eq!(
1564            settings.model_for(ChatProvider::Ollama),
1565            ChatProvider::Ollama.default_model(),
1566            "inactive providers keep their own default"
1567        );
1568
1569        let mut config = RagrigConfig::default();
1570        config.chat.provider = Provider::OpenAi;
1571        let settings = RagSettings::from_config(&config);
1572        assert_eq!(
1573            settings.model(),
1574            ChatProvider::OpenAi.default_model(),
1575            "the Ollama default means the selected provider's default"
1576        );
1577
1578        let mut config = RagrigConfig::default();
1579        config.chat.provider = Provider::Deepseek;
1580        config.chat.model = "llama3".to_owned();
1581        config.chat.deepseek_model = "deepseek-chat".to_owned();
1582        let settings = RagSettings::from_config(&config);
1583        assert_eq!(settings.model_for(ChatProvider::Ollama), "llama3");
1584        assert_eq!(settings.model_for(ChatProvider::DeepSeek), "deepseek-chat");
1585    }
1586
1587    #[test]
1588    fn memory_and_retrieval_switches_reach_the_setup() {
1589        let mut settings = fixture();
1590        settings.memory = MemoryChoice::Off;
1591        settings.hybrid = false;
1592
1593        let setup = settings.to_setup().expect("valid setup");
1594        assert!(!setup.memory_enabled());
1595        assert!(!setup.hybrid);
1596    }
1597
1598    #[test]
1599    fn hybrid_off_does_not_require_an_embedding_model() {
1600        let mut settings = fixture();
1601        settings.hybrid = false;
1602        settings.embed_model.clear();
1603
1604        assert!(settings.to_setup().is_ok());
1605
1606        settings.hybrid = true;
1607        assert!(settings.to_setup().is_err());
1608    }
1609
1610    #[test]
1611    fn layered_config_merges_one_setting_at_a_time() {
1612        let root = unique_temp_dir("ragrig-gpui-layers");
1613        let global = root.join("home/.ragrig/config.json");
1614        let workspace = root.join("paper/.ragrig/config.json");
1615        std::fs::create_dir_all(global.parent().expect("global parent")).expect("global dir");
1616        std::fs::create_dir_all(workspace.parent().expect("workspace parent"))
1617            .expect("workspace dir");
1618        std::fs::write(
1619            &global,
1620            r#"{"chat":{"model":"global-chat"},"embed":{"top_k":12},"parse":{"chunk_size":512}}"#,
1621        )
1622        .expect("global layer");
1623        std::fs::write(
1624            &workspace,
1625            r#"{"chat":{"model":"local-chat"},"hybrid":false,"parse":{"chunk_overlap":32}}"#,
1626        )
1627        .expect("workspace layer");
1628
1629        let settings = RagSettings::from_layer_paths(Some(&global), &workspace, None)
1630            .expect("layered settings");
1631        assert_eq!(
1632            settings.model_for(ChatProvider::Ollama),
1633            "local-chat",
1634            "workspace wins"
1635        );
1636        assert_eq!(settings.top_k, 12, "global wins over built-in");
1637        assert_eq!(
1638            settings.chunk_size, 512,
1639            "global survives the workspace layer"
1640        );
1641        assert_eq!(settings.chunk_overlap, 32, "workspace fills a missing key");
1642        assert!(!settings.hybrid, "the workspace hybrid extension is read");
1643        assert_eq!(
1644            settings.provider,
1645            RagSettings::default().provider,
1646            "untouched keys keep the built-in default"
1647        );
1648
1649        let _ = std::fs::remove_dir_all(root);
1650    }
1651
1652    #[test]
1653    fn runtime_switches_survive_a_layer_without_them() {
1654        let root = unique_temp_dir("ragrig-gpui-layer-switches");
1655        let global = root.join("home/.ragrig/config.json");
1656        let workspace = root.join("paper/.ragrig/config.json");
1657        std::fs::create_dir_all(global.parent().expect("global parent")).expect("global dir");
1658        std::fs::create_dir_all(workspace.parent().expect("workspace parent"))
1659            .expect("workspace dir");
1660        std::fs::write(&global, r#"{"chat":{"model":"layer-chat"}}"#).expect("layer");
1661        std::fs::write(&workspace, r#"{"parse":{"chunk_size":256}}"#).expect("layer");
1662
1663        let mut base = fixture();
1664        base.memory = MemoryChoice::Off;
1665        base.hybrid = false;
1666        let settings = RagSettings::from_layer_paths(Some(&global), &workspace, Some(&base))
1667            .expect("layered settings");
1668        assert_eq!(settings.model_for(ChatProvider::Ollama), "layer-chat");
1669        assert_eq!(settings.chunk_size, 256);
1670        assert_eq!(
1671            settings.memory,
1672            MemoryChoice::Off,
1673            "runtime memory survives"
1674        );
1675        assert!(!settings.hybrid, "runtime retrieval switch survives");
1676
1677        let _ = std::fs::remove_dir_all(root);
1678    }
1679
1680    #[test]
1681    fn a_prompt_file_overrides_the_inline_prompt() {
1682        let workspace = unique_temp_dir("ragrig-gpui-prompt-file");
1683        std::fs::write(workspace.join("Chat.md"), "# Chat\n\nUse the corpus.")
1684            .expect("prompt file");
1685        let mut settings = fixture();
1686        settings.workspace = workspace.display().to_string();
1687        settings.system_prompt = "inline".to_owned();
1688        settings.system_prompt_path = "Chat.md".to_owned();
1689
1690        assert_eq!(
1691            settings.to_setup().expect("setup").system_prompt.as_deref(),
1692            Some("# Chat\n\nUse the corpus.")
1693        );
1694        let applied = RagSettings {
1695            system_prompt_path: String::new(),
1696            system_prompt: "inline".to_owned(),
1697            ..settings.clone()
1698        };
1699        assert!(
1700            settings
1701                .dirty_since(&applied)
1702                .contains(RagField::SystemPrompt)
1703        );
1704
1705        let _ = std::fs::remove_dir_all(workspace);
1706    }
1707
1708    #[test]
1709    fn a_missing_prompt_file_is_reported() {
1710        let mut settings = fixture();
1711        settings.system_prompt_path = "/definitely/not/here/Chat.md".to_owned();
1712        let error = settings.to_setup().expect_err("missing prompt");
1713        assert!(error.contains("cannot read system prompt"), "{error}");
1714    }
1715
1716    #[test]
1717    fn using_the_built_in_prompt_clears_the_file() {
1718        let mut settings = fixture();
1719        settings.built_in_prompt = "Host prompt".to_owned();
1720        settings.system_prompt = "File prompt".to_owned();
1721        settings.system_prompt_path = "Chat.md".to_owned();
1722
1723        settings.use_built_in_prompt();
1724
1725        assert_eq!(settings.system_prompt, "Host prompt");
1726        assert!(settings.system_prompt_path.is_empty());
1727    }
1728
1729    #[test]
1730    fn profile_defaults_restore_the_built_in_prompt() {
1731        let mut settings = fixture();
1732        settings.built_in_prompt = "Host prompt".to_owned();
1733        settings.system_prompt = "Custom".to_owned();
1734        settings.system_prompt_path = "custom.md".to_owned();
1735
1736        settings.reset_to_profile_defaults().expect("reset");
1737
1738        assert_eq!(settings.system_prompt, "Host prompt");
1739        assert!(settings.system_prompt_path.is_empty());
1740    }
1741
1742    #[test]
1743    fn a_configured_bibliography_reaches_the_setup() {
1744        let mut settings = fixture();
1745        assert!(
1746            settings
1747                .to_setup()
1748                .expect("valid defaults")
1749                .bibliography
1750                .is_none()
1751        );
1752
1753        settings.bibliography = "  /tmp/library.bib ".to_owned();
1754        assert_eq!(
1755            settings.to_setup().expect("valid setup").bibliography,
1756            Some(std::path::PathBuf::from("/tmp/library.bib"))
1757        );
1758    }
1759
1760    #[test]
1761    fn invalid_values_are_rejected() {
1762        let mut settings = fixture();
1763        settings.chunk_overlap = settings.chunk_size;
1764        assert_eq!(
1765            settings.validate(),
1766            Err("chunk overlap must be smaller than the chunk size".to_owned())
1767        );
1768
1769        settings = fixture();
1770        settings.top_k = 0;
1771        assert_eq!(
1772            settings.validate(),
1773            Err("top-k must be at least 1".to_owned())
1774        );
1775
1776        settings = fixture();
1777        settings.similarity_threshold = 1.5;
1778        assert_eq!(
1779            settings.validate(),
1780            Err("similarity threshold must be between 0 and 1".to_owned())
1781        );
1782
1783        settings = fixture();
1784        settings.corpus_dirs = vec!["broken".to_owned()];
1785        assert_eq!(
1786            settings.validate(),
1787            Err("corpus 'broken' must use name=path".to_owned())
1788        );
1789    }
1790
1791    #[test]
1792    fn profiles_round_trip_prompt_and_api_key_slot() {
1793        let workspace = unique_temp_dir("ragrig-gpui-profile");
1794        let mut settings = fixture();
1795        settings.workspace = workspace.display().to_string();
1796        settings.set_model("qwen3.5:9b");
1797        settings.system_prompt = "Cite primary sources only.".to_owned();
1798        settings.profile_name = "lab".to_owned();
1799        settings.status = None;
1800
1801        settings.save_profile().expect("save profile");
1802        let path = workspace.join(".ragrig/profiles/lab.json");
1803        let json = std::fs::read_to_string(&path).expect("profile file");
1804        assert!(!json.contains("sk-"));
1805        assert!(json.contains("\"deepseek_api_key\": null"));
1806        assert!(json.contains("lab.prompt.md"));
1807        assert_eq!(
1808            std::fs::read_to_string(workspace.join(".ragrig/profiles/lab.prompt.md"))
1809                .expect("prompt file"),
1810            "Cite primary sources only."
1811        );
1812
1813        settings.workspace = workspace.display().to_string();
1814        settings.refresh_profiles();
1815        settings.profile_name = "lab".to_owned();
1816        settings.system_prompt = "overwritten".to_owned();
1817        settings.load_profile("lab").expect("load profile");
1818        assert_eq!(settings.model_for(ChatProvider::Ollama), "qwen3.5:9b");
1819        assert_eq!(settings.system_prompt, "Cite primary sources only.");
1820
1821        settings.profile_name = "../escape".to_owned();
1822        assert!(settings.save_profile().is_err());
1823
1824        let _ = std::fs::remove_dir_all(workspace);
1825    }
1826
1827    #[test]
1828    fn adding_a_corpus_replaces_the_same_name() {
1829        let mut settings = fixture();
1830        settings.add_corpus("papers", Path::new("/docs/a"));
1831        settings.add_corpus("papers", Path::new("/docs/b"));
1832        assert_eq!(settings.corpus_dirs, vec!["papers=/docs/b".to_owned()]);
1833    }
1834
1835    #[test]
1836    fn grobid_defaults_to_an_existing_server_and_the_full_image() {
1837        let settings = RagSettings::default();
1838        assert_eq!(settings.grobid_source, GrobidSource::Existing);
1839        assert_eq!(settings.grobid_url, DEFAULT_GROBID_URL.to_owned());
1840        assert_eq!(settings.grobid_image, DEFAULT_GROBID_IMAGE.to_owned());
1841        assert!(!settings.grobid_auto_start);
1842        assert_eq!(
1843            GrobidSource::from_label("Docker container"),
1844            Some(GrobidSource::Managed)
1845        );
1846        assert_eq!(GrobidSource::from_label("not a source"), None);
1847    }
1848
1849    #[test]
1850    fn changes_name_changed_fields_and_their_sections() {
1851        let applied = fixture();
1852        let mut settings = applied.clone();
1853        assert!(settings.dirty_since(&applied).is_empty());
1854
1855        settings.max_tokens = applied.max_tokens * 2;
1856        settings.chunk_size = applied.chunk_size * 2;
1857        settings.openalex = !applied.openalex;
1858        let changes = settings.dirty_since(&applied);
1859
1860        assert!(changes.contains(RagField::MaxTokens));
1861        assert!(changes.contains(RagField::ChunkSize));
1862        assert!(changes.contains(RagField::OpenAlex));
1863        assert!(!changes.contains(RagField::Provider));
1864        assert_eq!(changes.len(), 3);
1865        assert!(changes.section(RagSection::Chat));
1866        assert!(changes.section(RagSection::Parsing));
1867        assert!(changes.section(RagSection::References));
1868        assert!(!changes.section(RagSection::Memory));
1869        assert_eq!(changes.count_in(RagSection::Chat), 1);
1870        assert_eq!(changes.count_in(RagSection::Parsing), 1);
1871        assert_eq!(changes.count_in(RagSection::Memory), 0);
1872    }
1873
1874    #[test]
1875    fn every_editable_field_is_tracked() {
1876        type FieldMutation = (RagField, fn(&mut RagSettings));
1877        let applied = fixture();
1878        let mutators: &[FieldMutation] = &[
1879            (RagField::Workspace, |settings| {
1880                settings.workspace.push_str("/elsewhere")
1881            }),
1882            (RagField::Bibliography, |settings| {
1883                settings.bibliography.push_str("/other.bib")
1884            }),
1885            (RagField::Provider, |settings| {
1886                settings.provider = ChatProvider::DeepSeek
1887            }),
1888            (RagField::Model, |settings| {
1889                let model = settings.model().to_owned();
1890                settings.set_model(format!("{model}-alt"));
1891            }),
1892            (RagField::LlamaCppUrl, |settings| {
1893                settings.llamacpp_url.push_str(":9090")
1894            }),
1895            (RagField::Temperature, |settings| {
1896                settings.temperature += 0.1
1897            }),
1898            (RagField::MaxTokens, |settings| settings.max_tokens *= 2),
1899            (RagField::ContextTokens, |settings| {
1900                settings.context_tokens *= 2
1901            }),
1902            (RagField::Stream, |settings| {
1903                settings.stream = !settings.stream
1904            }),
1905            (RagField::SystemPrompt, |settings| {
1906                settings.system_prompt.push_str(" Be terse.")
1907            }),
1908            (RagField::EmbedModel, |settings| {
1909                settings.embed_model.push_str("-alt")
1910            }),
1911            (RagField::Hybrid, |settings| {
1912                settings.hybrid = !settings.hybrid
1913            }),
1914            (RagField::TopK, |settings| settings.top_k += 1),
1915            (RagField::SimilarityThreshold, |settings| {
1916                settings.similarity_threshold += 0.01
1917            }),
1918            (RagField::CorpusDirs, |settings| {
1919                settings.corpus_dirs.push("lab=/docs/lab".to_owned())
1920            }),
1921            (RagField::PdfBackend, |settings| {
1922                settings.pdf_backend = PdfBackend::Sink
1923            }),
1924            (RagField::SloppyPdf, |settings| {
1925                settings.sloppy_pdf = !settings.sloppy_pdf
1926            }),
1927            (RagField::ChunkSize, |settings| settings.chunk_size += 16),
1928            (RagField::ChunkOverlap, |settings| {
1929                settings.chunk_overlap += 8
1930            }),
1931            (RagField::Memory, |settings| {
1932                settings.memory = MemoryChoice::Off
1933            }),
1934            (RagField::MemoryModel, |settings| {
1935                settings.memory_model.push_str("-alt")
1936            }),
1937            (RagField::GrobidSource, |settings| {
1938                settings.grobid_source = GrobidSource::Managed
1939            }),
1940            (RagField::GrobidUrl, |settings| {
1941                settings.grobid_url.push_str("/v2")
1942            }),
1943            (RagField::GrobidImage, |settings| {
1944                settings.grobid_image.push_str("-crf")
1945            }),
1946            (RagField::GrobidAutoStart, |settings| {
1947                settings.grobid_auto_start = !settings.grobid_auto_start
1948            }),
1949            (RagField::RenamePdfs, |settings| {
1950                settings.rename_pdfs = !settings.rename_pdfs
1951            }),
1952            (RagField::OpenAlex, |settings| {
1953                settings.openalex = !settings.openalex
1954            }),
1955            (RagField::ResolveCitations, |settings| {
1956                settings.resolve_citations = !settings.resolve_citations
1957            }),
1958        ];
1959
1960        for (field, mutate) in mutators {
1961            let mut settings = applied.clone();
1962            mutate(&mut settings);
1963            let changes = settings.dirty_since(&applied);
1964            assert!(
1965                changes.contains(*field),
1966                "{field:?} is not part of the change set"
1967            );
1968            assert_eq!(changes.len(), 1, "{field:?} changed more than one field");
1969            assert!(
1970                changes.section(field.section()),
1971                "{field:?} did not mark its section"
1972            );
1973        }
1974    }
1975
1976    #[test]
1977    fn profile_bookkeeping_is_not_a_change() {
1978        let applied = fixture();
1979        let mut settings = applied.clone();
1980        settings.profile_name = "lab".to_owned();
1981        settings.profiles = vec!["lab".to_owned()];
1982        settings.status = Some("Saved profile 'lab'".to_owned());
1983
1984        assert!(settings.dirty_since(&applied).is_empty());
1985    }
1986
1987    #[test]
1988    fn profile_defaults_reload_the_selected_profile() {
1989        let workspace = unique_temp_dir("ragrig-gpui-defaults");
1990        let mut settings = fixture();
1991        settings.workspace = workspace.display().to_string();
1992        settings.profile_name = "lab".to_owned();
1993        settings.set_model("qwen3.5:9b");
1994        settings.top_k = 42;
1995        settings.save_profile().expect("save profile");
1996
1997        settings.set_model("scratch");
1998        settings.top_k = 3;
1999        settings.reset_to_profile_defaults().expect("reset");
2000
2001        assert_eq!(settings.model_for(ChatProvider::Ollama), "qwen3.5:9b");
2002        assert_eq!(settings.top_k, 42);
2003        assert_eq!(settings.workspace, workspace.display().to_string());
2004
2005        let _ = std::fs::remove_dir_all(workspace);
2006    }
2007
2008    #[test]
2009    fn profile_defaults_without_a_profile_keep_the_library_location() {
2010        let mut settings = fixture();
2011        settings.workspace = "/tmp/scico-workspace".to_owned();
2012        settings.bibliography = "/tmp/scico.bib".to_owned();
2013        settings.profile_name.clear();
2014        settings.top_k = 3;
2015        settings.temperature = 1.7;
2016
2017        settings.reset_to_profile_defaults().expect("reset");
2018
2019        let defaults = RagSettings::default();
2020        assert_eq!(settings.top_k, defaults.top_k);
2021        assert_eq!(settings.temperature, defaults.temperature);
2022        assert_eq!(settings.workspace, "/tmp/scico-workspace");
2023        assert_eq!(settings.bibliography, "/tmp/scico.bib");
2024    }
2025
2026    fn unique_temp_dir(prefix: &str) -> PathBuf {
2027        static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2028        let counter = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2029        let dir = std::env::temp_dir().join(format!("{prefix}-{}-{counter}", std::process::id()));
2030        std::fs::create_dir_all(&dir).expect("create temp dir");
2031        dir
2032    }
2033}