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