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 grobid_auto_start: bool,
399 pub rename_pdfs: bool,
401 pub openalex: bool,
403 pub resolve_citations: bool,
405 pub corpus_dirs: Vec<String>,
407 pub profile_name: String,
409 pub profiles: Vec<String>,
411 pub status: Option<String>,
413}
414
415impl Default for RagSettings {
416 fn default() -> Self {
417 let config = RagrigConfig::default();
418 let mut settings = Self::from_config(&config);
419 settings.workspace = default_workspace().to_string_lossy().into_owned();
420 settings
421 }
422}
423
424#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
429pub enum RagField {
430 Workspace,
432 Bibliography,
434 Provider,
436 Model,
438 Temperature,
440 MaxTokens,
442 ContextTokens,
444 Stream,
446 SystemPrompt,
448 EmbedModel,
450 Hybrid,
452 TopK,
454 SimilarityThreshold,
456 CorpusDirs,
458 PdfBackend,
460 SloppyPdf,
462 ChunkSize,
464 ChunkOverlap,
466 Memory,
468 MemoryModel,
470 GrobidSource,
472 GrobidUrl,
474 GrobidImage,
476 GrobidAutoStart,
478 RenamePdfs,
480 OpenAlex,
482 ResolveCitations,
484}
485
486impl RagField {
487 pub fn section(self) -> RagSection {
489 match self {
490 Self::Workspace | Self::Bibliography => RagSection::Workspace,
491 Self::Provider
492 | Self::Model
493 | Self::Temperature
494 | Self::MaxTokens
495 | Self::ContextTokens
496 | Self::Stream
497 | Self::SystemPrompt => RagSection::Chat,
498 Self::EmbedModel
499 | Self::Hybrid
500 | Self::TopK
501 | Self::SimilarityThreshold
502 | Self::CorpusDirs => RagSection::Retrieval,
503 Self::PdfBackend | Self::SloppyPdf | Self::ChunkSize | Self::ChunkOverlap => {
504 RagSection::Parsing
505 }
506 Self::Memory | Self::MemoryModel => RagSection::Memory,
507 Self::GrobidSource | Self::GrobidUrl | Self::GrobidImage | Self::GrobidAutoStart => {
508 RagSection::Services
509 }
510 Self::RenamePdfs | Self::OpenAlex | Self::ResolveCitations => RagSection::References,
511 }
512 }
513}
514
515#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
517pub enum RagSection {
518 Workspace,
520 Chat,
522 Retrieval,
524 Parsing,
526 Memory,
528 References,
530 Services,
532}
533
534impl RagSection {
535 pub const ALL: [Self; 7] = [
537 Self::Workspace,
538 Self::Chat,
539 Self::Retrieval,
540 Self::Parsing,
541 Self::Memory,
542 Self::References,
543 Self::Services,
544 ];
545
546 pub fn label(self) -> &'static str {
548 match self {
549 Self::Workspace => "Workspace",
550 Self::Chat => "Chat",
551 Self::Retrieval => "Retrieval",
552 Self::Parsing => "Parsing and chunking",
553 Self::Memory => "Memory",
554 Self::References => "References",
555 Self::Services => "External services",
556 }
557 }
558}
559
560#[derive(Clone, Debug, Default, PartialEq, Eq)]
562pub struct RagChanges {
563 fields: BTreeSet<RagField>,
564}
565
566impl RagChanges {
567 pub fn is_empty(&self) -> bool {
569 self.fields.is_empty()
570 }
571
572 pub fn any(&self) -> bool {
574 !self.is_empty()
575 }
576
577 pub fn contains(&self, field: RagField) -> bool {
579 self.fields.contains(&field)
580 }
581
582 pub fn section(&self, section: RagSection) -> bool {
584 self.fields.iter().any(|field| field.section() == section)
585 }
586
587 pub fn count_in(&self, section: RagSection) -> usize {
589 self.fields
590 .iter()
591 .filter(|field| field.section() == section)
592 .count()
593 }
594
595 pub fn len(&self) -> usize {
597 self.fields.len()
598 }
599
600 pub fn fields(&self) -> impl Iterator<Item = RagField> + '_ {
602 self.fields.iter().copied()
603 }
604
605 fn mark_if(&mut self, changed: bool, field: RagField) {
606 if changed {
607 self.fields.insert(field);
608 }
609 }
610}
611
612impl RagSettings {
613 pub fn model(&self) -> &str {
618 self.model_for(self.provider)
619 }
620
621 pub fn model_for(&self, provider: ChatProvider) -> &str {
623 self.models
624 .get(&provider)
625 .map(String::as_str)
626 .unwrap_or_default()
627 }
628
629 pub fn effective_model(&self) -> &str {
631 self.effective_model_for(self.provider)
632 }
633
634 pub fn effective_model_for(&self, provider: ChatProvider) -> &str {
636 let model = self.model_for(provider).trim();
637 if model.is_empty() {
638 provider.default_model()
639 } else {
640 model
641 }
642 }
643
644 pub fn set_model(&mut self, model: impl Into<String>) {
646 self.set_model_for(self.provider, model);
647 }
648
649 pub fn set_model_for(&mut self, provider: ChatProvider, model: impl Into<String>) {
651 self.models.insert(provider, model.into());
652 }
653
654 fn default_models() -> BTreeMap<ChatProvider, String> {
655 ChatProvider::options()
656 .iter()
657 .map(|(provider, _)| (*provider, provider.default_model().to_owned()))
658 .collect()
659 }
660
661 pub fn dirty_since(&self, applied: &Self) -> RagChanges {
664 let mut changes = RagChanges::default();
665 changes.mark_if(self.workspace != applied.workspace, RagField::Workspace);
666 changes.mark_if(
667 self.bibliography != applied.bibliography,
668 RagField::Bibliography,
669 );
670 changes.mark_if(self.provider != applied.provider, RagField::Provider);
671 changes.mark_if(self.models != applied.models, RagField::Model);
672 changes.mark_if(
673 self.temperature != applied.temperature,
674 RagField::Temperature,
675 );
676 changes.mark_if(self.max_tokens != applied.max_tokens, RagField::MaxTokens);
677 changes.mark_if(
678 self.context_tokens != applied.context_tokens,
679 RagField::ContextTokens,
680 );
681 changes.mark_if(self.stream != applied.stream, RagField::Stream);
682 changes.mark_if(
683 self.system_prompt != applied.system_prompt
684 || self.system_prompt_path != applied.system_prompt_path,
685 RagField::SystemPrompt,
686 );
687 changes.mark_if(
688 self.embed_model != applied.embed_model,
689 RagField::EmbedModel,
690 );
691 changes.mark_if(self.hybrid != applied.hybrid, RagField::Hybrid);
692 changes.mark_if(self.top_k != applied.top_k, RagField::TopK);
693 changes.mark_if(
694 self.similarity_threshold != applied.similarity_threshold,
695 RagField::SimilarityThreshold,
696 );
697 changes.mark_if(
698 self.corpus_dirs != applied.corpus_dirs,
699 RagField::CorpusDirs,
700 );
701 changes.mark_if(
702 self.pdf_backend != applied.pdf_backend,
703 RagField::PdfBackend,
704 );
705 changes.mark_if(self.sloppy_pdf != applied.sloppy_pdf, RagField::SloppyPdf);
706 changes.mark_if(self.chunk_size != applied.chunk_size, RagField::ChunkSize);
707 changes.mark_if(
708 self.chunk_overlap != applied.chunk_overlap,
709 RagField::ChunkOverlap,
710 );
711 changes.mark_if(self.memory != applied.memory, RagField::Memory);
712 changes.mark_if(
713 self.memory_model != applied.memory_model,
714 RagField::MemoryModel,
715 );
716 changes.mark_if(
717 self.grobid_source != applied.grobid_source,
718 RagField::GrobidSource,
719 );
720 changes.mark_if(self.grobid_url != applied.grobid_url, RagField::GrobidUrl);
721 changes.mark_if(
722 self.grobid_image != applied.grobid_image,
723 RagField::GrobidImage,
724 );
725 changes.mark_if(
726 self.grobid_auto_start != applied.grobid_auto_start,
727 RagField::GrobidAutoStart,
728 );
729 changes.mark_if(
730 self.rename_pdfs != applied.rename_pdfs,
731 RagField::RenamePdfs,
732 );
733 changes.mark_if(self.openalex != applied.openalex, RagField::OpenAlex);
734 changes.mark_if(
735 self.resolve_citations != applied.resolve_citations,
736 RagField::ResolveCitations,
737 );
738 changes
739 }
740
741 pub fn reset_to_profile_defaults(&mut self) -> Result<(), String> {
747 let name = self.profile_name.trim().to_owned();
748 if !name.is_empty() && self.profiles.iter().any(|profile| profile == &name) {
749 return self.load_profile(&name);
750 }
751
752 let workspace = self.workspace.clone();
753 let bibliography = self.bibliography.clone();
754 let built_in_prompt = self.built_in_prompt.clone();
755 let grobid_source = self.grobid_source;
756 let grobid_url = self.grobid_url.clone();
757 let grobid_image = self.grobid_image.clone();
758 let grobid_auto_start = self.grobid_auto_start;
759 let mut defaults = Self {
760 workspace,
761 bibliography,
762 grobid_source,
763 grobid_url,
764 grobid_image,
765 grobid_auto_start,
766 profile_name: self.profile_name.clone(),
767 built_in_prompt: built_in_prompt.clone(),
768 system_prompt: built_in_prompt,
769 system_prompt_path: String::new(),
770 ..Self::default()
771 };
772 defaults.refresh_profiles();
773 defaults.status = Some("Reset to built-in defaults — press Apply".to_owned());
774 *self = defaults;
775 Ok(())
776 }
777}
778
779impl RagSettings {
780 pub fn from_config(config: &RagrigConfig) -> Self {
782 let provider = ChatProvider::from(config.chat.provider.clone());
783 let mut models = Self::default_models();
784 if !config.chat.deepseek_model.trim().is_empty() {
785 models.insert(ChatProvider::DeepSeek, config.chat.deepseek_model.clone());
786 }
787 if !config.chat.model.trim().is_empty() {
788 match provider {
789 ChatProvider::DeepSeek => {
792 models.insert(ChatProvider::Ollama, config.chat.model.clone());
793 }
794 ChatProvider::Ollama => {
795 models.insert(ChatProvider::Ollama, config.chat.model.clone());
796 }
797 _ if config.chat.model == ChatProvider::Ollama.default_model() => {}
800 _ => {
801 models.insert(provider, config.chat.model.clone());
802 }
803 }
804 }
805 let pdf_backend = match config.parse.pdf_parser {
806 PdfParserBackend::Unpdf => PdfBackend::Unpdf,
807 PdfParserBackend::Sink => PdfBackend::Sink,
808 PdfParserBackend::Internal => PdfBackend::Internal,
809 _ => PdfBackend::Extract,
810 };
811 let memory = match config.memory.strategy {
812 MemoryStrategyKind::Transcript => MemoryChoice::Transcript,
813 MemoryStrategyKind::Log => MemoryChoice::Log,
814 MemoryStrategyKind::Summary => MemoryChoice::Summary,
815 MemoryStrategyKind::Off => MemoryChoice::Off,
816 _ => MemoryChoice::Rewrite,
817 };
818 let system_prompt_path = config
819 .chat
820 .system_prompt_path
821 .as_ref()
822 .map(|path| {
823 if path.is_absolute() {
824 path.clone()
825 } else {
826 config.workspace.join(path)
827 }
828 })
829 .map(|path| path.display().to_string())
830 .unwrap_or_default();
831 let system_prompt = if system_prompt_path.is_empty() {
832 default_prompt()
833 } else {
834 fs::read_to_string(&system_prompt_path)
835 .ok()
836 .filter(|text| !text.trim().is_empty())
837 .unwrap_or_else(default_prompt)
838 };
839
840 Self {
841 workspace: config.workspace.to_string_lossy().into_owned(),
842 bibliography: String::new(),
843 provider,
844 models,
845 temperature: config.chat.params.temperature.unwrap_or(0.2),
846 max_tokens: config.chat.params.max_tokens.unwrap_or(2048),
847 context_tokens: config.chat.context_tokens,
848 system_prompt,
849 system_prompt_path,
850 built_in_prompt: default_prompt(),
851 stream: true,
852 embed_model: config.embed.model.clone(),
853 hybrid: true,
854 top_k: config.embed.top_k,
855 similarity_threshold: config.embed.similarity_threshold,
856 chunk_size: config.parse.chunk_size,
857 chunk_overlap: config.parse.chunk_overlap,
858 pdf_backend,
859 sloppy_pdf: config.parse.sloppy_pdf,
860 memory,
861 memory_model: config.memory.model.clone(),
862 grobid_source: GrobidSource::Existing,
863 grobid_url: DEFAULT_GROBID_URL.to_owned(),
864 grobid_image: DEFAULT_GROBID_IMAGE.to_owned(),
865 grobid_auto_start: false,
866 rename_pdfs: false,
867 openalex: false,
868 resolve_citations: true,
869 corpus_dirs: config.corpus_dirs.clone(),
870 profile_name: String::new(),
871 profiles: Vec::new(),
872 status: None,
873 }
874 }
875
876 pub fn from_layers(workspace: &Path) -> Option<Self> {
883 let global = home_ragrig_dir().map(|dir| dir.join("config.json"));
884 Self::from_layer_paths(
885 global.as_deref(),
886 &workspace.join(".ragrig/config.json"),
887 None,
888 )
889 }
890
891 pub fn from_layers_over(workspace: &Path, base: &Self) -> Option<Self> {
898 let global = home_ragrig_dir().map(|dir| dir.join("config.json"));
899 Self::from_layer_paths(
900 global.as_deref(),
901 &workspace.join(".ragrig/config.json"),
902 Some(base),
903 )
904 }
905
906 fn from_layer_paths(
907 global: Option<&Path>,
908 workspace: &Path,
909 base: Option<&Self>,
910 ) -> Option<Self> {
911 let mut layers = serde_json::json!({});
912 let mut found = false;
913 for path in global.into_iter().chain(std::iter::once(workspace)) {
914 let Ok(text) = fs::read_to_string(path) else {
915 continue;
916 };
917 let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
918 continue;
919 };
920 merge_json(&mut layers, value);
921 found = true;
922 }
923 if !found {
924 return None;
925 }
926 let memory_present = layers.pointer("/memory/strategy").is_some();
927 let hybrid_present = layers.get("hybrid").is_some();
928 let merged_hybrid = layers.get("hybrid").and_then(|value| value.as_bool());
929 let mut merged = serde_json::to_value(RagrigConfig::default()).ok()?;
930 merge_json(&mut merged, layers);
931 let config: RagrigConfig = serde_json::from_value(merged).ok()?;
932 let mut config = config;
933 if let Some(root) = workspace.parent().and_then(Path::parent) {
936 config.workspace = root.to_path_buf();
937 }
938 let mut settings = Self::from_config(&config);
939 if let Some(hybrid) = merged_hybrid {
940 settings.hybrid = hybrid;
941 }
942 if let Some(base) = base {
943 if !memory_present {
944 settings.memory = base.memory;
945 }
946 if !hybrid_present {
947 settings.hybrid = base.hybrid;
948 }
949 }
950 Some(settings)
951 }
952
953 pub fn to_config(&self) -> Result<RagrigConfig, String> {
955 self.validate()?;
956
957 let workspace = PathBuf::from(self.workspace.trim());
958 let model = match self.provider {
959 ChatProvider::DeepSeek => self.effective_model_for(ChatProvider::Ollama),
962 _ => self.effective_model(),
963 };
964 let chat = ChatConfig {
965 provider: self.provider.into(),
966 model: model.to_owned(),
967 deepseek_model: self.effective_model_for(ChatProvider::DeepSeek).to_owned(),
968 deepseek_api_key: None,
970 params: GenerationParams {
971 temperature: Some(self.temperature),
972 top_p: None,
973 max_tokens: Some(self.max_tokens),
974 seed: None,
975 },
976 context_tokens: self.context_tokens,
977 system_prompt_path: None,
978 ..ChatConfig::default()
979 };
980
981 let embed = EmbedConfig {
982 provider: EmbeddingProvider::Ollama,
983 model: self.embed_model.trim().to_owned(),
984 top_k: self.top_k,
985 similarity_threshold: self.similarity_threshold,
986 ..EmbedConfig::default()
987 };
988
989 let parse = ParseConfig {
990 pdf_parser: match self.pdf_backend {
991 PdfBackend::Extract => PdfParserBackend::Extract,
992 PdfBackend::Unpdf => PdfParserBackend::Unpdf,
993 PdfBackend::Sink => PdfParserBackend::Sink,
994 PdfBackend::Internal => PdfParserBackend::Internal,
995 },
996 sloppy_pdf: self.sloppy_pdf,
997 chunk_size: self.chunk_size,
998 chunk_overlap: self.chunk_overlap,
999 };
1000
1001 let memory = MemoryConfig {
1002 model: self.memory_model.trim().to_owned(),
1003 rewrite_prompt_path: None,
1004 strategy: match self.memory {
1005 MemoryChoice::Rewrite => MemoryStrategyKind::Rewrite,
1006 MemoryChoice::Transcript => MemoryStrategyKind::Transcript,
1007 MemoryChoice::Log => MemoryStrategyKind::Log,
1008 MemoryChoice::Summary => MemoryStrategyKind::Summary,
1009 MemoryChoice::Off => MemoryStrategyKind::Off,
1010 },
1011 };
1012
1013 Ok(RagrigConfig {
1014 workspace,
1015 chat,
1016 embed,
1017 parse,
1018 memory,
1019 corpus_dirs: self
1020 .corpus_dirs
1021 .iter()
1022 .map(|entry| entry.trim().to_owned())
1023 .filter(|entry| !entry.is_empty())
1024 .collect(),
1025 corpus_urls: Vec::new(),
1026 semantic_scholar_api_key: None,
1027 })
1028 }
1029
1030 pub fn prompt_path(&self) -> Option<PathBuf> {
1032 let path = self.system_prompt_path.trim();
1033 if path.is_empty() {
1034 return None;
1035 }
1036 let path = Path::new(path);
1037 Some(if path.is_absolute() {
1038 path.to_path_buf()
1039 } else {
1040 Path::new(self.workspace.trim()).join(path)
1041 })
1042 }
1043
1044 pub fn effective_prompt(&self) -> Result<String, String> {
1046 match self.prompt_path() {
1047 Some(path) => {
1048 let text = fs::read_to_string(&path).map_err(|error| {
1049 format!("cannot read system prompt '{}': {error}", path.display())
1050 })?;
1051 if text.trim().is_empty() {
1052 return Err(format!("system prompt '{}' is empty", path.display()));
1053 }
1054 Ok(text)
1055 }
1056 None => Ok(self.system_prompt.clone()),
1057 }
1058 }
1059
1060 pub fn use_built_in_prompt(&mut self) {
1062 self.system_prompt_path.clear();
1063 self.system_prompt = self.built_in_prompt.clone();
1064 }
1065
1066 pub fn to_setup(&self) -> Result<RagSetup, String> {
1068 let config = self.to_config()?;
1069 let prompt = self.effective_prompt()?;
1070 let prompt = prompt.trim();
1071 let mut setup = if prompt.is_empty() {
1072 RagSetup::new(config)
1073 } else {
1074 RagSetup::with_system_prompt(config, prompt)
1075 };
1076 setup.stream = self.stream;
1077 setup.resolve_citations = self.resolve_citations;
1078 setup.hybrid = self.hybrid;
1079 if !self.bibliography.trim().is_empty() {
1080 setup.bibliography = Some(PathBuf::from(self.bibliography.trim()));
1081 }
1082 Ok(setup)
1083 }
1084
1085 pub fn validate(&self) -> Result<(), String> {
1087 if self.workspace.trim().is_empty() {
1088 return Err("choose a workspace folder".to_owned());
1089 }
1090 if self.model().trim().is_empty() {
1091 return Err(format!("choose a {} chat model", self.provider.label()));
1092 }
1093 if !self.temperature.is_finite() || !(0.0..=2.0).contains(&self.temperature) {
1097 return Err("temperature must be between 0 and 2".to_owned());
1098 }
1099 if self.max_tokens == 0 {
1100 return Err("max tokens must be at least 1".to_owned());
1101 }
1102 if self.context_tokens < 256 {
1103 return Err("context tokens must be at least 256".to_owned());
1104 }
1105 if self.hybrid {
1106 if self.embed_model.trim().is_empty() {
1107 return Err("choose an embedding model".to_owned());
1108 }
1109 if self.top_k == 0 {
1110 return Err("top-k must be at least 1".to_owned());
1111 }
1112 if !self.similarity_threshold.is_finite()
1113 || !(0.0..=1.0).contains(&self.similarity_threshold)
1114 {
1115 return Err("similarity threshold must be between 0 and 1".to_owned());
1116 }
1117 }
1118 if self.chunk_size == 0 {
1119 return Err("chunk size must be at least 1".to_owned());
1120 }
1121 if self.chunk_overlap >= self.chunk_size {
1122 return Err("chunk overlap must be smaller than the chunk size".to_owned());
1123 }
1124 if self.memory.uses_model() && self.memory_model.trim().is_empty() {
1125 return Err("choose a memory model or turn memory off".to_owned());
1126 }
1127 if self.grobid_url.trim().is_empty() {
1128 return Err("enter the GROBID server URL".to_owned());
1129 }
1130 if self.grobid_source == GrobidSource::Managed && self.grobid_image.trim().is_empty() {
1131 return Err("enter the Docker image for the managed GROBID container".to_owned());
1132 }
1133 for entry in &self.corpus_dirs {
1134 let entry = entry.trim();
1135 if entry.is_empty() {
1136 continue;
1137 }
1138 let Some((name, path)) = entry.split_once('=') else {
1139 return Err(format!("corpus '{entry}' must use name=path"));
1140 };
1141 if name.trim().is_empty() || path.trim().is_empty() {
1142 return Err(format!("corpus '{entry}' needs a name and a path"));
1143 }
1144 }
1145 Ok(())
1146 }
1147
1148 pub fn refresh_profiles(&mut self) {
1152 let workspace = Path::new(self.workspace.trim());
1153 let mut workspace_failed = None;
1154 let mut profiles = match RagrigConfig::list_profiles(workspace) {
1155 Ok(profiles) => profiles,
1156 Err(error) => {
1157 workspace_failed = Some(error);
1158 Vec::new()
1159 }
1160 };
1161 if let Some(home) = home_ragrig_dir() {
1162 for name in RagrigConfig::list_profiles(&home).unwrap_or_default() {
1163 if !profiles.contains(&name) {
1164 profiles.push(name);
1165 }
1166 }
1167 }
1168 profiles.sort();
1169 if let Some(error) = workspace_failed {
1170 self.status = Some(format!("cannot list workspace profiles: {error:#}"));
1171 }
1172 self.profiles = profiles;
1173 if !self.profile_name.trim().is_empty()
1174 && !self
1175 .profiles
1176 .iter()
1177 .any(|name| name == self.profile_name.trim())
1178 {
1179 self.profiles.insert(0, self.profile_name.trim().to_owned());
1180 }
1181 }
1182
1183 pub fn save_profile(&mut self) -> Result<(), String> {
1185 let name = self.profile_name.trim().to_owned();
1186 validate_profile_name(&name)?;
1187 let mut config = self.to_config()?;
1188 let workspace = Path::new(self.workspace.trim());
1189 let prompt = self.effective_prompt()?;
1190 let prompt = prompt.trim();
1191 if prompt.is_empty()
1192 || (self.system_prompt_path.trim().is_empty() && prompt == self.built_in_prompt.trim())
1193 {
1194 config.chat.system_prompt_path = None;
1195 } else {
1196 let dir = RagrigConfig::profiles_dir(workspace);
1197 fs::create_dir_all(&dir)
1198 .map_err(|error| format!("cannot create profiles folder: {error}"))?;
1199 let relative = PathBuf::from(format!(".ragrig/profiles/{name}.prompt.md"));
1200 fs::write(workspace.join(&relative), prompt).map_err(|error| {
1201 format!("cannot write the prompt for profile '{name}': {error}")
1202 })?;
1203 config.chat.system_prompt_path = Some(relative);
1204 }
1205 config
1206 .save_to_profile(workspace, &name)
1207 .map_err(|error| format!("cannot save profile '{name}': {error:#}"))?;
1208 self.refresh_profiles();
1209 self.status = Some(format!("Saved profile '{name}'"));
1210 Ok(())
1211 }
1212
1213 pub fn load_profile(&mut self, name: &str) -> Result<(), String> {
1215 validate_profile_name(name)?;
1216 let workspace = Path::new(self.workspace.trim());
1217 let mut config = match RagrigConfig::load_from_profile(workspace, name) {
1218 Ok(config) => config,
1219 Err(workspace_error) => match home_ragrig_dir() {
1220 Some(home) => RagrigConfig::load_from_profile(&home, name)
1221 .map_err(|_| format!("cannot load profile '{name}': {workspace_error:#}"))?,
1222 None => {
1223 return Err(format!("cannot load profile '{name}': {workspace_error:#}"));
1224 }
1225 },
1226 };
1227 config.workspace = workspace.to_path_buf();
1230 let profiles = self.profiles.clone();
1231 let built_in_prompt = self.built_in_prompt.clone();
1232 let hybrid = self.hybrid;
1235 let grobid_source = self.grobid_source;
1236 let grobid_url = self.grobid_url.clone();
1237 let grobid_image = self.grobid_image.clone();
1238 let grobid_auto_start = self.grobid_auto_start;
1239 let mut loaded = Self::from_config(&config);
1240 loaded.hybrid = hybrid;
1241 loaded.grobid_source = grobid_source;
1242 loaded.grobid_url = grobid_url;
1243 loaded.grobid_image = grobid_image;
1244 loaded.grobid_auto_start = grobid_auto_start;
1245 loaded.profiles = profiles;
1246 loaded.profile_name = name.to_owned();
1247 loaded.built_in_prompt = built_in_prompt.clone();
1248 if loaded.system_prompt_path.is_empty() {
1249 loaded.system_prompt = built_in_prompt;
1250 }
1251 loaded.status = Some(format!("Loaded profile '{name}' — press Apply"));
1252 *self = loaded;
1253 self.refresh_profiles();
1254 Ok(())
1255 }
1256
1257 pub fn add_corpus(&mut self, name: &str, folder: &Path) {
1259 let entry = format!("{name}={}", folder.display());
1260 self.corpus_dirs.retain(|existing| {
1261 existing
1262 .split_once('=')
1263 .is_none_or(|(existing_name, _)| existing_name != name)
1264 });
1265 self.corpus_dirs.push(entry);
1266 }
1267}
1268
1269fn default_workspace() -> PathBuf {
1270 std::env::var_os("RAGRIG_WORKSPACE")
1271 .map(PathBuf::from)
1272 .or_else(|| std::env::current_dir().ok())
1273 .unwrap_or_else(|| PathBuf::from("."))
1274}
1275
1276pub fn home_ragrig_dir() -> Option<PathBuf> {
1284 std::env::var_os("RAGRIG_HOME")
1285 .map(PathBuf::from)
1286 .or_else(|| dirs::home_dir().map(|home| home.join(".ragrig")))
1287}
1288
1289fn merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
1292 match (base, overlay) {
1293 (serde_json::Value::Object(base), serde_json::Value::Object(overlay)) => {
1294 for (key, value) in overlay {
1295 match base.get_mut(&key) {
1296 Some(existing) => merge_json(existing, value),
1297 None => {
1298 base.insert(key, value);
1299 }
1300 }
1301 }
1302 }
1303 (base, overlay) => *base = overlay,
1304 }
1305}
1306
1307fn default_prompt() -> String {
1308 "Answer from the attached context and the indexed corpus. Cite each paper by its @Key. \
1309 If the context does not support an answer, say what is missing instead of guessing."
1310 .to_owned()
1311}
1312
1313fn validate_profile_name(name: &str) -> Result<(), String> {
1314 if name.is_empty() {
1315 return Err("enter a profile name first".to_owned());
1316 }
1317 if !name
1318 .chars()
1319 .all(|character| character.is_ascii_alphanumeric() || character == '-' || character == '_')
1320 {
1321 return Err("profile names may use letters, digits, '-' and '_' only".to_owned());
1322 }
1323 Ok(())
1324}
1325
1326#[cfg(test)]
1327mod tests {
1328 use super::*;
1329
1330 fn fixture() -> RagSettings {
1331 RagSettings {
1332 workspace: std::env::temp_dir().display().to_string(),
1333 ..RagSettings::default()
1334 }
1335 }
1336
1337 #[test]
1338 fn defaults_convert_to_a_valid_setup() {
1339 let setup = fixture().to_setup().expect("valid defaults");
1340 assert_eq!(setup.config.parse.chunk_size, 1024);
1341 assert_eq!(setup.config.parse.chunk_overlap, 128);
1342 assert!(setup.system_prompt.is_some());
1343 assert!(setup.resolve_citations);
1344 assert!(setup.hybrid);
1345 assert!(setup.memory_enabled());
1346
1347 let mut settings = fixture();
1348 settings.resolve_citations = false;
1349 assert!(!settings.to_setup().expect("valid setup").resolve_citations);
1350 }
1351
1352 #[test]
1353 fn every_provider_has_a_default_model() {
1354 let chat = ChatConfig::default();
1355 assert_eq!(ChatProvider::Ollama.default_model(), chat.model);
1356 assert_eq!(ChatProvider::DeepSeek.default_model(), chat.deepseek_model);
1357 for (provider, _) in ChatProvider::options() {
1358 assert!(
1359 !provider.default_model().trim().is_empty(),
1360 "{provider:?} needs a default model"
1361 );
1362 }
1363 }
1364
1365 #[test]
1366 fn cloud_defaults_match_the_upstream_specs() {
1367 use ragrig::ChatAgentSpec;
1368
1369 for (backend, provider) in [
1370 ("openai", ChatProvider::OpenAi),
1371 ("claude", ChatProvider::Claude),
1372 ("mistral", ChatProvider::Mistral),
1373 ("openrouter", ChatProvider::OpenRouter),
1374 ] {
1375 let spec = ChatAgentSpec::parse(backend, None, None, None).expect("known backend");
1376 let model = match spec {
1377 ChatAgentSpec::OpenAi { model, .. }
1378 | ChatAgentSpec::Claude { model, .. }
1379 | ChatAgentSpec::Mistral { model, .. }
1380 | ChatAgentSpec::OpenRouter { model, .. } => model,
1381 _ => panic!("unexpected spec for {backend}"),
1382 };
1383 assert_eq!(provider.default_model(), model, "{backend} default drifted");
1384 }
1385 }
1386
1387 #[test]
1388 fn each_provider_keeps_its_own_model() {
1389 let mut settings = fixture();
1390 settings.set_model_for(ChatProvider::Ollama, "llama3");
1391 settings.set_model_for(ChatProvider::OpenAi, "gpt-4o-mini");
1392
1393 settings.provider = ChatProvider::OpenAi;
1394 assert_eq!(settings.model(), "gpt-4o-mini");
1395
1396 settings.provider = ChatProvider::Claude;
1397 assert_eq!(settings.model(), ChatProvider::Claude.default_model());
1398
1399 settings.provider = ChatProvider::Ollama;
1400 assert_eq!(settings.model(), "llama3");
1401 }
1402
1403 #[test]
1404 fn cloud_providers_map_to_upstream_backends_and_key_variables() {
1405 let providers = [
1406 (
1407 ChatProvider::DeepSeek,
1408 Provider::Deepseek,
1409 "DEEPSEEK_API_KEY",
1410 ),
1411 (ChatProvider::OpenAi, Provider::OpenAi, "OPENAI_API_KEY"),
1412 (ChatProvider::Claude, Provider::Claude, "ANTHROPIC_API_KEY"),
1413 (ChatProvider::Mistral, Provider::Mistral, "MISTRAL_API_KEY"),
1414 (
1415 ChatProvider::OpenRouter,
1416 Provider::OpenRouter,
1417 "OPENROUTER_API_KEY",
1418 ),
1419 ];
1420 for (provider, backend, key) in providers {
1421 assert_eq!(Provider::from(provider), backend);
1422 assert_eq!(ChatProvider::from(backend), provider);
1423 assert_eq!(provider.env_var(), Some(key));
1424 assert_eq!(ChatProvider::from_label(provider.label()), Some(provider));
1425 }
1426 assert_eq!(Provider::from(ChatProvider::Ollama), Provider::Ollama);
1427 assert_eq!(ChatProvider::Ollama.env_var(), None);
1428 }
1429
1430 #[test]
1431 fn a_missing_api_key_does_not_block_the_settings_form() {
1432 let mut settings = fixture();
1433 for provider in [
1436 ChatProvider::DeepSeek,
1437 ChatProvider::OpenAi,
1438 ChatProvider::Claude,
1439 ChatProvider::Mistral,
1440 ChatProvider::OpenRouter,
1441 ] {
1442 settings.provider = provider;
1443 settings.set_model(provider.default_model());
1444 assert!(
1445 settings.to_config().is_ok(),
1446 "{provider:?} must validate without its key"
1447 );
1448 }
1449
1450 let workspace = unique_temp_dir("ragrig-gpui-cloud-profile");
1451 settings.workspace = workspace.display().to_string();
1452 settings.profile_name = "cloud".to_owned();
1453 settings
1454 .save_profile()
1455 .expect("a profile saves without a key in the environment");
1456 assert!(workspace.join(".ragrig/profiles/cloud.json").is_file());
1457
1458 let _ = std::fs::remove_dir_all(workspace);
1459 }
1460
1461 #[test]
1462 fn the_selected_model_reaches_the_chat_config() {
1463 let mut settings = fixture();
1464 settings.set_model_for(ChatProvider::Ollama, "llama3");
1465 let config = settings.to_config().expect("valid configuration");
1466 assert_eq!(config.chat.provider, Provider::Ollama);
1467 assert_eq!(config.chat.model, "llama3");
1468
1469 let mut config = RagrigConfig::default();
1470 config.chat.provider = Provider::OpenAi;
1471 config.chat.model = "gpt-4o-mini".to_owned();
1472 let settings = RagSettings::from_config(&config);
1473 assert_eq!(settings.provider, ChatProvider::OpenAi);
1474 assert_eq!(settings.model(), "gpt-4o-mini");
1475 assert_eq!(
1476 settings.model_for(ChatProvider::Ollama),
1477 ChatProvider::Ollama.default_model(),
1478 "inactive providers keep their own default"
1479 );
1480
1481 let mut config = RagrigConfig::default();
1482 config.chat.provider = Provider::OpenAi;
1483 let settings = RagSettings::from_config(&config);
1484 assert_eq!(
1485 settings.model(),
1486 ChatProvider::OpenAi.default_model(),
1487 "the Ollama default means the selected provider's default"
1488 );
1489
1490 let mut config = RagrigConfig::default();
1491 config.chat.provider = Provider::Deepseek;
1492 config.chat.model = "llama3".to_owned();
1493 config.chat.deepseek_model = "deepseek-chat".to_owned();
1494 let settings = RagSettings::from_config(&config);
1495 assert_eq!(settings.model_for(ChatProvider::Ollama), "llama3");
1496 assert_eq!(settings.model_for(ChatProvider::DeepSeek), "deepseek-chat");
1497 }
1498
1499 #[test]
1500 fn memory_and_retrieval_switches_reach_the_setup() {
1501 let mut settings = fixture();
1502 settings.memory = MemoryChoice::Off;
1503 settings.hybrid = false;
1504
1505 let setup = settings.to_setup().expect("valid setup");
1506 assert!(!setup.memory_enabled());
1507 assert!(!setup.hybrid);
1508 }
1509
1510 #[test]
1511 fn hybrid_off_does_not_require_an_embedding_model() {
1512 let mut settings = fixture();
1513 settings.hybrid = false;
1514 settings.embed_model.clear();
1515
1516 assert!(settings.to_setup().is_ok());
1517
1518 settings.hybrid = true;
1519 assert!(settings.to_setup().is_err());
1520 }
1521
1522 #[test]
1523 fn layered_config_merges_one_setting_at_a_time() {
1524 let root = unique_temp_dir("ragrig-gpui-layers");
1525 let global = root.join("home/.ragrig/config.json");
1526 let workspace = root.join("paper/.ragrig/config.json");
1527 std::fs::create_dir_all(global.parent().expect("global parent")).expect("global dir");
1528 std::fs::create_dir_all(workspace.parent().expect("workspace parent"))
1529 .expect("workspace dir");
1530 std::fs::write(
1531 &global,
1532 r#"{"chat":{"model":"global-chat"},"embed":{"top_k":12},"parse":{"chunk_size":512}}"#,
1533 )
1534 .expect("global layer");
1535 std::fs::write(
1536 &workspace,
1537 r#"{"chat":{"model":"local-chat"},"hybrid":false,"parse":{"chunk_overlap":32}}"#,
1538 )
1539 .expect("workspace layer");
1540
1541 let settings = RagSettings::from_layer_paths(Some(&global), &workspace, None)
1542 .expect("layered settings");
1543 assert_eq!(
1544 settings.model_for(ChatProvider::Ollama),
1545 "local-chat",
1546 "workspace wins"
1547 );
1548 assert_eq!(settings.top_k, 12, "global wins over built-in");
1549 assert_eq!(
1550 settings.chunk_size, 512,
1551 "global survives the workspace layer"
1552 );
1553 assert_eq!(settings.chunk_overlap, 32, "workspace fills a missing key");
1554 assert!(!settings.hybrid, "the workspace hybrid extension is read");
1555 assert_eq!(
1556 settings.provider,
1557 RagSettings::default().provider,
1558 "untouched keys keep the built-in default"
1559 );
1560
1561 let _ = std::fs::remove_dir_all(root);
1562 }
1563
1564 #[test]
1565 fn runtime_switches_survive_a_layer_without_them() {
1566 let root = unique_temp_dir("ragrig-gpui-layer-switches");
1567 let global = root.join("home/.ragrig/config.json");
1568 let workspace = root.join("paper/.ragrig/config.json");
1569 std::fs::create_dir_all(global.parent().expect("global parent")).expect("global dir");
1570 std::fs::create_dir_all(workspace.parent().expect("workspace parent"))
1571 .expect("workspace dir");
1572 std::fs::write(&global, r#"{"chat":{"model":"layer-chat"}}"#).expect("layer");
1573 std::fs::write(&workspace, r#"{"parse":{"chunk_size":256}}"#).expect("layer");
1574
1575 let mut base = fixture();
1576 base.memory = MemoryChoice::Off;
1577 base.hybrid = false;
1578 let settings = RagSettings::from_layer_paths(Some(&global), &workspace, Some(&base))
1579 .expect("layered settings");
1580 assert_eq!(settings.model_for(ChatProvider::Ollama), "layer-chat");
1581 assert_eq!(settings.chunk_size, 256);
1582 assert_eq!(
1583 settings.memory,
1584 MemoryChoice::Off,
1585 "runtime memory survives"
1586 );
1587 assert!(!settings.hybrid, "runtime retrieval switch survives");
1588
1589 let _ = std::fs::remove_dir_all(root);
1590 }
1591
1592 #[test]
1593 fn a_prompt_file_overrides_the_inline_prompt() {
1594 let workspace = unique_temp_dir("ragrig-gpui-prompt-file");
1595 std::fs::write(workspace.join("Chat.md"), "# Chat\n\nUse the corpus.")
1596 .expect("prompt file");
1597 let mut settings = fixture();
1598 settings.workspace = workspace.display().to_string();
1599 settings.system_prompt = "inline".to_owned();
1600 settings.system_prompt_path = "Chat.md".to_owned();
1601
1602 assert_eq!(
1603 settings.to_setup().expect("setup").system_prompt.as_deref(),
1604 Some("# Chat\n\nUse the corpus.")
1605 );
1606 let applied = RagSettings {
1607 system_prompt_path: String::new(),
1608 system_prompt: "inline".to_owned(),
1609 ..settings.clone()
1610 };
1611 assert!(
1612 settings
1613 .dirty_since(&applied)
1614 .contains(RagField::SystemPrompt)
1615 );
1616
1617 let _ = std::fs::remove_dir_all(workspace);
1618 }
1619
1620 #[test]
1621 fn a_missing_prompt_file_is_reported() {
1622 let mut settings = fixture();
1623 settings.system_prompt_path = "/definitely/not/here/Chat.md".to_owned();
1624 let error = settings.to_setup().expect_err("missing prompt");
1625 assert!(error.contains("cannot read system prompt"), "{error}");
1626 }
1627
1628 #[test]
1629 fn using_the_built_in_prompt_clears_the_file() {
1630 let mut settings = fixture();
1631 settings.built_in_prompt = "Host prompt".to_owned();
1632 settings.system_prompt = "File prompt".to_owned();
1633 settings.system_prompt_path = "Chat.md".to_owned();
1634
1635 settings.use_built_in_prompt();
1636
1637 assert_eq!(settings.system_prompt, "Host prompt");
1638 assert!(settings.system_prompt_path.is_empty());
1639 }
1640
1641 #[test]
1642 fn profile_defaults_restore_the_built_in_prompt() {
1643 let mut settings = fixture();
1644 settings.built_in_prompt = "Host prompt".to_owned();
1645 settings.system_prompt = "Custom".to_owned();
1646 settings.system_prompt_path = "custom.md".to_owned();
1647
1648 settings.reset_to_profile_defaults().expect("reset");
1649
1650 assert_eq!(settings.system_prompt, "Host prompt");
1651 assert!(settings.system_prompt_path.is_empty());
1652 }
1653
1654 #[test]
1655 fn a_configured_bibliography_reaches_the_setup() {
1656 let mut settings = fixture();
1657 assert!(
1658 settings
1659 .to_setup()
1660 .expect("valid defaults")
1661 .bibliography
1662 .is_none()
1663 );
1664
1665 settings.bibliography = " /tmp/library.bib ".to_owned();
1666 assert_eq!(
1667 settings.to_setup().expect("valid setup").bibliography,
1668 Some(std::path::PathBuf::from("/tmp/library.bib"))
1669 );
1670 }
1671
1672 #[test]
1673 fn invalid_values_are_rejected() {
1674 let mut settings = fixture();
1675 settings.chunk_overlap = settings.chunk_size;
1676 assert_eq!(
1677 settings.validate(),
1678 Err("chunk overlap must be smaller than the chunk size".to_owned())
1679 );
1680
1681 settings = fixture();
1682 settings.top_k = 0;
1683 assert_eq!(
1684 settings.validate(),
1685 Err("top-k must be at least 1".to_owned())
1686 );
1687
1688 settings = fixture();
1689 settings.similarity_threshold = 1.5;
1690 assert_eq!(
1691 settings.validate(),
1692 Err("similarity threshold must be between 0 and 1".to_owned())
1693 );
1694
1695 settings = fixture();
1696 settings.corpus_dirs = vec!["broken".to_owned()];
1697 assert_eq!(
1698 settings.validate(),
1699 Err("corpus 'broken' must use name=path".to_owned())
1700 );
1701 }
1702
1703 #[test]
1704 fn profiles_round_trip_prompt_and_api_key_slot() {
1705 let workspace = unique_temp_dir("ragrig-gpui-profile");
1706 let mut settings = fixture();
1707 settings.workspace = workspace.display().to_string();
1708 settings.set_model("qwen3.5:9b");
1709 settings.system_prompt = "Cite primary sources only.".to_owned();
1710 settings.profile_name = "lab".to_owned();
1711 settings.status = None;
1712
1713 settings.save_profile().expect("save profile");
1714 let path = workspace.join(".ragrig/profiles/lab.json");
1715 let json = std::fs::read_to_string(&path).expect("profile file");
1716 assert!(!json.contains("sk-"));
1717 assert!(json.contains("\"deepseek_api_key\": null"));
1718 assert!(json.contains("lab.prompt.md"));
1719 assert_eq!(
1720 std::fs::read_to_string(workspace.join(".ragrig/profiles/lab.prompt.md"))
1721 .expect("prompt file"),
1722 "Cite primary sources only."
1723 );
1724
1725 settings.workspace = workspace.display().to_string();
1726 settings.refresh_profiles();
1727 settings.profile_name = "lab".to_owned();
1728 settings.system_prompt = "overwritten".to_owned();
1729 settings.load_profile("lab").expect("load profile");
1730 assert_eq!(settings.model_for(ChatProvider::Ollama), "qwen3.5:9b");
1731 assert_eq!(settings.system_prompt, "Cite primary sources only.");
1732
1733 settings.profile_name = "../escape".to_owned();
1734 assert!(settings.save_profile().is_err());
1735
1736 let _ = std::fs::remove_dir_all(workspace);
1737 }
1738
1739 #[test]
1740 fn adding_a_corpus_replaces_the_same_name() {
1741 let mut settings = fixture();
1742 settings.add_corpus("papers", Path::new("/docs/a"));
1743 settings.add_corpus("papers", Path::new("/docs/b"));
1744 assert_eq!(settings.corpus_dirs, vec!["papers=/docs/b".to_owned()]);
1745 }
1746
1747 #[test]
1748 fn grobid_defaults_to_an_existing_server_and_the_full_image() {
1749 let settings = RagSettings::default();
1750 assert_eq!(settings.grobid_source, GrobidSource::Existing);
1751 assert_eq!(settings.grobid_url, DEFAULT_GROBID_URL.to_owned());
1752 assert_eq!(settings.grobid_image, DEFAULT_GROBID_IMAGE.to_owned());
1753 assert!(!settings.grobid_auto_start);
1754 assert_eq!(
1755 GrobidSource::from_label("Docker container"),
1756 Some(GrobidSource::Managed)
1757 );
1758 assert_eq!(GrobidSource::from_label("not a source"), None);
1759 }
1760
1761 #[test]
1762 fn changes_name_changed_fields_and_their_sections() {
1763 let applied = fixture();
1764 let mut settings = applied.clone();
1765 assert!(settings.dirty_since(&applied).is_empty());
1766
1767 settings.max_tokens = applied.max_tokens * 2;
1768 settings.chunk_size = applied.chunk_size * 2;
1769 settings.openalex = !applied.openalex;
1770 let changes = settings.dirty_since(&applied);
1771
1772 assert!(changes.contains(RagField::MaxTokens));
1773 assert!(changes.contains(RagField::ChunkSize));
1774 assert!(changes.contains(RagField::OpenAlex));
1775 assert!(!changes.contains(RagField::Provider));
1776 assert_eq!(changes.len(), 3);
1777 assert!(changes.section(RagSection::Chat));
1778 assert!(changes.section(RagSection::Parsing));
1779 assert!(changes.section(RagSection::References));
1780 assert!(!changes.section(RagSection::Memory));
1781 assert_eq!(changes.count_in(RagSection::Chat), 1);
1782 assert_eq!(changes.count_in(RagSection::Parsing), 1);
1783 assert_eq!(changes.count_in(RagSection::Memory), 0);
1784 }
1785
1786 #[test]
1787 fn every_editable_field_is_tracked() {
1788 type FieldMutation = (RagField, fn(&mut RagSettings));
1789 let applied = fixture();
1790 let mutators: &[FieldMutation] = &[
1791 (RagField::Workspace, |settings| {
1792 settings.workspace.push_str("/elsewhere")
1793 }),
1794 (RagField::Bibliography, |settings| {
1795 settings.bibliography.push_str("/other.bib")
1796 }),
1797 (RagField::Provider, |settings| {
1798 settings.provider = ChatProvider::DeepSeek
1799 }),
1800 (RagField::Model, |settings| {
1801 let model = settings.model().to_owned();
1802 settings.set_model(format!("{model}-alt"));
1803 }),
1804 (RagField::Temperature, |settings| {
1805 settings.temperature += 0.1
1806 }),
1807 (RagField::MaxTokens, |settings| settings.max_tokens *= 2),
1808 (RagField::ContextTokens, |settings| {
1809 settings.context_tokens *= 2
1810 }),
1811 (RagField::Stream, |settings| {
1812 settings.stream = !settings.stream
1813 }),
1814 (RagField::SystemPrompt, |settings| {
1815 settings.system_prompt.push_str(" Be terse.")
1816 }),
1817 (RagField::EmbedModel, |settings| {
1818 settings.embed_model.push_str("-alt")
1819 }),
1820 (RagField::Hybrid, |settings| {
1821 settings.hybrid = !settings.hybrid
1822 }),
1823 (RagField::TopK, |settings| settings.top_k += 1),
1824 (RagField::SimilarityThreshold, |settings| {
1825 settings.similarity_threshold += 0.01
1826 }),
1827 (RagField::CorpusDirs, |settings| {
1828 settings.corpus_dirs.push("lab=/docs/lab".to_owned())
1829 }),
1830 (RagField::PdfBackend, |settings| {
1831 settings.pdf_backend = PdfBackend::Sink
1832 }),
1833 (RagField::SloppyPdf, |settings| {
1834 settings.sloppy_pdf = !settings.sloppy_pdf
1835 }),
1836 (RagField::ChunkSize, |settings| settings.chunk_size += 16),
1837 (RagField::ChunkOverlap, |settings| {
1838 settings.chunk_overlap += 8
1839 }),
1840 (RagField::Memory, |settings| {
1841 settings.memory = MemoryChoice::Off
1842 }),
1843 (RagField::MemoryModel, |settings| {
1844 settings.memory_model.push_str("-alt")
1845 }),
1846 (RagField::GrobidSource, |settings| {
1847 settings.grobid_source = GrobidSource::Managed
1848 }),
1849 (RagField::GrobidUrl, |settings| {
1850 settings.grobid_url.push_str("/v2")
1851 }),
1852 (RagField::GrobidImage, |settings| {
1853 settings.grobid_image.push_str("-crf")
1854 }),
1855 (RagField::GrobidAutoStart, |settings| {
1856 settings.grobid_auto_start = !settings.grobid_auto_start
1857 }),
1858 (RagField::RenamePdfs, |settings| {
1859 settings.rename_pdfs = !settings.rename_pdfs
1860 }),
1861 (RagField::OpenAlex, |settings| {
1862 settings.openalex = !settings.openalex
1863 }),
1864 (RagField::ResolveCitations, |settings| {
1865 settings.resolve_citations = !settings.resolve_citations
1866 }),
1867 ];
1868
1869 for (field, mutate) in mutators {
1870 let mut settings = applied.clone();
1871 mutate(&mut settings);
1872 let changes = settings.dirty_since(&applied);
1873 assert!(
1874 changes.contains(*field),
1875 "{field:?} is not part of the change set"
1876 );
1877 assert_eq!(changes.len(), 1, "{field:?} changed more than one field");
1878 assert!(
1879 changes.section(field.section()),
1880 "{field:?} did not mark its section"
1881 );
1882 }
1883 }
1884
1885 #[test]
1886 fn profile_bookkeeping_is_not_a_change() {
1887 let applied = fixture();
1888 let mut settings = applied.clone();
1889 settings.profile_name = "lab".to_owned();
1890 settings.profiles = vec!["lab".to_owned()];
1891 settings.status = Some("Saved profile 'lab'".to_owned());
1892
1893 assert!(settings.dirty_since(&applied).is_empty());
1894 }
1895
1896 #[test]
1897 fn profile_defaults_reload_the_selected_profile() {
1898 let workspace = unique_temp_dir("ragrig-gpui-defaults");
1899 let mut settings = fixture();
1900 settings.workspace = workspace.display().to_string();
1901 settings.profile_name = "lab".to_owned();
1902 settings.set_model("qwen3.5:9b");
1903 settings.top_k = 42;
1904 settings.save_profile().expect("save profile");
1905
1906 settings.set_model("scratch");
1907 settings.top_k = 3;
1908 settings.reset_to_profile_defaults().expect("reset");
1909
1910 assert_eq!(settings.model_for(ChatProvider::Ollama), "qwen3.5:9b");
1911 assert_eq!(settings.top_k, 42);
1912 assert_eq!(settings.workspace, workspace.display().to_string());
1913
1914 let _ = std::fs::remove_dir_all(workspace);
1915 }
1916
1917 #[test]
1918 fn profile_defaults_without_a_profile_keep_the_library_location() {
1919 let mut settings = fixture();
1920 settings.workspace = "/tmp/scico-workspace".to_owned();
1921 settings.bibliography = "/tmp/scico.bib".to_owned();
1922 settings.profile_name.clear();
1923 settings.top_k = 3;
1924 settings.temperature = 1.7;
1925
1926 settings.reset_to_profile_defaults().expect("reset");
1927
1928 let defaults = RagSettings::default();
1929 assert_eq!(settings.top_k, defaults.top_k);
1930 assert_eq!(settings.temperature, defaults.temperature);
1931 assert_eq!(settings.workspace, "/tmp/scico-workspace");
1932 assert_eq!(settings.bibliography, "/tmp/scico.bib");
1933 }
1934
1935 fn unique_temp_dir(prefix: &str) -> PathBuf {
1936 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1937 let counter = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1938 let dir = std::env::temp_dir().join(format!("{prefix}-{}-{counter}", std::process::id()));
1939 std::fs::create_dir_all(&dir).expect("create temp dir");
1940 dir
1941 }
1942}