diff --git a/CHANGELOG.md b/CHANGELOG.md index d8614bf..f9a0e16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ * Support searching by multiple terms - eg `emlop s -e gcc clang llvm rust` +* Support configuration file + - Located by default in `~/.config/emlop.toml`, overridable with `$EMLOP_CONFIG` env var + - Example config added to repo, should be installed alongside emlop docs + - Config options, when available, always correspond to a cli arg + - Many flags/args now take an optional `no` value, so the cli can override the conf * Improve predict: - Autodetect `tmpdir` using currently running emerge processes - Support multiple `--tmpdir` arguments diff --git a/Cargo.lock b/Cargo.lock index e7fcd2d..b3806ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -247,6 +247,7 @@ dependencies = [ "serde", "serde_json", "time", + "toml", ] [[package]] @@ -258,6 +259,12 @@ dependencies = [ "log", ] +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + [[package]] name = "flate2" version = "1.0.28" @@ -268,12 +275,28 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "hashbrown" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" + [[package]] name = "heck" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +[[package]] +name = "indexmap" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "itertools" version = "0.11.0" @@ -461,6 +484,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1" +dependencies = [ + "serde", +] + [[package]] name = "strsim" version = "0.10.0" @@ -535,6 +567,40 @@ dependencies = [ "time-core", ] +[[package]] +name = "toml" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a195ec8c9da26928f773888e0742ca3ca1040c6cd859c919c9f59c1954ab35" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d34d383cd00a163b4a5b85053df514d45bc330f6de7737edfe0a93311d1eaa03" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + [[package]] name = "unicode-ident" version = "1.0.12" @@ -621,3 +687,12 @@ name = "windows_x86_64_msvc" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" + +[[package]] +name = "winnow" +version = "0.5.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7520bbdec7211caa7c4e682eb1fbe07abe20cee6756b6e00f537c82c11816aa" +dependencies = [ + "memchr", +] diff --git a/Cargo.toml b/Cargo.toml index 08db2a4..3390e17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,9 @@ edition = "2021" rust-version = "1.70.0" exclude = ["benches", "docs", "rustfmt.toml", ".github", ".gitignore"] +[lints.clippy] +missing_const_for_fn = "warn" + [dependencies] anyhow = "1.0.32" atoi = "2.0.0" @@ -30,6 +33,7 @@ rev_lines = "0.3.0" serde = { version = "1.0.184", features = ["derive"] } serde_json = "1.0.89" time = {version = "~0.3.18", features = ["parsing", "formatting", "local-offset", "macros"]} +toml = "0.8.8" [dev-dependencies] assert_cmd = "~2.0.0" diff --git a/emlop.toml b/emlop.toml new file mode 100644 index 0000000..93a7167 --- /dev/null +++ b/emlop.toml @@ -0,0 +1,30 @@ +# This is an example `emlop` config file. +# +# It is loaded from `$HOME/.config/emlop.toml` by default. +# Use `$EMLOP_CONFIG` to set a different location, empty string to disable config loading. +# Entries have the same name and format as command-line args, see `emlop --help`. +# Some args are only avaible via the command line. + +# logfile = "/var/log/emerge.log" +# date = "rfc2822" +# duration = "human" +# utc = true +# header = true +[log] +# show = "mus" +# starttime = true +[predict] +# show = "emt" +# avg = "arith" +# limit = 20 +# unknown = 300 +# tmpdir = ["/foo", "/bar"] +[stats] +# show = "pts" +# avg = "arith" +# limit = 20 +# group = "y" +[accuracy] +# show = "mt" +# avg = "arith" +# limit = 20 diff --git a/src/commands.rs b/src/commands.rs index 05bb45c..c45721f 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,28 +1,17 @@ use crate::{datetime::*, parse::*, proces::*, table::*, *}; use std::{collections::{BTreeMap, HashMap}, - io::stdin, - path::PathBuf}; + io::stdin}; /// Straightforward display of merge events /// /// We store the start times in a hashmap to compute/print the duration when we reach a stop event. -pub fn cmd_list(args: &ArgMatches) -> Result { - let st = &Styles::from_args(args); - let show = *args.get_one("show").unwrap(); - let hist = get_hist(args.get_one::("logfile").unwrap().to_owned(), - get_parse(args, "from", parse_date, st.date_offset)?, - get_parse(args, "to", parse_date, st.date_offset)?, - show, - args.get_many::("search").unwrap_or_default().cloned().collect(), - args.get_flag("exact"))?; - let first = *args.get_one("first").unwrap_or(&usize::MAX); - let last = *args.get_one("last").unwrap_or(&usize::MAX); - let stt = args.get_flag("starttime"); +pub fn cmd_log(gc: &Conf, sc: &ConfLog) -> Result { + let hist = get_hist(&gc.logfile, gc.from, gc.to, sc.show, &sc.search, sc.exact)?; let mut merges: HashMap = HashMap::new(); let mut unmerges: HashMap = HashMap::new(); let mut found = 0; let mut sync_start: Option = None; - let mut tbl = Table::new(st).align_left(0).align_left(2).margin(2, " ").last(last); + let mut tbl = Table::new(gc).align_left(0).align_left(2).margin(2, " ").last(sc.last); tbl.header(["Date", "Duration", "Package/Repo"]); for p in hist { match p { @@ -33,9 +22,9 @@ pub fn cmd_list(args: &ArgMatches) -> Result { Hist::MergeStop { ts, ref key, .. } => { found += 1; let started = merges.remove(key).unwrap_or(ts + 1); - tbl.row([&[&FmtDate(if stt { started } else { ts })], + tbl.row([&[&FmtDate(if sc.starttime { started } else { ts })], &[&FmtDur(ts - started)], - &[&st.merge, &p.ebuild_version()]]); + &[&gc.merge, &p.ebuild_version()]]); }, Hist::UnmergeStart { ts, key, .. } => { // This'll overwrite any previous entry, if an unmerge started but never finished @@ -44,9 +33,9 @@ pub fn cmd_list(args: &ArgMatches) -> Result { Hist::UnmergeStop { ts, ref key, .. } => { found += 1; let started = unmerges.remove(key).unwrap_or(ts + 1); - tbl.row([&[&FmtDate(if stt { started } else { ts })], + tbl.row([&[&FmtDate(if sc.starttime { started } else { ts })], &[&FmtDur(ts - started)], - &[&st.unmerge, &p.ebuild_version()]]); + &[&gc.unmerge, &p.ebuild_version()]]); }, Hist::SyncStart { ts } => { // Some sync starts have multiple entries in old logs @@ -55,15 +44,15 @@ pub fn cmd_list(args: &ArgMatches) -> Result { Hist::SyncStop { ts, repo } => { if let Some(started) = sync_start.take() { found += 1; - tbl.row([&[&FmtDate(if stt { started } else { ts })], + tbl.row([&[&FmtDate(if sc.starttime { started } else { ts })], &[&FmtDur(ts - started)], - &[&st.clr, &"Sync ", &repo]]); + &[&gc.clr, &"Sync ", &repo]]); } else { warn!("Sync stop without a start at {ts}"); } }, } - if found >= first { + if found >= sc.first { break; } } @@ -77,7 +66,7 @@ struct Times { tot: i64, } impl Times { - fn new() -> Self { + const fn new() -> Self { Self { vals: vec![], count: 0, tot: 0 } } /// Digest new data point @@ -144,23 +133,12 @@ impl Times { /// /// First loop is like cmd_list but we store the merge time for each ebuild instead of printing it. /// Then we compute the stats per ebuild, and print that. -pub fn cmd_stats(args: &ArgMatches) -> Result { - let st = &Styles::from_args(args); - let show = *args.get_one("show").unwrap(); - let timespan_opt: Option<&Timespan> = args.get_one("group"); - let hist = get_hist(args.get_one::("logfile").unwrap().to_owned(), - get_parse(args, "from", parse_date, st.date_offset)?, - get_parse(args, "to", parse_date, st.date_offset)?, - show, - args.get_many::("search").unwrap_or_default().cloned().collect(), - args.get_flag("exact"))?; - let lim = *args.get_one("limit").unwrap(); - let avg = *args.get_one("avg").unwrap(); - let tsname = timespan_opt.map_or("", |timespan| timespan.name()); - let mut tbls = Table::new(st).align_left(0).align_left(1).margin(1, " "); - tbls.header([tsname, "Repo", "Sync count", "Total time", "Predict time"]); - let mut tblp = Table::new(st).align_left(0).align_left(1).margin(1, " "); - tblp.header([tsname, +pub fn cmd_stats(gc: &Conf, sc: &ConfStats) -> Result { + let hist = get_hist(&gc.logfile, gc.from, gc.to, sc.show, &sc.search, sc.exact)?; + let mut tbls = Table::new(gc).align_left(0).align_left(1).margin(1, " "); + tbls.header([sc.group.name(), "Repo", "Sync count", "Total time", "Predict time"]); + let mut tblp = Table::new(gc).align_left(0).align_left(1).margin(1, " "); + tblp.header([sc.group.name(), "Package", "Merge count", "Total time", @@ -168,8 +146,8 @@ pub fn cmd_stats(args: &ArgMatches) -> Result { "Unmerge count", "Total time", "Predict time"]); - let mut tblt = Table::new(st).align_left(0).margin(1, " "); - tblt.header([tsname, + let mut tblt = Table::new(gc).align_left(0).margin(1, " "); + tblt.header([sc.group.name(), "Merge count", "Total time", "Average time", @@ -184,18 +162,18 @@ pub fn cmd_stats(args: &ArgMatches) -> Result { let mut nextts = 0; let mut curts = 0; for p in hist { - if let Some(timespan) = timespan_opt { + if !matches!(sc.group, Timespan::None) { let t = p.ts(); if nextts == 0 { - nextts = timespan.next(t, st.date_offset); + nextts = sc.group.next(t, gc.date_offset); curts = t; } else if t > nextts { - let group = timespan.at(curts, st.date_offset); - cmd_stats_group(&mut tbls, &mut tblp, &mut tblt, st, lim, avg, show, group, - &sync_time, &pkg_time); + let group = sc.group.at(curts, gc.date_offset); + cmd_stats_group(gc, sc, &mut tbls, &mut tblp, &mut tblt, group, &sync_time, + &pkg_time); sync_time.clear(); pkg_time.clear(); - nextts = timespan.next(t, st.date_offset); + nextts = sc.group.next(t, gc.date_offset); curts = t; } } @@ -206,7 +184,7 @@ pub fn cmd_stats(args: &ArgMatches) -> Result { Hist::MergeStop { ts, ref key, .. } => { if let Some(start_ts) = merge_start.remove(key) { let (times, _) = pkg_time.entry(p.ebuild().to_owned()) - .or_insert_with(|| (Times::new(), Times::new())); + .or_insert((Times::new(), Times::new())); times.insert(ts - start_ts); } }, @@ -216,7 +194,7 @@ pub fn cmd_stats(args: &ArgMatches) -> Result { Hist::UnmergeStop { ts, ref key, .. } => { if let Some(start_ts) = unmerge_start.remove(key) { let (_, times) = pkg_time.entry(p.ebuild().to_owned()) - .or_insert_with(|| (Times::new(), Times::new())); + .or_insert((Times::new(), Times::new())); times.insert(ts - start_ts); } }, @@ -226,7 +204,7 @@ pub fn cmd_stats(args: &ArgMatches) -> Result { }, Hist::SyncStop { ts, repo } => { if let Some(start_ts) = sync_start.take() { - let times = sync_time.entry(repo).or_insert_with(Times::new); + let times = sync_time.entry(repo).or_insert(Times::new()); times.insert(ts - start_ts); } else { warn!("Sync stop without a start at {ts}") @@ -234,9 +212,8 @@ pub fn cmd_stats(args: &ArgMatches) -> Result { }, } } - let group = timespan_opt.map(|timespan| timespan.at(curts, st.date_offset)).unwrap_or_default(); - cmd_stats_group(&mut tbls, &mut tblp, &mut tblt, st, lim, avg, show, group, &sync_time, - &pkg_time); + let group = sc.group.at(curts, gc.date_offset); + cmd_stats_group(gc, sc, &mut tbls, &mut tblp, &mut tblt, group, &sync_time, &pkg_time); // Controlled drop to ensure table order and insert blank lines let (es, ep, et) = (!tbls.is_empty(), !tblp.is_empty(), !tblt.is_empty()); drop(tbls); @@ -253,41 +230,39 @@ pub fn cmd_stats(args: &ArgMatches) -> Result { // Reducing the arg count here doesn't seem worth it, for either readability or performance #[allow(clippy::too_many_arguments)] -fn cmd_stats_group(tbls: &mut Table<5>, +fn cmd_stats_group(gc: &Conf, + sc: &ConfStats, + tbls: &mut Table<5>, tblp: &mut Table<8>, tblt: &mut Table<7>, - st: &Styles, - lim: u16, - avg: Average, - show: Show, group: String, sync_time: &BTreeMap, pkg_time: &BTreeMap) { // Syncs - if show.sync && !sync_time.is_empty() { + if sc.show.sync && !sync_time.is_empty() { for (repo, time) in sync_time { tbls.row([&[&group], &[repo], - &[&st.cnt, &time.count], + &[&gc.cnt, &time.count], &[&FmtDur(time.tot)], - &[&FmtDur(time.pred(lim, avg))]]); + &[&FmtDur(time.pred(sc.lim, sc.avg))]]); } } // Packages - if show.pkg && !pkg_time.is_empty() { + if sc.show.pkg && !pkg_time.is_empty() { for (pkg, (merge, unmerge)) in pkg_time { tblp.row([&[&group], - &[&st.pkg, pkg], - &[&st.cnt, &merge.count], + &[&gc.pkg, pkg], + &[&gc.cnt, &merge.count], &[&FmtDur(merge.tot)], - &[&FmtDur(merge.pred(lim, avg))], - &[&st.cnt, &unmerge.count], + &[&FmtDur(merge.pred(sc.lim, sc.avg))], + &[&gc.cnt, &unmerge.count], &[&FmtDur(unmerge.tot)], - &[&FmtDur(unmerge.pred(lim, avg))]]); + &[&FmtDur(unmerge.pred(sc.lim, sc.avg))]]); } } // Totals - if show.tot && !pkg_time.is_empty() { + if sc.show.tot && !pkg_time.is_empty() { let mut merge_time = 0; let mut merge_count = 0; let mut unmerge_time = 0; @@ -299,10 +274,10 @@ fn cmd_stats_group(tbls: &mut Table<5>, unmerge_count += unmerge.count; } tblt.row([&[&group], - &[&st.cnt, &merge_count], + &[&gc.cnt, &merge_count], &[&FmtDur(merge_time)], &[&FmtDur(merge_time.checked_div(merge_count).unwrap_or(-1))], - &[&st.cnt, &unmerge_count], + &[&gc.cnt, &unmerge_count], &[&FmtDur(unmerge_time)], &[&FmtDur(unmerge_time.checked_div(unmerge_count).unwrap_or(-1))]]); } @@ -311,46 +286,30 @@ fn cmd_stats_group(tbls: &mut Table<5>, /// Predict future merge time /// /// Very similar to cmd_summary except we want total build time for a list of ebuilds. -pub fn cmd_predict(args: &ArgMatches) -> Result { - let st = &Styles::from_args(args); +pub fn cmd_predict(gc: &Conf, sc: &ConfPred) -> Result { let now = epoch_now(); - let show: Show = *args.get_one("show").unwrap(); - let first = *args.get_one("first").unwrap_or(&usize::MAX); - let last = match args.get_one("last") { - Some(&n) if show.tot => n + 1, - Some(&n) => n, - None => usize::MAX, - }; - let lim = *args.get_one("limit").unwrap(); - let avg = *args.get_one("avg").unwrap(); - let resume = args.get_one("resume").copied(); - let unknown_pred = *args.get_one("unknown").unwrap(); - let mut tbl = Table::new(st).align_left(0).align_left(2).margin(2, " ").last(last); - let mut tmpdirs: Vec = args.get_many("tmpdir").unwrap().cloned().collect(); + let last = if sc.show.tot { sc.last.saturating_add(1) } else { sc.last }; + let mut tbl = Table::new(gc).align_left(0).align_left(2).margin(2, " ").last(last); + let mut tmpdirs = sc.tmpdirs.clone(); // Gather and print info about current merge process. let mut cms = std::i64::MAX; for i in get_all_info(Some("emerge"), &mut tmpdirs) { cms = std::cmp::min(cms, i.start); - if show.emerge { + if sc.show.emerge { tbl.row([&[&i], &[&FmtDur(now - i.start)], &[]]); } } if cms == std::i64::MAX && std::io::stdin().is_terminal() - && (resume == Some(ResumeKind::No) || resume.is_none()) + && matches!(sc.resume, ResumeKind::No | ResumeKind::Current) { tbl.row([&[&"No ongoing merge found"], &[], &[]]); return Ok(false); } // Parse emerge log. - let hist = get_hist(args.get_one::("logfile").unwrap().to_owned(), - get_parse(args, "from", parse_date, st.date_offset)?, - get_parse(args, "to", parse_date, st.date_offset)?, - Show { merge: true, ..Show::default() }, - vec![], - false)?; + let hist = get_hist(&gc.logfile, gc.from, gc.to, Show::m(), &vec![], false)?; let mut started: BTreeMap = BTreeMap::new(); let mut times: HashMap = HashMap::new(); for p in hist { @@ -360,7 +319,7 @@ pub fn cmd_predict(args: &ArgMatches) -> Result { }, Hist::MergeStop { ts, .. } => { if let Some(start_ts) = started.remove(&Pkg::new(p.ebuild(), p.version())) { - let timevec = times.entry(p.ebuild().to_string()).or_insert_with(Times::new); + let timevec = times.entry(p.ebuild().to_string()).or_insert(Times::new()); timevec.insert(ts - start_ts); } }, @@ -371,7 +330,7 @@ pub fn cmd_predict(args: &ArgMatches) -> Result { // Build list of pending merges let pkgs: Vec = if std::io::stdin().is_terminal() { // From resume data + emerge.log after current merge process start time - let mut r = get_resume(resume.unwrap_or(ResumeKind::Main)); + let mut r = get_resume(sc.resume); for p in started.iter().filter(|&(_, t)| *t > cms).map(|(p, _)| p) { if !r.contains(p) { r.push(p.clone()) @@ -400,50 +359,50 @@ pub fn cmd_predict(args: &ArgMatches) -> Result { // Find the predicted time and adjust counters let (fmtpred, pred) = match times.get(p.ebuild()) { Some(tv) => { - let pred = tv.pred(lim, avg); + let pred = tv.pred(sc.lim, sc.avg); (pred, pred) }, None => { totunknown += 1; - (-1, unknown_pred) + (-1, sc.unknown) }, }; totpredict += std::cmp::max(0, pred - elapsed); totelapsed += elapsed; // Done - if show.merge && totcount <= first { + if sc.show.merge && totcount <= sc.first { if elapsed > 0 { let stage = get_buildlog(&p, &tmpdirs).unwrap_or_default(); - tbl.row([&[&st.pkg, &p.ebuild_version()], + tbl.row([&[&gc.pkg, &p.ebuild_version()], &[&FmtDur(fmtpred)], - &[&st.clr, &"- ", &FmtDur(elapsed), &st.clr, &stage]]); + &[&gc.clr, &"- ", &FmtDur(elapsed), &gc.clr, &stage]]); } else { - tbl.row([&[&st.pkg, &p.ebuild_version()], &[&FmtDur(fmtpred)], &[]]); + tbl.row([&[&gc.pkg, &p.ebuild_version()], &[&FmtDur(fmtpred)], &[]]); } } } if totcount > 0 { - if show.tot { + if sc.show.tot { let mut s: Vec<&dyn Disp> = vec![&"Estimate for ", - &st.cnt, + &gc.cnt, &totcount, - &st.clr, + &gc.clr, if totcount > 1 { &" ebuilds" } else { &" ebuild" }]; if totunknown > 0 { - s.extend::<[&dyn Disp; 5]>([&", ", &st.cnt, &totunknown, &st.clr, &" unknown"]); + s.extend::<[&dyn Disp; 5]>([&", ", &gc.cnt, &totunknown, &gc.clr, &" unknown"]); } - let tothidden = totcount.saturating_sub(first.min(last - 1)); + let tothidden = totcount.saturating_sub(sc.first.min(last - 1)); if tothidden > 0 { - s.extend::<[&dyn Disp; 5]>([&", ", &st.cnt, &tothidden, &st.clr, &" hidden"]); + s.extend::<[&dyn Disp; 5]>([&", ", &gc.cnt, &tothidden, &gc.clr, &" hidden"]); } let e = FmtDur(totelapsed); if totelapsed > 0 { - s.extend::<[&dyn Disp; 4]>([&", ", &e, &st.clr, &" elapsed"]); + s.extend::<[&dyn Disp; 4]>([&", ", &e, &gc.clr, &" elapsed"]); } tbl.row([&s, - &[&FmtDur(totpredict), &st.clr], - &[&"@ ", &st.dur, &FmtDate(now + totpredict)]]); + &[&FmtDur(totpredict), &gc.clr], + &[&"@ ", &gc.dur, &FmtDate(now + totpredict)]]); } } else { tbl.row([&[&"No pretended merge found"], &[], &[]]); @@ -451,23 +410,13 @@ pub fn cmd_predict(args: &ArgMatches) -> Result { Ok(totcount > 0) } -pub fn cmd_accuracy(args: &ArgMatches) -> Result { - let st = &Styles::from_args(args); - let show: Show = *args.get_one("show").unwrap(); - let hist = get_hist(args.get_one::("logfile").unwrap().to_owned(), - get_parse(args, "from", parse_date, st.date_offset)?, - get_parse(args, "to", parse_date, st.date_offset)?, - Show { merge: true, ..Show::default() }, - args.get_many::("search").unwrap_or_default().cloned().collect(), - args.get_flag("exact"))?; - let last = *args.get_one("last").unwrap_or(&usize::MAX); - let lim = *args.get_one("limit").unwrap(); - let avg = *args.get_one("avg").unwrap(); +pub fn cmd_accuracy(gc: &Conf, sc: &ConfAccuracy) -> Result { + let hist = get_hist(&gc.logfile, gc.from, gc.to, Show::m(), &sc.search, sc.exact)?; let mut pkg_starts: HashMap = HashMap::new(); let mut pkg_times: BTreeMap = BTreeMap::new(); let mut pkg_errs: BTreeMap> = BTreeMap::new(); let mut found = false; - let mut tbl = Table::new(st).align_left(0).align_left(1).last(last); + let mut tbl = Table::new(gc).align_left(0).align_left(1).last(sc.last); tbl.header(["Date", "Package", "Real", "Predicted", "Error"]); for p in hist { match p { @@ -478,13 +427,13 @@ pub fn cmd_accuracy(args: &ArgMatches) -> Result { Hist::MergeStop { ts, ref key, .. } => { found = true; if let Some(start) = pkg_starts.remove(key) { - let times = pkg_times.entry(p.ebuild().to_owned()).or_insert_with(Times::new); + let times = pkg_times.entry(p.ebuild().to_owned()).or_insert(Times::new()); let real = ts - start; - match times.pred(lim, avg) { + match times.pred(sc.lim, sc.avg) { -1 => { - if show.merge { + if sc.show.merge { tbl.row([&[&FmtDate(ts)], - &[&st.merge, &p.ebuild_version()], + &[&gc.merge, &p.ebuild_version()], &[&FmtDur(real)], &[], &[]]) @@ -492,12 +441,12 @@ pub fn cmd_accuracy(args: &ArgMatches) -> Result { }, pred => { let err = (pred - real).abs() as f64 * 100.0 / real as f64; - if show.merge { + if sc.show.merge { tbl.row([&[&FmtDate(ts)], - &[&st.merge, &p.ebuild_version()], + &[&gc.merge, &p.ebuild_version()], &[&FmtDur(real)], &[&FmtDur(pred)], - &[&st.cnt, &format!("{err:.1}%")]]) + &[&gc.cnt, &format!("{err:.1}%")]]) } let errs = pkg_errs.entry(p.ebuild().to_owned()).or_default(); errs.push(err); @@ -510,21 +459,20 @@ pub fn cmd_accuracy(args: &ArgMatches) -> Result { } } drop(tbl); - if show.tot { - let mut tbl = Table::new(st).align_left(0); + if sc.show.tot { + let mut tbl = Table::new(gc).align_left(0); tbl.header(["Package", "Error"]); for (p, e) in pkg_errs { let avg = e.iter().sum::() / e.len() as f64; - tbl.row([&[&st.pkg, &p], &[&st.cnt, &format!("{avg:.1}%")]]); + tbl.row([&[&gc.pkg, &p], &[&gc.cnt, &format!("{avg:.1}%")]]); } } Ok(found) } -pub fn cmd_complete(args: &ArgMatches) -> Result { - let shell: clap_complete::Shell = *args.get_one("shell").unwrap(); - let mut cli = cli::build_cli_nocomplete(); - clap_complete::generate(shell, &mut cli, "emlop", &mut std::io::stdout()); +pub fn cmd_complete(sc: &ConfComplete) -> Result { + let mut cli = build_cli_nocomplete(); + clap_complete::generate(sc.shell, &mut cli, "emlop", &mut std::io::stdout()); Ok(true) } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..d5690ea --- /dev/null +++ b/src/config.rs @@ -0,0 +1,240 @@ +mod cli; +mod toml; +mod types; + +pub use crate::config::{cli::*, types::*}; +use crate::{config::toml::Toml, *}; +use anyhow::Error; +use clap::ArgMatches; +use std::path::PathBuf; + + +pub enum Configs { + Log(Conf, ConfLog), + Stats(Conf, ConfStats), + Predict(Conf, ConfPred), + Accuracy(Conf, ConfAccuracy), + Complete(ConfComplete), +} + +/// Global config +/// +/// Colors use `prefix/suffix()` instead of `paint()` because `paint()` doesn't handle `'{:>9}'` +/// alignments properly. +pub struct Conf { + pub pkg: AnsiStr, + pub merge: AnsiStr, + pub unmerge: AnsiStr, + pub dur: AnsiStr, + pub cnt: AnsiStr, + pub clr: AnsiStr, + pub lineend: &'static [u8], + pub header: bool, + pub dur_t: DurationStyle, + pub date_offset: time::UtcOffset, + pub date_fmt: DateStyle, + pub out: OutStyle, + pub logfile: String, + pub from: Option, + pub to: Option, +} +pub struct ConfLog { + pub show: Show, + pub search: Vec, + pub exact: bool, + pub starttime: bool, + pub first: usize, + pub last: usize, +} +pub struct ConfPred { + pub show: Show, + pub avg: Average, + pub first: usize, + pub last: usize, + pub lim: u16, + pub resume: ResumeKind, + pub unknown: i64, + pub tmpdirs: Vec, +} +pub struct ConfStats { + pub show: Show, + pub search: Vec, + pub exact: bool, + pub avg: Average, + pub lim: u16, + pub group: Timespan, +} +pub struct ConfAccuracy { + pub show: Show, + pub search: Vec, + pub exact: bool, + pub avg: Average, + pub last: usize, + pub lim: u16, +} +pub struct ConfComplete { + pub shell: clap_complete::Shell, +} + +impl Configs { + pub fn load() -> Result { + let cli = cli::build_cli().get_matches(); + let level = match cli.get_count("verbose") { + 0 => LevelFilter::Error, + 1 => LevelFilter::Warn, + 2 => LevelFilter::Info, + 3 => LevelFilter::Debug, + _ => LevelFilter::Trace, + }; + env_logger::Builder::new().filter_level(level).format_timestamp(None).init(); + trace!("{:?}", cli); + let toml = Toml::load()?; + trace!("{:?}", toml); + let conf = Conf::try_new(&cli, &toml)?; + Ok(match cli.subcommand() { + Some(("log", sub)) => Self::Log(conf, ConfLog::try_new(sub, &toml)?), + Some(("stats", sub)) => Self::Stats(conf, ConfStats::try_new(sub, &toml)?), + Some(("predict", sub)) => Self::Predict(conf, ConfPred::try_new(sub, &toml)?), + Some(("accuracy", sub)) => Self::Accuracy(conf, ConfAccuracy::try_new(sub, &toml)?), + Some(("complete", sub)) => Self::Complete(ConfComplete::try_new(sub)?), + _ => unreachable!("clap should have exited already"), + }) + } +} + +// TODO nicer way to specify src +fn sel(cli: Option<&String>, + toml: Option<&T>, + clisrc: &'static str, + tomlsrc: &'static str, + arg: A, + def: R) + -> Result + where R: ArgParse + ArgParse +{ + if let Some(a) = cli { + R::parse(a, arg, clisrc) + } else if let Some(a) = toml { + R::parse(a, arg, tomlsrc) + } else { + Ok(def) + } +} + +macro_rules! sel { + ($cli: expr, $toml: expr, $name: ident, $arg: expr, $def: expr) => { + sel($cli.get_one::(stringify!($name)), + $toml.$name.as_ref(), + concat!("--", stringify!($name)), + stringify!($name), + $arg, + $def) + }; + ($cli: expr, $toml: expr, $section: ident, $name: ident, $arg: expr, $def: expr) => { + sel($cli.get_one::(stringify!($name)), + $toml.$section.as_ref().and_then(|t| t.$name.as_ref()), + concat!("--", stringify!($name)), + stringify!([$section] $name), + $arg, + $def) + } +} + + +impl Conf { + pub fn try_new(cli: &ArgMatches, toml: &Toml) -> Result { + let isterm = std::io::stdout().is_terminal(); + let color = match cli.get_one("color") { + Some(ColorStyle::Always) => true, + Some(ColorStyle::Never) => false, + None => isterm, + }; + let out = match cli.get_one("output") { + Some(o) => *o, + None if isterm => OutStyle::Columns, + None => OutStyle::Tab, + }; + let offset = get_offset(sel!(cli, toml, utc, (), false)?); + Ok(Self { logfile: sel!(cli, toml, logfile, (), String::from("/var/log/emerge.log"))?, + from: cli.get_one("from") + .map(|d| i64::parse(d, offset, "--from")) + .transpose()?, + to: cli.get_one("to").map(|d| i64::parse(d, offset, "--to")).transpose()?, + pkg: AnsiStr::from(if color { "\x1B[1;32m" } else { "" }), + merge: AnsiStr::from(if color { "\x1B[1;32m" } else { ">>> " }), + unmerge: AnsiStr::from(if color { "\x1B[1;31m" } else { "<<< " }), + dur: AnsiStr::from(if color { "\x1B[1;35m" } else { "" }), + cnt: AnsiStr::from(if color { "\x1B[2;33m" } else { "" }), + clr: AnsiStr::from(if color { "\x1B[0m" } else { "" }), + lineend: if color { b"\x1B[0m\n" } else { b"\n" }, + header: sel!(cli, toml, header, (), false)?, + dur_t: sel!(cli, toml, duration, (), DurationStyle::Hms)?, + date_offset: offset, + date_fmt: sel!(cli, toml, date, (), DateStyle::default())?, + out }) + } + #[cfg(test)] + pub fn from_str(s: impl AsRef) -> Self { + let cli = cli::build_cli().get_matches_from(s.as_ref().split_whitespace()); + Self::try_new(&cli, &Toml::default()).unwrap() + } +} + +impl ConfLog { + fn try_new(cli: &ArgMatches, toml: &Toml) -> Result { + Ok(Self { show: sel!(cli, toml, log, show, "musa", Show::m())?, + search: cli.get_many("search").unwrap_or_default().cloned().collect(), + exact: cli.get_flag("exact"), + starttime: sel!(cli, toml, log, starttime, (), false)?, + first: *cli.get_one("first").unwrap_or(&usize::MAX), + last: *cli.get_one("last").unwrap_or(&usize::MAX) }) + } +} + +impl ConfPred { + fn try_new(cli: &ArgMatches, toml: &Toml) -> Result { + let tmpdirs = if let Some(a) = cli.get_many::("tmpdir") { + a.cloned().collect() + } else if let Some(a) = toml.predict.as_ref().and_then(|t| t.tmpdir.as_ref()) { + a.to_vec() + } else { + vec![PathBuf::from("/var/tmp")] + }; + Ok(Self { show: sel!(cli, toml, predict, show, "emta", Show::emt())?, + avg: sel!(cli, toml, predict, avg, (), Average::Median)?, + lim: sel!(cli, toml, predict, limit, 1..65000, 10)? as u16, + unknown: sel!(cli, toml, predict, unknown, 0..3600, 10)?, + resume: *cli.get_one("resume").unwrap_or(&ResumeKind::Current), + tmpdirs, + first: *cli.get_one("first").unwrap_or(&usize::MAX), + last: *cli.get_one("last").unwrap_or(&usize::MAX) }) + } +} + +impl ConfStats { + fn try_new(cli: &ArgMatches, toml: &Toml) -> Result { + Ok(Self { show: sel!(cli, toml, stats, show, "ptsa", Show::p())?, + search: cli.get_many("search").unwrap_or_default().cloned().collect(), + exact: cli.get_flag("exact"), + lim: sel!(cli, toml, stats, limit, 1..65000, 10)? as u16, + avg: sel!(cli, toml, stats, avg, (), Average::Median)?, + group: sel!(cli, toml, stats, group, (), Timespan::None)? }) + } +} + +impl ConfAccuracy { + fn try_new(cli: &ArgMatches, toml: &Toml) -> Result { + Ok(Self { show: sel!(cli, toml, accuracy, show, "mta", Show::mt())?, + search: cli.get_many("search").unwrap_or_default().cloned().collect(), + exact: cli.get_flag("exact"), + avg: sel!(cli, toml, accuracy, avg, (), Average::Median)?, + lim: sel!(cli, toml, accuracy, limit, 1..65000, 10)? as u16, + last: *cli.get_one("last").unwrap_or(&usize::MAX) }) + } +} + +impl ConfComplete { + fn try_new(cli: &ArgMatches) -> Result { + Ok(Self { shell: *cli.get_one("shell").unwrap() }) + } +} diff --git a/src/cli.rs b/src/config/cli.rs similarity index 79% rename from src/cli.rs rename to src/config/cli.rs index cccf839..1766ba9 100644 --- a/src/cli.rs +++ b/src/config/cli.rs @@ -34,8 +34,6 @@ pub fn build_cli_nocomplete() -> Command { let show_l = Arg::new("show").short('s') .long("show") .value_name("m,u,s,a") - .value_parser(|s: &str| crate::Show::parse(s, "musa")) - .default_value("m") .display_order(3) .help_heading("Filter") .help("Show (m)erges, (u)nmerges, (s)yncs, and/or (a)ll") @@ -47,8 +45,6 @@ pub fn build_cli_nocomplete() -> Command { let show_s = Arg::new("show").short('s') .long("show") .value_name("p,t,s,a") - .value_parser(|s: &str| crate::Show::parse(s, "ptsa")) - .default_value("p") .display_order(3) .help_heading("Filter") .help("Show (p)ackages, (t)otals, (s)yncs, and/or (a)ll") @@ -60,8 +56,6 @@ pub fn build_cli_nocomplete() -> Command { let show_p = Arg::new("show").short('s') .long("show") .value_name("e,m,t,a") - .value_parser(|s: &str| crate::Show::parse(s, "emta")) - .default_value("emt") .display_order(3) .help_heading("Filter") .help("Show (e)emerge processes, (m)erges, (t)otal, and/or (a)ll") @@ -73,8 +67,6 @@ pub fn build_cli_nocomplete() -> Command { let show_a = Arg::new("show").short('s') .long("show") .value_name("m,t,a") - .value_parser(|s: &str| crate::Show::parse(s, "mta")) - .default_value("mt") .display_order(3) .help_heading("Filter") .help("Show (m)erges, (t)otals, and/or (a)ll") @@ -82,38 +74,39 @@ pub fn build_cli_nocomplete() -> Command { m: Package merges\n \ t: Totals\n \ a: All of the above"); - - let from = Arg::new("from").value_name("date") - .short('f') + let h = "Only parse log entries after \n \ + 2018-03-04|2018-03-04 12:34:56|2018-03-04T12:34: Absolute ISO date\n \ + 123456789: Absolute unix timestamp\n \ + 1 year, 2 months|10d: Relative date"; + let from = Arg::new("from").short('f') .long("from") - .display_order(4) + .value_name("date") .global(true) .num_args(1) + .display_order(4) .help_heading("Filter") - .help("Only parse log entries after ") - .long_help("Only parse log entries after \n \ - 2018-03-04|2018-03-04 12:34:56|2018-03-04T12:34: Absolute ISO date\n \ - 123456789: Absolute unix timestamp\n \ - 1 year, 2 months|10d: Relative date"); - let to = Arg::new("to").value_name("date") - .short('t') + .help(h.split_once('\n').unwrap().0) + .long_help(h); + let h = "Only parse log entries before \n \ + 2018-03-04|2018-03-04 12:34:56|2018-03-04T12:34: Absolute ISO date\n \ + 123456789: Absolute unix timestamp\n \ + 1 year, 2 months|10d: Relative date"; + let to = Arg::new("to").short('t') .long("to") - .display_order(5) + .value_name("date") .global(true) .num_args(1) + .display_order(5) .help_heading("Filter") - .help("Only parse log entries before ") - .long_help("Only parse log entries before \n \ - 2018-03-04|2018-03-04 12:34:56|2018-03-04T12:34: Absolute ISO date\n \ - 123456789: Absolute unix timestamp\n \ - 1 year, 2 months|10d: Relative date"); + .help(h.split_once('\n').unwrap().0) + .long_help(h); let first = Arg::new("first").short('N') .long("first") .value_name("num") - .display_order(6) .num_args(..=1) .default_missing_value("1") .value_parser(value_parser!(usize)) + .display_order(6) .help_heading("Filter") .help("Show only the first entries") .long_help("Show only the first entries\n \ @@ -122,60 +115,69 @@ pub fn build_cli_nocomplete() -> Command { let last = Arg::new("last").short('n') .long("last") .value_name("num") - .display_order(7) .num_args(..=1) .default_missing_value("1") .value_parser(value_parser!(usize)) + .display_order(7) .help_heading("Filter") .help("Show only the last entries") .long_help("Show only the last entries\n \ (empty)|1: last entry\n \ 5: last 5 entries\n"); + let h = "Use main, backup, any, or no portage resume list\n\ + This is ignored if STDIN is a piped `emerge -p` output\n \ + (default): Use main resume list, if currently emerging\n \ + any|a|(empty): Use main or backup resume list\n \ + main|m: Use main resume list\n \ + backup|b: Use backup resume list\n \ + no|n: Never use resume list"; + let resume = Arg::new("resume").long("resume") + .value_name("source") + .value_parser(value_parser!(crate::config::ResumeKind)) + .hide_possible_values(true) + .num_args(..=1) + .default_missing_value("any") + .display_order(8) + .help_heading("Filter") + .help(h.split_once('\n').unwrap().0) + .long_help(h); //////////////////////////////////////////////////////////// // Stats arguments //////////////////////////////////////////////////////////// let group = Arg::new("group").short('g') .long("groupby") - .display_order(1) - .value_name("y,m,w,d") - .value_parser(value_parser!(crate::datetime::Timespan)) + .value_name("y,m,w,d,n") .hide_possible_values(true) + .display_order(10) .help_heading("Stats") - .help("Group by (y)ear, (m)onth, (w)eek, or (d)ay") - .long_help("Group by (y)ear, (m)onth, (w)eek, or (d)ay\n\ - The grouping key is displayed in the first column. \ + .help("Group by (y)ear, (m)onth, (w)eek, (d)ay, (n)one") + .long_help("Group by (y)ear, (m)onth, (w)eek, (d)ay, or (n)one\n\ + The grouping key is displayed in the first column.\n\ Weeks start on monday and are formated as \ 'year-weeknumber'."); let limit = Arg::new("limit").long("limit") - .display_order(2) - .num_args(1) .value_name("num") - .value_parser(value_parser!(u16).range(1..)) - .default_value("10") + .num_args(1) + .display_order(11) .help_heading("Stats") .help("Use the last merge times to predict durations"); let avg = Arg::new("avg").long("avg") .value_name("fn") - .display_order(3) - .value_parser(value_parser!(crate::Average)) .hide_possible_values(true) - .default_value("median") + .display_order(12) .help_heading("Stats") .help("Select function used to predict durations") .long_help("Select function used to predict durations\n \ arith|a: simple 'sum/count' average\n \ - median|m: middle value, mitigates outliers\n \ + (defaut)|median|m: middle value, mitigates outliers\n \ weighted-arith|wa: 'sum/count' with more weight for recent values\n \ weighted-median|wm: \"middle\" value shifted toward recent values"); - let unknown = Arg::new("unknown").long("unknown") - .display_order(4) .num_args(1) .value_name("secs") - .value_parser(value_parser!(i64).range(0..)) - .default_value("10") + .display_order(13) .help_heading("Stats") .help("Assume unkown packages take seconds to merge"); @@ -184,79 +186,76 @@ pub fn build_cli_nocomplete() -> Command { //////////////////////////////////////////////////////////// let header = Arg::new("header").short('H') .long("header") - .action(SetTrue) + .value_name("bool") .global(true) - .display_order(1) + .num_args(..=1) + .default_missing_value("y") + .display_order(20) .help_heading("Format") .help("Show table header"); let date = - Arg::new("date").value_name("format") - .long("date") - .display_order(2) + Arg::new("date").long("date") + .value_name("format") .global(true) - .value_parser(value_parser!(crate::datetime::DateStyle)) - .hide_possible_values(true) - .default_value("ymdhms") - .display_order(52) + .display_order(21) .help_heading("Format") .help("Output dates in different formats") .long_help("Output dates in different formats\n \ - ymd|d: 2022-01-31\n \ - ymdhms|dt: 2022-01-31 08:59:46\n \ - ymdhmso|dto: 2022-01-31 08:59:46 +00:00\n \ - rfc3339|3339: 2022-01-31T08:59:46+00:00\n \ - rfc2822|2822: Mon, 31 Jan 2022 08:59:46 +00:00\n \ - compact: 20220131085946\n \ - unix: 1643619586"); - let duration = Arg::new("duration").value_name("format") - .long("duration") - .display_order(3) + ymd|d: 2022-01-31\n \ + (default)|ymdhms|dt: 2022-01-31 08:59:46\n \ + ymdhmso|dto: 2022-01-31 08:59:46 +00:00\n \ + rfc3339|3339: 2022-01-31T08:59:46+00:00\n \ + rfc2822|2822: Mon, 31 Jan 2022 08:59:46 +00:00\n \ + compact: 20220131085946\n \ + unix: 1643619586"); + let duration = Arg::new("duration").long("duration") + .value_name("format") .global(true) - .value_parser(value_parser!(crate::DurationStyle)) .hide_possible_values(true) - .default_value("hms") - .display_order(51) + .display_order(22) .help_heading("Format") .help("Output durations in different formats") .long_help("Output durations in different formats\n \ - hms: 10:30\n \ - hmsfixed: 0:10:30\n \ - secs|s: 630\n \ - human|h: 10 minutes, 30 seconds"); + hms|(default): 10:30\n \ + hmsfixed: 0:10:30\n \ + secs|s: 630\n \ + human|h: 10 minutes, 30 seconds"); let utc = Arg::new("utc").long("utc") + .value_name("bool") .global(true) - .action(SetTrue) - .display_order(4) + .num_args(..=1) + .default_missing_value("y") + .display_order(23) .help_heading("Format") .help("Parse/display dates in UTC instead of local time"); let starttime = Arg::new("starttime").long("starttime") - .action(SetTrue) - .display_order(5) + .value_name("bool") + .num_args(..=1) + .default_missing_value("y") + .display_order(24) .help_heading("Format") .help("Display start time instead of end time"); let color = Arg::new("color").long("color") - .alias("colour") - .display_order(6) + .value_name("when") .global(true) .value_parser(value_parser!(crate::ColorStyle)) .hide_possible_values(true) .num_args(..=1) .default_missing_value("y") - .value_name("when") - .display_order(55) + .display_order(25) .help_heading("Format") .help("Enable color (always/never/y/n)") .long_help("Enable color (always/never/y/n)\n \ (default): colored if on tty\n \ (empty)|always|y: colored\n \ never|n: not colored"); - let output = Arg::new("output").long("output") - .short('o') + let output = Arg::new("output").short('o') + .long("output") .value_name("format") .global(true) .value_parser(value_parser!(crate::OutStyle)) .hide_possible_values(true) - .display_order(7) + .display_order(26) .help_heading("Format") .help("Ouput format (columns/c/tab/t)") .long_help("Ouput format (columns/c/tab/t)\n \ @@ -267,45 +266,27 @@ pub fn build_cli_nocomplete() -> Command { //////////////////////////////////////////////////////////// // Misc arguments //////////////////////////////////////////////////////////// - let logfile = Arg::new("logfile").value_name("file") + let logfile = Arg::new("logfile").short('F') .long("logfile") - .short('F') + .value_name("file") .global(true) .num_args(1) - .default_value("/var/log/emerge.log") - .display_order(1) + .display_order(30) .help("Location of emerge log file"); - let tmpdir = Arg::new("tmpdir").value_name("dir") - .long("tmpdir") + let tmpdir = Arg::new("tmpdir").long("tmpdir") + .value_name("dir") .num_args(1) .action(Append) - .default_value("/var/tmp") .value_parser(value_parser!(PathBuf)) - .display_order(2) + .display_order(31) .help("Location of portage tmpdir") .long_help("Location of portage tmpdir\n\ Multiple folders can be provided\n\ Emlop also looks for tmpdir using current emerge processes"); - let h = "Use main, backup, any, or no portage resume list\n\ - This is ignored if STDIN is a piped `emerge -p` output\n \ - (default): Use main resume list, if currently emerging\n \ - any|a|(empty): Use main or backup resume list\n \ - main|m: Use main resume list\n \ - backup|b: Use backup resume list\n \ - no|n: Never use resume list"; - let resume = Arg::new("resume").long("resume") - .value_name("source") - .value_parser(value_parser!(crate::ResumeKind)) - .hide_possible_values(true) - .num_args(..=1) - .default_missing_value("any") - .display_order(3) - .help(h.split_once('\n').unwrap().0) - .long_help(h); let verbose = Arg::new("verbose").short('v') .global(true) .action(Count) - .display_order(4) + .display_order(33) .help("Increase verbosity (can be given multiple times)") .long_help("Increase verbosity (defaults to errors only)\n \ -v: show warnings\n \ @@ -369,10 +350,11 @@ pub fn build_cli_nocomplete() -> Command { let about = "A fast, accurate, ergonomic EMerge LOg Parser\n\ https://github.com/vincentdephily/emlop"; let after_help = - "Subcommands and long args can be abbreviated (eg `emlop l -ss --head -f1w`)\n\ - Subcommands have their own -h / --help\n\ - Exit code is 0 if sucessful, 1 if search found nothing, 2 in case of \ - other errors"; + concat!("Commands and long args can be abbreviated (eg `emlop l -ss --head -f1w`)\n\ + Commands have their own -h / --help\n\ + Exit code is 0 if sucessful, 1 if search found nothing, 2 in case of other errors\n\ + Config can be set in $HOME/.config/emlop.toml, see example in /usr/share/doc/emlop-", + crate_version!()); let styles = styling::Styles::styled().header(styling::AnsiColor::Blue.on_default() | styling::Effects::BOLD) @@ -464,7 +446,6 @@ mod test { assert_eq!(one!(ColorStyle, "color", "l --color never"), Some(&ColorStyle::Never)); let pathvec = |s: &str| Some(s.split_whitespace().map(PathBuf::from).collect()); - assert_eq!(many!(PathBuf, "tmpdir", "p"), pathvec("/var/tmp")); assert_eq!(many!(PathBuf, "tmpdir", "p --tmpdir a"), pathvec("a")); assert_eq!(many!(PathBuf, "tmpdir", "p --tmpdir a --tmpdir b"), pathvec("a b")); } diff --git a/src/config/toml.rs b/src/config/toml.rs new file mode 100644 index 0000000..80805b2 --- /dev/null +++ b/src/config/toml.rs @@ -0,0 +1,66 @@ +use anyhow::{Context, Error}; +use serde::Deserialize; +use std::{env::var, fs::File, io::Read, path::PathBuf}; + +#[derive(Deserialize, Debug)] +pub struct TomlLog { + pub show: Option, + pub starttime: Option, +} +#[derive(Deserialize, Debug)] +pub struct TomlPred { + pub show: Option, + pub avg: Option, + pub limit: Option, + pub unknown: Option, + pub tmpdir: Option>, +} +#[derive(Deserialize, Debug)] +pub struct TomlStats { + pub show: Option, + pub avg: Option, + pub limit: Option, + pub group: Option, +} +#[derive(Deserialize, Debug)] +pub struct TomlAccuracy { + pub show: Option, + pub avg: Option, + pub limit: Option, +} +#[derive(Deserialize, Debug, Default)] +pub struct Toml { + pub logfile: Option, + pub date: Option, + pub duration: Option, + pub header: Option, + pub utc: Option, + pub log: Option, + pub predict: Option, + pub stats: Option, + pub accuracy: Option, +} +impl Toml { + pub fn load() -> Result { + match var("EMLOP_CONFIG").ok() { + Some(s) if s.is_empty() => Ok(Self::default()), + Some(s) => Self::doload(s.as_str()), + _ => Self::doload(&format!("{}/.config/emlop.toml", + var("HOME").unwrap_or("".to_string()))), + } + } + fn doload(name: &str) -> Result { + log::debug!("Loading config {name:?}"); + match File::open(name) { + Err(e) => { + log::warn!("Cannot open {name:?}: {e}"); + Ok(Self::default()) + }, + Ok(mut f) => { + let mut buf = String::new(); + f.read_to_string(&mut buf).with_context(|| format!("Cannot read {name:?}"))?; + toml::from_str(&buf).with_context(|| format!("Cannot parse {name:?}")) + }, + } + } +} diff --git a/src/config/types.rs b/src/config/types.rs new file mode 100644 index 0000000..f40a67d --- /dev/null +++ b/src/config/types.rs @@ -0,0 +1,227 @@ +use std::{ops::Range, str::FromStr}; + +/// Parsing trait for args +/// +/// Similar to std::convert::From but takes extra context and returns a custom error +pub trait ArgParse { + fn parse(val: &T, arg: A, src: &'static str) -> Result + where Self: Sized; +} +impl ArgParse for String { + fn parse(s: &String, _: (), _src: &'static str) -> Result { + Ok((*s).clone()) + } +} +impl ArgParse for bool { + fn parse(b: &bool, _: (), _src: &'static str) -> Result { + Ok(*b) + } +} +impl ArgParse for bool { + fn parse(s: &String, _: (), src: &'static str) -> Result { + match s.as_str() { + "y" | "yes" => Ok(true), + "n" | "no" => Ok(false), + _ => Err(ArgError::new(s, src).pos("y(es) n(o)")), + } + } +} +impl ArgParse> for i64 { + fn parse(s: &String, r: Range, src: &'static str) -> Result { + let i = i64::from_str(s).map_err(|_| ArgError::new(s, src).msg("Not an integer"))?; + Self::parse(&i, r, src) + } +} +impl ArgParse> for i64 { + fn parse(i: &i64, r: Range, src: &'static str) -> Result { + if r.contains(i) { + Ok(*i) + } else { + Err(ArgError::new(i, src).msg(format!("Should be between {} and {}", r.start, r.end))) + } + } +} + + +/// Argument parsing error +/// +/// Designed to look like clap::Error, but more tailored to our usecase +#[derive(Debug, PartialEq)] +pub struct ArgError { + val: String, + src: &'static str, + msg: String, + possible: &'static str, +} +impl ArgError { + /// Instantiate basic error with value and source + pub fn new(val: impl ToString, src: &'static str) -> Self { + Self { val: val.to_string(), src, msg: String::new(), possible: "" } + } + /// Set extra error message + pub fn msg(mut self, msg: impl ToString) -> Self { + self.msg = msg.to_string(); + self + } + /// Set possible values (as a space-delimited string or as a set of letters) + pub const fn pos(mut self, possible: &'static str) -> Self { + self.possible = possible; + self + } +} +impl std::error::Error for ArgError {} +impl std::fmt::Display for ArgError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + let r = "\x1B[1;31m"; + let g = "\x1B[32m"; + let b = "\x1B[33m"; + let c = "\x1B[m"; + write!(f, "{r}error{c}: invalid value '{b}{}{c}' for '{g}{}{c}'", self.val, self.src)?; + if !self.msg.is_empty() { + write!(f, ": {}", self.msg)?; + } + if !self.possible.is_empty() { + if self.possible.contains(' ') { + let mut sep = "\n possible values: "; + for p in self.possible.split_ascii_whitespace() { + write!(f, "{sep}{g}{p}{c}")?; + sep = ", "; + } + } else { + let mut sep = "\n possible value: combination of "; + for p in self.possible.chars() { + write!(f, "{sep}{g}{p}{c}")?; + sep = ", "; + } + } + } + write!(f, "\n\nFor more information, try '{g}--help{c}'.") + } +} + + +#[derive(Clone, Copy)] +pub enum Average { + Arith, + Median, + WeightedArith, + WeightedMedian, +} +impl ArgParse for Average { + fn parse(v: &String, _: (), s: &'static str) -> Result { + match v.as_str() { + "a" | "arith" => Ok(Self::Arith), + "m" | "median" => Ok(Self::Median), + "wa" | "weighted-arith" => Ok(Self::WeightedArith), + "wm" | "weighted-median" => Ok(Self::WeightedMedian), + _ => Err(ArgError::new(v, s).pos("(a)rith (m)edian wa/weightedarith wm/weigtedmedian")), + } + } +} + +#[derive(Clone, Copy, clap::ValueEnum)] +pub enum ResumeKind { + #[clap(hide(true))] + Current, + #[clap(alias("a"))] + Any, + #[clap(alias("m"))] + Main, + #[clap(alias("b"))] + Backup, + #[clap(alias("n"))] + No, +} + +#[derive(Clone, Copy)] +pub enum DurationStyle { + Hms, + HmsFixed, + Secs, + Human, +} +impl ArgParse for DurationStyle { + fn parse(v: &String, _: (), s: &'static str) -> Result { + match v.as_str() { + "hms" => Ok(Self::Hms), + "hmsfixed" => Ok(Self::HmsFixed), + "s" | "secs" => Ok(Self::Secs), + "h" | "human" => Ok(Self::Human), + _ => Err(ArgError::new(v, s).pos("hms hmsfixed (s)ecs (h)uman")), + } + } +} + +#[cfg_attr(test, derive(PartialEq, Eq, Debug))] +#[derive(Clone, Copy, clap::ValueEnum)] +pub enum ColorStyle { + #[clap(alias("y"))] + Always, + #[clap(alias("n"))] + Never, +} + +#[derive(Clone, Copy, clap::ValueEnum, PartialEq, Eq)] +pub enum OutStyle { + #[clap(alias("c"))] + Columns, + #[clap(alias("t"))] + Tab, +} + +#[derive(Clone, Copy)] +pub struct Show { + pub pkg: bool, + pub tot: bool, + pub sync: bool, + pub merge: bool, + pub unmerge: bool, + pub emerge: bool, +} +impl Show { + pub const fn m() -> Self { + Self { pkg: false, tot: false, sync: false, merge: true, unmerge: false, emerge: false } + } + pub const fn emt() -> Self { + Self { pkg: false, tot: true, sync: false, merge: true, unmerge: false, emerge: true } + } + pub const fn p() -> Self { + Self { pkg: true, tot: false, sync: false, merge: false, unmerge: false, emerge: false } + } + pub const fn mt() -> Self { + Self { pkg: false, tot: true, sync: false, merge: true, unmerge: false, emerge: false } + } +} +impl ArgParse for Show { + fn parse(show: &String, valid: &'static str, src: &'static str) -> Result { + debug_assert!(valid.is_ascii()); // Because we use `chars()` we need to stick to ascii for `valid`. + if show.chars().all(|c| valid.contains(c)) { + Ok(Self { pkg: show.contains('p') || show.contains('a'), + tot: show.contains('t') || show.contains('a'), + sync: show.contains('s') || show.contains('a'), + merge: show.contains('m') || show.contains('a'), + unmerge: show.contains('u') || show.contains('a'), + emerge: show.contains('e') || show.contains('a') }) + } else { + Err(ArgError::new(show, src).msg("Invalid letter").pos(valid)) + } + } +} +impl std::fmt::Display for Show { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut sep = ""; + for (b, s) in [(self.pkg, "pkg"), + (self.tot, "total"), + (self.sync, "sync"), + (self.merge, "merge"), + (self.unmerge, "unmerge"), + (self.emerge, "emerge")] + { + if b { + write!(f, "{sep}{s}")?; + sep = ","; + } + } + Ok(()) + } +} diff --git a/src/datetime.rs b/src/datetime.rs index 4407e3d..9433ff3 100644 --- a/src/datetime.rs +++ b/src/datetime.rs @@ -1,4 +1,6 @@ -use crate::{table::Disp, wtb, DurationStyle, Styles}; +use crate::{config::{ArgError, ArgParse}, + table::Disp, + wtb, Conf, DurationStyle}; use anyhow::{bail, Error}; use log::{debug, warn}; use regex::Regex; @@ -26,10 +28,14 @@ pub fn get_offset(utc: bool) -> UtcOffset { // See #[derive(Clone, Copy)] pub struct DateStyle(&'static [time::format_description::FormatItem<'static>]); -impl FromStr for DateStyle { - type Err = &'static str; - fn from_str(s: &str) -> Result { - let fmt = match s { +impl Default for DateStyle { + fn default() -> Self { + Self(format_description!("[year]-[month]-[day] [hour]:[minute]:[second]")) + } +} +impl ArgParse for DateStyle { + fn parse(s: &String, _: (), src: &'static str) -> Result { + Ok(Self(match s.as_str() { "ymd" | "d" => format_description!("[year]-[month]-[day]"), "ymdhms" | "dt" => format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"), "ymdhmso" | "dto" => format_description!("[year]-[month]-[day] [hour]:[minute]:[second] [offset_hour sign:mandatory]:[offset_minute]"), @@ -37,9 +43,8 @@ impl FromStr for DateStyle { "rfc2822" | "2822" => format_description!("[weekday repr:short], [day] [month repr:short] [year] [hour]:[minute]:[second] [offset_hour sign:mandatory]:[offset_minute]"), "compact" => format_description!("[year][month][day][hour][minute][second]"), "unix" => &[], - _ => return Err("Valid values are ymd, d, ymdhms, dt, ymdhmso, dto, rfc3339, 3339, rfc2822, 2822, compact, unix"), - }; - Ok(Self(fmt)) + _ => return Err(ArgError::new(s, src).pos("ymd d ymdhms dt ymdhmso dto rfc3339 3339 rfc2822 2822 compact unix")) + })) } } @@ -52,14 +57,14 @@ pub fn fmt_utctime(ts: i64) -> String { pub struct FmtDate(pub i64); /// Format dates according to user preferencess impl Disp for FmtDate { - fn out(&self, buf: &mut Vec, st: &Styles) -> usize { + fn out(&self, buf: &mut Vec, conf: &Conf) -> usize { let start = buf.len(); - if st.date_fmt.0.is_empty() { + if conf.date_fmt.0.is_empty() { write!(buf, "{}", self.0).expect("write to buf"); } else { OffsetDateTime::from_unix_timestamp(self.0).expect("unix from i64") - .to_offset(st.date_offset) - .format_into(buf, &st.date_fmt.0) + .to_offset(conf.date_offset) + .format_into(buf, &conf.date_fmt.0) .expect("write to buf"); } buf.len() - start @@ -71,20 +76,25 @@ pub fn epoch_now() -> i64 { } /// Parse datetime in various formats, returning unix timestamp -pub fn parse_date(s: &str, offset: UtcOffset) -> Result { - let s = s.trim(); - i64::from_str(s).or_else(|e| { - debug!("{s}: bad timestamp: {e}"); - parse_date_yyyymmdd(s, offset) - }) - .or_else(|e| { - debug!("{s}: bad absolute date: {e}"); - parse_date_ago(s) - }) - .map_err(|e| { - debug!("{s}: bad relative date: {e}"); - "Enable debug log level for details" - }) +impl ArgParse for i64 { + fn parse(val: &String, offset: UtcOffset, src: &'static str) -> Result { + let s = val.trim(); + let et = match i64::from_str(s) { + Ok(i) => return Ok(i), + Err(et) => et, + }; + let ea = match parse_date_yyyymmdd(s, offset) { + Ok(i) => return Ok(i), + Err(ea) => ea, + }; + match parse_date_ago(s) { + Ok(i) => Ok(i), + Err(er) => { + let m = format!("Not a unix timestamp ({et}), absolute date ({ea}), or relative date ({er})"); + Err(ArgError::new(val, src).msg(m)) + }, + } + } } /// Parse a number of day/years/hours/etc in the past, relative to current time @@ -176,28 +186,37 @@ fn parse_date_yyyymmdd(s: &str, offset: UtcOffset) -> Result { Ok(OffsetDateTime::try_from(p)?.unix_timestamp()) } -#[derive(Debug, Clone, Copy, clap::ValueEnum)] +#[derive(Clone, Copy)] pub enum Timespan { - #[clap(id("y"))] Year, - #[clap(id("m"))] Month, - #[clap(id("w"))] Week, - #[clap(id("d"))] Day, + None, +} +impl ArgParse for Timespan { + fn parse(v: &String, _: (), s: &'static str) -> Result { + match v.as_str() { + "y" | "year" => Ok(Self::Year), + "m" | "month" => Ok(Self::Month), + "w" | "week" => Ok(Self::Week), + "d" | "day" => Ok(Self::Day), + "n" | "none" => Ok(Self::None), + _ => Err(ArgError::new(v, s).pos("(y)ear (m)onth (w)eek (d)ay (n)one")), + } + } } impl Timespan { /// Given a unix timestamp, advance to the beginning of the next year/month/week/day. pub fn next(&self, ts: i64, offset: UtcOffset) -> i64 { let d = OffsetDateTime::from_unix_timestamp(ts).unwrap().to_offset(offset).date(); let d2 = match self { - Timespan::Year => Date::from_calendar_date(d.year() + 1, Month::January, 1).unwrap(), - Timespan::Month => { + Self::Year => Date::from_calendar_date(d.year() + 1, Month::January, 1).unwrap(), + Self::Month => { let year = if d.month() == Month::December { d.year() + 1 } else { d.year() }; Date::from_calendar_date(year, d.month().next(), 1).unwrap() }, - Timespan::Week => { + Self::Week => { let til_monday = match d.weekday() { Weekday::Monday => 7, Weekday::Tuesday => 6, @@ -209,29 +228,32 @@ impl Timespan { }; d.checked_add(Duration::days(til_monday)).unwrap() }, - Timespan::Day => d.checked_add(Duration::DAY).unwrap(), + Self::Day => d.checked_add(Duration::DAY).unwrap(), + Self::None => panic!("Called next() on a Timespan::None"), }; let res = d2.with_hms(0, 0, 0).unwrap().assume_offset(offset).unix_timestamp(); - debug!("{} + {:?} = {}", fmt_utctime(ts), self, fmt_utctime(res)); + debug!("{} + {} = {}", fmt_utctime(ts), self.name(), fmt_utctime(res)); res } pub fn at(&self, ts: i64, offset: UtcOffset) -> String { let d = OffsetDateTime::from_unix_timestamp(ts).unwrap().to_offset(offset); match self { - Timespan::Year => d.format(format_description!("[year]")).unwrap(), - Timespan::Month => d.format(format_description!("[year]-[month]")).unwrap(), - Timespan::Week => d.format(format_description!("[year]-[week_number]")).unwrap(), - Timespan::Day => d.format(format_description!("[year]-[month]-[day]")).unwrap(), + Self::Year => d.format(format_description!("[year]")).unwrap(), + Self::Month => d.format(format_description!("[year]-[month]")).unwrap(), + Self::Week => d.format(format_description!("[year]-[week_number]")).unwrap(), + Self::Day => d.format(format_description!("[year]-[month]-[day]")).unwrap(), + Self::None => String::new(), } } - pub fn name(&self) -> &'static str { + pub const fn name(&self) -> &'static str { match self { - Timespan::Year => "Year", - Timespan::Month => "Month", - Timespan::Week => "Week", - Timespan::Day => "Date", + Self::Year => "Year", + Self::Month => "Month", + Self::Week => "Week", + Self::Day => "Date", + Self::None => "", } } } @@ -239,20 +261,20 @@ impl Timespan { /// Wrapper around a duration (seconds) to implement `table::Disp` pub struct FmtDur(pub i64); impl crate::table::Disp for FmtDur { - fn out(&self, buf: &mut Vec, st: &Styles) -> usize { + fn out(&self, buf: &mut Vec, conf: &Conf) -> usize { use std::io::Write; use DurationStyle::*; let sec = self.0; - let dur = st.dur.val; + let dur = conf.dur.val; let start = buf.len(); - match st.dur_t { + match conf.dur_t { _ if sec < 0 => wtb!(buf, "{dur}?"), - HMS if sec >= 3600 => { + Hms if sec >= 3600 => { wtb!(buf, "{dur}{}:{:02}:{:02}", sec / 3600, sec % 3600 / 60, sec % 60) }, - HMS if sec >= 60 => wtb!(buf, "{dur}{}:{:02}", sec % 3600 / 60, sec % 60), - HMS | Secs => wtb!(buf, "{dur}{sec}"), - HMSFixed => wtb!(buf, "{dur}{}:{:02}:{:02}", sec / 3600, sec % 3600 / 60, sec % 60), + Hms if sec >= 60 => wtb!(buf, "{dur}{}:{:02}", sec % 3600 / 60, sec % 60), + Hms | Secs => wtb!(buf, "{dur}{sec}"), + HmsFixed => wtb!(buf, "{dur}{}:{:02}:{:02}", sec / 3600, sec % 3600 / 60, sec % 60), Human if sec == 0 => wtb!(buf, "{dur}0 second"), Human => { let a = [(sec / 86400, "day"), @@ -266,7 +288,7 @@ impl crate::table::Disp for FmtDur { } }, } - buf.len() - start - st.dur.val.len() + buf.len() - start - conf.dur.val.len() } } @@ -279,6 +301,9 @@ mod test { fn parse_3339(s: &str) -> OffsetDateTime { OffsetDateTime::parse(s, &Rfc3339).expect(s) } + fn parse_date(s: &str, o: UtcOffset) -> Result { + i64::parse(&String::from(s), o, "") + } fn ts(t: OffsetDateTime) -> i64 { t.unix_timestamp() } @@ -376,7 +401,7 @@ mod test { { for (st, exp) in [("hms", hms), ("hmsfixed", fixed), ("secs", secs), ("human", human)] { let mut buf = vec![]; - FmtDur(i).out(&mut buf, &Styles::from_str(format!("emlop l --color=n --dur {st}"))); + FmtDur(i).out(&mut buf, &Conf::from_str(format!("emlop l --color=n --dur {st}"))); assert_eq!(exp, &String::from_utf8(buf).unwrap()); } } diff --git a/src/main.rs b/src/main.rs index f00eeef..44f9c00 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,46 +1,38 @@ #![cfg_attr(feature = "unstable", feature(test))] -mod cli; mod commands; +mod config; mod datetime; mod parse; mod proces; mod table; -use crate::{commands::*, datetime::*, parse::AnsiStr}; +use crate::{commands::*, config::*, datetime::*, parse::AnsiStr}; use anyhow::Error; -use clap::{error::ErrorKind, ArgMatches, Error as ClapErr}; use log::*; use std::{io::IsTerminal, str::FromStr}; fn main() { - let args = cli::build_cli().get_matches(); - let level = match args.get_count("verbose") { - 0 => LevelFilter::Error, - 1 => LevelFilter::Warn, - 2 => LevelFilter::Info, - 3 => LevelFilter::Debug, - _ => LevelFilter::Trace, - }; - env_logger::Builder::new().filter_level(level).format_timestamp(None).init(); - trace!("{:?}", args); - let res = match args.subcommand() { - Some(("log", sub_args)) => cmd_list(sub_args), - Some(("stats", sub_args)) => cmd_stats(sub_args), - Some(("predict", sub_args)) => cmd_predict(sub_args), - Some(("accuracy", sub_args)) => cmd_accuracy(sub_args), - Some(("complete", sub_args)) => cmd_complete(sub_args), - _ => unreachable!("clap should have exited already"), + let res = match Configs::load() { + Ok(Configs::Log(gc, sc)) => cmd_log(&gc, &sc), + Ok(Configs::Stats(gc, sc)) => cmd_stats(&gc, &sc), + Ok(Configs::Predict(gc, sc)) => cmd_predict(&gc, &sc), + Ok(Configs::Accuracy(gc, sc)) => cmd_accuracy(&gc, &sc), + Ok(Configs::Complete(sc)) => cmd_complete(&sc), + Err(e) => Err(e), }; match res { Ok(true) => std::process::exit(0), Ok(false) => std::process::exit(1), - Err(e) => match e.downcast::() { - Ok(ce) => ce.format(&mut cli::build_cli()).exit(), - Err(e) => { - log_err(e); - std::process::exit(2) - }, + Err(e) => { + match e.downcast::() { + Ok(ce) => ce.format(&mut build_cli()).print().unwrap_or(()), + Err(e) => match e.downcast::() { + Ok(ae) => eprintln!("{ae}"), + Err(e) => log_err(e), + }, + } + std::process::exit(2) }, } } @@ -52,177 +44,6 @@ pub fn log_err(e: Error) { } } -/// Parse and return optional argument from an ArgMatches -/// -/// This is similar to clap's `get_one()` with `value_parser` except it allows late parsing with an -/// argument. -pub fn get_parse(args: &ArgMatches, - name: &str, - parse: P, - arg: A) - -> Result, ClapErr> - where P: FnOnce(&str, A) -> Result -{ - match args.get_one::(name) { - None => Ok(None), - Some(s) => match parse(s, arg) { - Ok(v) => Ok(Some(v)), - Err(e) => Err(ClapErr::raw(ErrorKind::InvalidValue, - format!("\"{s}\" isn't a valid for '--{name}': {e}"))), - }, - } -} - /// Alias to `write!(...).expect("write to buf")` just to save on typing/indent. #[macro_export] macro_rules! wtb { ($b:ident, $($arg:expr),+) => {write!($b, $($arg),+).expect("write to buf")} } - -#[derive(Clone, Copy, Default)] -pub struct Show { - pub pkg: bool, - pub tot: bool, - pub sync: bool, - pub merge: bool, - pub unmerge: bool, - pub emerge: bool, -} -impl Show { - fn parse(show: &str, valid: &str) -> Result { - debug_assert!(valid.is_ascii()); // Because we use `chars()` we need to stick to ascii for `valid`. - if show.chars().all(|c| valid.contains(c)) { - Ok(Self { pkg: show.contains('p') || show.contains('a'), - tot: show.contains('t') || show.contains('a'), - sync: show.contains('s') || show.contains('a'), - merge: show.contains('m') || show.contains('a'), - unmerge: show.contains('u') || show.contains('a'), - emerge: show.contains('e') || show.contains('a') }) - } else { - Err(format!("Valid values are letters of '{valid}'")) - } - } -} -impl std::fmt::Display for Show { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut sep = ""; - for (b, s) in [(self.pkg, "pkg"), - (self.tot, "total"), - (self.sync, "sync"), - (self.merge, "merge"), - (self.unmerge, "unmerge"), - (self.emerge, "emerge")] - { - if b { - write!(f, "{sep}{s}")?; - sep = ","; - } - } - Ok(()) - } -} - -#[derive(Clone, Copy, clap::ValueEnum)] -pub enum Average { - #[clap(alias("a"))] - Arith, - #[clap(alias("m"))] - Median, - #[clap(alias("wa"))] - WeightedArith, - #[clap(alias("wm"))] - WeightedMedian, -} - -#[derive(Clone, Copy, PartialEq, Eq, clap::ValueEnum)] -pub enum ResumeKind { - #[clap(alias("a"))] - Any, - #[clap(alias("m"))] - Main, - #[clap(alias("b"))] - Backup, - #[clap(alias("n"))] - No, -} - -#[derive(Clone, Copy, clap::ValueEnum)] -pub enum DurationStyle { - HMS, - #[clap(id("hmsfixed"))] - HMSFixed, - #[clap(alias("s"))] - Secs, - #[clap(alias("h"))] - Human, -} - -#[cfg_attr(test, derive(PartialEq, Eq, Debug))] -#[derive(Clone, Copy, clap::ValueEnum)] -pub enum ColorStyle { - #[clap(alias("y"))] - Always, - #[clap(alias("n"))] - Never, -} - -#[derive(Clone, Copy, clap::ValueEnum, PartialEq, Eq)] -pub enum OutStyle { - #[clap(alias("c"))] - Columns, - #[clap(alias("t"))] - Tab, -} - -/// Holds styling preferences. -/// -/// Colors use `prefix/suffix()` instead of `paint()` because `paint()` doesn't handle `'{:>9}'` -/// alignments properly. -pub struct Styles { - pkg: AnsiStr, - merge: AnsiStr, - unmerge: AnsiStr, - dur: AnsiStr, - cnt: AnsiStr, - clr: AnsiStr, - lineend: &'static [u8], - header: bool, - dur_t: DurationStyle, - date_offset: time::UtcOffset, - date_fmt: DateStyle, - out: OutStyle, -} -impl Styles { - fn from_args(args: &ArgMatches) -> Self { - let isterm = std::io::stdout().is_terminal(); - let color = match args.get_one("color") { - Some(ColorStyle::Always) => true, - Some(ColorStyle::Never) => false, - None => isterm, - }; - let out = match args.get_one("output") { - Some(o) => *o, - None if isterm => OutStyle::Columns, - None => OutStyle::Tab, - }; - let header = args.get_flag("header"); - let dur_t = *args.get_one("duration").unwrap(); - let date_fmt = *args.get_one("date").unwrap(); - let date_offset = get_offset(args.get_flag("utc")); - Styles { pkg: AnsiStr::from(if color { "\x1B[1;32m" } else { "" }), - merge: AnsiStr::from(if color { "\x1B[1;32m" } else { ">>> " }), - unmerge: AnsiStr::from(if color { "\x1B[1;31m" } else { "<<< " }), - dur: AnsiStr::from(if color { "\x1B[1;35m" } else { "" }), - cnt: AnsiStr::from(if color { "\x1B[2;33m" } else { "" }), - clr: AnsiStr::from(if color { "\x1B[0m" } else { "" }), - lineend: if color { b"\x1B[0m\n" } else { b"\n" }, - header, - dur_t, - date_offset, - date_fmt, - out } - } - #[cfg(test)] - fn from_str(s: impl AsRef) -> Self { - let args = cli::build_cli().get_matches_from(s.as_ref().split_whitespace()); - Self::from_args(&args) - } -} diff --git a/src/parse/ansi.rs b/src/parse/ansi.rs index 2e75d65..b6f6b60 100644 --- a/src/parse/ansi.rs +++ b/src/parse/ansi.rs @@ -82,7 +82,7 @@ impl From<&'static str> for AnsiStr { } } impl crate::table::Disp for AnsiStr { - fn out(&self, buf: &mut Vec, _st: &crate::Styles) -> usize { + fn out(&self, buf: &mut Vec, _conf: &crate::Conf) -> usize { buf.extend_from_slice(self.val.as_bytes()); self.len } diff --git a/src/parse/current.rs b/src/parse/current.rs index 984f4e4..890030a 100644 --- a/src/parse/current.rs +++ b/src/parse/current.rs @@ -81,14 +81,14 @@ pub fn get_resume(kind: ResumeKind) -> Vec { get_resume_priv(kind, "/var/cache/edb/mtimedb").unwrap_or_default() } fn get_resume_priv(kind: ResumeKind, file: &str) -> Option> { - if kind == ResumeKind::No { + if matches!(kind, ResumeKind::No) { return Some(vec![]); } let reader = File::open(file).map_err(|e| warn!("Cannot open {file:?}: {e}")).ok()?; let db: Mtimedb = from_reader(reader).map_err(|e| warn!("Cannot parse {file:?}: {e}")).ok()?; let r = match kind { ResumeKind::Any => db.resume.or(db.resume_backup)?, - ResumeKind::Main => db.resume?, + ResumeKind::Main | ResumeKind::Current => db.resume?, ResumeKind::Backup => db.resume_backup?, ResumeKind::No => unreachable!(), }; @@ -112,11 +112,11 @@ fn read_buildlog(file: File, max: usize) -> String { for line in rev_lines::RevLines::new(BufReader::new(file)).map_while(Result::ok) { if line.starts_with(">>>") { let tag = line.split_ascii_whitespace().skip(1).take(2).collect::>().join(" "); - if last.is_empty() { - return format!(" ({})", tag.trim_matches('.')); + return if last.is_empty() { + format!(" ({})", tag.trim_matches('.')) } else { - return format!(" ({}: {})", tag.trim_matches('.'), last); - } + format!(" ({}: {})", tag.trim_matches('.'), last) + }; } if last.is_empty() { let stripped = Ansi::strip(&line, max); diff --git a/src/parse/history.rs b/src/parse/history.rs index 36b8459..fe57957 100644 --- a/src/parse/history.rs +++ b/src/parse/history.rs @@ -57,7 +57,7 @@ impl Hist { _ => unreachable!("No ebuild/version for {:?}", self), } } - pub fn ts(&self) -> i64 { + pub const fn ts(&self) -> i64 { match self { Self::MergeStart { ts, .. } => *ts, Self::MergeStop { ts, .. } => *ts, @@ -83,16 +83,16 @@ fn open_any_buffered(name: &str) -> Result, max_ts: Option, show: Show, - search_terms: Vec, + search_terms: &Vec, search_exact: bool) -> Result, Error> { debug!("File: {file}"); debug!("Show: {show}"); - let mut buf = open_any_buffered(&file)?; + let mut buf = open_any_buffered(file)?; let (ts_min, ts_max) = filter_ts(min_ts, max_ts)?; let filter = FilterStr::try_new(search_terms, search_exact)?; let (tx, rx): (Sender, Receiver) = bounded(256); @@ -110,7 +110,7 @@ pub fn get_hist(file: String, Ok(_) => { if let Some((t, s)) = parse_ts(&line, ts_min, ts_max) { if prev_t > t { - warn!("{file}:{curline}: System clock jump: {} -> {}", + warn!("logfile:{curline}: System clock jump: {} -> {}", fmt_utctime(prev_t), fmt_utctime(t)); } @@ -144,7 +144,7 @@ pub fn get_hist(file: String, } }, // Could be invalid UTF8, system read error... - Err(e) => warn!("{file}:{curline}: {e}"), + Err(e) => warn!("logfile:{curline}: {e}"), } line.clear(); curline += 1; @@ -179,19 +179,21 @@ enum FilterStr { Re { r: RegexSet }, } impl FilterStr { - fn try_new(terms: Vec, exact: bool) -> Result { + fn try_new(terms: &Vec, exact: bool) -> Result { debug!("Search: {terms:?} {exact}"); Ok(match (terms.len(), exact) { (0, _) => Self::True, (_, true) => { let (b, c) = terms.iter().cloned().partition(|s| s.contains('/')); - Self::Eq { a: terms, b, c: c.into_iter().map(|s| format!("/{s}")).collect() } + Self::Eq { a: terms.clone(), + b, + c: c.into_iter().map(|s| format!("/{s}")).collect() } }, (1, false) => { Self::Re1 { r: RegexBuilder::new(&terms[0]).case_insensitive(true).build()? } }, (_, false) => { - Self::Re { r: RegexSetBuilder::new(&terms).case_insensitive(true).build()? } + Self::Re { r: RegexSetBuilder::new(terms).case_insensitive(true).build()? } }, }) } @@ -342,14 +344,16 @@ mod tests { "shortline" => (1327867709, 1327871057), o => unimplemented!("Unknown test log file {:?}", o), }; - let hist = get_hist(format!("tests/emerge.{}.log", file), + let hist = get_hist(&format!("tests/emerge.{}.log", file), filter_mints, filter_maxts, - Show { merge: parse_merge, - unmerge: parse_unmerge, + Show { pkg: false, + tot: false, sync: parse_sync, - ..Show::default() }, - filter_terms.clone(), + merge: parse_merge, + unmerge: parse_unmerge, + emerge: false }, + &filter_terms, exact).unwrap(); let re_atom = Regex::new("^[a-zA-Z0-9-]+/[a-zA-Z0-9_+-]+$").unwrap(); let re_version = Regex::new("^[0-9][0-9a-z._-]*$").unwrap(); @@ -516,7 +520,7 @@ mod tests { ("a.", false, "ab", true, true),]; for (terms, e, s, mpkg, mstr) in t { let t: Vec = terms.split_whitespace().map(str::to_string).collect(); - let f = FilterStr::try_new(t.clone(), e).unwrap(); + let f = FilterStr::try_new(&t, e).unwrap(); assert_eq!(f.match_pkg(s), mpkg, "filter({t:?}, {e}).match_pkg({s:?})"); assert_eq!(f.match_str(s), mstr, "filter({t:?}, {e}).match_str({s:?})"); } @@ -524,7 +528,7 @@ mod tests { #[test] fn split_atom() { - let f = FilterStr::try_new(vec![], false).unwrap(); + let f = FilterStr::try_new(&vec![], false).unwrap(); let g = |s| find_version(s, &f).map(|n| (&s[..n - 1], &s[n..])); assert_eq!(None, g("")); assert_eq!(None, g("a")); diff --git a/src/table.rs b/src/table.rs index fa4facb..127c203 100644 --- a/src/table.rs +++ b/src/table.rs @@ -1,13 +1,13 @@ -use crate::{OutStyle, Styles}; +use crate::{Conf, OutStyle}; use std::{collections::VecDeque, io::{stdout, BufWriter, Write as _}}; pub trait Disp { /// Write to buf and returns the number of visible chars written - fn out(&self, buf: &mut Vec, st: &Styles) -> usize; + fn out(&self, buf: &mut Vec, conf: &Conf) -> usize; } impl Disp for T { - fn out(&self, buf: &mut Vec, _st: &Styles) -> usize { + fn out(&self, buf: &mut Vec, _conf: &Conf) -> usize { let start = buf.len(); write!(buf, "{self}").expect("write to buf"); buf.len() - start @@ -33,8 +33,8 @@ pub struct Table<'a, const N: usize> { /// Whether a header has been set have_header: bool, - /// Main style - styles: &'a Styles, + /// Main config + conf: &'a Conf, /// Column alignments (defaults to Right) aligns: [Align; N], /// Margin between columns, printed left of the column, defaults to `" "` @@ -45,11 +45,11 @@ pub struct Table<'a, const N: usize> { impl<'a, const N: usize> Table<'a, N> { /// Initialize new table - pub fn new(st: &'a Styles) -> Table { + pub fn new(conf: &'a Conf) -> Table { Self { rows: VecDeque::with_capacity(32), buf: Vec::with_capacity(1024), widths: [0; N], - styles: st, + conf, have_header: false, aligns: [Align::Right; N], margins: [" "; N], @@ -72,7 +72,7 @@ impl<'a, const N: usize> Table<'a, N> { } /// Add a section header pub fn header(&mut self, row: [&str; N]) { - if self.styles.header { + if self.conf.header { self.last = self.last.saturating_add(1); self.have_header = true; @@ -102,7 +102,7 @@ impl<'a, const N: usize> Table<'a, N> { let mut idxrow = [(0, 0, 0); N]; for i in 0..N { let start = self.buf.len(); - let len = row[i].iter().map(|c| c.out(&mut self.buf, self.styles)).sum(); + let len = row[i].iter().map(|c| c.out(&mut self.buf, self.conf)).sum(); self.widths[i] = usize::max(self.widths[i], len); idxrow[i] = (len, start, self.buf.len()); } @@ -131,7 +131,7 @@ impl<'a, const N: usize> Table<'a, N> { continue; } let (len, pos0, pos1) = row[i]; - if self.styles.out == OutStyle::Tab { + if self.conf.out == OutStyle::Tab { if !first { out.write_all(b"\t").unwrap_or(()); } @@ -158,7 +158,7 @@ impl<'a, const N: usize> Table<'a, N> { } first = false; } - out.write_all(self.styles.lineend).unwrap_or(()); + out.write_all(self.conf.lineend).unwrap_or(()); } } } @@ -184,24 +184,24 @@ mod test { #[test] fn last() { - let st = Styles::from_str("emlop log --color=n -H"); + let conf = Conf::from_str("emlop log --color=n -H"); // No limit - let mut t = Table::<1>::new(&st); + let mut t = Table::<1>::new(&conf); for i in 1..10 { t.row([&[&format!("{i}")]]); } check(t, "1\n2\n3\n4\n5\n6\n7\n8\n9\n"); // 5 max - let mut t = Table::<1>::new(&st).last(5); + let mut t = Table::<1>::new(&conf).last(5); for i in 1..10 { t.row([&[&format!("{i}")]]); } check(t, "5\n6\n7\n8\n9\n"); // 5 max ignoring header - let mut t = Table::new(&st).last(5); + let mut t = Table::new(&conf).last(5); t.header(["h"]); for i in 1..10 { t.row([&[&format!("{i}")]]); @@ -211,8 +211,8 @@ mod test { #[test] fn align_cols() { - let st = Styles::from_str("emlop log --color=n --output=c"); - let mut t = Table::<2>::new(&st).align_left(0); + let conf = Conf::from_str("emlop log --color=n --output=c"); + let mut t = Table::<2>::new(&conf).align_left(0); t.row([&[&"short"], &[&1]]); t.row([&[&"looooooooooooong"], &[&1]]); t.row([&[&"high"], &[&9999]]); @@ -225,8 +225,8 @@ mod test { #[test] fn align_tab() { - let st = Styles::from_str("emlop log --color=n --output=t"); - let mut t = Table::<2>::new(&st).align_left(0); + let conf = Conf::from_str("emlop log --color=n --output=t"); + let mut t = Table::<2>::new(&conf).align_left(0); t.row([&[&"short"], &[&1]]); t.row([&[&"looooooooooooong"], &[&1]]); t.row([&[&"high"], &[&9999]]); @@ -238,10 +238,10 @@ mod test { #[test] fn color() { - let st = Styles::from_str("emlop log --color=y --output=c"); - let mut t = Table::<2>::new(&st).align_left(0); + let conf = Conf::from_str("emlop log --color=y --output=c"); + let mut t = Table::<2>::new(&conf).align_left(0); t.row([&[&"123"], &[&1]]); - t.row([&[&st.merge, &1, &st.dur, &2, &st.cnt, &3, &st.clr], &[&1]]); + t.row([&[&conf.merge, &1, &conf.dur, &2, &conf.cnt, &3, &conf.clr], &[&1]]); let res = "123 1\x1B[0m\n\ \x1B[1;32m1\x1B[1;35m2\x1B[2;33m3\x1B[0m 1\x1B[0m\n"; let (l1, l2) = res.split_once('\n').expect("two lines"); @@ -252,10 +252,10 @@ mod test { #[test] fn nocolor() { - let st = Styles::from_str("emlop log --color=n --output=c"); - let mut t = Table::<2>::new(&st).align_left(0); + let conf = Conf::from_str("emlop log --color=n --output=c"); + let mut t = Table::<2>::new(&conf).align_left(0); t.row([&[&"123"], &[&1]]); - t.row([&[&st.merge, &1, &st.dur, &2, &st.cnt, &3, &st.clr], &[&1]]); + t.row([&[&conf.merge, &1, &conf.dur, &2, &conf.cnt, &3, &conf.clr], &[&1]]); let res = "123 1\n\ >>> 123 1\n"; check(t, res); diff --git a/tests/commands.rs b/tests/commands.rs index 632f455..1d88ce8 100644 --- a/tests/commands.rs +++ b/tests/commands.rs @@ -20,6 +20,7 @@ fn ts(secs: i64) -> i64 { fn emlop(args: &str) -> Command { let mut e = Command::new(env!("CARGO_BIN_EXE_emlop")); e.env("TZ", "UTC"); + e.env("EMLOP_CONFIG", ""); e.args(args.replace("%F", "-F tests/emerge.").split_whitespace()); e }