1use std::collections::{HashMap, HashSet};
22use std::path::{Path, PathBuf};
23use std::process::Command;
24
25pub const DEFAULT_STALE_AFTER_DAYS: i64 = 180;
27
28fn run_git(args: &[&str], cwd: &Path) -> Option<String> {
32 let output = Command::new("git").args(args).current_dir(cwd).output().ok()?;
33 if !output.status.success() {
34 return None;
35 }
36 Some(String::from_utf8_lossy(&output.stdout).into_owned())
37}
38
39pub fn run_git_text(args: &[&str], cwd: &Path) -> Option<String> {
45 run_git(args, cwd).map(|t| t.replace("\r\n", "\n").replace('\r', "\n"))
46}
47
48pub fn repository_root(directory: &Path) -> Option<PathBuf> {
51 let out = run_git(&["rev-parse", "--show-toplevel"], directory)?;
52 let root = out.trim();
53 if root.is_empty() {
54 None
55 } else {
56 Some(PathBuf::from(root))
57 }
58}
59
60pub fn pathspec(repo_root: &Path, path: &Path) -> String {
64 let abspath = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
65 let root = repo_root.canonicalize().unwrap_or_else(|_| repo_root.to_path_buf());
66 match abspath.strip_prefix(&root) {
67 Ok(rel) => rel.to_string_lossy().into_owned(),
68 Err(_) => abspath.to_string_lossy().into_owned(),
69 }
70}
71
72pub fn last_committed(repo_root: &Path, path: &Path) -> Option<String> {
76 let spec = pathspec(repo_root, path);
77 let out = run_git(&["log", "-1", "--format=%cI", "--", &spec], repo_root)?;
78 let stamp = out.trim();
79 if stamp.is_empty() {
80 None
81 } else {
82 Some(stamp.to_string())
83 }
84}
85
86pub fn first_committed(repo_root: &Path, path: &Path) -> Option<String> {
92 let spec = pathspec(repo_root, path);
93 let out = run_git(
94 &["log", "--reverse", "--format=%cI", "--", &spec],
95 repo_root,
96 )?;
97 out.lines()
98 .map(str::trim)
99 .find(|l| !l.is_empty())
100 .map(str::to_string)
101}
102
103pub fn last_committed_for_paths(
106 directory: &Path,
107 paths: &[PathBuf],
108) -> Vec<(PathBuf, Option<String>)> {
109 match repository_root(directory) {
110 None => paths.iter().map(|p| (p.clone(), None)).collect(),
111 Some(root) => last_committed_for_paths_in_repo(&root, paths),
112 }
113}
114
115pub fn last_committed_for_paths_in_repo(
120 repo_root: &Path,
121 paths: &[PathBuf],
122) -> Vec<(PathBuf, Option<String>)> {
123 const MAX_PATHSPEC_BYTES: usize = 64 * 1024;
124 const MAX_PATHS_PER_RUN: usize = 2_048;
125
126 let specs: Vec<String> = paths.iter().map(|path| pathspec(repo_root, path)).collect();
127 let mut unique = Vec::new();
128 let mut seen = HashSet::new();
129 for spec in &specs {
130 if !Path::new(spec).is_absolute() && seen.insert(spec.clone()) {
134 unique.push(spec.clone());
135 }
136 }
137
138 let mut committed: HashMap<String, String> = HashMap::new();
139 let mut start = 0;
140 while start < unique.len() {
141 let mut end = start;
142 let mut bytes = 0;
143 while end < unique.len() && end - start < MAX_PATHS_PER_RUN {
144 let next = unique[end].len() + 1;
145 if end > start && bytes + next > MAX_PATHSPEC_BYTES {
146 break;
147 }
148 bytes += next;
149 end += 1;
150 }
151 collect_last_committed(repo_root, &unique[start..end], &mut committed);
152 start = end;
153 }
154
155 paths
156 .iter()
157 .zip(specs)
158 .map(|(path, spec)| (path.clone(), committed.get(&spec).cloned()))
159 .collect()
160}
161
162fn collect_last_committed(
163 repo_root: &Path,
164 specs: &[String],
165 committed: &mut HashMap<String, String>,
166) {
167 if specs.is_empty() {
168 return;
169 }
170 let mut args = vec!["log", "-z", "--format=%x1e%cI", "--name-only", "--"];
171 args.extend(specs.iter().map(String::as_str));
172 let Some(output) = run_git(&args, repo_root) else {
173 return;
174 };
175 let wanted: HashSet<&str> = specs.iter().map(String::as_str).collect();
176 let mut stamp: Option<&str> = None;
177 let mut first_name = false;
178 for token in output.split('\0') {
179 if let Some(value) = token.strip_prefix('\x1e') {
180 stamp = Some(value.trim());
181 first_name = true;
182 continue;
183 }
184 let Some(current) = stamp else {
185 continue;
186 };
187 let name = if first_name {
190 first_name = false;
191 token.strip_prefix('\n').unwrap_or(token)
192 } else {
193 token
194 };
195 if wanted.contains(name) {
196 committed
197 .entry(name.to_string())
198 .or_insert_with(|| current.to_string());
199 }
200 }
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct Staleness {
207 pub last_committed: Option<String>,
209 pub age_days: Option<i64>,
212 pub stale: Option<bool>,
214}
215
216impl Staleness {
217 pub fn unknown() -> Self {
219 Staleness {
220 last_committed: None,
221 age_days: None,
222 stale: None,
223 }
224 }
225}
226
227pub fn staleness(
235 last_committed: Option<&str>,
236 threshold_days: i64,
237 reference_epoch_secs: i64,
238) -> Staleness {
239 let stamp = match last_committed {
240 None => return Staleness::unknown(),
241 Some(s) => s,
242 };
243 let committed_epoch = match parse_iso8601_epoch(stamp) {
244 Some(e) => e,
245 None => return Staleness::unknown(),
246 };
247 let delta = reference_epoch_secs - committed_epoch;
249 let age_days = floor_div(delta, 86_400);
250 Staleness {
251 last_committed: Some(stamp.to_string()),
252 age_days: Some(age_days),
253 stale: Some(age_days > threshold_days),
254 }
255}
256
257fn floor_div(a: i64, b: i64) -> i64 {
259 let q = a / b;
260 let r = a % b;
261 if (r != 0) && ((r < 0) != (b < 0)) {
262 q - 1
263 } else {
264 q
265 }
266}
267
268pub fn isoformat_roundtrip(stamp: &str) -> String {
273 let mut s = stamp.to_string();
274 if s.len() > 10 && s.as_bytes()[10] == b' ' {
275 s.replace_range(10..11, "T");
276 }
277 if s.ends_with('Z') || s.ends_with('z') {
278 s.truncate(s.len() - 1);
279 s.push_str("+00:00");
280 return s;
281 }
282 if let Some(pos) = s.rfind(['+', '-']) {
285 if pos > 10 {
286 let body = &s[pos + 1..];
287 if body.len() == 4 && body.bytes().all(|b| b.is_ascii_digit()) {
288 let fixed = format!("{}:{}", &body[..2], &body[2..]);
289 s.replace_range(pos + 1.., &fixed);
290 } else if body.len() == 2 && body.bytes().all(|b| b.is_ascii_digit()) {
291 let fixed = format!("{body}:00");
292 s.replace_range(pos + 1.., &fixed);
293 }
294 }
295 }
296 s
297}
298
299pub fn parse_iso8601_epoch(s: &str) -> Option<i64> {
305 let bytes = s.as_bytes();
306 if bytes.len() < 19 {
307 return None;
308 }
309 let year: i64 = s.get(0..4)?.parse().ok()?;
311 if bytes[4] != b'-' {
312 return None;
313 }
314 let month: i64 = s.get(5..7)?.parse().ok()?;
315 if bytes[7] != b'-' {
316 return None;
317 }
318 let day: i64 = s.get(8..10)?.parse().ok()?;
319 if bytes[10] != b'T' && bytes[10] != b' ' {
321 return None;
322 }
323 let hour: i64 = s.get(11..13)?.parse().ok()?;
325 if bytes[13] != b':' {
326 return None;
327 }
328 let minute: i64 = s.get(14..16)?.parse().ok()?;
329 if bytes[16] != b':' {
330 return None;
331 }
332 let second: i64 = s.get(17..19)?.parse().ok()?;
333
334 let mut rest = &s[19..];
336 if let Some(stripped) = rest.strip_prefix('.') {
337 let non_digit = stripped
339 .char_indices()
340 .find(|(_, c)| !c.is_ascii_digit())
341 .map(|(i, _)| i)
342 .unwrap_or(stripped.len());
343 rest = &stripped[non_digit..];
344 }
345
346 let offset_secs = parse_offset(rest)?;
347
348 let days = days_from_civil(year, month, day);
349 let local_secs = days * 86_400 + hour * 3_600 + minute * 60 + second;
350 Some(local_secs - offset_secs)
352}
353
354fn parse_offset(rest: &str) -> Option<i64> {
356 if rest == "Z" || rest == "z" {
357 return Some(0);
358 }
359 let bytes = rest.as_bytes();
360 if bytes.is_empty() {
361 return None; }
363 let sign = match bytes[0] {
364 b'+' => 1,
365 b'-' => -1,
366 _ => return None,
367 };
368 let body = &rest[1..];
369 let (hh, mm) = if body.len() == 5 && body.as_bytes()[2] == b':' {
370 (&body[0..2], &body[3..5]) } else if body.len() == 4 {
372 (&body[0..2], &body[2..4]) } else if body.len() == 2 {
374 (&body[0..2], "00") } else {
376 return None;
377 };
378 let h: i64 = hh.parse().ok()?;
379 let m: i64 = mm.parse().ok()?;
380 Some(sign * (h * 3_600 + m * 60))
381}
382
383fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
387 let y = if m <= 2 { y - 1 } else { y };
388 let era = if y >= 0 { y } else { y - 399 } / 400;
389 let yoe = y - era * 400; let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146_097 + doe - 719_468
393}