diff --git a/Cargo.lock b/Cargo.lock index ee6db6a..1e76eb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,12 +116,19 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + [[package]] name = "lssh" version = "0.1.0" dependencies = [ "clap", "glob", + "log", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c700200..abc6a5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,3 +8,4 @@ edition = "2024" [dependencies] clap = { version = "4.5.55", features = ["derive"] } glob = "0.3.3" +log = { version = "0.4.29", features = ["std"] } diff --git a/src/logger.rs b/src/logger.rs new file mode 100644 index 0000000..849391a --- /dev/null +++ b/src/logger.rs @@ -0,0 +1,50 @@ +use std::io::{self, IsTerminal}; + +use log::{Level, LevelFilter, Log}; + +const CSI: &str = "\x1B["; + +struct Logger { + is_terminal: bool, +} + +pub fn init(level: LevelFilter) { + let logger = Logger { + is_terminal: io::stderr().is_terminal(), + }; + + log::set_boxed_logger(Box::new(logger)).expect("init only called once"); + log::set_max_level(level); +} + +impl Log for Logger { + fn enabled(&self, metadata: &log::Metadata) -> bool { + metadata.level() <= log::max_level() + } + + fn log(&self, record: &log::Record) { + if !self.enabled(record.metadata()) { + return; + } + + match record.level() { + Level::Error if self.is_terminal => eprint!("{CSI}1;31merror:{CSI}0m "), + Level::Warn if self.is_terminal => eprint!("{CSI}1;33mwarn:{CSI}0m "), + Level::Info if self.is_terminal => eprint!("{CSI}1minfo:{CSI}0m "), + Level::Debug if self.is_terminal => eprint!("{CSI}1mdebug:{CSI}0m "), + Level::Trace if self.is_terminal => eprint!("{CSI}1mtrace:{CSI}0m "), + level => eprint!("{}: ", level.as_str().to_lowercase()), + } + + // let module_path = record.module_path().unwrap_or("lssh"); + // if self.is_terminal { + // eprint!("{CSI}1m{module_path}:{CSI}0m "); + // } else { + // eprint!("{module_path}: "); + // } + + eprintln!("{}", record.args()); + } + + fn flush(&self) {} +} diff --git a/src/main.rs b/src/main.rs index abffde1..8e6b090 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ //! [ssh(1)]: //! [ssh_config(5)]: +mod logger; mod ssh; use std::{ @@ -24,6 +25,7 @@ use std::{ use clap::Parser; use glob::glob; +use log::LevelFilter; use crate::ssh::{SSH_SYSTEM_CONFIG, SSH_USER_CONFIG, Words, expand_inlude_args, is_host_pattern}; @@ -33,11 +35,48 @@ struct Cli { /// This emulates the behaviour of the ssh(1) -F option #[arg(short = 'F')] file: Option, + /// Increase logging verbosity + #[arg(short = 'v', action = clap::ArgAction::Count)] + verbose: u8, +} + +macro_rules! continue_if_error { + ($l:tt: $e:expr) => { + match $e { + Ok(v) => v, + Err(e) => { + log::$l!("{e}"); + continue; + } + } + }; +} + +macro_rules! io_error { + ($kind:ident) => { + Err(std::io::Error::new( + std::io::ErrorKind::$kind, + format!("{}", std::io::ErrorKind::$kind), + )) + }; + ($kind:ident, $prefix:expr) => { + Err(io::Error::new( + std::io::ErrorKind::$kind, + format!("{}: {}", $prefix, std::io::ErrorKind::$kind), + )) + }; } fn main() { let cli = Cli::parse(); + logger::init(match cli.verbose { + 0 => LevelFilter::Error, + 1 => LevelFilter::Info, + 2 => LevelFilter::Debug, + 3.. => LevelFilter::Trace, + }); + let filenames = match &cli.file { Some(path) if path == "none" => vec![], Some(path) => vec![path.clone()], @@ -70,8 +109,14 @@ fn main() { } fn find_hosts(filename: &Path) -> io::Result> { - if !filename.is_file() { - return Err(io::Error::other("no such file")); + log::info!("reading config file: {}", filename.display()); + + if !filename.exists() { + return io_error!(NotFound, filename.display()); + } + + if filename.is_dir() { + return io_error!(IsADirectory, filename.display()); } let mut reader = { @@ -81,6 +126,7 @@ fn find_hosts(filename: &Path) -> io::Result> { let mut hosts = HashSet::new(); let mut buf = String::new(); + loop { buf.clear(); let n = (&mut reader).take(1024 * 16).read_line(&mut buf)?; @@ -97,60 +143,55 @@ fn find_hosts(filename: &Path) -> io::Result> { }; match keyword { + k if k.starts_with('#') => {} k if k.eq_ignore_ascii_case("include") => { find_hosts_in_include_directive(&mut hosts, words); } k if k.eq_ignore_ascii_case("host") => { find_hosts_in_host_directive(&mut hosts, words); } - _ => continue, + k => { + log::trace!("skip unrelated keyword: '{k}'"); + } } } } fn find_hosts_in_include_directive(hosts: &mut HashSet, words: Words<'_>) { for arg in words { - let Ok(arg) = arg else { - // TODO: print warning - continue; - }; + let arg = continue_if_error!(warn: arg); // Expanding env vars before globbing is what ssh does as well. - let Ok(pattern) = expand_inlude_args(arg) else { - // TODO: print warning - continue; - }; + let pattern = continue_if_error!(warn: expand_inlude_args(arg)); - let Ok(paths) = glob(&pattern) else { - // TODO: print warning - continue; - }; + if arg != pattern { + log::debug!("expanded include '{arg}' -> '{pattern}'"); + } + let paths = continue_if_error!(warn: glob(&pattern)); + + let mut matches = 0; for path in paths { - // TODO: print warning - let Ok(path) = path else { - // TODO: print warning - continue; - }; + matches += 1; - let Ok(ihosts) = find_hosts(&path) else { - // TODO: print warning - continue; - }; + let path = continue_if_error!(error: path); + let ihosts = continue_if_error!(error: find_hosts(&path)); hosts.extend(ihosts); } + + if matches < 1 { + log::warn!("include {pattern} matched no files"); + } } } fn find_hosts_in_host_directive(hosts: &mut HashSet, words: Words<'_>) { for word in words { - let Ok(word) = word else { - // TODO: print warning - continue; - }; + let word = continue_if_error!(warn: word); if is_host_pattern(word) { + log::debug!("skip host pattern: '{word}'"); continue; }