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 hidden refs (git-native).
213    #[default]
214    Native,
215    /// Store session bodies in SQLite-backed storage.
216    Sqlite,
217}
218
219// ── Serde default functions ─────────────────────────────────────────────
220
221fn default_true() -> bool {
222    true
223}
224fn default_false() -> bool {
225    false
226}
227fn default_debounce() -> u64 {
228    5
229}
230fn default_max_retries() -> u32 {
231    3
232}
233fn default_health_check_interval() -> u64 {
234    300
235}
236fn default_realtime_debounce_ms() -> u64 {
237    500
238}
239fn default_detail_realtime_preview_enabled() -> bool {
240    false
241}
242fn default_detail_auto_expand_selected_event() -> bool {
243    true
244}
245fn default_publish_on() -> PublishMode {
246    PublishMode::Manual
247}
248fn default_git_retention_keep_days() -> u32 {
249    30
250}
251fn default_git_retention_interval_secs() -> u64 {
252    86_400
253}
254fn default_server_url() -> String {
255    "https://opensession.io".to_string()
256}
257fn default_nickname() -> String {
258    "user".to_string()
259}
260fn default_exclude_patterns() -> Vec<String> {
261    vec![
262        "*.env".to_string(),
263        "*secret*".to_string(),
264        "*credential*".to_string(),
265    ]
266}
267
268pub const DEFAULT_WATCH_PATHS: &[&str] = &[
269    "~/.claude/projects",
270    "~/.codex/sessions",
271    "~/.local/share/opencode/storage/session",
272    "~/.cline/data/tasks",
273    "~/.local/share/amp/threads",
274    "~/.gemini/tmp",
275    "~/Library/Application Support/Cursor/User",
276    "~/.config/Cursor/User",
277];
278
279pub fn default_watch_paths() -> Vec<String> {
280    DEFAULT_WATCH_PATHS
281        .iter()
282        .map(|path| (*path).to_string())
283        .collect()
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn git_storage_method_requires_canonical_values() {
292        let parsed: Result<DaemonConfig, _> = toml::from_str(
293            r#"
294[git_storage]
295method = "platform_api"
296"#,
297        );
298        assert!(parsed.is_err(), "legacy aliases must be rejected");
299    }
300
301    #[test]
302    fn unknown_watcher_flags_are_ignored() {
303        let cfg: DaemonConfig = toml::from_str(
304            r#"
305[watchers]
306claude_code = false
307opencode = false
308cursor = false
309custom_paths = ["~/.codex/sessions"]
310"#,
311        )
312        .expect("parse watcher config");
313
314        assert_eq!(
315            cfg.watchers.custom_paths,
316            vec!["~/.codex/sessions".to_string()]
317        );
318    }
319
320    #[test]
321    fn watcher_settings_serialize_only_current_fields() {
322        let cfg = DaemonConfig::default();
323        let encoded = toml::to_string(&cfg).expect("serialize config");
324
325        assert!(encoded.contains("custom_paths"));
326        assert!(!encoded.contains("\nclaude_code ="));
327        assert!(!encoded.contains("\nopencode ="));
328        assert!(!encoded.contains("\ncursor ="));
329    }
330
331    #[test]
332    fn git_retention_defaults_are_stable() {
333        let cfg = DaemonConfig::default();
334        assert!(!cfg.git_storage.retention.enabled);
335        assert_eq!(cfg.git_storage.retention.keep_days, 30);
336        assert_eq!(cfg.git_storage.retention.interval_secs, 86_400);
337    }
338
339    #[test]
340    fn git_retention_fields_deserialize_from_toml() {
341        let cfg: DaemonConfig = toml::from_str(
342            r#"
343[git_storage]
344method = "native"
345
346[git_storage.retention]
347enabled = true
348keep_days = 14
349interval_secs = 43200
350"#,
351        )
352        .expect("parse retention config");
353
354        assert_eq!(cfg.git_storage.method, GitStorageMethod::Native);
355        assert!(cfg.git_storage.retention.enabled);
356        assert_eq!(cfg.git_storage.retention.keep_days, 14);
357        assert_eq!(cfg.git_storage.retention.interval_secs, 43_200);
358    }
359}