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