use core::fmt; use std::{ fs::{File, Metadata}, io::{BufRead, BufReader}, str::FromStr, time::{Duration, SystemTime}, }; use camino::{Utf8Path, Utf8PathBuf}; use getopts::Options; struct Context { root: Utf8PathBuf, quiet: bool, format: Format, first_line: bool, ignores: Vec, } impl Context { pub fn new(root: Utf8PathBuf) -> Self { Self { root, quiet: false, format: Format::Csv, first_line: true, ignores: vec![], } } /// Reads the ignore file. Errors are fatal and exit the process. pub fn ignore_file(&mut self, ignore_path: Utf8PathBuf) { let mut bufread = match File::open(&ignore_path) { Ok(f) => BufReader::new(f), Err(e) => { self.print_err(format!("{ignore_path}: {e}")); std::process::exit(1); } }; let mut line = String::new(); loop { match bufread.read_line(&mut line) { Ok(0) => break, Ok(_) => (), Err(e) => { self.print_err(format!("{ignore_path}: {e}")); std::process::exit(1); } } if line.starts_with("\\#") || line.starts_with("/") { self.ignores.push(String::from((&line[1..]).trim())); } else if line.starts_with("#") || line.is_empty() || line.trim().is_empty() { () } else { self.ignores.push(line.trim().to_owned()); } line.clear(); } } /// Parses the provided string as a foramt or fails, exiting the process. pub fn format(&mut self, raw: &str) { match raw.parse() { Ok(fmt) => self.format = fmt, Err(()) => { eprintln!("failed to parse '{raw}' as an output format"); std::process::exit(1); } } } pub fn is_file_ignored>(&self, path: P) -> bool { match path.as_ref().strip_prefix(&self.root) { Ok(rel) => { for ignore in &self.ignores { if rel.starts_with(ignore) { return true; } } false } Err(_) => false, } } pub fn print_err(&self, message: M) { if !self.quiet { eprintln!("{message}") } } } #[derive(Debug, PartialEq, Eq)] pub enum Format { Csv, Json, } impl FromStr for Format { type Err = (); fn from_str(raw: &str) -> Result { match raw.trim() { "csv" | "CSV" => Ok(Self::Csv), "json" | "JSON" => Ok(Self::Json), _ => Err(()), } } } fn print_usage(opts: &Options) { let breif = "Usage: whenwasit [options] PATH"; println!("{}", opts.usage(&breif)); } fn main() { let mut opts = Options::new(); #[rustfmt::skip] opts.optopt("", "ignore", "file of paths to ignore, one per line", "PATH"); opts.optflag("h", "help", "print this help thing"); opts.optflag("q", "quiet", "don't print errors to STDERR"); #[rustfmt::skip] opts.optopt("f", "format", "output format. defaults to csv.\nformats: [csv, json]", "FORMAT"); let matches = match opts.parse(std::env::args()) { Ok(m) => m, Err(e) => { eprintln!("{e}"); return; } }; if matches.opt_present("h") { print_usage(&opts); return; } let root = match matches.free.get(1) { None => { println!("ERROR No path provided\n"); print_usage(&opts); return; } Some(p) => Utf8PathBuf::from(p), }; let mut context = Context::new(root.clone()); context.quiet = matches.opt_present("quiet"); if let Some(fmt) = matches.opt_str("format") { context.format(&fmt); } if let Some(ignore) = matches.opt_str("ignore") { let ignore = Utf8PathBuf::from(ignore); context.ignore_file(ignore); } if context.format == Format::Json { print!("{{") } process(&mut context, &root); if context.format == Format::Json { print!("}}\n") } } /// Loop through the provided directory printing CSV rows. The `root_path` is /// used to make printed paths relative to it. /// /// Does two passes: First pass prints files. Second pass recurs, printing directories. fn process(ctx: &mut Context, path: &Utf8Path) { match std::fs::metadata(&path) { Err(e) => { ctx.print_err(format!("{path}: {e}")); return; } Ok(meta) => { let this_times = Times::metadata(&meta); row(ctx, &path, &this_times); } } let readdir = match path.read_dir_utf8() { Err(e) => { ctx.print_err(format!("{path}: {e}")); return; } Ok(read) => read, }; // 1st loop - print details about every file for entry in readdir { let entry = match entry { Ok(e) => e, Err(err) => { ctx.print_err(format!("{path}: failed to read dir entry: {err}")); continue; } }; let entry_path = entry.path(); match entry.metadata() { Err(err) => { ctx.print_err(format!("{entry_path}: {err}")); continue; } Ok(meta) => { if !meta.is_file() || ctx.is_file_ignored(entry_path) { continue; } let times = Times::metadata(&meta); row(ctx, entry_path, ×) } }; } let readdir = match path.read_dir_utf8() { Err(e) => { ctx.print_err(format!("{path}: {e}")); return; } Ok(read) => read, }; // 2nd loop - run resursivly on directories for entry in readdir { let entry = match entry { Ok(e) => e, Err(err) => { ctx.print_err(format!("{path}: failed to read dir entry: {err}")); continue; } }; let entry_path = entry.path(); match entry.metadata() { Err(err) => { ctx.print_err(format!("{entry_path}: {err}")); continue; } Ok(meta) => { if !meta.is_dir() || ctx.is_file_ignored(entry_path) { continue; } process(ctx, entry_path) } } } } fn row>(ctx: &mut Context, path: P, times: &Times) { let relative = path .as_ref() .strip_prefix(&ctx.root) .expect("row wasn't relative to the root; what happened?"); fn time_string(time: Option) -> String { time.map(|d| d.as_secs().to_string()).unwrap_or_default() } match ctx.format { Format::Csv => { println!( "{},{},{},{}", escape_csv(relative), time_string(times.created()), time_string(times.modified()), time_string(times.accessed()) ); } Format::Json => { if !ctx.first_line { print!(","); } else { ctx.first_line = false; } print!( r#"{{"path": "{}", "btime":{}, "mtime":{}, "atime":{}}}"#, escape_json(relative), time_string(times.created()), time_string(times.modified()), time_string(times.accessed()) ); } } } fn escape_csv>(raw: S) -> String { escape(raw, ',') } fn escape_json>(raw: S) -> String { escape(raw, '"') } /// Blackslash-escape one char or the backslash itself fn escape>(raw: S, esc: char) -> String { let raw = raw.as_ref(); let mut escaped = String::with_capacity(raw.len()); for c in raw.chars() { if c == '\\' || c == esc { escaped.push('\\'); escaped.push(c); } else { escaped.push(c); } } escaped } struct Times { created: Option, modified: Option, accessed: Option, } impl Times { /// Get the btime, mtime, atime from the Metadata. If a time cannot /// be attained, leave a None in it's place pub fn metadata(meta: &Metadata) -> Self { Self { created: meta.created().ok(), modified: meta.modified().ok(), accessed: meta.accessed().ok(), } } /// The time since the Unix Epoch the file was created pub fn created(&self) -> Option { Self::from_epoch(self.created.as_ref()) } /// The time since the Unix Epoch the file was last modified pub fn modified(&self) -> Option { Self::from_epoch(self.modified.as_ref()) } /// The time since the Unix Epoch the file was last accessed pub fn accessed(&self) -> Option { Self::from_epoch(self.accessed.as_ref()) } /// SystemTime relative to the Unix Epoch. fn from_epoch(maybe_time: Option<&SystemTime>) -> Option { // As far as I know, a SystemTime can not be below UNIX_EPOCH. This // unwrap should never panic, but we fal to a duration of 0 just in case maybe_time.map(|st| { st.duration_since(SystemTime::UNIX_EPOCH) .unwrap_or(Duration::from_secs(0)) }) } }