1use std::fs;
2use std::io::Write;
3use std::path::{Path, PathBuf};
4
5use log::LevelFilter;
6use simplelog::{
7 ColorChoice, CombinedLogger, ConfigBuilder, SharedLogger, TermLogger, TerminalMode, WriteLogger,
8};
9
10const MAX_LOG_SIZE: u64 = 5 * 1024 * 1024; pub fn log_path(paths: Option<&crate::runtime::env::Paths>) -> Option<PathBuf> {
18 paths.map(crate::runtime::env::Paths::log_file)
19}
20
21fn rotate_if_needed(path: &Path) {
24 if let Ok(meta) = fs::metadata(path) {
25 if meta.len() > MAX_LOG_SIZE {
26 let backup = path.with_file_name("purple.log.1");
27 let _ = fs::rename(path, backup);
28 }
29 }
30}
31
32fn resolve_level(verbose: bool, env_override: Option<&str>) -> LevelFilter {
34 if let Some(val) = env_override {
35 match val.to_lowercase().as_str() {
36 "trace" => return LevelFilter::Trace,
37 "debug" => return LevelFilter::Debug,
38 "info" => return LevelFilter::Info,
39 "warn" => return LevelFilter::Warn,
40 "error" => return LevelFilter::Error,
41 "off" => return LevelFilter::Off,
42 _ => {}
43 }
44 }
45 if verbose {
46 LevelFilter::Debug
47 } else {
48 LevelFilter::Warn
49 }
50}
51
52pub fn init(verbose: bool, cli_stderr: bool, env: &crate::runtime::env::Env) {
57 let Some(path) = log_path(env.paths()) else {
58 return;
59 };
60
61 if let Some(parent) = path.parent() {
62 let _ = fs::create_dir_all(parent);
63 }
64
65 rotate_if_needed(&path);
66
67 let level = resolve_level(verbose, env.var("PURPLE_LOG"));
68 let config = ConfigBuilder::new()
69 .set_time_format_rfc3339()
70 .set_target_level(LevelFilter::Off)
71 .set_thread_level(LevelFilter::Off)
72 .build();
73
74 let mut loggers: Vec<Box<dyn SharedLogger>> = Vec::with_capacity(2);
75
76 if let Ok(file) = fs::OpenOptions::new().create(true).append(true).open(&path) {
77 loggers.push(WriteLogger::new(level, config.clone(), file));
78 }
79
80 if cli_stderr {
81 loggers.push(TermLogger::new(
82 level,
83 config,
84 TerminalMode::Stderr,
85 ColorChoice::Auto,
86 ));
87 }
88
89 if !loggers.is_empty() {
90 if let Err(e) = CombinedLogger::init(loggers) {
91 eprintln!("{}", crate::messages::logging::init_failed(&e));
92 }
93 }
94}
95
96fn format_now_utc() -> String {
98 let now = std::time::SystemTime::now();
99 let secs = now
100 .duration_since(std::time::UNIX_EPOCH)
101 .unwrap_or_default()
102 .as_secs();
103 let days = secs / 86400;
104 let time_of_day = secs % 86400;
105 let hours = time_of_day / 3600;
106 let minutes = (time_of_day % 3600) / 60;
107 let seconds = time_of_day % 60;
108
109 let (year, month, day) = epoch_days_to_date(days);
110 format!("{year:04}-{month:02}-{day:02} {hours:02}:{minutes:02}:{seconds:02}Z")
111}
112
113fn epoch_days_to_date(days: u64) -> (u64, u64, u64) {
116 let z = days + 719_468;
117 let era = z / 146_097;
118 let doe = z % 146_097;
119 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
120 let y = yoe + era * 400;
121 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
122 let mp = (5 * doy + 2) / 153;
123 let d = doy - (153 * mp + 2) / 5 + 1;
124 let m = if mp < 10 { mp + 3 } else { mp - 9 };
125 let y = if m <= 2 { y + 1 } else { y };
126 (y, m, d)
127}
128
129pub struct BannerInfo<'a> {
131 pub version: &'a str,
132 pub config_path: &'a str,
133 pub providers: &'a [String],
134 pub askpass_sources: &'a [String],
135 pub vault_ssh_info: Option<&'a str>,
136 pub ssh_version: &'a str,
137 pub term: &'a str,
138 pub colorterm: &'a str,
139 pub level: &'a str,
140 pub theme: &'a str,
142 pub hosts: usize,
144 pub patterns: usize,
146 pub snippets: usize,
148 pub proxy_env: &'a str,
151}
152
153pub fn write_banner(info: &BannerInfo<'_>, paths: Option<&crate::runtime::env::Paths>) {
160 let Some(path) = log_path(paths) else { return };
161 let Ok(mut file) = fs::OpenOptions::new().create(true).append(true).open(&path) else {
162 return;
163 };
164
165 let now = format_now_utc();
166 let os = std::env::consts::OS;
167 let arch = std::env::consts::ARCH;
168 let providers_joined = if info.providers.is_empty() {
169 "none".to_string()
170 } else {
171 info.providers.join(",")
172 };
173 let askpass_joined = if info.askpass_sources.is_empty() {
174 "none".to_string()
175 } else {
176 info.askpass_sources.join(",")
177 };
178
179 let mut banner = format!(
180 "--- purple v{} started at {now} ---\n\
181 \x20 os={os} arch={arch} config={}\n\
182 \x20 ssh={}\n\
183 \x20 term={} colorterm={}\n\
184 \x20 theme={}\n\
185 \x20 hosts={} patterns={} snippets={}\n\
186 \x20 providers={providers_joined}\n\
187 \x20 askpass={askpass_joined}\n\
188 \x20 proxy_env={}\n",
189 info.version,
190 info.config_path,
191 info.ssh_version,
192 info.term,
193 info.colorterm,
194 info.theme,
195 info.hosts,
196 info.patterns,
197 info.snippets,
198 info.proxy_env,
199 );
200 if let Some(vault_info) = info.vault_ssh_info {
201 banner.push_str(&format!(" vault_ssh={vault_info}\n"));
202 }
203 banner.push_str(&format!(" log_level={}\n", info.level));
204
205 let _ = file.write_all(banner.as_bytes());
211}
212
213pub fn level_name(verbose: bool, env: &crate::runtime::env::Env) -> String {
215 resolve_level(verbose, env.var("PURPLE_LOG"))
216 .as_str()
217 .to_lowercase()
218}
219
220pub fn detect_ssh_version() -> String {
228 use std::sync::mpsc;
229 use std::time::Duration;
230
231 let child = std::process::Command::new("ssh")
232 .arg("-V")
233 .stdout(std::process::Stdio::piped())
234 .stderr(std::process::Stdio::piped())
235 .spawn();
236
237 let Ok(child) = child else {
238 eprintln!("{}", crate::messages::logging::SSH_VERSION_FAILED);
239 return "unknown".to_string();
240 };
241
242 let (tx, rx) = mpsc::channel();
243 std::thread::spawn(move || {
244 let _ = tx.send(child.wait_with_output());
245 });
246
247 match rx.recv_timeout(Duration::from_secs(2)) {
248 Ok(Ok(output)) => {
249 let out = if output.stderr.is_empty() {
250 output.stdout
251 } else {
252 output.stderr
253 };
254 String::from_utf8(out)
255 .map(|s| s.trim().to_string())
256 .unwrap_or_else(|_| "unknown".to_string())
257 }
258 _ => "unknown".to_string(),
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use std::io::Write;
266
267 #[test]
268 fn rotate_if_needed_renames_large_file() {
269 let dir = tempfile::tempdir().unwrap();
270 let log = dir.path().join("purple.log");
271 let backup = dir.path().join("purple.log.1");
272
273 let mut f = fs::File::create(&log).unwrap();
274 let data = vec![0u8; (MAX_LOG_SIZE + 1) as usize];
275 f.write_all(&data).unwrap();
276 drop(f);
277
278 rotate_if_needed(&log);
279
280 assert!(!log.exists());
281 assert!(backup.exists());
282 assert!(fs::metadata(&backup).unwrap().len() > MAX_LOG_SIZE);
283 }
284
285 #[test]
286 fn rotate_if_needed_leaves_small_file() {
287 let dir = tempfile::tempdir().unwrap();
288 let log = dir.path().join("purple.log");
289
290 fs::write(&log, "small content").unwrap();
291
292 rotate_if_needed(&log);
293
294 assert!(log.exists());
295 assert!(!dir.path().join("purple.log.1").exists());
296 }
297
298 #[test]
299 fn rotate_if_needed_handles_missing_file() {
300 let dir = tempfile::tempdir().unwrap();
301 let log = dir.path().join("purple.log");
302
303 rotate_if_needed(&log);
305 }
306
307 #[test]
308 fn resolve_level_defaults_to_warn() {
309 assert_eq!(resolve_level(false, None), LevelFilter::Warn);
310 }
311
312 #[test]
313 fn resolve_level_verbose_returns_debug() {
314 assert_eq!(resolve_level(true, None), LevelFilter::Debug);
315 }
316
317 #[test]
318 fn resolve_level_env_overrides_verbose() {
319 assert_eq!(resolve_level(false, Some("trace")), LevelFilter::Trace);
320 assert_eq!(resolve_level(true, Some("error")), LevelFilter::Error);
321 }
322
323 #[test]
324 fn resolve_level_ignores_unknown_env_value() {
325 assert_eq!(resolve_level(false, Some("bogus")), LevelFilter::Warn);
326 assert_eq!(resolve_level(true, Some("bogus")), LevelFilter::Debug);
327 }
328
329 #[test]
330 fn epoch_days_to_date_unix_epoch() {
331 assert_eq!(epoch_days_to_date(0), (1970, 1, 1));
333 }
334
335 #[test]
336 fn epoch_days_to_date_known_date() {
337 assert_eq!(epoch_days_to_date(20553), (2026, 4, 10));
339 }
340
341 #[test]
342 fn epoch_days_to_date_leap_year() {
343 assert_eq!(epoch_days_to_date(11016), (2000, 2, 29));
345 }
346
347 #[test]
348 fn format_now_utc_returns_valid_timestamp() {
349 let ts = format_now_utc();
350 assert_eq!(ts.len(), 20);
352 assert_eq!(&ts[4..5], "-");
353 assert_eq!(&ts[7..8], "-");
354 assert_eq!(&ts[10..11], " ");
355 assert_eq!(&ts[13..14], ":");
356 assert_eq!(&ts[16..17], ":");
357 assert!(ts.ends_with('Z'));
358 }
359
360 #[test]
361 fn log_path_ends_with_purple_log() {
362 let paths = crate::runtime::env::Paths::new("/home/u");
363 let path = log_path(Some(&paths)).expect("paths present");
364 assert!(path.ends_with(".purple/purple.log"));
365 }
366
367 #[test]
368 fn level_name_defaults_to_warn() {
369 let env = crate::runtime::env::Env::empty();
371 let name = level_name(false, &env);
372 assert_eq!(name, "warn");
373 }
374
375 #[test]
376 fn write_banner_creates_output() {
377 let info = BannerInfo {
380 version: "0.0.0-test",
381 config_path: "/tmp/config",
382 providers: &["testprov".to_string()],
383 askpass_sources: &["keychain:".to_string()],
384 vault_ssh_info: Some("enabled (addr=https://vault:8200)"),
385 ssh_version: "OpenSSH_9.0",
386 term: "xterm-256color",
387 colorterm: "truecolor",
388 level: "warn",
389 theme: "Purple",
390 hosts: 42,
391 patterns: 3,
392 snippets: 7,
393 proxy_env: "none",
394 };
395
396 assert_eq!(info.version, "0.0.0-test");
398 assert_eq!(info.providers.len(), 1);
399 assert!(info.vault_ssh_info.is_some());
400 assert_eq!(info.theme, "Purple");
401 assert_eq!(info.hosts, 42);
402 assert_eq!(info.snippets, 7);
403 assert_eq!(info.proxy_env, "none");
404
405 let ts = format_now_utc();
408 assert!(ts.ends_with('Z'));
409 assert_eq!(ts.len(), 20);
410 }
411}