1use serde::{Deserialize, Serialize};
8
9pub const CONFIG_FILE_NAME: &str = "opensession.toml";
11
12#[derive(Debug, Clone, Serialize, Deserialize, Default)]
14pub struct DaemonConfig {
15 #[serde(default)]
16 pub daemon: DaemonSettings,
17 #[serde(default)]
18 pub server: ServerSettings,
19 #[serde(default)]
20 pub identity: IdentitySettings,
21 #[serde(default)]
22 pub privacy: PrivacySettings,
23 #[serde(default)]
24 pub watchers: WatcherSettings,
25 #[serde(default)]
26 pub git_storage: GitStorageSettings,
27 #[serde(default)]
28 pub summary: SummarySettings,
29 #[serde(default)]
30 pub vector_search: VectorSearchSettings,
31 #[serde(default)]
32 pub change_reader: ChangeReaderSettings,
33 #[serde(default)]
34 pub lifecycle: LifecycleSettings,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct DaemonSettings {
39 #[serde(default = "default_false")]
40 pub auto_publish: bool,
41 #[serde(default = "default_debounce")]
42 pub debounce_secs: u64,
43 #[serde(default = "default_publish_on")]
44 pub publish_on: PublishMode,
45 #[serde(default = "default_max_retries")]
46 pub max_retries: u32,
47 #[serde(default = "default_health_check_interval")]
48 pub health_check_interval_secs: u64,
49 #[serde(default = "default_realtime_debounce_ms")]
50 pub realtime_debounce_ms: u64,
51 #[serde(default = "default_detail_realtime_preview_enabled")]
53 pub detail_realtime_preview_enabled: bool,
54 #[serde(default = "default_detail_auto_expand_selected_event")]
56 pub detail_auto_expand_selected_event: bool,
57 #[serde(default = "default_session_default_view")]
59 pub session_default_view: SessionDefaultView,
60}
61
62impl Default for DaemonSettings {
63 fn default() -> Self {
64 Self {
65 auto_publish: false,
66 debounce_secs: 5,
67 publish_on: PublishMode::Manual,
68 max_retries: 3,
69 health_check_interval_secs: 300,
70 realtime_debounce_ms: 500,
71 detail_realtime_preview_enabled: false,
72 detail_auto_expand_selected_event: true,
73 session_default_view: SessionDefaultView::default(),
74 }
75 }
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
79#[serde(rename_all = "snake_case")]
80pub enum PublishMode {
81 SessionEnd,
82 Realtime,
83 Manual,
84}
85
86impl PublishMode {
87 pub fn cycle(&self) -> Self {
88 match self {
89 Self::SessionEnd => Self::Realtime,
90 Self::Realtime => Self::Manual,
91 Self::Manual => Self::SessionEnd,
92 }
93 }
94
95 pub fn display(&self) -> &'static str {
96 match self {
97 Self::SessionEnd => "Session End",
98 Self::Realtime => "Realtime",
99 Self::Manual => "Manual",
100 }
101 }
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
105#[serde(rename_all = "snake_case")]
106pub enum CalendarDisplayMode {
107 Smart,
108 Relative,
109 Absolute,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
113#[serde(rename_all = "snake_case")]
114pub enum SessionDefaultView {
115 #[default]
116 Full,
117 Compressed,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct ServerSettings {
122 #[serde(default = "default_server_url")]
123 pub url: String,
124 #[serde(default)]
125 pub api_key: String,
126}
127
128impl Default for ServerSettings {
129 fn default() -> Self {
130 Self {
131 url: default_server_url(),
132 api_key: String::new(),
133 }
134 }
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct IdentitySettings {
139 #[serde(default = "default_nickname")]
140 pub nickname: String,
141}
142
143impl Default for IdentitySettings {
144 fn default() -> Self {
145 Self {
146 nickname: default_nickname(),
147 }
148 }
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct PrivacySettings {
153 #[serde(default = "default_true")]
154 pub strip_paths: bool,
155 #[serde(default = "default_true")]
156 pub strip_env_vars: bool,
157 #[serde(default = "default_exclude_patterns")]
158 pub exclude_patterns: Vec<String>,
159 #[serde(default)]
160 pub exclude_tools: Vec<String>,
161}
162
163impl Default for PrivacySettings {
164 fn default() -> Self {
165 Self {
166 strip_paths: true,
167 strip_env_vars: true,
168 exclude_patterns: default_exclude_patterns(),
169 exclude_tools: Vec::new(),
170 }
171 }
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct WatcherSettings {
176 #[serde(default = "default_watch_paths")]
177 pub custom_paths: Vec<String>,
178}
179
180impl Default for WatcherSettings {
181 fn default() -> Self {
182 Self {
183 custom_paths: default_watch_paths(),
184 }
185 }
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct GitStorageSettings {
190 #[serde(default)]
191 pub method: GitStorageMethod,
192 #[serde(default)]
193 pub token: String,
194 #[serde(default)]
195 pub retention: GitRetentionSettings,
196}
197
198impl Default for GitStorageSettings {
199 fn default() -> Self {
200 Self {
201 method: GitStorageMethod::Native,
202 token: String::new(),
203 retention: GitRetentionSettings::default(),
204 }
205 }
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize, Default)]
209pub struct SummarySettings {
210 #[serde(default)]
211 pub provider: SummaryProviderSettings,
212 #[serde(default)]
213 pub prompt: SummaryPromptSettings,
214 #[serde(default)]
215 pub response: SummaryResponseSettings,
216 #[serde(default)]
217 pub storage: SummaryStorageSettings,
218 #[serde(default)]
220 pub source_mode: SummarySourceMode,
221 #[serde(default)]
222 pub batch: SummaryBatchSettings,
223}
224
225impl SummarySettings {
226 pub fn is_configured(&self) -> bool {
227 match self.provider.id {
228 SummaryProvider::Disabled => false,
229 SummaryProvider::Ollama => !self.provider.model.trim().is_empty(),
230 SummaryProvider::CodexExec | SummaryProvider::ClaudeCli => true,
231 }
232 }
233
234 pub fn provider_transport(&self) -> SummaryProviderTransport {
235 self.provider.id.transport()
236 }
237
238 pub fn allows_git_changes_fallback(&self) -> bool {
239 matches!(self.source_mode, SummarySourceMode::SessionOrGitChanges)
240 }
241
242 pub fn should_generate_on_session_save(&self) -> bool {
243 matches!(self.storage.trigger, SummaryTriggerMode::OnSessionSave)
244 }
245
246 pub fn persists_to_local_db(&self) -> bool {
247 matches!(self.storage.backend, SummaryStorageBackend::LocalDb)
248 }
249
250 pub fn persists_to_hidden_ref(&self) -> bool {
251 matches!(self.storage.backend, SummaryStorageBackend::HiddenRef)
252 }
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct SummaryProviderSettings {
257 #[serde(default)]
258 pub id: SummaryProvider,
259 #[serde(default = "default_summary_endpoint")]
260 pub endpoint: String,
261 #[serde(default)]
262 pub model: String,
263}
264
265impl Default for SummaryProviderSettings {
266 fn default() -> Self {
267 Self {
268 id: SummaryProvider::default(),
269 endpoint: default_summary_endpoint(),
270 model: String::new(),
271 }
272 }
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize, Default)]
276pub struct SummaryPromptSettings {
277 #[serde(default)]
278 pub template: String,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize, Default)]
282pub struct SummaryResponseSettings {
283 #[serde(default)]
284 pub style: SummaryResponseStyle,
285 #[serde(default)]
286 pub shape: SummaryOutputShape,
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize, Default)]
290pub struct SummaryStorageSettings {
291 #[serde(default)]
292 pub trigger: SummaryTriggerMode,
293 #[serde(default)]
294 pub backend: SummaryStorageBackend,
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct SummaryBatchSettings {
299 #[serde(default)]
300 pub execution_mode: SummaryBatchExecutionMode,
301 #[serde(default)]
302 pub scope: SummaryBatchScope,
303 #[serde(default = "default_summary_batch_recent_days")]
304 pub recent_days: u16,
305}
306
307impl Default for SummaryBatchSettings {
308 fn default() -> Self {
309 Self {
310 execution_mode: SummaryBatchExecutionMode::default(),
311 scope: SummaryBatchScope::default(),
312 recent_days: default_summary_batch_recent_days(),
313 }
314 }
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct VectorSearchSettings {
319 #[serde(default = "default_false")]
320 pub enabled: bool,
321 #[serde(default)]
322 pub provider: VectorSearchProvider,
323 #[serde(default = "default_vector_model")]
324 pub model: String,
325 #[serde(default = "default_vector_endpoint")]
326 pub endpoint: String,
327 #[serde(default)]
328 pub granularity: VectorSearchGranularity,
329 #[serde(default)]
330 pub chunking_mode: VectorChunkingMode,
331 #[serde(default = "default_vector_chunk_size_lines")]
332 pub chunk_size_lines: u16,
333 #[serde(default = "default_vector_chunk_overlap_lines")]
334 pub chunk_overlap_lines: u16,
335 #[serde(default = "default_vector_top_k_chunks")]
336 pub top_k_chunks: u16,
337 #[serde(default = "default_vector_top_k_sessions")]
338 pub top_k_sessions: u16,
339}
340
341impl Default for VectorSearchSettings {
342 fn default() -> Self {
343 Self {
344 enabled: default_false(),
345 provider: VectorSearchProvider::default(),
346 model: default_vector_model(),
347 endpoint: default_vector_endpoint(),
348 granularity: VectorSearchGranularity::default(),
349 chunking_mode: VectorChunkingMode::default(),
350 chunk_size_lines: default_vector_chunk_size_lines(),
351 chunk_overlap_lines: default_vector_chunk_overlap_lines(),
352 top_k_chunks: default_vector_top_k_chunks(),
353 top_k_sessions: default_vector_top_k_sessions(),
354 }
355 }
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct ChangeReaderSettings {
360 #[serde(default = "default_false")]
361 pub enabled: bool,
362 #[serde(default)]
363 pub scope: ChangeReaderScope,
364 #[serde(default = "default_true")]
365 pub qa_enabled: bool,
366 #[serde(default = "default_change_reader_max_context_chars")]
367 pub max_context_chars: u32,
368 #[serde(default)]
369 pub voice: ChangeReaderVoiceSettings,
370}
371
372impl Default for ChangeReaderSettings {
373 fn default() -> Self {
374 Self {
375 enabled: default_false(),
376 scope: ChangeReaderScope::default(),
377 qa_enabled: default_true(),
378 max_context_chars: default_change_reader_max_context_chars(),
379 voice: ChangeReaderVoiceSettings::default(),
380 }
381 }
382}
383
384#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct ChangeReaderVoiceSettings {
386 #[serde(default = "default_false")]
387 pub enabled: bool,
388 #[serde(default)]
389 pub provider: ChangeReaderVoiceProvider,
390 #[serde(default = "default_change_reader_voice_model")]
391 pub model: String,
392 #[serde(default = "default_change_reader_voice_name")]
393 pub voice: String,
394 #[serde(default)]
395 pub api_key: String,
396}
397
398impl Default for ChangeReaderVoiceSettings {
399 fn default() -> Self {
400 Self {
401 enabled: default_false(),
402 provider: ChangeReaderVoiceProvider::default(),
403 model: default_change_reader_voice_model(),
404 voice: default_change_reader_voice_name(),
405 api_key: String::new(),
406 }
407 }
408}
409
410#[derive(Debug, Clone, Serialize, Deserialize)]
411pub struct LifecycleSettings {
412 #[serde(default = "default_true")]
413 pub enabled: bool,
414 #[serde(default = "default_lifecycle_session_ttl_days")]
415 pub session_ttl_days: u32,
416 #[serde(default = "default_lifecycle_summary_ttl_days")]
417 pub summary_ttl_days: u32,
418 #[serde(default = "default_lifecycle_cleanup_interval_secs")]
419 pub cleanup_interval_secs: u64,
420}
421
422impl Default for LifecycleSettings {
423 fn default() -> Self {
424 Self {
425 enabled: true,
426 session_ttl_days: default_lifecycle_session_ttl_days(),
427 summary_ttl_days: default_lifecycle_summary_ttl_days(),
428 cleanup_interval_secs: default_lifecycle_cleanup_interval_secs(),
429 }
430 }
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
434#[serde(rename_all = "snake_case")]
435pub enum ChangeReaderScope {
436 #[default]
437 SummaryOnly,
438 FullContext,
439}
440
441#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
442#[serde(rename_all = "snake_case")]
443pub enum VectorSearchProvider {
444 #[default]
445 Ollama,
446}
447
448#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
449#[serde(rename_all = "snake_case")]
450pub enum VectorSearchGranularity {
451 #[default]
452 EventLineChunk,
453}
454
455#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
456#[serde(rename_all = "snake_case")]
457pub enum VectorChunkingMode {
458 #[default]
459 Auto,
460 Manual,
461}
462
463#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
464#[serde(rename_all = "snake_case")]
465pub enum ChangeReaderVoiceProvider {
466 #[default]
467 Openai,
468}
469
470#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
471#[serde(rename_all = "snake_case")]
472pub enum SummaryProvider {
473 #[default]
474 Disabled,
475 Ollama,
476 CodexExec,
477 ClaudeCli,
478}
479
480impl SummaryProvider {
481 pub fn transport(&self) -> SummaryProviderTransport {
482 match self {
483 Self::Disabled => SummaryProviderTransport::None,
484 Self::Ollama => SummaryProviderTransport::Http,
485 Self::CodexExec | Self::ClaudeCli => SummaryProviderTransport::Cli,
486 }
487 }
488}
489
490#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
491#[serde(rename_all = "snake_case")]
492pub enum SummaryProviderTransport {
493 #[default]
494 None,
495 Cli,
496 Http,
497}
498
499#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
500#[serde(rename_all = "snake_case")]
501pub enum SummaryResponseStyle {
502 Compact,
503 #[default]
504 Standard,
505 Detailed,
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
509#[serde(rename_all = "snake_case")]
510pub enum SummarySourceMode {
511 #[default]
512 SessionOnly,
513 SessionOrGitChanges,
514}
515
516#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
517#[serde(rename_all = "snake_case")]
518pub enum SummaryOutputShape {
519 #[default]
520 Layered,
521 FileList,
522 SecurityFirst,
523}
524
525#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
526#[serde(rename_all = "snake_case")]
527pub enum SummaryTriggerMode {
528 Manual,
529 #[default]
530 OnSessionSave,
531}
532
533#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
534#[serde(rename_all = "snake_case")]
535pub enum SummaryStorageBackend {
536 None,
537 #[default]
538 HiddenRef,
539 LocalDb,
540}
541
542#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
543#[serde(rename_all = "snake_case")]
544pub enum SummaryBatchExecutionMode {
545 Manual,
546 #[default]
547 OnAppStart,
548}
549
550#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
551#[serde(rename_all = "snake_case")]
552pub enum SummaryBatchScope {
553 #[default]
554 RecentDays,
555 All,
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize)]
559pub struct GitRetentionSettings {
560 #[serde(default = "default_false")]
561 pub enabled: bool,
562 #[serde(default = "default_git_retention_keep_days")]
563 pub keep_days: u32,
564 #[serde(default = "default_git_retention_interval_secs")]
565 pub interval_secs: u64,
566}
567
568impl Default for GitRetentionSettings {
569 fn default() -> Self {
570 Self {
571 enabled: false,
572 keep_days: default_git_retention_keep_days(),
573 interval_secs: default_git_retention_interval_secs(),
574 }
575 }
576}
577
578#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
579#[serde(rename_all = "snake_case")]
580pub enum GitStorageMethod {
581 #[default]
583 Native,
584 Sqlite,
586}
587
588fn default_true() -> bool {
591 true
592}
593fn default_false() -> bool {
594 false
595}
596fn default_debounce() -> u64 {
597 5
598}
599fn default_max_retries() -> u32 {
600 3
601}
602fn default_health_check_interval() -> u64 {
603 300
604}
605fn default_realtime_debounce_ms() -> u64 {
606 500
607}
608fn default_detail_realtime_preview_enabled() -> bool {
609 false
610}
611fn default_detail_auto_expand_selected_event() -> bool {
612 true
613}
614fn default_session_default_view() -> SessionDefaultView {
615 SessionDefaultView::Full
616}
617fn default_publish_on() -> PublishMode {
618 PublishMode::Manual
619}
620fn default_git_retention_keep_days() -> u32 {
621 30
622}
623fn default_git_retention_interval_secs() -> u64 {
624 86_400
625}
626fn default_summary_endpoint() -> String {
627 "http://127.0.0.1:11434".to_string()
628}
629fn default_vector_endpoint() -> String {
630 "http://127.0.0.1:11434".to_string()
631}
632fn default_vector_model() -> String {
633 "bge-m3".to_string()
634}
635fn default_vector_chunk_size_lines() -> u16 {
636 12
637}
638fn default_vector_chunk_overlap_lines() -> u16 {
639 3
640}
641fn default_vector_top_k_chunks() -> u16 {
642 30
643}
644fn default_vector_top_k_sessions() -> u16 {
645 20
646}
647fn default_change_reader_max_context_chars() -> u32 {
648 12_000
649}
650fn default_change_reader_voice_model() -> String {
651 "gpt-4o-mini-tts".to_string()
652}
653fn default_change_reader_voice_name() -> String {
654 "alloy".to_string()
655}
656fn default_summary_batch_recent_days() -> u16 {
657 30
658}
659fn default_lifecycle_session_ttl_days() -> u32 {
660 30
661}
662fn default_lifecycle_summary_ttl_days() -> u32 {
663 30
664}
665fn default_lifecycle_cleanup_interval_secs() -> u64 {
666 3_600
667}
668fn default_server_url() -> String {
669 "https://opensession.io".to_string()
670}
671fn default_nickname() -> String {
672 "user".to_string()
673}
674fn default_exclude_patterns() -> Vec<String> {
675 vec![
676 "*.env".to_string(),
677 "*secret*".to_string(),
678 "*credential*".to_string(),
679 ]
680}
681
682pub const DEFAULT_WATCH_PATHS: &[&str] = &[
683 "~/.claude/projects",
684 "~/.codex/sessions",
685 "~/.local/share/opencode/storage/session",
686 "~/.cline/data/tasks",
687 "~/.local/share/amp/threads",
688 "~/.gemini/tmp",
689 "~/Library/Application Support/Cursor/User",
690 "~/.config/Cursor/User",
691];
692
693pub fn default_watch_paths() -> Vec<String> {
694 DEFAULT_WATCH_PATHS
695 .iter()
696 .map(|path| (*path).to_string())
697 .collect()
698}
699
700#[cfg(test)]
701mod tests {
702 use super::*;
703
704 #[test]
705 fn git_storage_method_requires_canonical_values() {
706 let parsed: Result<DaemonConfig, _> = toml::from_str(
707 r#"
708[git_storage]
709method = "platform_api"
710"#,
711 );
712 assert!(parsed.is_err(), "legacy aliases must be rejected");
713 }
714
715 #[test]
716 fn unknown_watcher_flags_are_ignored() {
717 let cfg: DaemonConfig = toml::from_str(
718 r#"
719[watchers]
720claude_code = false
721opencode = false
722cursor = false
723custom_paths = ["~/.codex/sessions"]
724"#,
725 )
726 .expect("parse watcher config");
727
728 assert_eq!(
729 cfg.watchers.custom_paths,
730 vec!["~/.codex/sessions".to_string()]
731 );
732 }
733
734 #[test]
735 fn watcher_settings_serialize_only_current_fields() {
736 let cfg = DaemonConfig::default();
737 let encoded = toml::to_string(&cfg).expect("serialize config");
738
739 assert!(encoded.contains("custom_paths"));
740 assert!(!encoded.contains("\nclaude_code ="));
741 assert!(!encoded.contains("\nopencode ="));
742 assert!(!encoded.contains("\ncursor ="));
743 }
744
745 #[test]
746 fn git_retention_defaults_are_stable() {
747 let cfg = DaemonConfig::default();
748 assert!(!cfg.git_storage.retention.enabled);
749 assert_eq!(cfg.git_storage.retention.keep_days, 30);
750 assert_eq!(cfg.git_storage.retention.interval_secs, 86_400);
751 }
752
753 #[test]
754 fn git_retention_fields_deserialize_from_toml() {
755 let cfg: DaemonConfig = toml::from_str(
756 r#"
757[git_storage]
758method = "native"
759
760[git_storage.retention]
761enabled = true
762keep_days = 14
763interval_secs = 43200
764"#,
765 )
766 .expect("parse retention config");
767
768 assert_eq!(cfg.git_storage.method, GitStorageMethod::Native);
769 assert!(cfg.git_storage.retention.enabled);
770 assert_eq!(cfg.git_storage.retention.keep_days, 14);
771 assert_eq!(cfg.git_storage.retention.interval_secs, 43_200);
772 }
773
774 #[test]
775 fn summary_provider_requires_canonical_values() {
776 let parsed: Result<DaemonConfig, _> = toml::from_str(
777 r#"
778[summary.provider]
779id = "openai"
780"#,
781 );
782 assert!(
783 parsed.is_err(),
784 "unsupported summary provider must be rejected"
785 );
786 }
787
788 #[test]
789 fn summary_settings_deserialize_from_toml() {
790 let cfg: DaemonConfig = toml::from_str(
791 r#"
792[summary]
793source_mode = "session_or_git_changes"
794
795[summary.provider]
796id = "ollama"
797endpoint = "http://localhost:11434"
798model = "llama3.2:3b"
799
800[summary.prompt]
801template = "Use {{HAIL_COMPACT}} only"
802
803[summary.response]
804style = "detailed"
805shape = "security_first"
806
807[summary.storage]
808trigger = "on_session_save"
809backend = "local_db"
810
811[summary.batch]
812execution_mode = "manual"
813scope = "all"
814recent_days = 90
815"#,
816 )
817 .expect("parse summary settings");
818
819 assert_eq!(cfg.summary.provider.id, SummaryProvider::Ollama);
820 assert_eq!(cfg.summary.provider.endpoint, "http://localhost:11434");
821 assert_eq!(cfg.summary.provider.model, "llama3.2:3b");
822 assert_eq!(
823 cfg.summary.source_mode,
824 SummarySourceMode::SessionOrGitChanges
825 );
826 assert_eq!(cfg.summary.prompt.template, "Use {{HAIL_COMPACT}} only");
827 assert_eq!(cfg.summary.response.style, SummaryResponseStyle::Detailed);
828 assert_eq!(
829 cfg.summary.response.shape,
830 SummaryOutputShape::SecurityFirst
831 );
832 assert_eq!(
833 cfg.summary.storage.trigger,
834 SummaryTriggerMode::OnSessionSave
835 );
836 assert_eq!(cfg.summary.storage.backend, SummaryStorageBackend::LocalDb);
837 assert_eq!(
838 cfg.summary.batch.execution_mode,
839 SummaryBatchExecutionMode::Manual
840 );
841 assert_eq!(cfg.summary.batch.scope, SummaryBatchScope::All);
842 assert_eq!(cfg.summary.batch.recent_days, 90);
843 assert!(cfg.summary.is_configured());
844 }
845
846 #[test]
847 fn summary_batch_defaults_are_stable() {
848 let cfg = DaemonConfig::default();
849 assert_eq!(
850 cfg.summary.batch.execution_mode,
851 SummaryBatchExecutionMode::OnAppStart
852 );
853 assert_eq!(cfg.summary.batch.scope, SummaryBatchScope::RecentDays);
854 assert_eq!(cfg.summary.batch.recent_days, 30);
855 }
856
857 #[test]
858 fn summary_response_style_requires_canonical_values() {
859 let parsed: Result<DaemonConfig, _> = toml::from_str(
860 r#"
861[summary.response]
862style = "verbose"
863"#,
864 );
865 assert!(
866 parsed.is_err(),
867 "unsupported summary response_style must be rejected"
868 );
869 }
870
871 #[test]
872 fn summary_source_mode_requires_canonical_values() {
873 let parsed: Result<DaemonConfig, _> = toml::from_str(
874 r#"
875[summary]
876source_mode = "git_only"
877"#,
878 );
879 assert!(
880 parsed.is_err(),
881 "unsupported summary source_mode must be rejected"
882 );
883 }
884
885 #[test]
886 fn summary_output_shape_requires_canonical_values() {
887 let parsed: Result<DaemonConfig, _> = toml::from_str(
888 r#"
889[summary.response]
890shape = "grouped"
891"#,
892 );
893 assert!(
894 parsed.is_err(),
895 "unsupported summary output_shape must be rejected"
896 );
897 }
898
899 #[test]
900 fn summary_trigger_mode_requires_canonical_values() {
901 let parsed: Result<DaemonConfig, _> = toml::from_str(
902 r#"
903[summary.storage]
904trigger = "always"
905"#,
906 );
907 assert!(
908 parsed.is_err(),
909 "unsupported summary trigger_mode must be rejected"
910 );
911 }
912
913 #[test]
914 fn summary_storage_backend_requires_canonical_values() {
915 let parsed: Result<DaemonConfig, _> = toml::from_str(
916 r#"
917[summary.storage]
918backend = "remote_db"
919"#,
920 );
921 assert!(
922 parsed.is_err(),
923 "unsupported summary storage.backend must be rejected"
924 );
925 }
926
927 #[test]
928 fn summary_batch_execution_mode_requires_canonical_values() {
929 let parsed: Result<DaemonConfig, _> = toml::from_str(
930 r#"
931[summary.batch]
932execution_mode = "scheduled"
933"#,
934 );
935 assert!(
936 parsed.is_err(),
937 "unsupported summary batch execution mode must be rejected"
938 );
939 }
940
941 #[test]
942 fn summary_batch_scope_requires_canonical_values() {
943 let parsed: Result<DaemonConfig, _> = toml::from_str(
944 r#"
945[summary.batch]
946scope = "recent_weeks"
947"#,
948 );
949 assert!(
950 parsed.is_err(),
951 "unsupported summary batch scope must be rejected"
952 );
953 }
954
955 #[test]
956 fn summary_provider_accepts_cli_variants() {
957 let codex_cfg: DaemonConfig = toml::from_str(
958 r#"
959[summary.provider]
960id = "codex_exec"
961"#,
962 )
963 .expect("parse codex summary provider");
964 assert_eq!(codex_cfg.summary.provider.id, SummaryProvider::CodexExec);
965 assert!(codex_cfg.summary.is_configured());
966
967 let claude_cfg: DaemonConfig = toml::from_str(
968 r#"
969[summary.provider]
970id = "claude_cli"
971"#,
972 )
973 .expect("parse claude summary provider");
974 assert_eq!(claude_cfg.summary.provider.id, SummaryProvider::ClaudeCli);
975 assert!(claude_cfg.summary.is_configured());
976 }
977
978 #[test]
979 fn summary_is_configured_requires_model_only_for_ollama() {
980 let mut cfg = DaemonConfig::default();
981 cfg.summary.provider.id = SummaryProvider::Ollama;
982 cfg.summary.provider.model.clear();
983 assert!(!cfg.summary.is_configured());
984
985 cfg.summary.provider.model = "llama3.2:3b".to_string();
986 assert!(cfg.summary.is_configured());
987
988 cfg.summary.provider.id = SummaryProvider::CodexExec;
989 cfg.summary.provider.model.clear();
990 assert!(cfg.summary.is_configured());
991
992 cfg.summary.provider.id = SummaryProvider::ClaudeCli;
993 assert!(cfg.summary.is_configured());
994 }
995
996 #[test]
997 fn summary_git_fallback_availability_depends_on_source_mode() {
998 let mut cfg = DaemonConfig::default();
999 cfg.summary.source_mode = SummarySourceMode::SessionOnly;
1000 assert!(!cfg.summary.allows_git_changes_fallback());
1001
1002 cfg.summary.source_mode = SummarySourceMode::SessionOrGitChanges;
1003 assert!(cfg.summary.allows_git_changes_fallback());
1004 }
1005
1006 #[test]
1007 fn summary_default_storage_uses_hidden_ref_backend() {
1008 let cfg = DaemonConfig::default();
1009 assert_eq!(
1010 cfg.summary.storage.trigger,
1011 SummaryTriggerMode::OnSessionSave
1012 );
1013 assert_eq!(
1014 cfg.summary.storage.backend,
1015 SummaryStorageBackend::HiddenRef
1016 );
1017 assert!(cfg.summary.should_generate_on_session_save());
1018 assert!(cfg.summary.persists_to_hidden_ref());
1019 }
1020
1021 #[test]
1022 fn summary_provider_transport_matches_provider_kind() {
1023 let mut cfg = DaemonConfig::default();
1024 cfg.summary.provider.id = SummaryProvider::Disabled;
1025 assert_eq!(
1026 cfg.summary.provider_transport(),
1027 SummaryProviderTransport::None
1028 );
1029
1030 cfg.summary.provider.id = SummaryProvider::Ollama;
1031 assert_eq!(
1032 cfg.summary.provider_transport(),
1033 SummaryProviderTransport::Http
1034 );
1035
1036 cfg.summary.provider.id = SummaryProvider::CodexExec;
1037 assert_eq!(
1038 cfg.summary.provider_transport(),
1039 SummaryProviderTransport::Cli
1040 );
1041 }
1042
1043 #[test]
1044 fn vector_search_defaults_are_stable() {
1045 let cfg = DaemonConfig::default();
1046 assert!(!cfg.vector_search.enabled);
1047 assert_eq!(cfg.vector_search.provider, VectorSearchProvider::Ollama);
1048 assert_eq!(cfg.vector_search.model, "bge-m3");
1049 assert_eq!(cfg.vector_search.endpoint, "http://127.0.0.1:11434");
1050 assert_eq!(
1051 cfg.vector_search.granularity,
1052 VectorSearchGranularity::EventLineChunk
1053 );
1054 assert_eq!(cfg.vector_search.chunking_mode, VectorChunkingMode::Auto);
1055 assert_eq!(cfg.vector_search.chunk_size_lines, 12);
1056 assert_eq!(cfg.vector_search.chunk_overlap_lines, 3);
1057 assert_eq!(cfg.vector_search.top_k_chunks, 30);
1058 assert_eq!(cfg.vector_search.top_k_sessions, 20);
1059 }
1060
1061 #[test]
1062 fn vector_search_settings_deserialize_from_toml() {
1063 let cfg: DaemonConfig = toml::from_str(
1064 r#"
1065[vector_search]
1066enabled = true
1067provider = "ollama"
1068model = "bge-m3"
1069endpoint = "http://localhost:11434"
1070granularity = "event_line_chunk"
1071chunking_mode = "manual"
1072chunk_size_lines = 16
1073chunk_overlap_lines = 4
1074top_k_chunks = 60
1075top_k_sessions = 10
1076"#,
1077 )
1078 .expect("parse vector search settings");
1079
1080 assert!(cfg.vector_search.enabled);
1081 assert_eq!(cfg.vector_search.provider, VectorSearchProvider::Ollama);
1082 assert_eq!(cfg.vector_search.model, "bge-m3");
1083 assert_eq!(cfg.vector_search.endpoint, "http://localhost:11434");
1084 assert_eq!(
1085 cfg.vector_search.granularity,
1086 VectorSearchGranularity::EventLineChunk
1087 );
1088 assert_eq!(cfg.vector_search.chunking_mode, VectorChunkingMode::Manual);
1089 assert_eq!(cfg.vector_search.chunk_size_lines, 16);
1090 assert_eq!(cfg.vector_search.chunk_overlap_lines, 4);
1091 assert_eq!(cfg.vector_search.top_k_chunks, 60);
1092 assert_eq!(cfg.vector_search.top_k_sessions, 10);
1093 }
1094
1095 #[test]
1096 fn vector_search_provider_requires_canonical_values() {
1097 let parsed: Result<DaemonConfig, _> = toml::from_str(
1098 r#"
1099[vector_search]
1100provider = "openai"
1101"#,
1102 );
1103 assert!(
1104 parsed.is_err(),
1105 "unsupported vector provider must be rejected"
1106 );
1107 }
1108
1109 #[test]
1110 fn vector_search_granularity_requires_canonical_values() {
1111 let parsed: Result<DaemonConfig, _> = toml::from_str(
1112 r#"
1113[vector_search]
1114granularity = "session_text"
1115"#,
1116 );
1117 assert!(
1118 parsed.is_err(),
1119 "unsupported vector granularity must be rejected"
1120 );
1121 }
1122
1123 #[test]
1124 fn vector_search_chunking_mode_requires_canonical_values() {
1125 let parsed: Result<DaemonConfig, _> = toml::from_str(
1126 r#"
1127[vector_search]
1128chunking_mode = "adaptive"
1129"#,
1130 );
1131 assert!(
1132 parsed.is_err(),
1133 "unsupported vector chunking mode must be rejected"
1134 );
1135 }
1136
1137 #[test]
1138 fn change_reader_defaults_are_stable() {
1139 let cfg = DaemonConfig::default();
1140 assert!(!cfg.change_reader.enabled);
1141 assert_eq!(cfg.change_reader.scope, ChangeReaderScope::SummaryOnly);
1142 assert!(cfg.change_reader.qa_enabled);
1143 assert_eq!(cfg.change_reader.max_context_chars, 12_000);
1144 assert!(!cfg.change_reader.voice.enabled);
1145 assert_eq!(
1146 cfg.change_reader.voice.provider,
1147 ChangeReaderVoiceProvider::Openai
1148 );
1149 assert_eq!(cfg.change_reader.voice.model, "gpt-4o-mini-tts");
1150 assert_eq!(cfg.change_reader.voice.voice, "alloy");
1151 assert!(cfg.change_reader.voice.api_key.is_empty());
1152 }
1153
1154 #[test]
1155 fn change_reader_settings_deserialize_from_toml() {
1156 let cfg: DaemonConfig = toml::from_str(
1157 r#"
1158[change_reader]
1159enabled = true
1160scope = "full_context"
1161qa_enabled = false
1162max_context_chars = 24000
1163[change_reader.voice]
1164enabled = true
1165provider = "openai"
1166model = "gpt-4o-mini-tts"
1167voice = "nova"
1168api_key = "sk-local"
1169"#,
1170 )
1171 .expect("parse change reader settings");
1172
1173 assert!(cfg.change_reader.enabled);
1174 assert_eq!(cfg.change_reader.scope, ChangeReaderScope::FullContext);
1175 assert!(!cfg.change_reader.qa_enabled);
1176 assert_eq!(cfg.change_reader.max_context_chars, 24_000);
1177 assert!(cfg.change_reader.voice.enabled);
1178 assert_eq!(
1179 cfg.change_reader.voice.provider,
1180 ChangeReaderVoiceProvider::Openai
1181 );
1182 assert_eq!(cfg.change_reader.voice.model, "gpt-4o-mini-tts");
1183 assert_eq!(cfg.change_reader.voice.voice, "nova");
1184 assert_eq!(cfg.change_reader.voice.api_key, "sk-local");
1185 }
1186
1187 #[test]
1188 fn change_reader_scope_requires_canonical_values() {
1189 let parsed: Result<DaemonConfig, _> = toml::from_str(
1190 r#"
1191[change_reader]
1192scope = "full"
1193"#,
1194 );
1195 assert!(
1196 parsed.is_err(),
1197 "unsupported change reader scope must be rejected"
1198 );
1199 }
1200
1201 #[test]
1202 fn change_reader_voice_provider_requires_canonical_values() {
1203 let parsed: Result<DaemonConfig, _> = toml::from_str(
1204 r#"
1205[change_reader.voice]
1206provider = "azure"
1207"#,
1208 );
1209 assert!(
1210 parsed.is_err(),
1211 "unsupported change reader voice provider must be rejected"
1212 );
1213 }
1214
1215 #[test]
1216 fn lifecycle_defaults_are_stable() {
1217 let cfg = DaemonConfig::default();
1218 assert!(cfg.lifecycle.enabled);
1219 assert_eq!(cfg.lifecycle.session_ttl_days, 30);
1220 assert_eq!(cfg.lifecycle.summary_ttl_days, 30);
1221 assert_eq!(cfg.lifecycle.cleanup_interval_secs, 3_600);
1222 }
1223
1224 #[test]
1225 fn lifecycle_settings_deserialize_from_toml() {
1226 let cfg: DaemonConfig = toml::from_str(
1227 r#"
1228[lifecycle]
1229enabled = true
1230session_ttl_days = 45
1231summary_ttl_days = 14
1232cleanup_interval_secs = 7200
1233"#,
1234 )
1235 .expect("parse lifecycle settings");
1236
1237 assert!(cfg.lifecycle.enabled);
1238 assert_eq!(cfg.lifecycle.session_ttl_days, 45);
1239 assert_eq!(cfg.lifecycle.summary_ttl_days, 14);
1240 assert_eq!(cfg.lifecycle.cleanup_interval_secs, 7_200);
1241 }
1242
1243 #[test]
1244 fn daemon_default_session_view_deserializes_from_toml() {
1245 let cfg: DaemonConfig = toml::from_str(
1246 r#"
1247[daemon]
1248session_default_view = "compressed"
1249"#,
1250 )
1251 .expect("parse daemon session_default_view");
1252
1253 assert_eq!(
1254 cfg.daemon.session_default_view,
1255 SessionDefaultView::Compressed
1256 );
1257 }
1258
1259 #[test]
1260 fn daemon_default_session_view_requires_canonical_values() {
1261 let parsed: Result<DaemonConfig, _> = toml::from_str(
1262 r#"
1263[daemon]
1264session_default_view = "compact"
1265"#,
1266 );
1267 assert!(
1268 parsed.is_err(),
1269 "unsupported session_default_view must fail"
1270 );
1271 }
1272}