Skip to main content

purple_ssh/
logging.rs

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
10// Fault domain convention:
11// - error! and warn! statements carry a prefix: [external], [config], or [purple]
12// - info! and debug! are operational flow markers without prefixes
13
14const MAX_LOG_SIZE: u64 = 5 * 1024 * 1024; // 5MB
15
16/// Return the path to the log file: ~/.purple/purple.log
17pub fn log_path() -> Option<PathBuf> {
18    dirs::home_dir().map(|h| h.join(".purple").join("purple.log"))
19}
20
21/// Rotate log file if it exceeds MAX_LOG_SIZE.
22/// Renames purple.log -> purple.log.1 (overwrites previous backup).
23fn 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
32/// Determine log level. `env_override` takes precedence over `verbose` flag.
33fn 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
52/// Initialize logging. Call once at the start of main().
53///
54/// - `verbose`: whether --verbose was passed
55/// - `cli_stderr`: if true, also log to stderr (for CLI subcommands, not TUI)
56pub 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
95/// Format current UTC time as YYYY-MM-DD HH:MM:SS.
96fn 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
112/// Convert days since Unix epoch to (year, month, day).
113/// Algorithm from <http://howardhinnant.github.io/date_algorithms.html>.
114fn 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
128/// Startup banner info. Struct avoids argument-order bugs between &str params.
129pub 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    /// Theme currently in effect (loaded from preferences or `--theme`).
140    pub theme: &'a str,
141    /// Total number of host entries parsed from the SSH config.
142    pub hosts: usize,
143    /// Total number of patterns (wildcard / multi-alias `Host` lines).
144    pub patterns: usize,
145    /// Total number of snippets loaded from the snippet store.
146    pub snippets: usize,
147    /// Comma-separated list of env vars that affect proxy behaviour (HTTP_PROXY,
148    /// HTTPS_PROXY, NO_PROXY) when any are set. `"none"` when none are present.
149    pub proxy_env: &'a str,
150}
151
152/// Write startup banner directly to log file, bypassing level filters.
153/// All timestamps are UTC (suffixed with Z).
154///
155/// Note: writes directly to the log file, bypassing simplelog's CombinedLogger.
156/// This means the banner will not appear on stderr in CLI mode. This is intentional:
157/// the banner is diagnostic context for the log file, not user-facing output.
158pub 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    // Note: banner lines use \x20 (space) prefix to prevent the Rust string
205    // continuation from collapsing leading whitespace. This is a rustfmt-safe
206    // idiom for multi-line format strings with indentation.
207
208    // Non-fatal: banner write failure does not affect logging
209    let _ = file.write_all(banner.as_bytes());
210}
211
212/// Return the effective log level name as a lowercase string.
213pub 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
220/// Detect SSH version by running `ssh -V` (output goes to stderr).
221/// Uses a 2-second timeout via mpsc channel to avoid hanging startup
222/// if the ssh binary is broken or on a slow filesystem.
223///
224/// On timeout, the spawned thread and child process continue running until
225/// `ssh -V` exits naturally. This is acceptable because `ssh -V` is
226/// near-instant and this only runs once at startup.
227pub 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        // Should not panic
304        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        // Day 0 = 1970-01-01
332        assert_eq!(epoch_days_to_date(0), (1970, 1, 1));
333    }
334
335    #[test]
336    fn epoch_days_to_date_known_date() {
337        // 2026-04-10 = day 20553
338        assert_eq!(epoch_days_to_date(20553), (2026, 4, 10));
339    }
340
341    #[test]
342    fn epoch_days_to_date_leap_year() {
343        // 2000-02-29 = day 11016
344        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        // Should be in YYYY-MM-DD HH:MM:SSZ format
351        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        // level_name reads PURPLE_LOG env var internally, so we only test
369        // the verbose=false path (env var is not set in most test runners)
370        let name = level_name(false);
371        // Without PURPLE_LOG set, should be "warn"
372        assert!(name == "warn" || std::env::var("PURPLE_LOG").is_ok());
373    }
374
375    #[test]
376    fn write_banner_creates_output() {
377        // write_banner uses log_path() which returns ~/.purple/purple.log,
378        // so we test the banner formatting logic via struct construction
379        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        // Verify the banner struct fields are accessible and well-formed
397        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        // We can't easily redirect log_path() in a unit test, but we can
406        // verify the format_now_utc helper used by write_banner
407        let ts = format_now_utc();
408        assert!(ts.ends_with('Z'));
409        assert_eq!(ts.len(), 20);
410    }
411}