Skip to main content

opensession_runtime_config/
lib.rs

1//! Shared runtime configuration types.
2//!
3//! `opensession-daemon`, desktop runtime, and CLI read/write `opensession.toml`
4//! using these types. Runtime-specific logic (watch-path resolution, project
5//! config merging, UI/IPC adapters) lives in each runtime crate.
6
7use serde::{Deserialize, Serialize};
8
9/// Canonical config file name used by daemon/desktop/cli.
10pub const CONFIG_FILE_NAME: &str = "opensession.toml";
11
12/// Top-level daemon configuration (persisted as `opensession.toml`).
13#[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    /// Enable realtime file preview refresh in TUI session detail.
52    #[serde(default = "default_detail_realtime_preview_enabled")]
53    pub detail_realtime_preview_enabled: bool,
54    /// Expand selected timeline event detail rows by default in TUI session detail.
55    #[serde(default = "default_detail_auto_expand_selected_event")]
56    pub detail_auto_expand_selected_event: bool,
57    /// Default detail view mode for session timeline rendering.
58    #[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    /// Kept for CLI/CI and non-desktop runtimes.
219    #[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    /// Store sessions as git objects on hidden refs (git-native).
582    #[default]
583    Native,
584    /// Store session bodies in SQLite-backed storage.
585    Sqlite,
586}
587
588// ── Serde default functions ─────────────────────────────────────────────
589
590fn 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}