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