Skip to main content

cc_switch/daemon/
logging.rs

1use tracing::level_filters::LevelFilter;
2use tracing_appender::non_blocking::WorkerGuard;
3use tracing_subscriber::EnvFilter;
4use tracing_subscriber::layer::SubscriberExt;
5use tracing_subscriber::util::SubscriberInitExt;
6
7pub enum LogMode {
8    Foreground,
9    Background,
10}
11
12/// Resolve the effective log level from (highest priority first):
13/// 1. `env_val` — CCS_LOG env var (full EnvFilter syntax)
14/// 2. `cli_level` — --log-level flag (single level string)
15/// 3. `verbose` — -v count: 0-1=info, 2=debug, 3+=trace
16/// 4. Default: info
17pub fn resolve_log_level(
18    cli_level: Option<&str>,
19    verbose: u8,
20    env_val: Option<&str>,
21) -> LevelFilter {
22    if let Some(env) = env_val
23        && let Ok(lf) = env.parse::<LevelFilter>()
24    {
25        return lf;
26    }
27
28    if let Some(flag) = cli_level
29        && let Ok(lf) = flag.parse::<LevelFilter>()
30    {
31        return lf;
32    }
33
34    match verbose {
35        0 | 1 => LevelFilter::INFO,
36        2 => LevelFilter::DEBUG,
37        _ => LevelFilter::TRACE,
38    }
39}
40
41/// Initialize the global tracing subscriber. Returns a guard that must be held
42/// for the daemon's lifetime to ensure the non-blocking writer flushes on drop.
43pub fn init_tracing(mode: LogMode, level: LevelFilter) -> WorkerGuard {
44    let log_dir = log_directory();
45    std::fs::create_dir_all(&log_dir).ok();
46
47    let file_appender = tracing_appender::rolling::daily(&log_dir, "daemon");
48    let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
49
50    let env_filter = EnvFilter::builder()
51        .with_default_directive(level.into())
52        .from_env_lossy();
53
54    let file_layer = tracing_subscriber::fmt::layer()
55        .with_writer(non_blocking)
56        .with_ansi(false)
57        .with_target(true)
58        .with_thread_ids(false);
59
60    let registry = tracing_subscriber::registry()
61        .with(env_filter)
62        .with(file_layer);
63
64    match mode {
65        LogMode::Foreground => {
66            let stderr_layer = tracing_subscriber::fmt::layer()
67                .with_writer(std::io::stderr)
68                .with_ansi(true)
69                .with_target(true);
70            registry.with(stderr_layer).init();
71        }
72        LogMode::Background => {
73            registry
74                .with(None::<tracing_subscriber::fmt::Layer<_>>)
75                .init();
76        }
77    }
78
79    guard
80}
81
82/// Clean up log files older than `retention_days` in the log directory.
83pub fn cleanup_old_logs(retention_days: u32) {
84    let log_dir = log_directory();
85    cleanup_old_logs_in_dir(&log_dir, retention_days);
86}
87
88fn cleanup_old_logs_in_dir(log_dir: &std::path::Path, retention_days: u32) {
89    let entries = match std::fs::read_dir(log_dir) {
90        Ok(e) => e,
91        Err(_) => return,
92    };
93
94    let today = chrono::Utc::now().date_naive();
95
96    for entry in entries.flatten() {
97        let name = entry.file_name();
98        let name_str = name.to_string_lossy();
99
100        // tracing-appender daily files: "daemon.YYYY-MM-DD"
101        let date_part = match name_str.strip_prefix("daemon.") {
102            Some(rest) => rest,
103            None => continue,
104        };
105
106        let file_date = match chrono::NaiveDate::parse_from_str(date_part, "%Y-%m-%d") {
107            Ok(d) => d,
108            Err(_) => continue,
109        };
110
111        let age_days = (today - file_date).num_days();
112        if age_days > retention_days as i64 {
113            let _ = std::fs::remove_file(entry.path());
114        }
115    }
116}
117
118fn log_directory() -> std::path::PathBuf {
119    dirs::home_dir()
120        .unwrap_or_else(|| std::path::PathBuf::from("."))
121        .join(".cc-switch")
122        .join("logs")
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn cleanup_removes_old_files() {
131        let dir = tempfile::TempDir::new().unwrap();
132        let old_file = dir.path().join("daemon.2020-01-01");
133        let recent_file = dir.path().join("daemon.2026-05-27");
134        let unrelated = dir.path().join("other.txt");
135
136        std::fs::write(&old_file, "old").unwrap();
137        std::fs::write(&recent_file, "recent").unwrap();
138        std::fs::write(&unrelated, "keep").unwrap();
139
140        cleanup_old_logs_in_dir(dir.path(), 7);
141
142        assert!(!old_file.exists(), "old file should be deleted");
143        assert!(recent_file.exists(), "recent file should be kept");
144        assert!(unrelated.exists(), "unrelated file should be kept");
145    }
146
147    #[test]
148    fn resolve_level_default_is_info() {
149        assert_eq!(resolve_log_level(None, 0, None), LevelFilter::INFO);
150    }
151
152    #[test]
153    fn resolve_level_verbose_2_is_debug() {
154        assert_eq!(resolve_log_level(None, 2, None), LevelFilter::DEBUG);
155    }
156
157    #[test]
158    fn resolve_level_verbose_3_is_trace() {
159        assert_eq!(resolve_log_level(None, 3, None), LevelFilter::TRACE);
160    }
161
162    #[test]
163    fn resolve_level_cli_overrides_verbose() {
164        assert_eq!(
165            resolve_log_level(Some("error"), 3, None),
166            LevelFilter::ERROR
167        );
168    }
169
170    #[test]
171    fn resolve_level_env_overrides_all() {
172        assert_eq!(
173            resolve_log_level(Some("error"), 0, Some("trace")),
174            LevelFilter::TRACE
175        );
176    }
177
178    #[test]
179    fn resolve_level_invalid_env_falls_back() {
180        assert_eq!(
181            resolve_log_level(Some("warn"), 0, Some("not_a_level")),
182            LevelFilter::WARN
183        );
184    }
185}