Skip to main content

big_code_analysis/vcs/
error.rs

1//! Error type for the change-history (VCS) metrics pipeline.
2//!
3//! Generic over the backend: backend-specific failures (a `gix` open
4//! error, a rev-walk failure, a blob-diff failure) are mapped to
5//! string-carrying variants here rather than leaking the backend's
6//! concrete error types into the generic surface. This keeps the
7//! generic module tree free of any `gix` reference so a future
8//! backend (`vcs-hg`, `vcs-jj`) can reuse it unchanged (issue #335).
9
10use std::path::PathBuf;
11
12/// Error returned by [`build_history_index`](crate::vcs::build_history_index)
13/// and the surrounding VCS pipeline.
14///
15/// `#[non_exhaustive]` so new variants land additively as backends and
16/// edge cases accrue; match with a trailing `_` arm to stay
17/// forward-compatible.
18#[non_exhaustive]
19#[derive(Debug)]
20pub enum Error {
21    /// The supplied path is not inside the working tree of any
22    /// supported VCS. Distinct from a repository with no tracked text
23    /// files at the target ref, which succeeds with an empty index. (A
24    /// freshly-initialised repository whose `HEAD` is unborn instead
25    /// surfaces as [`Error::ResolveRef`], since there is no commit to
26    /// resolve.)
27    NotARepository(PathBuf),
28    /// Opening or discovering the repository failed for a reason other
29    /// than "no repository here" (corrupt repo, permission denied, …).
30    OpenRepository(String),
31    /// The `--ref` revision could not be resolved to a commit.
32    ResolveRef {
33        /// The revision spec the caller supplied (e.g. `HEAD`, a SHA).
34        reference: String,
35        /// Backend-rendered explanation of why resolution failed.
36        reason: String,
37    },
38    /// Walking commit history failed.
39    Walk(String),
40    /// Computing a tree-to-tree or blob diff failed.
41    Diff(String),
42    /// Loading or applying the repository `.mailmap` failed.
43    Mailmap(String),
44    /// The bot-exclusion pattern is not a valid regular expression.
45    InvalidBotPattern(String),
46    /// A configured time window could not be parsed.
47    InvalidWindow(String),
48    /// The `--as-of` timestamp could not be parsed.
49    InvalidTimestamp(String),
50    /// The risk-formula name is not one of `weighted` / `percentile`.
51    InvalidFormula(String),
52    /// The file-type scope could not be parsed: an empty value, or a
53    /// custom extension list that normalised to nothing (issue #576).
54    InvalidFileTypeScope(String),
55    /// The bus-factor coverage threshold is outside the open interval
56    /// `(0, 1)` (issue #332).
57    InvalidBusFactorThreshold(String),
58    /// The opt-in author-hash key is unusable — empty, or supplied
59    /// without `--emit-author-details` (which it has no effect without)
60    /// (issue #956).
61    InvalidAuthorHashKey(String),
62    /// The historical-trend parameters are out of range — the point
63    /// count is below the two-point minimum or above
64    /// [`MAX_TREND_POINTS`](crate::vcs::trend::MAX_TREND_POINTS) (issue
65    /// #333).
66    InvalidTrend(String),
67    /// Blaming a file for per-function attribution failed (issue #329).
68    Blame(String),
69    /// An arbitrary unified diff supplied to
70    /// [`score_diff`](crate::vcs::score_diff) could not be parsed (issue
71    /// #580). A client-input error: the diff was malformed (a hunk header
72    /// the parser could not read, a body line outside any hunk, …).
73    InvalidDiff(String),
74    /// Reading, writing, or clearing the persistent history cache failed
75    /// (issue #334). A *missing* or *corrupt* cache entry is not an error —
76    /// it is silently ignored and the history is recomputed — so this
77    /// variant is reserved for genuine I/O failures the caller asked to
78    /// surface (e.g. `--clear-cache` on an unwritable directory).
79    Cache(String),
80}
81
82/// Emits [`Error::is_client_input`] and [`Error::client_input_samples`]
83/// from a single variant list.
84///
85/// Both halves are generated from the same `client_input` group, so they
86/// cannot drift. That coupling is the point. `is_client_input` alone is
87/// already compile-forced — its generated match has no wildcard arm, so
88/// adding an [`Error`] variant fails to build until it is listed in one
89/// group or the other (issue #641). But a front end that turns a
90/// client-input variant into its own machine-readable token cannot be
91/// compile-forced in the same way: [`Error`] is `#[non_exhaustive]`, so a
92/// match in any *other* crate must carry a wildcard and the compiler has
93/// nothing to complain about. `InvalidAuthorHashKey` reached the web
94/// crate's `vcs_error_kind` wildcard for exactly that reason and was
95/// reported to clients as an internal server fault (issue #1245).
96///
97/// Listing a variant under `client_input` therefore also yields a
98/// constructed sample of it, and the web crate's token guard iterates
99/// those samples rather than a hand-written copy of the list. Adding a
100/// twelfth client-input variant is a compile error here; fixing that
101/// compile error is what makes the token guard fail until a token is
102/// chosen.
103///
104/// `client_input` takes `pat_param`, not `pat`, so that one entry cannot
105/// quietly stand for two variants. `pat` admits a top-level or-pattern,
106/// and `Self::InvalidWindow(_) | Self::InvalidTimestamp(_) => <one
107/// sample>` would keep the match exhaustive and correct while yielding a
108/// sample for only the first — reopening #1245 for the second. That is
109/// the shape a maintainer reaches for, because it is how the arms were
110/// written before this macro existed. `pat_param` rejects the `|`
111/// outright ("no rules expected `|`"). The `environment` group keeps
112/// `pat` because it produces no samples and nothing depends on its
113/// entries being one-to-one.
114macro_rules! classify_error_variants {
115    (
116        client_input { $($client:pat_param => $sample:expr),+ $(,)? }
117        environment { $($environment:pat),+ $(,)? }
118    ) => {
119        impl Error {
120            /// Whether this error was caused by client-supplied input (a bad
121            /// path, revision, window, timestamp, formula, pattern, threshold,
122            /// author-hash key, trend parameter, file-type scope, or diff) as
123            /// opposed to an environment or backend failure (opening the
124            /// repository, walking history, diffing, `.mailmap`, blame, or the
125            /// persistent cache).
126            ///
127            /// Front ends use this to choose a status: a web boundary maps
128            /// client-input errors to `400 Bad Request` and the rest to
129            /// `500 Internal Server Error` (see `vcs_error_response` in the web
130            /// crate).
131            ///
132            /// The match is intentionally exhaustive (no wildcard arm): adding a
133            /// new [`Error`] variant is a compile error here until it is
134            /// classified, which prevents the silent fall-through that twice
135            /// mis-mapped client-input variants to `500` (`InvalidFileTypeScope`,
136            /// `InvalidDiff`; see issue #641).
137            #[must_use]
138            pub fn is_client_input(&self) -> bool {
139                match self {
140                    $($client => true,)+
141                    $($environment => false,)+
142                }
143            }
144
145            /// One constructed sample per client-input variant, in
146            /// declaration order.
147            ///
148            /// Test support for front ends that must map every
149            /// client-input variant onto something of their own — a
150            /// machine-readable error token, a help string, an exit code.
151            /// `#[non_exhaustive]` denies them an exhaustive match, so
152            /// iterating these samples is the only way such a mapping can
153            /// be checked for completeness (issue #1245).
154            ///
155            /// Not part of the stability contract: `#[doc(hidden)]`, and
156            /// the payloads are placeholders whose exact text may change
157            /// at any time. Assert on the *variant*, never on a sample's
158            /// rendered message.
159            #[doc(hidden)]
160            #[must_use]
161            pub fn client_input_samples() -> Vec<Self> {
162                vec![$($sample),+]
163            }
164        }
165    };
166}
167
168// Adding an entry to `client_input` is a cross-crate obligation, not a
169// local one: every client-input variant owes the web surface its own
170// `error_kind` token (`vcs_error_kind` in `big-code-analysis-web`), plus
171// a line in the vocabulary lists in `STABILITY.md` and the book. Nothing
172// here can enforce that — `Error` is `#[non_exhaustive]`, so the web
173// match must carry a wildcard — which is why the sample below exists for
174// the guard test to iterate. One entry per variant; the samples are
175// throwaway payloads, deliberately unlike any real message.
176classify_error_variants! {
177    client_input {
178        Self::NotARepository(_) => Self::NotARepository(PathBuf::from("/not-a-repository")),
179        Self::ResolveRef { .. } => Self::ResolveRef {
180            reference: "HEAD".to_owned(),
181            reason: "unborn branch".to_owned(),
182        },
183        Self::InvalidBotPattern(_) => Self::InvalidBotPattern("[".to_owned()),
184        Self::InvalidWindow(_) => Self::InvalidWindow("banana".to_owned()),
185        Self::InvalidTimestamp(_) => Self::InvalidTimestamp("yesterday".to_owned()),
186        Self::InvalidFormula(_) => Self::InvalidFormula("astrology".to_owned()),
187        Self::InvalidFileTypeScope(_) => Self::InvalidFileTypeScope(String::new()),
188        Self::InvalidBusFactorThreshold(_) => Self::InvalidBusFactorThreshold("1.5".to_owned()),
189        Self::InvalidAuthorHashKey(_) => Self::InvalidAuthorHashKey("hunter2".to_owned()),
190        Self::InvalidTrend(_) => Self::InvalidTrend("1".to_owned()),
191        Self::InvalidDiff(_) => Self::InvalidDiff("not a unified diff".to_owned()),
192    }
193    environment {
194        Self::OpenRepository(_),
195        Self::Walk(_),
196        Self::Diff(_),
197        Self::Mailmap(_),
198        Self::Blame(_),
199        Self::Cache(_),
200    }
201}
202
203impl std::fmt::Display for Error {
204    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205        // bca: suppress(cyclomatic)
206        // Exhaustive one-write-per-variant Display match: cyclomatic here
207        // is the variant count, not branching logic, and the arms mirror
208        // the compile-enforced `Error` enum one-to-one. Splitting it into
209        // sub-matches would be an arbitrary partition with no semantic
210        // boundary that would drift out of sync with the enum.
211        match self {
212            Self::NotARepository(path) => {
213                write!(
214                    f,
215                    "{} is not inside a supported version-control working tree",
216                    path.display()
217                )
218            }
219            Self::OpenRepository(reason) => write!(f, "failed to open repository: {reason}"),
220            Self::ResolveRef { reference, reason } => {
221                write!(f, "failed to resolve revision {reference:?}: {reason}")
222            }
223            Self::Walk(reason) => write!(f, "failed to walk commit history: {reason}"),
224            Self::Diff(reason) => write!(f, "failed to compute diff: {reason}"),
225            Self::Mailmap(reason) => write!(f, "failed to apply .mailmap: {reason}"),
226            Self::InvalidBotPattern(reason) => write!(f, "invalid bot pattern: {reason}"),
227            Self::InvalidWindow(reason) => write!(f, "invalid time window: {reason}"),
228            Self::InvalidTimestamp(reason) => write!(f, "invalid timestamp: {reason}"),
229            Self::InvalidFormula(name) => write!(
230                f,
231                "unknown risk formula {name:?} (expected `weighted` or `percentile`)"
232            ),
233            Self::InvalidFileTypeScope(reason) => write!(f, "invalid file-type scope: {reason}"),
234            Self::InvalidBusFactorThreshold(reason) => {
235                write!(f, "invalid bus-factor threshold: {reason}")
236            }
237            Self::InvalidAuthorHashKey(reason) => {
238                write!(f, "invalid author-hash key: {reason}")
239            }
240            Self::InvalidTrend(reason) => write!(f, "invalid trend parameters: {reason}"),
241            Self::Blame(reason) => write!(f, "failed to blame file: {reason}"),
242            Self::InvalidDiff(reason) => write!(f, "invalid unified diff: {reason}"),
243            Self::Cache(reason) => write!(f, "history cache error: {reason}"),
244        }
245    }
246}
247
248impl std::error::Error for Error {}
249
250#[cfg(test)]
251#[path = "error_tests.rs"]
252mod tests;