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() -> Option<PathBuf> {
18 dirs::home_dir().map(|h| h.join(".purple").join("purple.log"))
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) {
57 let Some(path) = log_path() else { return };
58
59 if let Some(parent) = path.parent() {
60 let _ = fs::create_dir_all(parent);
61 }
62
63 rotate_if_needed(&path);
64
65 let env_val = std::env::var("PURPLE_LOG").ok();
66 let level = resolve_level(verbose, env_val.as_deref());
67 let config = ConfigBuilder::new()
68 .set_time_format_rfc3339()
69 .set_target_level(LevelFilter::Off)
70 .set_thread_level(LevelFilter::Off)
71 .build();
72
73 let mut loggers: Vec<Box<dyn SharedLogger>> = Vec::with_capacity(2);
74
75 if let Ok(file) = fs::OpenOptions::new().create(true).append(true).open(&path) {
76 loggers.push(WriteLogger::new(level, config.clone(), file));
77 }
78
79 if cli_stderr {
80 loggers.push(TermLogger::new(
81 level,
82 config,
83 TerminalMode::Stderr,
84 ColorChoice::Auto,
85 ));
86 }
87
88 if !loggers.is_empty() {
89 if let Err(e) = CombinedLogger::init(loggers) {
90 eprintln!("{}", crate::messages::logging::init_failed(&e));
91 }
92 }
93}
94
95fn format_now_utc() -> String {
97 let now = std::time::SystemTime::now();
98 let secs = now
99 .duration_since(std::time::UNIX_EPOCH)
100 .unwrap_or_default()
101 .as_secs();
102 let days = secs / 86400;
103 let time_of_day = secs % 86400;
104 let hours = time_of_day / 3600;
105 let minutes = (time_of_day % 3600) / 60;
106 let seconds = time_of_day % 60;
107
108 let (year, month, day) = epoch_days_to_date(days);
109 format!("{year:04}-{month:02}-{day:02} {hours:02}:{minutes:02}:{seconds:02}Z")
110}
111
112fn epoch_days_to_date(days: u64) -> (u64, u64, u64) {
115 let z = days + 719_468;
116 let era = z / 146_097;
117 let doe = z % 146_097;
118 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
119 let y = yoe + era * 400;
120 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
121 let mp = (5 * doy + 2) / 153;
122 let d = doy - (153 * mp + 2) / 5 + 1;
123 let m = if mp < 10 { mp + 3 } else { mp - 9 };
124 let y = if m <= 2 { y + 1 } else { y };
125 (y, m, d)
126}
127
128pub struct BannerInfo<'a> {
130 pub version: &'a str,
131 pub config_path: &'a str,
132 pub providers: &'a [String],
133 pub askpass_sources: &'a [String],
134 pub vault_ssh_info: Option<&'a str>,
135 pub ssh_version: &'a str,
136 pub term: &'a str,
137 pub colorterm: &'a str,
138 pub level: &'a str,
139 pub theme: &'a str,
141 pub hosts: usize,
143 pub patterns: usize,
145 pub snippets: usize,
147 pub proxy_env: &'a str,
150}
151
152pub fn write_banner(info: &BannerInfo<'_>) {
159 let Some(path) = log_path() else { return };
160 let Ok(mut file) = fs::OpenOptions::new().create(true).append(true).open(&path) else {
161 return;
162 };
163
164 let now = format_now_utc();
165 let os = std::env::consts::OS;
166 let arch = std::env::consts::ARCH;
167 let providers_joined = if info.providers.is_empty() {
168 "none".to_string()
169 } else {
170 info.providers.join(",")
171 };
172 let askpass_joined = if info.askpass_sources.is_empty() {
173 "none".to_string()
174 } else {
175 info.askpass_sources.join(",")
176 };
177
178 let mut banner = format!(
179 "--- purple v{} started at {now} ---\n\
180 \x20 os={os} arch={arch} config={}\n\
181 \x20 ssh={}\n\
182 \x20 term={} colorterm={}\n\
183 \x20 theme={}\n\
184 \x20 hosts={} patterns={} snippets={}\n\
185 \x20 providers={providers_joined}\n\
186 \x20 askpass={askpass_joined}\n\
187 \x20 proxy_env={}\n",
188 info.version,
189 info.config_path,
190 info.ssh_version,
191 info.term,
192 info.colorterm,
193 info.theme,
194 info.hosts,
195 info.patterns,
196 info.snippets,
197 info.proxy_env,
198 );
199 if let Some(vault_info) = info.vault_ssh_info {
200 banner.push_str(&format!(" vault_ssh={vault_info}\n"));
201 }
202 banner.push_str(&format!(" log_level={}\n", info.level));
203
204 let _ = file.write_all(banner.as_bytes());
210}
211
212pub fn level_name(verbose: bool) -> String {
214 let env_val = std::env::var("PURPLE_LOG").ok();
215 resolve_level(verbose, env_val.as_deref())
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 path = log_path().expect("home dir should exist in test");
363 assert!(path.ends_with(".purple/purple.log"));
364 }
365
366 #[test]
367 fn level_name_defaults_to_warn() {
368 let name = level_name(false);
371 assert!(name == "warn" || std::env::var("PURPLE_LOG").is_ok());
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}