Skip to main content

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/// The oldest timestamp a window of `window_secs` reaches back to from
299/// `reference`.
300///
301/// Saturating, and that is the whole point of the function existing. Both
302/// operands are attacker-adjacent: `reference` is an `--as-of` value or a
303/// committer timestamp read straight out of an object header, and
304/// `window_secs` is user-supplied up to roughly `i64::MAX` through
305/// [`parse_window`]. A plain `-` therefore panics in a debug build and, in
306/// release, wraps to a boundary in the far *future* — which silently
307/// reverses the meaning of every comparison downstream. Saturating to
308/// `i64::MIN` instead yields an unbounded-past boundary: every commit is
309/// inside the window, the safe total behaviour for a degenerate clock.
310///
311/// Every window boundary in the subsystem goes through here so the walk,
312/// the replay, and the cache all agree on the same value at the extremes
313/// (#1271).
314pub(crate) fn window_boundary(reference: i64, window_secs: i64) -> i64 {
315    reference.saturating_sub(window_secs)
316}
317
318/// Round a second count to the nearest whole day, saturating into
319/// `u32`. Window lengths never approach `u32::MAX` days in practice,
320/// but the saturation keeps the conversion total and lint-clean.
321fn secs_to_days(secs: i64) -> u32 {
322    // Saturating: `secs` in the top half-day of i64 would overflow the bare
323    // `+ SECONDS_PER_DAY / 2` rounding term. Saturating keeps i64::MAX at
324    // i64::MAX, so it divides to a huge positive day count and `try_from`
325    // saturates to u32::MAX — rather than wrapping negative and flooring to 0.
326    let days = secs.saturating_add(SECONDS_PER_DAY / 2) / SECONDS_PER_DAY;
327    u32::try_from(days.max(0)).unwrap_or(u32::MAX)
328}
329
330/// Validate a bus-factor coverage threshold, accepting only a finite
331/// value in the open interval `(0, 1)`.
332///
333/// A `0` would make the first key-developer removal "exceed" the
334/// abandonment fraction (bus factor always 1) and a `1` could never be
335/// exceeded (bus factor = every author), so both extremes are user errors
336/// rather than values to silently clamp. The single source of truth
337/// shared by every front end.
338///
339/// # Errors
340///
341/// Returns [`Error::InvalidBusFactorThreshold`] when `threshold` is
342/// non-finite or outside `(0, 1)`.
343pub fn validate_bus_factor_threshold(threshold: f64) -> Result<f64, Error> {
344    if threshold.is_finite() && threshold > 0.0 && threshold < 1.0 {
345        Ok(threshold)
346    } else {
347        Err(Error::InvalidBusFactorThreshold(format!(
348            "{threshold} is not in the open interval (0, 1)"
349        )))
350    }
351}
352
353/// Parse a human time-window string into seconds.
354///
355/// Accepts a suffix form — `<number><unit>` with unit `d` (days),
356/// `w` (weeks), `mo` (months), or `y` (years) — or an ISO 8601 duration
357/// (`P12M`, `P90D`, `P2Y`, `P8W`). Months and years use the average
358/// Gregorian length, so `12mo`, `1y`, and `P1Y` all resolve to
359/// 365 days.
360///
361/// # Errors
362///
363/// Returns [`Error::InvalidWindow`] when the input is empty, carries an
364/// unrecognised unit, or has a non-numeric magnitude.
365pub fn parse_window(spec: &str) -> Result<i64, Error> {
366    let trimmed = spec.trim();
367    if trimmed.is_empty() {
368        return Err(window_error(spec, "is empty"));
369    }
370    if let Some(rest) = trimmed.strip_prefix(['P', 'p']) {
371        return parse_iso8601(rest, spec);
372    }
373    // Suffix form: split the trailing alphabetic unit from the leading
374    // numeric magnitude.
375    let split = trimmed
376        .find(|c: char| c.is_ascii_alphabetic())
377        .ok_or_else(|| window_error(spec, "has no unit"))?;
378    let (number, unit) = trimmed.split_at(split);
379    // Report the full original input rather than the split-off magnitude:
380    // for "bogus" the magnitude is empty, and quoting `""` told the user
381    // nothing about what they typed (issue #607).
382    let magnitude: i64 = number
383        .trim()
384        .parse()
385        .map_err(|_| window_error(spec, "has a non-numeric magnitude"))?;
386    let factor = unit_factor(unit)
387        .ok_or_else(|| window_error(spec, &format!("has an unknown unit {unit:?}")))?;
388    checked_window(magnitude, factor, spec)
389}
390
391/// Build an [`Error::InvalidWindow`] that quotes the full offending input
392/// and appends the accepted-format hint, so every window-parse failure
393/// names what the user typed and how to fix it (issue #607).
394fn window_error(spec: &str, problem: &str) -> Error {
395    Error::InvalidWindow(format!("{spec:?} {problem} ({WINDOW_FORMAT_HINT})"))
396}
397
398/// Seconds-per-unit for the suffix form. `mo` is months (the bare `m`
399/// is intentionally rejected as ambiguous between minutes and months).
400fn unit_factor(unit: &str) -> Option<i64> {
401    match unit {
402        "d" => Some(SECONDS_PER_DAY),
403        "w" => Some(SECONDS_PER_WEEK),
404        "mo" => Some(SECONDS_PER_MONTH),
405        "y" => Some(SECONDS_PER_YEAR),
406        _ => None,
407    }
408}
409
410/// Parse the post-`P` body of an ISO 8601 duration. Only the date
411/// portion is meaningful for a history window; a `T` time section is
412/// rejected rather than silently ignored.
413fn parse_iso8601(body: &str, original: &str) -> Result<i64, Error> {
414    if body.is_empty() {
415        return Err(window_error(original, "has no fields"));
416    }
417    let mut total: i64 = 0;
418    let mut digits = String::new();
419    for ch in body.chars() {
420        if ch.is_ascii_digit() {
421            digits.push(ch);
422            continue;
423        }
424        if digits.is_empty() {
425            return Err(window_error(
426                original,
427                &format!("field {ch:?} has no magnitude"),
428            ));
429        }
430        let magnitude: i64 = digits
431            .parse()
432            .map_err(|_| window_error(original, "has a non-numeric magnitude"))?;
433        digits.clear();
434        // Date designators only: Y, M (months — date context), W, D.
435        let factor = match ch {
436            'Y' => SECONDS_PER_YEAR,
437            'M' => SECONDS_PER_MONTH,
438            'W' => SECONDS_PER_WEEK,
439            'D' => SECONDS_PER_DAY,
440            _ => {
441                return Err(window_error(
442                    original,
443                    &format!("has an unsupported ISO 8601 designator {ch:?}"),
444                ));
445            }
446        };
447        total = total
448            .checked_add(
449                magnitude
450                    .checked_mul(factor)
451                    .ok_or_else(|| window_error(original, "overflows"))?,
452            )
453            .ok_or_else(|| window_error(original, "overflows"))?;
454    }
455    if !digits.is_empty() {
456        return Err(window_error(
457            original,
458            &format!("ends with a magnitude {digits:?} lacking a designator"),
459        ));
460    }
461    reject_non_positive(total, original)
462}
463
464/// Multiply a magnitude by its unit factor, rejecting negatives and
465/// overflow.
466fn checked_window(magnitude: i64, factor: i64, spec: &str) -> Result<i64, Error> {
467    if magnitude < 0 {
468        return Err(window_error(spec, "is negative"));
469    }
470    let product = magnitude
471        .checked_mul(factor)
472        .ok_or_else(|| window_error(spec, "overflows"))?;
473    reject_non_positive(product, spec)
474}
475
476/// A zero-length window degenerates the walk (its boundary collapses
477/// onto `now`, admitting no history), so reject it rather than silently
478/// producing an empty result.
479fn reject_non_positive(seconds: i64, spec: &str) -> Result<i64, Error> {
480    if seconds <= 0 {
481        return Err(window_error(spec, "is not a positive duration"));
482    }
483    Ok(seconds)
484}
485
486#[cfg(test)]
487#[path = "options_tests.rs"]
488mod tests;