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