Skip to main content

grit_lib/
diagnostics.rs

1//! Git-style diagnostic prefixes and typed warning sinks for embedders and the CLI.
2//!
3//! Prefix helpers keep `fatal:` / `warning:` literals out of call sites. Structured
4//! [`Warning`] values flow through [`DiagnosticSink`] implementations; human-readable
5//! text is formatted by embedders (for example `grit-cli`), not by `grit-lib`.
6//!
7//! Library guide: [diagnostics](https://grit-scm.com/docs/library/embedding)
8//!
9//! ```rust
10//! use grit_lib::repo::{InitOptions, Repository};
11//! # fn main() -> grit_lib::error::Result<()> {
12//! let dir = tempfile::tempdir()?;
13//! let _repo = Repository::init(dir.path(), &InitOptions::default())?;
14//! # Ok(())
15//! # }
16//! ```
17
18use std::collections::HashSet;
19use std::path::PathBuf;
20use std::sync::{Arc, Mutex};
21
22/// Prefix a body with Git's `warning: ` diagnostic marker.
23#[must_use]
24pub fn warning_line(body: &str) -> String {
25    ["warning", ": ", body].concat()
26}
27
28/// Prefix a body with Git's `error: ` diagnostic marker.
29#[must_use]
30pub fn error_line(body: &str) -> String {
31    ["error", ": ", body].concat()
32}
33
34/// Prefix a body with Git's `fatal: ` diagnostic marker.
35#[must_use]
36pub fn fatal_line(body: &str) -> String {
37    ["fatal", ": ", body].concat()
38}
39
40/// A non-fatal condition worth surfacing to the user or an embedder.
41#[non_exhaustive]
42#[derive(Debug, Clone, PartialEq, Eq, Hash)]
43pub enum Warning {
44    /// A ref name could mean both a branch/tag ref and an object id prefix.
45    AmbiguousRefname {
46        /// User-supplied revision or pathspec fragment.
47        spec: String,
48    },
49    /// A symbolic ref pointed at a missing or invalid target (except bare `HEAD`).
50    DanglingSymref {
51        /// Pack file name or config identifier.
52        name: String,
53    },
54    /// Repository config was skipped because the git directory uses an unsupported format.
55    IgnoredGitDir {
56        /// Repository-relative or filesystem path related to the failure.
57        path: PathBuf,
58        /// Why the value or operation was rejected.
59        reason: String,
60    },
61    /// `core.bare=true` while `core.worktree` is also set.
62    CoreBareWithWorktree,
63    /// A config value looked boolean but could not be parsed.
64    BadBooleanConfig {
65        /// Config key name that failed validation.
66        key: String,
67        /// Raw config value that could not be parsed.
68        value: String,
69    },
70    /// `mailmap.blob` (or the bare-repository `HEAD:.mailmap` default) names an object that
71    /// is not a blob; it was skipped.
72    MailmapUnreadable {
73        /// The configured blob revision, as written.
74        spec: String,
75        /// Kind of the object the revision resolved to.
76        kind: crate::objects::ObjectKind,
77    },
78    /// A ref was updated but its reflog line could not be appended; the update stands.
79    ReflogNotWritten {
80        /// Ref whose reflog was not updated (for example `refs/heads/main` or `HEAD`).
81        refname: String,
82        /// Why the append failed.
83        detail: String,
84    },
85    /// Reflog `@{date}` cutoff is older than every reflog entry; the oldest entry was used.
86    ReflogDateBeforeOldestEntry {
87        /// Human-readable ref label from the revision spec.
88        ref_display: String,
89        /// Parsed cutoff instant (Unix seconds).
90        cutoff_sec: i64,
91    },
92    /// A traditional hook script exists but is not executable.
93    NonExecutableHookIgnored {
94        /// Hook script basename that was skipped.
95        hook_name: String,
96        /// Resolved path to the hook script, when known.
97        hook_path: Option<PathBuf>,
98    },
99    /// A hook whose outcome does not affect the operation (such as `post-commit`) could not be
100    /// run; the operation already completed.
101    HookNotRun {
102        /// Hook event name (`post-commit`, …).
103        hook_name: String,
104        /// Why it did not run, including the underlying OS error.
105        detail: String,
106    },
107    /// Returned when midx checksum mismatch.
108    MidxChecksumMismatch,
109    /// Returned when midx ignoring existing checksum mismatch.
110    MidxIgnoringExistingChecksumMismatch,
111    /// Returned when midx chunk table error.
112    MidxChunkTableError {
113        /// Additional human-readable detail for the failure.
114        detail: String,
115    },
116    /// Returned when midx rev index wrong size.
117    MidxRevIndexWrongSize,
118    /// Returned when midx bitmap missing reverse index.
119    MidxBitmapMissingReverseIndex,
120    /// Returned when midx pack index unavailable.
121    MidxPackIndexUnavailable {
122        /// Pack file basename (`.pack` suffix).
123        pack: String,
124    },
125    /// Returned when midx unknown preferred pack.
126    MidxUnknownPreferredPack {
127        /// Pack file name or config identifier.
128        name: String,
129    },
130    /// A `.bitmap` sidecar was present but could not be loaded; reachability falls back without it.
131    PackBitmapIgnored {
132        /// Repository-relative or filesystem path related to the failure.
133        path: PathBuf,
134        /// Why the value or operation was rejected.
135        reason: String,
136    },
137    /// A work-tree path could not be accessed (for example permission denied); the scan continued.
138    WorktreeAccessFailed {
139        /// Repository-relative path (directories often end with `/`).
140        path: String,
141        /// OS error detail.
142        detail: String,
143    },
144}
145
146/// Optional trace events (network debugging, etc.), separate from [`Warning`].
147#[non_exhaustive]
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub enum Trace {
150    /// Returned when network.
151    Network {
152        /// Human-readable message body.
153        message: String,
154    },
155}
156
157/// Receives warnings and optional trace lines from `grit-lib`.
158pub trait DiagnosticSink: Send + Sync {
159    /// Record a non-fatal warning.
160    fn warn(&self, warning: Warning);
161    /// Record a trace event (no-op by default).
162    fn trace(&self, event: Trace) {
163        let _ = event;
164    }
165}
166
167/// Discards all diagnostics (default for [`crate::repo::Repository`]).
168#[derive(Debug, Default, Clone, Copy)]
169pub struct NullDiagnostics;
170
171impl DiagnosticSink for NullDiagnostics {
172    fn warn(&self, _warning: Warning) {}
173}
174
175/// Collects warnings (and traces) for tests and the CLI.
176#[derive(Debug)]
177pub struct CollectingDiagnostics {
178    warnings: Mutex<Vec<Warning>>,
179    traces: Mutex<Vec<Trace>>,
180    dedupe: bool,
181    seen: Mutex<HashSet<Warning>>,
182}
183
184impl Default for CollectingDiagnostics {
185    fn default() -> Self {
186        Self::new()
187    }
188}
189
190impl CollectingDiagnostics {
191    /// Create a collector that records every warning.
192    #[must_use]
193    pub fn new() -> Self {
194        Self {
195            warnings: Mutex::new(Vec::new()),
196            traces: Mutex::new(Vec::new()),
197            dedupe: false,
198            seen: Mutex::new(HashSet::new()),
199        }
200    }
201
202    /// When `dedupe` is true, identical [`Warning`] values are stored at most once.
203    #[must_use]
204    pub fn with_dedupe(dedupe: bool) -> Self {
205        Self {
206            dedupe,
207            ..Self::new()
208        }
209    }
210
211    /// Snapshot collected warnings (clone of each entry).
212    #[must_use]
213    pub fn warnings(&self) -> Vec<Warning> {
214        self.warnings
215            .lock()
216            .unwrap_or_else(|e| e.into_inner())
217            .clone()
218    }
219
220    /// Snapshot collected trace events.
221    #[must_use]
222    pub fn traces(&self) -> Vec<Trace> {
223        self.traces
224            .lock()
225            .unwrap_or_else(|e| e.into_inner())
226            .clone()
227    }
228
229    /// Drop all collected warnings and traces.
230    pub fn clear(&self) {
231        self.warnings
232            .lock()
233            .unwrap_or_else(|e| e.into_inner())
234            .clear();
235        self.traces
236            .lock()
237            .unwrap_or_else(|e| e.into_inner())
238            .clear();
239        self.seen.lock().unwrap_or_else(|e| e.into_inner()).clear();
240    }
241}
242
243impl DiagnosticSink for CollectingDiagnostics {
244    fn warn(&self, warning: Warning) {
245        if self.dedupe {
246            let mut seen = self.seen.lock().unwrap_or_else(|e| e.into_inner());
247            if !seen.insert(warning.clone()) {
248                return;
249            }
250        }
251        self.warnings
252            .lock()
253            .unwrap_or_else(|e| e.into_inner())
254            .push(warning);
255    }
256
257    fn trace(&self, event: Trace) {
258        self.traces
259            .lock()
260            .unwrap_or_else(|e| e.into_inner())
261            .push(event);
262    }
263}
264
265/// Shared handle used when threading diagnostics through load paths.
266pub type DiagnosticsHandle = Arc<dyn DiagnosticSink + Send + Sync>;
267
268/// Emit `warning` through `handle`.
269pub fn warn(handle: &DiagnosticsHandle, warning: Warning) {
270    handle.warn(warning);
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn collecting_diagnostics_records_warnings() {
279        let sink = CollectingDiagnostics::new();
280        sink.warn(Warning::AmbiguousRefname { spec: "abc".into() });
281        assert_eq!(sink.warnings().len(), 1);
282    }
283
284    #[test]
285    fn collecting_diagnostics_dedupes_identical_warnings() {
286        let sink = CollectingDiagnostics::with_dedupe(true);
287        let w = Warning::DanglingSymref {
288            name: "refs/remotes/origin/HEAD".into(),
289        };
290        sink.warn(w.clone());
291        sink.warn(w);
292        assert_eq!(sink.warnings().len(), 1);
293    }
294}