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