1use 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
22pub 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#[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
69fn 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
81fn session(repo: &Path, scope: &Scope, route: &Route, o: &Opts, cfg: ®ress::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, ¬es::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: ®ress::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
108pub 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
155pub 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#[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 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}