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 pub author_hash_key: Option<AuthorHashKey>,
235 /// Emit stats for files deleted at the target ref (default: off).
236 pub include_deleted: bool,
237 /// Compute the directory- / repo-level bus-factor aggregate from the
238 /// walk (issue #332). Default off: it retains per-file authorship
239 /// beyond the per-file [`Stats`](crate::vcs::Stats), which the
240 /// repeated JIT-prior and per-file-injection walks neither need nor
241 /// should pay for.
242 pub compute_bus_factor: bool,
243 /// Coverage (abandonment) threshold for the bus factor, in `(0, 1)`
244 /// — the fraction of files that must be orphaned for the greedy
245 /// removal to stop (default [`DEFAULT_BUS_FACTOR_THRESHOLD`], `0.5`
246 /// per Avelino). Ignored unless `compute_bus_factor` is set.
247 pub bus_factor_threshold: f64,
248 /// Which tracked files to rank (issue #576). Defaults to
249 /// [`FileTypeScope::Metrics`] — only files bca has metrics for — so
250 /// high-churn non-source files do not dominate the risk ranking and
251 /// the change-history view aligns with the AST hotspot tables.
252 pub file_types: FileTypeScope,
253}
254
255impl Default for Options {
256 fn default() -> Self {
257 Self {
258 // The default-window constants are valid by construction;
259 // `expect` documents the invariant (AGENTS.md permits it
260 // for provably-unreachable cases). A unit test pins it.
261 long_window_secs: parse_window(DEFAULT_LONG_WINDOW)
262 .expect("DEFAULT_LONG_WINDOW parses"),
263 recent_window_secs: parse_window(DEFAULT_RECENT_WINDOW)
264 .expect("DEFAULT_RECENT_WINDOW parses"),
265 reference: "HEAD".to_owned(),
266 full_history: false,
267 include_merges: false,
268 follow_renames: true,
269 exclude_bots: true,
270 bot_pattern: DEFAULT_BOT_PATTERN.to_owned(),
271 as_of: None,
272 risk_formula: RiskFormula::Weighted,
273 emit_author_details: false,
274 author_hash_key: None,
275 include_deleted: false,
276 compute_bus_factor: false,
277 bus_factor_threshold: DEFAULT_BUS_FACTOR_THRESHOLD,
278 file_types: FileTypeScope::Metrics,
279 }
280 }
281}
282
283impl Options {
284 /// Long window expressed in whole days (for the serialized
285 /// `long_window_days` field and the `new_file`/age cap).
286 #[must_use]
287 pub fn long_window_days(&self) -> u32 {
288 secs_to_days(self.long_window_secs)
289 }
290
291 /// Recent window expressed in whole days.
292 #[must_use]
293 pub fn recent_window_days(&self) -> u32 {
294 secs_to_days(self.recent_window_secs)
295 }
296}
297
298/// Round a second count to the nearest whole day, saturating into
299/// `u32`. Window lengths never approach `u32::MAX` days in practice,
300/// but the saturation keeps the conversion total and lint-clean.
301fn secs_to_days(secs: i64) -> u32 {
302 // Saturating: `secs` in the top half-day of i64 would overflow the bare
303 // `+ SECONDS_PER_DAY / 2` rounding term. Saturating keeps i64::MAX at
304 // i64::MAX, so it divides to a huge positive day count and `try_from`
305 // saturates to u32::MAX — rather than wrapping negative and flooring to 0.
306 let days = secs.saturating_add(SECONDS_PER_DAY / 2) / SECONDS_PER_DAY;
307 u32::try_from(days.max(0)).unwrap_or(u32::MAX)
308}
309
310/// Validate a bus-factor coverage threshold, accepting only a finite
311/// value in the open interval `(0, 1)`.
312///
313/// A `0` would make the first key-developer removal "exceed" the
314/// abandonment fraction (bus factor always 1) and a `1` could never be
315/// exceeded (bus factor = every author), so both extremes are user errors
316/// rather than values to silently clamp. The single source of truth
317/// shared by every front end.
318///
319/// # Errors
320///
321/// Returns [`Error::InvalidBusFactorThreshold`] when `threshold` is
322/// non-finite or outside `(0, 1)`.
323pub fn validate_bus_factor_threshold(threshold: f64) -> Result<f64, Error> {
324 if threshold.is_finite() && threshold > 0.0 && threshold < 1.0 {
325 Ok(threshold)
326 } else {
327 Err(Error::InvalidBusFactorThreshold(format!(
328 "{threshold} is not in the open interval (0, 1)"
329 )))
330 }
331}
332
333/// Parse a human time-window string into seconds.
334///
335/// Accepts a suffix form — `<number><unit>` with unit `d` (days),
336/// `w` (weeks), `mo` (months), or `y` (years) — or an ISO 8601 duration
337/// (`P12M`, `P90D`, `P2Y`, `P8W`). Months and years use the average
338/// Gregorian length, so `12mo`, `1y`, and `P1Y` all resolve to
339/// 365 days.
340///
341/// # Errors
342///
343/// Returns [`Error::InvalidWindow`] when the input is empty, carries an
344/// unrecognised unit, or has a non-numeric magnitude.
345pub fn parse_window(spec: &str) -> Result<i64, Error> {
346 let trimmed = spec.trim();
347 if trimmed.is_empty() {
348 return Err(window_error(spec, "is empty"));
349 }
350 if let Some(rest) = trimmed.strip_prefix(['P', 'p']) {
351 return parse_iso8601(rest, spec);
352 }
353 // Suffix form: split the trailing alphabetic unit from the leading
354 // numeric magnitude.
355 let split = trimmed
356 .find(|c: char| c.is_ascii_alphabetic())
357 .ok_or_else(|| window_error(spec, "has no unit"))?;
358 let (number, unit) = trimmed.split_at(split);
359 // Report the full original input rather than the split-off magnitude:
360 // for "bogus" the magnitude is empty, and quoting `""` told the user
361 // nothing about what they typed (issue #607).
362 let magnitude: i64 = number
363 .trim()
364 .parse()
365 .map_err(|_| window_error(spec, "has a non-numeric magnitude"))?;
366 let factor = unit_factor(unit)
367 .ok_or_else(|| window_error(spec, &format!("has an unknown unit {unit:?}")))?;
368 checked_window(magnitude, factor, spec)
369}
370
371/// Build an [`Error::InvalidWindow`] that quotes the full offending input
372/// and appends the accepted-format hint, so every window-parse failure
373/// names what the user typed and how to fix it (issue #607).
374fn window_error(spec: &str, problem: &str) -> Error {
375 Error::InvalidWindow(format!("{spec:?} {problem} ({WINDOW_FORMAT_HINT})"))
376}
377
378/// Seconds-per-unit for the suffix form. `mo` is months (the bare `m`
379/// is intentionally rejected as ambiguous between minutes and months).
380fn unit_factor(unit: &str) -> Option<i64> {
381 match unit {
382 "d" => Some(SECONDS_PER_DAY),
383 "w" => Some(SECONDS_PER_WEEK),
384 "mo" => Some(SECONDS_PER_MONTH),
385 "y" => Some(SECONDS_PER_YEAR),
386 _ => None,
387 }
388}
389
390/// Parse the post-`P` body of an ISO 8601 duration. Only the date
391/// portion is meaningful for a history window; a `T` time section is
392/// rejected rather than silently ignored.
393fn parse_iso8601(body: &str, original: &str) -> Result<i64, Error> {
394 if body.is_empty() {
395 return Err(window_error(original, "has no fields"));
396 }
397 let mut total: i64 = 0;
398 let mut digits = String::new();
399 for ch in body.chars() {
400 if ch.is_ascii_digit() {
401 digits.push(ch);
402 continue;
403 }
404 if digits.is_empty() {
405 return Err(window_error(
406 original,
407 &format!("field {ch:?} has no magnitude"),
408 ));
409 }
410 let magnitude: i64 = digits
411 .parse()
412 .map_err(|_| window_error(original, "has a non-numeric magnitude"))?;
413 digits.clear();
414 // Date designators only: Y, M (months — date context), W, D.
415 let factor = match ch {
416 'Y' => SECONDS_PER_YEAR,
417 'M' => SECONDS_PER_MONTH,
418 'W' => SECONDS_PER_WEEK,
419 'D' => SECONDS_PER_DAY,
420 _ => {
421 return Err(window_error(
422 original,
423 &format!("has an unsupported ISO 8601 designator {ch:?}"),
424 ));
425 }
426 };
427 total = total
428 .checked_add(
429 magnitude
430 .checked_mul(factor)
431 .ok_or_else(|| window_error(original, "overflows"))?,
432 )
433 .ok_or_else(|| window_error(original, "overflows"))?;
434 }
435 if !digits.is_empty() {
436 return Err(window_error(
437 original,
438 &format!("ends with a magnitude {digits:?} lacking a designator"),
439 ));
440 }
441 reject_non_positive(total, original)
442}
443
444/// Multiply a magnitude by its unit factor, rejecting negatives and
445/// overflow.
446fn checked_window(magnitude: i64, factor: i64, spec: &str) -> Result<i64, Error> {
447 if magnitude < 0 {
448 return Err(window_error(spec, "is negative"));
449 }
450 let product = magnitude
451 .checked_mul(factor)
452 .ok_or_else(|| window_error(spec, "overflows"))?;
453 reject_non_positive(product, spec)
454}
455
456/// A zero-length window degenerates the walk (its boundary collapses
457/// onto `now`, admitting no history), so reject it rather than silently
458/// producing an empty result.
459fn reject_non_positive(seconds: i64, spec: &str) -> Result<i64, Error> {
460 if seconds <= 0 {
461 return Err(window_error(spec, "is not a positive duration"));
462 }
463 Ok(seconds)
464}
465
466#[cfg(test)]
467#[path = "options_tests.rs"]
468mod tests;