Skip to main content

opensession_runtime_config/
lib.rs

1//! Shared daemon/TUI configuration types.
2//!
3//! Both `opensession-daemon` and `opensession-tui` read/write `opensession.toml`
4//! using these types. Daemon-specific logic (watch-path resolution, project
5//! config merging) lives in the daemon crate; TUI-specific logic (settings
6//! layout, field editing) lives in the TUI crate.
7
8use serde::{Deserialize, Serialize};
9
10/// Canonical config file name used by daemon/cli/tui.
11pub const CONFIG_FILE_NAME: &str = "opensession.toml";
12
13/// Top-level daemon configuration (persisted as `opensession.toml`).
14#[derive(Debug, Clone, Serialize, Deserialize, Default)]
15pub struct DaemonConfig {
16    #[serde(default)]
17    pub daemon: DaemonSettings,
18    #[serde(default)]
19    pub server: ServerSettings,
20    #[serde(default)]
21    pub identity: IdentitySettings,
22    #[serde(default)]
23    pub privacy: PrivacySettings,
24    #[serde(default)]
25    pub watchers: WatcherSettings,
26    #[serde(default)]
27    pub git_storage: GitStorageSettings,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct DaemonSettings {
32    #[serde(default = "default_false")]
33    pub auto_publish: bool,
34    #[serde(default = "default_debounce")]
35    pub debounce_secs: u64,
36    #[serde(default = "default_publish_on")]
37    pub publish_on: PublishMode,
38    #[serde(default = "default_max_retries")]
39    pub max_retries: u32,
40    #[serde(default = "default_health_check_interval")]
41    pub health_check_interval_secs: u64,
42    #[serde(default = "default_realtime_debounce_ms")]
43    pub realtime_debounce_ms: u64,
44    /// Enable realtime file preview refresh in TUI session detail.
45    #[serde(default = "default_detail_realtime_preview_enabled")]
46    pub detail_realtime_preview_enabled: bool,
47    /// Expand selected timeline event detail rows by default in TUI session detail.
48    #[serde(default = "default_detail_auto_expand_selected_event")]
49    pub detail_auto_expand_selected_event: bool,
50    /// Enable timeline/event summary generation in TUI detail view.
51    #[serde(default = "default_false")]
52    pub summary_enabled: bool,
53    /// Summary engine/provider selection.
54    /// Examples: `anthropic`, `openai`, `openai-compatible`, `gemini`, `cli:auto`, `cli:codex`.
55    #[serde(default)]
56    pub summary_provider: Option<String>,
57    /// Optional model override for summary generation.
58    #[serde(default)]
59    pub summary_model: Option<String>,
60    /// Summary verbosity mode (`normal` or `minimal`).
61    #[serde(default = "default_summary_content_mode")]
62    pub summary_content_mode: String,
63    /// Persist summary cache entries on disk/local DB.
64    #[serde(default = "default_true")]
65    pub summary_disk_cache_enabled: bool,
66    /// OpenAI-compatible endpoint full URL override.
67    #[serde(default)]
68    pub summary_openai_compat_endpoint: Option<String>,
69    /// OpenAI-compatible base URL override.
70    #[serde(default)]
71    pub summary_openai_compat_base: Option<String>,
72    /// OpenAI-compatible path override.
73    #[serde(default)]
74    pub summary_openai_compat_path: Option<String>,
75    /// OpenAI-compatible payload style (`chat` or `responses`).
76    #[serde(default)]
77    pub summary_openai_compat_style: Option<String>,
78    /// OpenAI-compatible API key.
79    #[serde(default)]
80    pub summary_openai_compat_key: Option<String>,
81    /// OpenAI-compatible API key header (default `Authorization` when omitted).
82    #[serde(default)]
83    pub summary_openai_compat_key_header: Option<String>,
84    /// Number of events to include in each summary window.
85    /// `0` means adaptive mode.
86    #[serde(default = "default_summary_event_window")]
87    pub summary_event_window: u32,
88    /// Debounce before dispatching summary jobs.
89    #[serde(default = "default_summary_debounce_ms")]
90    pub summary_debounce_ms: u64,
91    /// Max concurrent summary jobs.
92    #[serde(default = "default_summary_max_inflight")]
93    pub summary_max_inflight: u32,
94    /// Internal one-way migration marker for summary window v2 semantics.
95    #[serde(default = "default_false")]
96    pub summary_window_migrated_v2: bool,
97}
98
99impl Default for DaemonSettings {
100    fn default() -> Self {
101        Self {
102            auto_publish: false,
103            debounce_secs: 5,
104            publish_on: PublishMode::Manual,
105            max_retries: 3,
106            health_check_interval_secs: 300,
107            realtime_debounce_ms: 500,
108            detail_realtime_preview_enabled: false,
109            detail_auto_expand_selected_event: true,
110            summary_enabled: false,
111            summary_provider: None,
112            summary_model: None,
113            summary_content_mode: default_summary_content_mode(),
114            summary_disk_cache_enabled: true,
115            summary_openai_compat_endpoint: None,
116            summary_openai_compat_base: None,
117            summary_openai_compat_path: None,
118            summary_openai_compat_style: None,
119            summary_openai_compat_key: None,
120            summary_openai_compat_key_header: None,
121            summary_event_window: default_summary_event_window(),
122            summary_debounce_ms: default_summary_debounce_ms(),
123            summary_max_inflight: default_summary_max_inflight(),
124            summary_window_migrated_v2: false,
125        }
126    }
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
130#[serde(rename_all = "snake_case")]
131pub enum PublishMode {
132    SessionEnd,
133    Realtime,
134    Manual,
135}
136
137impl PublishMode {
138    pub fn cycle(&self) -> Self {
139        match self {
140            Self::SessionEnd => Self::Realtime,
141            Self::Realtime => Self::Manual,
142            Self::Manual => Self::SessionEnd,
143        }
144    }
145
146    pub fn display(&self) -> &'static str {
147        match self {
148            Self::SessionEnd => "Session End",
149            Self::Realtime => "Realtime",
150            Self::Manual => "Manual",
151        }
152    }
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
156#[serde(rename_all = "snake_case")]
157pub enum CalendarDisplayMode {
158    Smart,
159    Relative,
160    Absolute,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct ServerSettings {
165    #[serde(default = "default_server_url")]
166    pub url: String,
167    #[serde(default)]
168    pub api_key: String,
169}
170
171impl Default for ServerSettings {
172    fn default() -> Self {
173        Self {
174            url: default_server_url(),
175            api_key: String::new(),
176        }
177    }
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct IdentitySettings {
182    #[serde(default = "default_nickname")]
183    pub nickname: String,
184    /// Team ID to upload sessions to
185    #[serde(default)]
186    pub team_id: String,
187}
188
189impl Default for IdentitySettings {
190    fn default() -> Self {
191        Self {
192            nickname: default_nickname(),
193            team_id: String::new(),
194        }
195    }
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct PrivacySettings {
200    #[serde(default = "default_true")]
201    pub strip_paths: bool,
202    #[serde(default = "default_true")]
203    pub strip_env_vars: bool,
204    #[serde(default = "default_exclude_patterns")]
205    pub exclude_patterns: Vec<String>,
206    #[serde(default)]
207    pub exclude_tools: Vec<String>,
208}
209
210impl Default for PrivacySettings {
211    fn default() -> Self {
212        Self {
213            strip_paths: true,
214            strip_env_vars: true,
215            exclude_patterns: default_exclude_patterns(),
216            exclude_tools: Vec::new(),
217        }
218    }
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct WatcherSettings {
223    /// Deprecated agent toggles kept for backward-compatible config parsing.
224    #[serde(default = "default_true", skip_serializing)]
225    pub claude_code: bool,
226    /// Deprecated agent toggles kept for backward-compatible config parsing.
227    #[serde(default = "default_true", skip_serializing)]
228    pub opencode: bool,
229    /// Deprecated agent toggles kept for backward-compatible config parsing.
230    #[serde(default = "default_true", skip_serializing)]
231    pub cursor: bool,
232    #[serde(default = "default_watch_paths")]
233    pub custom_paths: Vec<String>,
234}
235
236impl Default for WatcherSettings {
237    fn default() -> Self {
238        Self {
239            claude_code: true,
240            opencode: true,
241            cursor: true,
242            custom_paths: default_watch_paths(),
243        }
244    }
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct GitStorageSettings {
249    #[serde(default)]
250    pub method: GitStorageMethod,
251    #[serde(default)]
252    pub token: String,
253    #[serde(default)]
254    pub retention: GitRetentionSettings,
255}
256
257impl Default for GitStorageSettings {
258    fn default() -> Self {
259        Self {
260            method: GitStorageMethod::Native,
261            token: String::new(),
262            retention: GitRetentionSettings::default(),
263        }
264    }
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct GitRetentionSettings {
269    #[serde(default = "default_false")]
270    pub enabled: bool,
271    #[serde(default = "default_git_retention_keep_days")]
272    pub keep_days: u32,
273    #[serde(default = "default_git_retention_interval_secs")]
274    pub interval_secs: u64,
275}
276
277impl Default for GitRetentionSettings {
278    fn default() -> Self {
279        Self {
280            enabled: false,
281            keep_days: default_git_retention_keep_days(),
282            interval_secs: default_git_retention_interval_secs(),
283        }
284    }
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
288#[serde(rename_all = "snake_case")]
289pub enum GitStorageMethod {
290    /// Store sessions as git objects on an orphan branch (branch-based git-native).
291    #[default]
292    #[serde(alias = "platform_api", alias = "platform-api", alias = "api")]
293    Native,
294    /// Store session bodies in SQLite-backed storage.
295    #[serde(alias = "none", alias = "sqlite_local", alias = "sqlite-local")]
296    Sqlite,
297    /// Unknown/invalid values are normalized by compatibility fallbacks.
298    #[serde(other)]
299    Unknown,
300}
301
302// ── Serde default functions ─────────────────────────────────────────────
303
304fn default_true() -> bool {
305    true
306}
307fn default_false() -> bool {
308    false
309}
310fn default_debounce() -> u64 {
311    5
312}
313fn default_max_retries() -> u32 {
314    3
315}
316fn default_health_check_interval() -> u64 {
317    300
318}
319fn default_realtime_debounce_ms() -> u64 {
320    500
321}
322fn default_detail_realtime_preview_enabled() -> bool {
323    false
324}
325fn default_detail_auto_expand_selected_event() -> bool {
326    true
327}
328fn default_publish_on() -> PublishMode {
329    PublishMode::Manual
330}
331fn default_summary_content_mode() -> String {
332    "normal".to_string()
333}
334fn default_summary_event_window() -> u32 {
335    0
336}
337fn default_summary_debounce_ms() -> u64 {
338    250
339}
340fn default_summary_max_inflight() -> u32 {
341    2
342}
343fn default_git_retention_keep_days() -> u32 {
344    30
345}
346fn default_git_retention_interval_secs() -> u64 {
347    86_400
348}
349fn default_server_url() -> String {
350    "https://opensession.io".to_string()
351}
352fn default_nickname() -> String {
353    "user".to_string()
354}
355fn default_exclude_patterns() -> Vec<String> {
356    vec![
357        "*.env".to_string(),
358        "*secret*".to_string(),
359        "*credential*".to_string(),
360    ]
361}
362
363pub const DEFAULT_WATCH_PATHS: &[&str] = &[
364    "~/.claude/projects",
365    "~/.codex/sessions",
366    "~/.local/share/opencode/storage/session",
367    "~/.cline/data/tasks",
368    "~/.local/share/amp/threads",
369    "~/.gemini/tmp",
370    "~/Library/Application Support/Cursor/User",
371    "~/.config/Cursor/User",
372];
373
374pub fn default_watch_paths() -> Vec<String> {
375    DEFAULT_WATCH_PATHS
376        .iter()
377        .map(|path| (*path).to_string())
378        .collect()
379}
380
381/// Apply compatibility fallbacks after loading raw TOML.
382/// Returns true when any field was updated.
383pub fn apply_compat_fallbacks(config: &mut DaemonConfig, root: Option<&toml::Value>) -> bool {
384    let mut changed = false;
385
386    if config.git_storage.method == GitStorageMethod::Unknown {
387        config.git_storage.method = GitStorageMethod::Native;
388        changed = true;
389    }
390
391    if config.identity.team_id.trim().is_empty() {
392        if let Some(team_id) = root
393            .and_then(toml::Value::as_table)
394            .and_then(|table| table.get("server"))
395            .and_then(toml::Value::as_table)
396            .and_then(|section| section.get("team_id"))
397            .and_then(toml::Value::as_str)
398            .map(str::trim)
399            .filter(|v| !v.is_empty())
400        {
401            config.identity.team_id = team_id.to_string();
402            changed = true;
403        }
404    }
405
406    if config.watchers.custom_paths.is_empty() {
407        config.watchers.custom_paths = default_watch_paths();
408        changed = true;
409    }
410
411    changed
412}
413
414/// True when `[git_storage].method` is absent/invalid in the source TOML.
415///
416/// Retained as a compatibility helper for callers that still inspect legacy
417/// config layouts directly.
418pub fn config_file_missing_git_storage_method(root: Option<&toml::Value>) -> bool {
419    let Some(root) = root else {
420        return false;
421    };
422    let Some(table) = root.as_table() else {
423        return false;
424    };
425    let Some(git_storage) = table.get("git_storage") else {
426        return true;
427    };
428    match git_storage.as_table() {
429        Some(section) => !section.contains_key("method"),
430        None => true,
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[test]
439    fn apply_compat_fallbacks_populates_legacy_fields() {
440        let mut cfg = DaemonConfig::default();
441        cfg.git_storage.method = GitStorageMethod::Unknown;
442        cfg.identity.team_id.clear();
443        cfg.watchers.custom_paths.clear();
444
445        let root: toml::Value = toml::from_str(
446            r#"
447[server]
448team_id = "team-legacy"
449
450[git_storage]
451"#,
452        )
453        .expect("parse toml");
454
455        let changed = apply_compat_fallbacks(&mut cfg, Some(&root));
456        assert!(changed);
457        assert_eq!(cfg.git_storage.method, GitStorageMethod::Native);
458        assert_eq!(cfg.identity.team_id, "team-legacy");
459        assert!(!cfg.watchers.custom_paths.is_empty());
460    }
461
462    #[test]
463    fn git_storage_method_legacy_aliases_are_accepted() {
464        let legacy_none: DaemonConfig = toml::from_str(
465            r#"
466[git_storage]
467method = "none"
468"#,
469        )
470        .expect("parse toml");
471        assert_eq!(legacy_none.git_storage.method, GitStorageMethod::Sqlite);
472
473        let legacy_platform_api: DaemonConfig = toml::from_str(
474            r#"
475[git_storage]
476method = "platform_api"
477"#,
478        )
479        .expect("parse toml");
480        assert_eq!(
481            legacy_platform_api.git_storage.method,
482            GitStorageMethod::Native
483        );
484    }
485
486    #[test]
487    fn apply_compat_fallbacks_is_noop_for_modern_values() {
488        let mut cfg = DaemonConfig::default();
489        cfg.identity.team_id = "team-modern".to_string();
490        cfg.watchers.custom_paths = vec!["/tmp/one".to_string()];
491
492        let root: toml::Value = toml::from_str(
493            r#"
494[server]
495team_id = "team-from-file"
496
497[git_storage]
498method = "native"
499"#,
500        )
501        .expect("parse toml");
502
503        let before = cfg.clone();
504        let changed = apply_compat_fallbacks(&mut cfg, Some(&root));
505        assert!(!changed);
506        assert_eq!(cfg.identity.team_id, before.identity.team_id);
507        assert_eq!(cfg.watchers.custom_paths, before.watchers.custom_paths);
508        assert_eq!(cfg.git_storage.method, before.git_storage.method);
509    }
510
511    #[test]
512    fn legacy_watcher_flags_are_not_serialized() {
513        let cfg = DaemonConfig::default();
514        let encoded = toml::to_string(&cfg).expect("serialize config");
515
516        assert!(encoded.contains("custom_paths"));
517        assert!(!encoded.contains("\nclaude_code ="));
518        assert!(!encoded.contains("\nopencode ="));
519        assert!(!encoded.contains("\ncursor ="));
520    }
521
522    #[test]
523    fn daemon_summary_defaults_are_stable() {
524        let cfg = DaemonConfig::default();
525        assert!(cfg.daemon.detail_auto_expand_selected_event);
526        assert!(!cfg.daemon.summary_enabled);
527        assert_eq!(cfg.daemon.summary_provider, None);
528        assert_eq!(cfg.daemon.summary_model, None);
529        assert_eq!(cfg.daemon.summary_content_mode, "normal");
530        assert!(cfg.daemon.summary_disk_cache_enabled);
531        assert_eq!(cfg.daemon.summary_event_window, 0);
532        assert_eq!(cfg.daemon.summary_debounce_ms, 250);
533        assert_eq!(cfg.daemon.summary_max_inflight, 2);
534        assert!(!cfg.daemon.summary_window_migrated_v2);
535    }
536
537    #[test]
538    fn daemon_summary_fields_deserialize_from_toml() {
539        let cfg: DaemonConfig = toml::from_str(
540            r#"
541[daemon]
542summary_enabled = true
543summary_provider = "openai"
544summary_model = "gpt-4o-mini"
545summary_content_mode = "minimal"
546summary_disk_cache_enabled = false
547summary_event_window = 8
548summary_debounce_ms = 100
549summary_max_inflight = 4
550summary_window_migrated_v2 = false
551detail_auto_expand_selected_event = false
552"#,
553        )
554        .expect("parse summary config");
555
556        assert!(!cfg.daemon.detail_auto_expand_selected_event);
557        assert!(cfg.daemon.summary_enabled);
558        assert_eq!(cfg.daemon.summary_provider.as_deref(), Some("openai"));
559        assert_eq!(cfg.daemon.summary_model.as_deref(), Some("gpt-4o-mini"));
560        assert_eq!(cfg.daemon.summary_content_mode, "minimal");
561        assert!(!cfg.daemon.summary_disk_cache_enabled);
562        assert_eq!(cfg.daemon.summary_event_window, 8);
563        assert_eq!(cfg.daemon.summary_debounce_ms, 100);
564        assert_eq!(cfg.daemon.summary_max_inflight, 4);
565        assert!(!cfg.daemon.summary_window_migrated_v2);
566    }
567
568    #[test]
569    fn git_retention_defaults_are_stable() {
570        let cfg = DaemonConfig::default();
571        assert!(!cfg.git_storage.retention.enabled);
572        assert_eq!(cfg.git_storage.retention.keep_days, 30);
573        assert_eq!(cfg.git_storage.retention.interval_secs, 86_400);
574    }
575
576    #[test]
577    fn git_retention_fields_deserialize_from_toml() {
578        let cfg: DaemonConfig = toml::from_str(
579            r#"
580[git_storage]
581method = "native"
582
583[git_storage.retention]
584enabled = true
585keep_days = 14
586interval_secs = 43200
587"#,
588        )
589        .expect("parse retention config");
590
591        assert_eq!(cfg.git_storage.method, GitStorageMethod::Native);
592        assert!(cfg.git_storage.retention.enabled);
593        assert_eq!(cfg.git_storage.retention.keep_days, 14);
594        assert_eq!(cfg.git_storage.retention.interval_secs, 43_200);
595    }
596}