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    //
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;