big_code_analysis/vcs/options.rs
1//! Configuration for a change-history walk.
2//!
3//! [`Options`] is a plain data struct: the CLI / web / Python layers
4//! fill it in from user input, and [`build_history_index`](crate::vcs::build_history_index)
5//! consumes it. Time windows are stored already resolved to seconds so
6//! the generic core never re-parses user duration strings; the
7//! [`parse_window`] helper is exposed for those front ends to perform
8//! that resolution (and to surface a typed [`Error`] on bad input).
9
10use std::path::Path;
11
12use super::error::Error;
13use super::identity::AuthorHashKey;
14
15/// Seconds in a day.
16pub(crate) const SECONDS_PER_DAY: i64 = 86_400;
17/// Seconds in a week.
18const SECONDS_PER_WEEK: i64 = 7 * SECONDS_PER_DAY;
19/// Seconds in an average Gregorian month (30.436875 days). Months and
20/// years are inherently approximate; the average keeps `12mo` and `1y`
21/// numerically identical (both 365.2425 days → 365 days), matching the
22/// `long_window_days: 365` shown in the issue's output sample.
23const SECONDS_PER_MONTH: i64 = 2_629_746;
24/// Seconds in an average Gregorian year (365.2425 days).
25const SECONDS_PER_YEAR: i64 = 31_556_952;
26
27/// Default long window (`12mo` ≈ 365 days).
28pub const DEFAULT_LONG_WINDOW: &str = "12mo";
29/// Default recent window (`90d`).
30pub const DEFAULT_RECENT_WINDOW: &str = "90d";
31
32/// Human-facing reminder of the accepted [`parse_window`] grammar,
33/// appended to every window-parse error so the message is actionable
34/// without consulting the docs (issue #607).
35const WINDOW_FORMAT_HINT: &str =
36 "expected <N>d|w|mo|y or an ISO 8601 duration, e.g. 12mo, 90d, or P1Y6M";
37
38/// Default bus-factor coverage (abandonment) threshold (`0.5`, per
39/// Avelino). Re-exported from the bus-factor module so the front ends
40/// share one source of truth for the default and the validation bound.
41pub const DEFAULT_BUS_FACTOR_THRESHOLD: f64 = super::bus_factor::DEFAULT_COVERAGE_THRESHOLD;
42
43/// Default bot-author exclusion pattern (case-insensitive, matched as a
44/// substring against both the canonical author name and email). The
45/// `[bot]` suffixes are regex-escaped. Mirrors the well-known automation
46/// identities called out in issue #328.
47pub const DEFAULT_BOT_PATTERN: &str = r"dependabot\[bot\]|renovate\[bot\]|github-actions\[bot\]|pre-commit-ci\[bot\]|mergify\[bot\]|pyup-bot";
48
49/// Which composite risk-score formula to apply.
50#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
51pub enum RiskFormula {
52 /// Log-scaled weighted sum with categorical bumps (the `v1`
53 /// formula documented in [`score`](crate::vcs::score)).
54 #[default]
55 Weighted,
56 /// Each signal re-ranked to its percentile within the analyzed
57 /// set, then averaged. The literature recommends relative triggers
58 /// over hard thresholds for cross-project robustness.
59 Percentile,
60}
61
62impl std::str::FromStr for RiskFormula {
63 type Err = Error;
64
65 /// Parse the user-facing formula name. The single source of truth
66 /// shared by the web (`POST /vcs`) and Python (`vcs_metrics`) front
67 /// ends; the CLI uses a clap `ValueEnum` instead.
68 fn from_str(s: &str) -> Result<Self, Error> {
69 match s {
70 "weighted" => Ok(Self::Weighted),
71 "percentile" => Ok(Self::Percentile),
72 other => Err(Error::InvalidFormula(other.to_owned())),
73 }
74 }
75}
76
77/// Which tracked files the change-history walk ranks (issue #576).
78///
79/// Applied as an **additional** extension-only filter on top of the
80/// `--paths` / `--include` / `--exclude` globs (AND semantics): a file
81/// must pass both to be ranked. The check never reads blob content, so a
82/// language detected only by an in-file modeline (Emacs / Vim) — never by
83/// its extension — is treated as out-of-scope under [`Metrics`](Self::Metrics);
84/// this is the one documented divergence from the content-aware `bca
85/// metrics` walk.
86#[derive(Clone, Debug, Default, PartialEq, Eq)]
87pub enum FileTypeScope {
88 /// Only files bca computes metrics for — the same set `bca metrics`
89 /// would analyze, resolved by extension via
90 /// [`get_language_for_file`](crate::get_language_for_file). The
91 /// default: it keeps the change-history ranking aligned with the AST
92 /// hotspot tables (which only cover files-with-metrics) and keeps
93 /// high-churn non-source files (`CHANGELOG.md`, `Cargo.lock`, CI
94 /// config) out of the risk ranking. Extension-less files
95 /// (`Makefile`, `Dockerfile`, `LICENSE`) and unknown extensions are
96 /// excluded.
97 #[default]
98 Metrics,
99 /// Every tracked, non-binary, non-symlink text file — the behaviour
100 /// before the `metrics` default was introduced.
101 All,
102 /// A user-supplied allow-list of file extensions, normalised to
103 /// lowercase with any leading dot stripped (`rs`, `py`, `toml`). A
104 /// file is in scope iff its lowercased extension is in the list.
105 Custom(Vec<String>),
106}
107
108impl FileTypeScope {
109 /// Whether `path` is in scope, judged by extension only (no blob
110 /// content is read, so this stays a cheap pre-filter on the file
111 /// enumeration).
112 #[must_use]
113 pub fn includes(&self, path: &Path) -> bool {
114 match self {
115 Self::All => true,
116 // Route through the same extension predicate the metrics walk
117 // resolves a language with, so the `metrics` scope stays in
118 // lockstep with the analyzable-file set as languages are
119 // added or removed.
120 Self::Metrics => crate::get_language_for_file(path).is_some(),
121 Self::Custom(extensions) => path
122 .extension()
123 .and_then(|ext| ext.to_str())
124 // The stored extensions are already lowercased, so an
125 // ASCII case-insensitive compare avoids allocating a
126 // lowercased copy of every file's extension in this
127 // per-file path.
128 .is_some_and(|ext| {
129 extensions
130 .iter()
131 .any(|allowed| allowed.eq_ignore_ascii_case(ext))
132 }),
133 }
134 }
135
136 /// Parse a custom comma-separated extension list, normalising each
137 /// entry (trim, strip a leading dot, lowercase) and dropping blanks.
138 ///
139 /// # Errors
140 ///
141 /// Returns [`Error::InvalidFileTypeScope`] when the list normalises
142 /// to nothing (empty, or only blanks / bare dots) — a scope that
143 /// would silently rank no files — or when an entry carries an interior
144 /// dot (`d.ts`, `tar.gz`, `.rs.bak`): `Path::extension()` returns only
145 /// the final component, so a multi-dot suffix can never match and would
146 /// silently rank nothing. Rejecting it turns that footgun into a loud
147 /// error (multi-dot-suffix *support* is a separate, larger change).
148 fn from_extensions(list: &str) -> Result<Self, Error> {
149 let mut extensions: Vec<String> = Vec::new();
150 for raw in list.split(',') {
151 let normalized = raw.trim().trim_start_matches('.').to_lowercase();
152 if normalized.is_empty() {
153 continue;
154 }
155 if normalized.contains('.') {
156 return Err(Error::InvalidFileTypeScope(format!(
157 "{:?} is a multi-dot suffix; `Path::extension()` only \
158 matches the final component, so it would rank no files",
159 raw.trim()
160 )));
161 }
162 if !extensions.contains(&normalized) {
163 extensions.push(normalized);
164 }
165 }
166 if extensions.is_empty() {
167 return Err(Error::InvalidFileTypeScope(format!(
168 "{list:?} lists no usable file extensions"
169 )));
170 }
171 Ok(Self::Custom(extensions))
172 }
173}
174
175impl std::str::FromStr for FileTypeScope {
176 type Err = Error;
177
178 /// Parse the user-facing scope: the keywords `metrics` / `all`, or
179 /// any other value as a comma-separated custom extension list. The
180 /// single source of truth shared by the CLI (`--file-types`), the
181 /// `bca.toml` `[vcs] file_types` key, the web front end, and Python.
182 fn from_str(s: &str) -> Result<Self, Error> {
183 match s.trim() {
184 "" => Err(Error::InvalidFileTypeScope("the value is empty".to_owned())),
185 "metrics" => Ok(Self::Metrics),
186 "all" => Ok(Self::All),
187 list => Self::from_extensions(list),
188 }
189 }
190}
191
192/// Configuration for a single change-history walk.
193// The booleans are independent on/off CLI toggles (`--full-history`,
194// `--include-merges`, …); packing them into a flags newtype would hide
195// each one's meaning at construction sites for no real gain.
196#[allow(clippy::struct_excessive_bools)]
197#[derive(Clone, Debug)]
198// Sealed against external struct-literal construction so future additive
199// fields stay non-breaking: downstream crates start from `Options::default()`
200// and assign the `pub` fields they care about (see STABILITY.md).
201#[non_exhaustive]
202pub struct Options {
203 /// Long observation window, in seconds (default ≈ 365 days).
204 pub long_window_secs: i64,
205 /// Recent observation window, in seconds (default 90 days).
206 pub recent_window_secs: i64,
207 /// Revision to start the walk from (default `HEAD`).
208 pub reference: String,
209 /// Walk the full commit DAG rather than first-parent only.
210 pub full_history: bool,
211 /// Include merge commits (default: skip them).
212 pub include_merges: bool,
213 /// Follow file renames across history (default: on).
214 pub follow_renames: bool,
215 /// Exclude bot author identities (default: on).
216 pub exclude_bots: bool,
217 /// Regex matched against author name/email to detect bots.
218 pub bot_pattern: String,
219 /// Reference "now" as a Unix timestamp for reproducible snapshots
220 /// (`--as-of`). `None` means wall-clock time at walk start.
221 pub as_of: Option<i64>,
222 /// Which composite score to compute.
223 pub risk_formula: RiskFormula,
224 /// Emit SHA-256-hashed canonical author identities (default: off —
225 /// author identities never leave the process otherwise).
226 pub emit_author_details: bool,
227 /// Optional secret key that hardens `emit_author_details` into a keyed
228 /// HMAC (issue #956). `None` (the default) emits the bare SHA-256
229 /// pseudonym. Has no effect unless `emit_author_details` is set. The
230 /// key is a finalization-time concern (like `emit_author_details`
231 /// itself), so it never enters the persistent-cache fingerprint: the
232 /// same cached walk re-finalizes under any key without a re-walk (see
233 /// [`AuthorId::emit_hashed`](super::identity::AuthorId::emit_hashed)).
234 //
235 // Named by path in .github/codeql/extensions/rust-models: a rename or
236 // move must update that model, or CodeQL again reports every window
237 // constant as a hard-coded HMAC key (alerts #799-#803).
238 pub author_hash_key: Option<AuthorHashKey>,
239 /// Emit stats for files deleted at the target ref (default: off).
240 pub include_deleted: bool,
241 /// Compute the directory- / repo-level bus-factor aggregate from the
242 /// walk (issue #332). Default off: it retains per-file authorship
243 /// beyond the per-file [`Stats`](crate::vcs::Stats), which the
244 /// repeated JIT-prior and per-file-injection walks neither need nor
245 /// should pay for.
246 pub compute_bus_factor: bool,
247 /// Coverage (abandonment) threshold for the bus factor, in `(0, 1)`
248 /// — the fraction of files that must be orphaned for the greedy
249 /// removal to stop (default [`DEFAULT_BUS_FACTOR_THRESHOLD`], `0.5`
250 /// per Avelino). Ignored unless `compute_bus_factor` is set.
251 pub bus_factor_threshold: f64,
252 /// Which tracked files to rank (issue #576). Defaults to
253 /// [`FileTypeScope::Metrics`] — only files bca has metrics for — so
254 /// high-churn non-source files do not dominate the risk ranking and
255 /// the change-history view aligns with the AST hotspot tables.
256 pub file_types: FileTypeScope,
257}
258
259impl Default for Options {
260 fn default() -> Self {
261 Self {
262 // The default-window constants are valid by construction;
263 // `expect` documents the invariant (AGENTS.md permits it
264 // for provably-unreachable cases). A unit test pins it.
265 long_window_secs: parse_window(DEFAULT_LONG_WINDOW)
266 .expect("DEFAULT_LONG_WINDOW parses"),
267 recent_window_secs: parse_window(DEFAULT_RECENT_WINDOW)
268 .expect("DEFAULT_RECENT_WINDOW parses"),
269 reference: "HEAD".to_owned(),
270 full_history: false,
271 include_merges: false,
272 follow_renames: true,
273 exclude_bots: true,
274 bot_pattern: DEFAULT_BOT_PATTERN.to_owned(),
275 as_of: None,
276 risk_formula: RiskFormula::Weighted,
277 emit_author_details: false,
278 author_hash_key: None,
279 include_deleted: false,
280 compute_bus_factor: false,
281 bus_factor_threshold: DEFAULT_BUS_FACTOR_THRESHOLD,
282 file_types: FileTypeScope::Metrics,
283 }
284 }
285}
286
287impl Options {
288 /// Long window expressed in whole days (for the serialized
289 /// `long_window_days` field and the `new_file`/age cap).
290 #[must_use]
291 pub fn long_window_days(&self) -> u32 {
292 secs_to_days(self.long_window_secs)
293 }
294
295 /// Recent window expressed in whole days.
296 #[must_use]
297 pub fn recent_window_days(&self) -> u32 {
298 secs_to_days(self.recent_window_secs)
299 }
300}
301
302/// The oldest timestamp a window of `window_secs` reaches back to from
303/// `reference`.
304///
305/// Saturating, and that is the whole point of the function existing. Both
306/// operands are attacker-adjacent: `reference` is an `--as-of` value or a
307/// committer timestamp read straight out of an object header, and
308/// `window_secs` is user-supplied up to roughly `i64::MAX` through
309/// [`parse_window`]. A plain `-` therefore panics in a debug build and, in
310/// release, wraps to a boundary in the far *future* — which silently
311/// reverses the meaning of every comparison downstream. Saturating to
312/// `i64::MIN` instead yields an unbounded-past boundary: every commit is
313/// inside the window, the safe total behaviour for a degenerate clock.
314///
315/// Every window boundary in the subsystem goes through here so the walk,
316/// the replay, and the cache all agree on the same value at the extremes
317/// (#1271).
318pub(crate) fn window_boundary(reference: i64, window_secs: i64) -> i64 {
319 reference.saturating_sub(window_secs)
320}
321
322/// Round a second count to the nearest whole day, saturating into
323/// `u32`. Window lengths never approach `u32::MAX` days in practice,
324/// but the saturation keeps the conversion total and lint-clean.
325fn secs_to_days(secs: i64) -> u32 {
326 // Saturating: `secs` in the top half-day of i64 would overflow the bare
327 // `+ SECONDS_PER_DAY / 2` rounding term. Saturating keeps i64::MAX at
328 // i64::MAX, so it divides to a huge positive day count and `try_from`
329 // saturates to u32::MAX — rather than wrapping negative and flooring to 0.
330 let days = secs.saturating_add(SECONDS_PER_DAY / 2) / SECONDS_PER_DAY;
331 u32::try_from(days.max(0)).unwrap_or(u32::MAX)
332}
333
334/// Validate a bus-factor coverage threshold, accepting only a finite
335/// value in the open interval `(0, 1)`.
336///
337/// A `0` would make the first key-developer removal "exceed" the
338/// abandonment fraction (bus factor always 1) and a `1` could never be
339/// exceeded (bus factor = every author), so both extremes are user errors
340/// rather than values to silently clamp. The single source of truth
341/// shared by every front end.
342///
343/// # Errors
344///
345/// Returns [`Error::InvalidBusFactorThreshold`] when `threshold` is
346/// non-finite or outside `(0, 1)`.
347pub fn validate_bus_factor_threshold(threshold: f64) -> Result<f64, Error> {
348 if threshold.is_finite() && threshold > 0.0 && threshold < 1.0 {
349 Ok(threshold)
350 } else {
351 Err(Error::InvalidBusFactorThreshold(format!(
352 "{threshold} is not in the open interval (0, 1)"
353 )))
354 }
355}
356
357/// Parse a human time-window string into seconds.
358///
359/// Accepts a suffix form — `<number><unit>` with unit `d` (days),
360/// `w` (weeks), `mo` (months), or `y` (years) — or an ISO 8601 duration
361/// (`P12M`, `P90D`, `P2Y`, `P8W`). Months and years use the average
362/// Gregorian length, so `12mo`, `1y`, and `P1Y` all resolve to
363/// 365 days.
364///
365/// # Errors
366///
367/// Returns [`Error::InvalidWindow`] when the input is empty, carries an
368/// unrecognised unit, or has a non-numeric magnitude.
369pub fn parse_window(spec: &str) -> Result<i64, Error> {
370 let trimmed = spec.trim();
371 if trimmed.is_empty() {
372 return Err(window_error(spec, "is empty"));
373 }
374 if let Some(rest) = trimmed.strip_prefix(['P', 'p']) {
375 return parse_iso8601(rest, spec);
376 }
377 // Suffix form: split the trailing alphabetic unit from the leading
378 // numeric magnitude.
379 let split = trimmed
380 .find(|c: char| c.is_ascii_alphabetic())
381 .ok_or_else(|| window_error(spec, "has no unit"))?;
382 let (number, unit) = trimmed.split_at(split);
383 // Report the full original input rather than the split-off magnitude:
384 // for "bogus" the magnitude is empty, and quoting `""` told the user
385 // nothing about what they typed (issue #607).
386 let magnitude: i64 = number
387 .trim()
388 .parse()
389 .map_err(|_| window_error(spec, "has a non-numeric magnitude"))?;
390 let factor = unit_factor(unit)
391 .ok_or_else(|| window_error(spec, &format!("has an unknown unit {unit:?}")))?;
392 checked_window(magnitude, factor, spec)
393}
394
395/// Build an [`Error::InvalidWindow`] that quotes the full offending input
396/// and appends the accepted-format hint, so every window-parse failure
397/// names what the user typed and how to fix it (issue #607).
398fn window_error(spec: &str, problem: &str) -> Error {
399 Error::InvalidWindow(format!("{spec:?} {problem} ({WINDOW_FORMAT_HINT})"))
400}
401
402/// Seconds-per-unit for the suffix form. `mo` is months (the bare `m`
403/// is intentionally rejected as ambiguous between minutes and months).
404fn unit_factor(unit: &str) -> Option<i64> {
405 match unit {
406 "d" => Some(SECONDS_PER_DAY),
407 "w" => Some(SECONDS_PER_WEEK),
408 "mo" => Some(SECONDS_PER_MONTH),
409 "y" => Some(SECONDS_PER_YEAR),
410 _ => None,
411 }
412}
413
414/// Parse the post-`P` body of an ISO 8601 duration. Only the date
415/// portion is meaningful for a history window; a `T` time section is
416/// rejected rather than silently ignored.
417fn parse_iso8601(body: &str, original: &str) -> Result<i64, Error> {
418 if body.is_empty() {
419 return Err(window_error(original, "has no fields"));
420 }
421 let mut total: i64 = 0;
422 let mut digits = String::new();
423 for ch in body.chars() {
424 if ch.is_ascii_digit() {
425 digits.push(ch);
426 continue;
427 }
428 if digits.is_empty() {
429 return Err(window_error(
430 original,
431 &format!("field {ch:?} has no magnitude"),
432 ));
433 }
434 let magnitude: i64 = digits
435 .parse()
436 .map_err(|_| window_error(original, "has a non-numeric magnitude"))?;
437 digits.clear();
438 // Date designators only: Y, M (months — date context), W, D.
439 let factor = match ch {
440 'Y' => SECONDS_PER_YEAR,
441 'M' => SECONDS_PER_MONTH,
442 'W' => SECONDS_PER_WEEK,
443 'D' => SECONDS_PER_DAY,
444 _ => {
445 return Err(window_error(
446 original,
447 &format!("has an unsupported ISO 8601 designator {ch:?}"),
448 ));
449 }
450 };
451 total = total
452 .checked_add(
453 magnitude
454 .checked_mul(factor)
455 .ok_or_else(|| window_error(original, "overflows"))?,
456 )
457 .ok_or_else(|| window_error(original, "overflows"))?;
458 }
459 if !digits.is_empty() {
460 return Err(window_error(
461 original,
462 &format!("ends with a magnitude {digits:?} lacking a designator"),
463 ));
464 }
465 reject_non_positive(total, original)
466}
467
468/// Multiply a magnitude by its unit factor, rejecting negatives and
469/// overflow.
470fn checked_window(magnitude: i64, factor: i64, spec: &str) -> Result<i64, Error> {
471 if magnitude < 0 {
472 return Err(window_error(spec, "is negative"));
473 }
474 let product = magnitude
475 .checked_mul(factor)
476 .ok_or_else(|| window_error(spec, "overflows"))?;
477 reject_non_positive(product, spec)
478}
479
480/// A zero-length window degenerates the walk (its boundary collapses
481/// onto `now`, admitting no history), so reject it rather than silently
482/// producing an empty result.
483fn reject_non_positive(seconds: i64, spec: &str) -> Result<i64, Error> {
484 if seconds <= 0 {
485 return Err(window_error(spec, "is not a positive duration"));
486 }
487 Ok(seconds)
488}
489
490#[cfg(test)]
491#[path = "options_tests.rs"]
492mod tests;