opensession_runtime_config/
lib.rs1use serde::{Deserialize, Serialize};
9
10pub const CONFIG_FILE_NAME: &str = "opensession.toml";
12
13#[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 #[serde(default = "default_detail_realtime_preview_enabled")]
46 pub detail_realtime_preview_enabled: bool,
47 #[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 #[default]
214 #[serde(alias = "platform_api", alias = "platform-api", alias = "api")]
215 Native,
216 #[serde(alias = "none", alias = "sqlite_local", alias = "sqlite-local")]
218 Sqlite,
219 #[serde(other)]
221 Unknown,
222}
223
224fn 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
291pub 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}