cc_switch/daemon/
logging.rs1use 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
12pub 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
41pub 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
82pub 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 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}