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}
51
52impl Default for DaemonSettings {
53    fn default() -> Self {
54        Self {
55            auto_publish: false,
56            debounce_secs: 5,
57            publish_on: PublishMode::Manual,
58            max_retries: 3,
59            health_check_interval_secs: 300,
60            realtime_debounce_ms: 500,
61            detail_realtime_preview_enabled: false,
62            detail_auto_expand_selected_event: true,
63        }
64    }
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
68#[serde(rename_all = "snake_case")]
69pub enum PublishMode {
70    SessionEnd,
71    Realtime,
72    Manual,
73}
74
75impl PublishMode {
76    pub fn cycle(&self) -> Self {
77        match self {
78            Self::SessionEnd => Self::Realtime,
79            Self::Realtime => Self::Manual,
80            Self::Manual => Self::SessionEnd,
81        }
82    }
83
84    pub fn display(&self) -> &'static str {
85        match self {
86            Self::SessionEnd => "Session End",
87            Self::Realtime => "Realtime",
88            Self::Manual => "Manual",
89        }
90    }
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94#[serde(rename_all = "snake_case")]
95pub enum CalendarDisplayMode {
96    Smart,
97    Relative,
98    Absolute,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct ServerSettings {
103    #[serde(default = "default_server_url")]
104    pub url: String,
105    #[serde(default)]
106    pub api_key: String,
107}
108
109impl Default for ServerSettings {
110    fn default() -> Self {
111        Self {
112            url: default_server_url(),
113            api_key: String::new(),
114        }
115    }
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct IdentitySettings {
120    #[serde(default = "default_nickname")]
121    pub nickname: String,
122}
123
124impl Default for IdentitySettings {
125    fn default() -> Self {
126        Self {
127            nickname: default_nickname(),
128        }
129    }
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct PrivacySettings {
134    #[serde(default = "default_true")]
135    pub strip_paths: bool,
136    #[serde(default = "default_true")]
137    pub strip_env_vars: bool,
138    #[serde(default = "default_exclude_patterns")]
139    pub exclude_patterns: Vec<String>,
140    #[serde(default)]
141    pub exclude_tools: Vec<String>,
142}
143
144impl Default for PrivacySettings {
145    fn default() -> Self {
146        Self {
147            strip_paths: true,
148            strip_env_vars: true,
149            exclude_patterns: default_exclude_patterns(),
150            exclude_tools: Vec::new(),
151        }
152    }
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct WatcherSettings {
157    #[serde(default = "default_watch_paths")]
158    pub custom_paths: Vec<String>,
159}
160
161impl Default for WatcherSettings {
162    fn default() -> Self {
163        Self {
164            custom_paths: default_watch_paths(),
165        }
166    }
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct GitStorageSettings {
171    #[serde(default)]
172    pub method: GitStorageMethod,
173    #[serde(default)]
174    pub token: String,
175    #[serde(default)]
176    pub retention: GitRetentionSettings,
177}
178
179impl Default for GitStorageSettings {
180    fn default() -> Self {
181        Self {
182            method: GitStorageMethod::Native,
183            token: String::new(),
184            retention: GitRetentionSettings::default(),
185        }
186    }
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct GitRetentionSettings {
191    #[serde(default = "default_false")]
192    pub enabled: bool,
193    #[serde(default = "default_git_retention_keep_days")]
194    pub keep_days: u32,
195    #[serde(default = "default_git_retention_interval_secs")]
196    pub interval_secs: u64,
197}
198
199impl Default for GitRetentionSettings {
200    fn default() -> Self {
201        Self {
202            enabled: false,
203            keep_days: default_git_retention_keep_days(),
204            interval_secs: default_git_retention_interval_secs(),
205        }
206    }
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
210#[serde(rename_all = "snake_case")]
211pub enum GitStorageMethod {
212    /// Store sessions as git objects on an orphan branch (branch-based git-native).
213    #[default]
214    #[serde(alias = "platform_api", alias = "platform-api", alias = "api")]
215    Native,
216    /// Store session bodies in SQLite-backed storage.
217    #[serde(alias = "none", alias = "sqlite_local", alias = "sqlite-local")]
218    Sqlite,
219    /// Unknown/invalid values are normalized by compatibility fallbacks.
220    #[serde(other)]
221    Unknown,
222}
223
224// ── Serde default functions ─────────────────────────────────────────────
225
226fn default_true() -> bool {
227    true
228}
229fn default_false() -> bool {
230    false
231}
232fn default_debounce() -> u64 {
233    5
234}
235fn default_max_retries() -> u32 {
236    3
237}
238fn default_health_check_interval() -> u64 {
239    300
240}
241fn default_realtime_debounce_ms() -> u64 {
242    500
243}
244fn default_detail_realtime_preview_enabled() -> bool {
245    false
246}
247fn default_detail_auto_expand_selected_event() -> bool {
248    true
249}
250fn default_publish_on() -> PublishMode {
251    PublishMode::Manual
252}
253fn default_git_retention_keep_days() -> u32 {
254    30
255}
256fn default_git_retention_interval_secs() -> u64 {
257    86_400
258}
259fn default_server_url() -> String {
260    "https://opensession.io".to_string()
261}
262fn default_nickname() -> String {
263    "user".to_string()
264}
265fn default_exclude_patterns() -> Vec<String> {
266    vec![
267        "*.env".to_string(),
268        "*secret*".to_string(),
269        "*credential*".to_string(),
270    ]
271}
272
273pub const DEFAULT_WATCH_PATHS: &[&str] = &[
274    "~/.claude/projects",
275    "~/.codex/sessions",
276    "~/.local/share/opencode/storage/session",
277    "~/.cline/data/tasks",
278    "~/.local/share/amp/threads",
279    "~/.gemini/tmp",
280    "~/Library/Application Support/Cursor/User",
281    "~/.config/Cursor/User",
282];
283
284pub fn default_watch_paths() -> Vec<String> {
285    DEFAULT_WATCH_PATHS
286        .iter()
287        .map(|path| (*path).to_string())
288        .collect()
289}
290
291/// Apply compatibility fallbacks after loading raw TOML.
292/// Returns true when any field was updated.
293pub fn apply_compat_fallbacks(config: &mut DaemonConfig, _root: Option<&toml::Value>) -> bool {
294    let mut changed = false;
295
296    if config.git_storage.method == GitStorageMethod::Unknown {
297        config.git_storage.method = GitStorageMethod::Native;
298        changed = true;
299    }
300
301    if config.watchers.custom_paths.is_empty() {
302        config.watchers.custom_paths = default_watch_paths();
303        changed = true;
304    }
305
306    changed
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn apply_compat_fallbacks_populates_missing_fields() {
315        let mut cfg = DaemonConfig::default();
316        cfg.git_storage.method = GitStorageMethod::Unknown;
317        cfg.watchers.custom_paths.clear();
318
319        let root: toml::Value = toml::from_str(
320            r#"
321[git_storage]
322"#,
323        )
324        .expect("parse toml");
325
326        let changed = apply_compat_fallbacks(&mut cfg, Some(&root));
327        assert!(changed);
328        assert_eq!(cfg.git_storage.method, GitStorageMethod::Native);
329        assert!(!cfg.watchers.custom_paths.is_empty());
330    }
331
332    #[test]
333    fn git_storage_method_compat_aliases_are_accepted() {
334        let compat_none: DaemonConfig = toml::from_str(
335            r#"
336[git_storage]
337method = "none"
338"#,
339        )
340        .expect("parse toml");
341        assert_eq!(compat_none.git_storage.method, GitStorageMethod::Sqlite);
342
343        let compat_platform_api: DaemonConfig = toml::from_str(
344            r#"
345[git_storage]
346method = "platform_api"
347"#,
348        )
349        .expect("parse toml");
350        assert_eq!(
351            compat_platform_api.git_storage.method,
352            GitStorageMethod::Native
353        );
354    }
355
356    #[test]
357    fn apply_compat_fallbacks_is_noop_for_modern_values() {
358        let mut cfg = DaemonConfig::default();
359        cfg.watchers.custom_paths = vec!["/tmp/one".to_string()];
360
361        let root: toml::Value = toml::from_str(
362            r#"
363[git_storage]
364method = "native"
365"#,
366        )
367        .expect("parse toml");
368
369        let before = cfg.clone();
370        let changed = apply_compat_fallbacks(&mut cfg, Some(&root));
371        assert!(!changed);
372        assert_eq!(cfg.watchers.custom_paths, before.watchers.custom_paths);
373        assert_eq!(cfg.git_storage.method, before.git_storage.method);
374    }
375
376    #[test]
377    fn unknown_watcher_flags_are_ignored() {
378        let cfg: DaemonConfig = toml::from_str(
379            r#"
380[watchers]
381claude_code = false
382opencode = false
383cursor = false
384custom_paths = ["~/.codex/sessions"]
385"#,
386        )
387        .expect("parse watcher config");
388
389        assert_eq!(
390            cfg.watchers.custom_paths,
391            vec!["~/.codex/sessions".to_string()]
392        );
393    }
394
395    #[test]
396    fn watcher_settings_serialize_only_current_fields() {
397        let cfg = DaemonConfig::default();
398        let encoded = toml::to_string(&cfg).expect("serialize config");
399
400        assert!(encoded.contains("custom_paths"));
401        assert!(!encoded.contains("\nclaude_code ="));
402        assert!(!encoded.contains("\nopencode ="));
403        assert!(!encoded.contains("\ncursor ="));
404    }
405
406    #[test]
407    fn git_retention_defaults_are_stable() {
408        let cfg = DaemonConfig::default();
409        assert!(!cfg.git_storage.retention.enabled);
410        assert_eq!(cfg.git_storage.retention.keep_days, 30);
411        assert_eq!(cfg.git_storage.retention.interval_secs, 86_400);
412    }
413
414    #[test]
415    fn git_retention_fields_deserialize_from_toml() {
416        let cfg: DaemonConfig = toml::from_str(
417            r#"
418[git_storage]
419method = "native"
420
421[git_storage.retention]
422enabled = true
423keep_days = 14
424interval_secs = 43200
425"#,
426        )
427        .expect("parse retention config");
428
429        assert_eq!(cfg.git_storage.method, GitStorageMethod::Native);
430        assert!(cfg.git_storage.retention.enabled);
431        assert_eq!(cfg.git_storage.retention.keep_days, 14);
432        assert_eq!(cfg.git_storage.retention.interval_secs, 43_200);
433    }
434}