1use 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 #[serde(default = "default_false")]
52 pub summary_enabled: bool,
53 #[serde(default)]
56 pub summary_provider: Option<String>,
57 #[serde(default)]
59 pub summary_model: Option<String>,
60 #[serde(default = "default_summary_content_mode")]
62 pub summary_content_mode: String,
63 #[serde(default = "default_true")]
65 pub summary_disk_cache_enabled: bool,
66 #[serde(default)]
68 pub summary_openai_compat_endpoint: Option<String>,
69 #[serde(default)]
71 pub summary_openai_compat_base: Option<String>,
72 #[serde(default)]
74 pub summary_openai_compat_path: Option<String>,
75 #[serde(default)]
77 pub summary_openai_compat_style: Option<String>,
78 #[serde(default)]
80 pub summary_openai_compat_key: Option<String>,
81 #[serde(default)]
83 pub summary_openai_compat_key_header: Option<String>,
84 #[serde(default = "default_summary_event_window")]
87 pub summary_event_window: u32,
88 #[serde(default = "default_summary_debounce_ms")]
90 pub summary_debounce_ms: u64,
91 #[serde(default = "default_summary_max_inflight")]
93 pub summary_max_inflight: u32,
94 #[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 #[default]
276 #[serde(alias = "platform_api", alias = "platform-api", alias = "api")]
277 Native,
278 #[serde(alias = "none", alias = "sqlite_local", alias = "sqlite-local")]
280 Sqlite,
281 #[serde(other)]
283 Unknown,
284}
285
286fn 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
365pub 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}