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