Skip to main content

borg/notes/
regress_cli.rs

1//! `borg regress`: the decision → fix links regress.rs would make, as a dry run (nothing is
2//! written: no note, no record).
3//!
4//! Without `--replay`, the latest session that started in this repo (or `--session ID`), over the
5//! commits it landed. `--replay <A..B>` takes every non-merge commit of the range for a fix (at most
6//! `regress::REPLAY_CAP`), its session and task from its note; `--any-origin` keeps origins without
7//! borg reasoning, which measures the detector on a repo without notes. `--explain` lists every
8//! candidate left out and why, `--json` prints `{"links", "skipped", "scanned"}`.
9//!
10//! **Privacy** as `borg why`: an off cwd, an invalid config, or a repo that routes elsewhere prints
11//! nothing and exits 0.
12
13use std::fmt::Write as _;
14use std::path::{Path, PathBuf};
15
16use crate::collective::{self, Config, Route};
17use crate::learn;
18use crate::notes::links::Link;
19use crate::notes::regress::{self, Detected, Scope, Skipped};
20use crate::notes::{self, redact};
21
22/// Its flags (completions.rs).
23pub const FLAGS: &[&str] = &["--session", "--replay", "--any-origin", "--explain", "--json", "--dir"];
24
25const USAGE: &str = "usage: borg regress [--session ID] [--replay <A..B>] [--any-origin] [--explain] [--json] [--dir DIR]";
26
27#[derive(Debug, Default, PartialEq, Eq)]
28struct Opts {
29    session: Option<String>,
30    replay: Option<String>,
31    json: bool,
32    explain: bool,
33    any_origin: bool,
34    dir: Option<String>,
35}
36
37fn parse(args: &[&str]) -> Option<Opts> {
38    let mut o = Opts::default();
39    let mut it = args.iter();
40    while let Some(a) = it.next() {
41        let mut value = || it.next().map(|v| (*v).to_string());
42        match *a {
43            "--json" => o.json = true,
44            "--explain" => o.explain = true,
45            "--any-origin" => o.any_origin = true,
46            "--session" => o.session = Some(value()?),
47            "--replay" => o.replay = Some(value()?),
48            "--dir" => o.dir = Some(value()?),
49            _ => return None,
50        }
51    }
52    Some(o)
53}
54
55/// What a dry run found.
56#[derive(Debug, Default, serde::Serialize)]
57pub struct Report {
58    pub links: Vec<Link>,
59    pub skipped: Vec<Skipped>,
60    pub scanned: usize,
61    #[serde(skip)]
62    pub notes: Vec<String>,
63}
64
65fn resolve(p: &Path) -> PathBuf {
66    std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
67}
68
69/// The repo `cwd` is in and its collective, or `None` when either routes off or they route apart.
70fn scoped(cfg: Option<&Config>, base: &Path, cwd: &str) -> Result<Option<(PathBuf, Route)>, String> {
71    let cwd = resolve(Path::new(cwd)).to_string_lossy().into_owned();
72    let route = collective::route(cfg, &cwd, base);
73    if route == Route::Off {
74        return Ok(None);
75    }
76    let repo = notes::toplevel(&cwd).ok_or(format!("{cwd} is not in a git repo"))?;
77    let repo = resolve(&repo);
78    Ok((collective::route(cfg, &repo.to_string_lossy(), base) == route).then_some((repo, route)))
79}
80
81/// The links the latest (or the named) session's landed commits would make.
82fn session(repo: &Path, scope: &Scope, route: &Route, o: &Opts, cfg: &regress::Cfg) -> Result<Report, String> {
83    let dir = std::env::var_os("BORG_STATE").map_or_else(|| scope.store.join("state"), PathBuf::from);
84    let here = repo.to_string_lossy();
85    let (session, start) = match &o.session {
86        Some(s) => (s.clone(), std::fs::read_to_string(dir.join(format!("{s}.head"))).ok().and_then(|t| serde_json::from_str::<notes::Start>(&t).ok())),
87        None => notes::latest_session(&dir, &here).map(|(s, st)| (s, Some(st))).ok_or("no drone session started in this repo; pass --session ID")?,
88    };
89    let start = start.filter(|st| st.repo == here).ok_or("the session did not start in this repo")?;
90    let commits = notes::landed(repo, &start, &notes::windows(&dir, &session, &start));
91    let landed = notes::Landed { dir: &dir, session: &session, repo, store: &scope.store, route, commits: &commits, start: Some(&start) };
92    let Detected { links, skipped } = regress::session_links(&landed, scope, cfg);
93    Ok(Report { links, skipped, scanned: commits.len(), notes: Vec::new() })
94}
95
96fn replayed(repo: &Path, scope: &Scope, range: &str, o: &Opts, cfg: &regress::Cfg) -> Result<Report, String> {
97    let r = regress::replay(repo, range, scope, cfg, o.any_origin)?;
98    let mut notes = Vec::new();
99    if r.capped {
100        notes.push(format!("only the first {} commits of {range} were scanned", regress::REPLAY_CAP));
101    }
102    if r.shallow {
103        notes.push("repo is shallow: links stop at the graft".into());
104    }
105    Ok(Report { links: r.found.links, skipped: r.found.skipped, scanned: r.scanned, notes })
106}
107
108/// The dry run from `cwd`, or `None` when it routes off or elsewhere (module doc).
109///
110/// # Errors
111/// Bad arguments, a cwd outside git, a session that did not start here, a range git cannot list.
112pub fn run(cfg: Option<&Config>, base: &Path, cwd: &str, args: &[&str]) -> Result<Option<String>, String> {
113    let o = parse(args).ok_or(USAGE)?;
114    let Some((repo, route)) = scoped(cfg, base, o.dir.as_deref().unwrap_or(cwd))? else { return Ok(None) };
115    let Route::Collective { home, .. } = &route else { return Ok(None) };
116    let scope = Scope::of(&route, home).ok_or(USAGE)?;
117    let rcfg = regress::Cfg::from_env();
118    let report = match &o.replay {
119        Some(range) => replayed(&repo, &scope, range, &o, &rcfg)?,
120        None => session(&repo, &scope, &route, &o, &rcfg)?,
121    };
122    Ok(Some(if o.json { serde_json::to_string(&report).map_err(|e| e.to_string())? } else { render(&report, o.explain) }))
123}
124
125fn short(sha: &str) -> &str {
126    sha.get(..7).unwrap_or(sha)
127}
128
129fn spans(l: &Link) -> String {
130    let span = |(a, b): &(u32, u32)| if a == b { a.to_string() } else { format!("{a}-{b}") };
131    l.files.iter().map(|t| format!("{}:{}", t.path, t.lines.iter().map(span).collect::<Vec<_>>().join(","))).collect::<Vec<_>>().join(" ")
132}
133
134fn link_line(l: &Link) -> String {
135    let (verb, strength) = if l.strong() { ("fixes", format!("fix [{}]", l.signals.join(","))) } else { ("revisits", "revisited".to_string()) };
136    let mut s = format!(
137        "{} {} {:?} {verb} {} {} ({}d)  {strength}  {} line(s)  {}\n",
138        short(&l.fix),
139        l.fixed.date,
140        redact(&l.fixed.subject),
141        short(&l.commit),
142        l.decision.date,
143        l.age_days,
144        l.lines,
145        spans(l)
146    );
147    for (label, task) in [("decided:", &l.decision.task), ("fixed:  ", &l.fixed.task)] {
148        if !task.is_empty() {
149            let _ = writeln!(s, "  {label} {}", redact(task));
150        }
151    }
152    s
153}
154
155/// The report as text: one line per link, then (`explain`) every candidate left out.
156pub fn render(r: &Report, explain: bool) -> String {
157    let mut s: String = r.links.iter().map(link_line).collect();
158    if explain && !r.skipped.is_empty() {
159        s.push_str("--explain:\n");
160        for k in &r.skipped {
161            let from = match k.origin.len() {
162                0 => String::new(),
163                40 => format!(" <- {}", short(&k.origin)),
164                _ => format!(" <- {}", k.origin),
165            };
166            let _ = writeln!(s, "  skip {}{from}: {}", short(&k.fix), k.reason);
167        }
168    }
169    for n in &r.notes {
170        let _ = writeln!(s, "{n}");
171    }
172    let strong = r.links.iter().filter(|l| l.strong()).count();
173    let _ = writeln!(s, "{} link(s), {strong} fix; {} commit(s) scanned (dry run: nothing written)", r.links.len(), r.scanned);
174    s
175}
176
177/// `borg regress [--session ID] [--replay <A..B>] [--any-origin] [--explain] [--json] [--dir DIR]`.
178#[must_use]
179pub fn main(args: &[&str]) -> i32 {
180    if parse(args).is_none() {
181        eprintln!("{USAGE}");
182        return 2;
183    }
184    let cwd = std::env::current_dir().unwrap_or_default().display().to_string();
185    // An invalid config routes everything off: nothing is shown.
186    let Ok(cfg) = collective::load() else { return 0 };
187    match run(cfg.as_ref(), &learn::home(), &cwd, args) {
188        Ok(None) => 0,
189        Ok(Some(out)) => {
190            print!("{out}");
191            if !out.ends_with('\n') {
192                println!();
193            }
194            0
195        }
196        Err(e) => {
197            eprintln!("borg regress: {e}");
198            1
199        }
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn parses_flags() {
209        let o = parse(&["--replay", "a..b", "--json", "--explain", "--any-origin", "--dir", "/r"]).unwrap();
210        assert_eq!((o.replay.as_deref(), o.json, o.explain, o.any_origin, o.dir.as_deref()), (Some("a..b"), true, true, true, Some("/r")));
211        assert!(parse(&["--replay"]).is_none() && parse(&["--bogus"]).is_none());
212        assert_eq!(parse(&[]).unwrap(), Opts::default());
213    }
214}