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}
185
186impl Default for IdentitySettings {
187    fn default() -> Self {
188        Self {
189            nickname: default_nickname(),
190        }
191    }
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct PrivacySettings {
196    #[serde(default = "default_true")]
197    pub strip_paths: bool,
198    #[serde(default = "default_true")]
199    pub strip_env_vars: bool,
200    #[serde(default = "default_exclude_patterns")]
201    pub exclude_patterns: Vec<String>,
202    #[serde(default)]
203    pub exclude_tools: Vec<String>,
204}
205
206impl Default for PrivacySettings {
207    fn default() -> Self {
208        Self {
209            strip_paths: true,
210            strip_env_vars: true,
211            exclude_patterns: default_exclude_patterns(),
212            exclude_tools: Vec::new(),
213        }
214    }
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct WatcherSettings {
219    #[serde(default = "default_watch_paths")]
220    pub custom_paths: Vec<String>,
221}
222
223impl Default for WatcherSettings {
224    fn default() -> Self {
225        Self {
226            custom_paths: default_watch_paths(),
227        }
228    }
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct GitStorageSettings {
233    #[serde(default)]
234    pub method: GitStorageMethod,
235    #[serde(default)]
236    pub token: String,
237    #[serde(default)]
238    pub retention: GitRetentionSettings,
239}
240
241impl Default for GitStorageSettings {
242    fn default() -> Self {
243        Self {
244            method: GitStorageMethod::Native,
245            token: String::new(),
246            retention: GitRetentionSettings::default(),
247        }
248    }
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct GitRetentionSettings {
253    #[serde(default = "default_false")]
254    pub enabled: bool,
255    #[serde(default = "default_git_retention_keep_days")]
256    pub keep_days: u32,
257    #[serde(default = "default_git_retention_interval_secs")]
258    pub interval_secs: u64,
259}
260
261impl Default for GitRetentionSettings {
262    fn default() -> Self {
263        Self {
264            enabled: false,
265            keep_days: default_git_retention_keep_days(),
266            interval_secs: default_git_retention_interval_secs(),
267        }
268    }
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
272#[serde(rename_all = "snake_case")]
273pub enum GitStorageMethod {
274    /// Store sessions as git objects on an orphan branch (branch-based git-native).
275    #[default]
276    #[serde(alias = "platform_api", alias = "platform-api", alias = "api")]
277    Native,
278    /// Store session bodies in SQLite-backed storage.
279    #[serde(alias = "none", alias = "sqlite_local", alias = "sqlite-local")]
280    Sqlite,
281    /// Unknown/invalid values are normalized by compatibility fallbacks.
282    #[serde(other)]
283    Unknown,
284}
285
286// ── Serde default functions ─────────────────────────────────────────────
287
288fn default_true() -> bool {
289    true
290}
291fn default_false() -> bool {
292    false
293}
294fn default_debounce() -> u64 {
295    5
296}
297fn default_max_retries() -> u32 {
298    3
299}
300fn default_health_check_interval() -> u64 {
301    300
302}
303fn default_realtime_debounce_ms() -> u64 {
304    500
305}
306fn default_detail_realtime_preview_enabled() -> bool {
307    false
308}
309fn default_detail_auto_expand_selected_event() -> bool {
310    true
311}
312fn default_publish_on() -> PublishMode {
313    PublishMode::Manual
314}
315fn default_summary_content_mode() -> String {
316    "normal".to_string()
317}
318fn default_summary_event_window() -> u32 {
319    0
320}
321fn default_summary_debounce_ms() -> u64 {
322    250
323}
324fn default_summary_max_inflight() -> u32 {
325    2
326}
327fn default_git_retention_keep_days() -> u32 {
328    30
329}
330fn default_git_retention_interval_secs() -> u64 {
331    86_400
332}
333fn default_server_url() -> String {
334    "https://opensession.io".to_string()
335}
336fn default_nickname() -> String {
337    "user".to_string()
338}
339fn default_exclude_patterns() -> Vec<String> {
340    vec![
341        "*.env".to_string(),
342        "*secret*".to_string(),
343        "*credential*".to_string(),
344    ]
345}
346
347pub const DEFAULT_WATCH_PATHS: &[&str] = &[
348    "~/.claude/projects",
349    "~/.codex/sessions",
350    "~/.local/share/opencode/storage/session",
351    "~/.cline/data/tasks",
352    "~/.local/share/amp/threads",
353    "~/.gemini/tmp",
354    "~/Library/Application Support/Cursor/User",
355    "~/.config/Cursor/User",
356];
357
358pub fn default_watch_paths() -> Vec<String> {
359    DEFAULT_WATCH_PATHS
360        .iter()
361        .map(|path| (*path).to_string())
362        .collect()
363}
364
365/// Apply compatibility fallbacks after loading raw TOML.
366/// Returns true when any field was updated.
367pub fn apply_compat_fallbacks(config: &mut DaemonConfig, _root: Option<&toml::Value>) -> bool {
368    let mut changed = false;
369
370    if config.git_storage.method == GitStorageMethod::Unknown {
371        config.git_storage.method = GitStorageMethod::Native;
372        changed = true;
373    }
374
375    if config.watchers.custom_paths.is_empty() {
376        config.watchers.custom_paths = default_watch_paths();
377        changed = true;
378    }
379
380    changed
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[test]
388    fn apply_compat_fallbacks_populates_missing_fields() {
389        let mut cfg = DaemonConfig::default();
390        cfg.git_storage.method = GitStorageMethod::Unknown;
391        cfg.watchers.custom_paths.clear();
392
393        let root: toml::Value = toml::from_str(
394            r#"
395[git_storage]
396"#,
397        )
398        .expect("parse toml");
399
400        let changed = apply_compat_fallbacks(&mut cfg, Some(&root));
401        assert!(changed);
402        assert_eq!(cfg.git_storage.method, GitStorageMethod::Native);
403        assert!(!cfg.watchers.custom_paths.is_empty());
404    }
405
406    #[test]
407    fn git_storage_method_compat_aliases_are_accepted() {
408        let compat_none: DaemonConfig = toml::from_str(
409            r#"
410[git_storage]
411method = "none"
412"#,
413        )
414        .expect("parse toml");
415        assert_eq!(compat_none.git_storage.method, GitStorageMethod::Sqlite);
416
417        let compat_platform_api: DaemonConfig = toml::from_str(
418            r#"
419[git_storage]
420method = "platform_api"
421"#,
422        )
423        .expect("parse toml");
424        assert_eq!(
425            compat_platform_api.git_storage.method,
426            GitStorageMethod::Native
427        );
428    }
429
430    #[test]
431    fn apply_compat_fallbacks_is_noop_for_modern_values() {
432        let mut cfg = DaemonConfig::default();
433        cfg.watchers.custom_paths = vec!["/tmp/one".to_string()];
434
435        let root: toml::Value = toml::from_str(
436            r#"
437[git_storage]
438method = "native"
439"#,
440        )
441        .expect("parse toml");
442
443        let before = cfg.clone();
444        let changed = apply_compat_fallbacks(&mut cfg, Some(&root));
445        assert!(!changed);
446        assert_eq!(cfg.watchers.custom_paths, before.watchers.custom_paths);
447        assert_eq!(cfg.git_storage.method, before.git_storage.method);
448    }
449
450    #[test]
451    fn unknown_watcher_flags_are_ignored() {
452        let cfg: DaemonConfig = toml::from_str(
453            r#"
454[watchers]
455claude_code = false
456opencode = false
457cursor = false
458custom_paths = ["~/.codex/sessions"]
459"#,
460        )
461        .expect("parse watcher config");
462
463        assert_eq!(
464            cfg.watchers.custom_paths,
465            vec!["~/.codex/sessions".to_string()]
466        );
467    }
468
469    #[test]
470    fn watcher_settings_serialize_only_current_fields() {
471        let cfg = DaemonConfig::default();
472        let encoded = toml::to_string(&cfg).expect("serialize config");
473
474        assert!(encoded.contains("custom_paths"));
475        assert!(!encoded.contains("\nclaude_code ="));
476        assert!(!encoded.contains("\nopencode ="));
477        assert!(!encoded.contains("\ncursor ="));
478    }
479
480    #[test]
481    fn daemon_summary_defaults_are_stable() {
482        let cfg = DaemonConfig::default();
483        assert!(cfg.daemon.detail_auto_expand_selected_event);
484        assert!(!cfg.daemon.summary_enabled);
485        assert_eq!(cfg.daemon.summary_provider, None);
486        assert_eq!(cfg.daemon.summary_model, None);
487        assert_eq!(cfg.daemon.summary_content_mode, "normal");
488        assert!(cfg.daemon.summary_disk_cache_enabled);
489        assert_eq!(cfg.daemon.summary_event_window, 0);
490        assert_eq!(cfg.daemon.summary_debounce_ms, 250);
491        assert_eq!(cfg.daemon.summary_max_inflight, 2);
492        assert!(!cfg.daemon.summary_window_migrated_v2);
493    }
494
495    #[test]
496    fn daemon_summary_fields_deserialize_from_toml() {
497        let cfg: DaemonConfig = toml::from_str(
498            r#"
499[daemon]
500summary_enabled = true
501summary_provider = "openai"
502summary_model = "gpt-4o-mini"
503summary_content_mode = "minimal"
504summary_disk_cache_enabled = false
505summary_event_window = 8
506summary_debounce_ms = 100
507summary_max_inflight = 4
508summary_window_migrated_v2 = false
509detail_auto_expand_selected_event = false
510"#,
511        )
512        .expect("parse summary config");
513
514        assert!(!cfg.daemon.detail_auto_expand_selected_event);
515        assert!(cfg.daemon.summary_enabled);
516        assert_eq!(cfg.daemon.summary_provider.as_deref(), Some("openai"));
517        assert_eq!(cfg.daemon.summary_model.as_deref(), Some("gpt-4o-mini"));
518        assert_eq!(cfg.daemon.summary_content_mode, "minimal");
519        assert!(!cfg.daemon.summary_disk_cache_enabled);
520        assert_eq!(cfg.daemon.summary_event_window, 8);
521        assert_eq!(cfg.daemon.summary_debounce_ms, 100);
522        assert_eq!(cfg.daemon.summary_max_inflight, 4);
523        assert!(!cfg.daemon.summary_window_migrated_v2);
524    }
525
526    #[test]
527    fn git_retention_defaults_are_stable() {
528        let cfg = DaemonConfig::default();
529        assert!(!cfg.git_storage.retention.enabled);
530        assert_eq!(cfg.git_storage.retention.keep_days, 30);
531        assert_eq!(cfg.git_storage.retention.interval_secs, 86_400);
532    }
533
534    #[test]
535    fn git_retention_fields_deserialize_from_toml() {
536        let cfg: DaemonConfig = toml::from_str(
537            r#"
538[git_storage]
539method = "native"
540
541[git_storage.retention]
542enabled = true
543keep_days = 14
544interval_secs = 43200
545"#,
546        )
547        .expect("parse retention config");
548
549        assert_eq!(cfg.git_storage.method, GitStorageMethod::Native);
550        assert!(cfg.git_storage.retention.enabled);
551        assert_eq!(cfg.git_storage.retention.keep_days, 14);
552        assert_eq!(cfg.git_storage.retention.interval_secs, 43_200);
553    }
554}