Skip to main content

vcs_modify_guard/allow_options/
mod.rs

1use std::path::{Path, PathBuf};
2
3use crate::{
4    ModifyGuardError,
5    repository::{Repository, RepositoryChanges},
6};
7
8#[cfg(test)]
9mod tests;
10
11/// Options for `--allow-*` style safety checks before modifying files.
12///
13/// This is the main entry point for most users of this crate.
14///
15/// This type matches the semantics of `cargo fix`:
16///
17/// - `allow_no_vcs` treats modification as safe even when no supported VCS
18///   repository is found
19/// - `allow_dirty` treats modification as safe even when the path is dirty or
20///   has staged changes
21/// - `allow_staged` treats modification as safe even when the path has staged
22///   changes, but still considers dirty files unsafe
23///
24/// These options are not interpreted independently. Higher-precedence options
25/// imply lower-precedence ones, matching `cargo fix`:
26///
27/// - `allow_no_vcs` skips repository discovery and repository state checks
28///   entirely
29/// - `allow_dirty` still requires repository discovery, but implies
30///   `allow_staged` and skips dirty and staged change checks
31/// - `allow_staged` still requires repository discovery and change queries,
32///   but dirty files remain unsafe
33///
34/// By default, checks are scoped to the queried path. Use
35/// [`Self::check_entire_repository`] to check the containing repository as a
36/// whole instead.
37///
38/// # Example
39///
40/// ```no_run
41/// use std::path::Path;
42///
43/// use vcs_modify_guard::{AllowOptions, ModificationSafety, UnsafeModificationReason};
44///
45/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
46/// let safety = AllowOptions::new()
47///     .allow_staged(true)
48///     .check_safe_to_modify(Path::new("."))?;
49///
50/// match safety {
51///     ModificationSafety::Safe => {}
52///     ModificationSafety::Unsafe(reason) => match reason {
53///         UnsafeModificationReason::NoVcs => {
54///             eprintln!("The target path is not in a VCS repository.");
55///             return Err("blocked by no VCS".into());
56///         }
57///         UnsafeModificationReason::Dirty {
58///             dirty_files,
59///             staged_files,
60///             ..
61///         } => {
62///             eprintln!("Dirty files:");
63///             for wt_path in dirty_files {
64///                 eprintln!("* {}", wt_path.display());
65///             }
66///             for wt_path in staged_files {
67///                 eprintln!("* {} (staged)", wt_path.display());
68///             }
69///             return Err("blocked by dirty files".into());
70///         }
71///         UnsafeModificationReason::Staged { staged_files, .. } => {
72///             eprintln!("Staged files:");
73///             for wt_path in staged_files {
74///                 eprintln!("* {}", wt_path.display());
75///             }
76///             return Err("blocked by staged changes".into());
77///         }
78///         _ => {
79///             eprintln!("The target path has unsafe modifications under it.");
80///             return Err("blocked by unsafe modifications".into());
81///         }
82///     },
83/// }
84/// # Ok(())
85/// # }
86/// ```
87#[expect(
88    missing_copy_implementations,
89    reason = "Copy is intentionally not part of the API contract"
90)]
91#[expect(
92    clippy::struct_excessive_bools,
93    reason = "This struct represents independent `--allow-*` and scope configuration flags whose combinations are meaningful, not a state machine"
94)]
95#[derive(Debug, Clone)]
96pub struct AllowOptions {
97    allow_no_vcs: bool,
98    allow_dirty: bool,
99    allow_staged: bool,
100    check_entire_repository: bool,
101}
102
103impl Default for AllowOptions {
104    #[inline]
105    fn default() -> Self {
106        Self::new()
107    }
108}
109
110impl AllowOptions {
111    /// Creates an `AllowOptions` value with all `--allow-*` options disabled.
112    #[inline]
113    #[must_use]
114    pub const fn new() -> Self {
115        Self {
116            allow_no_vcs: false,
117            allow_dirty: false,
118            allow_staged: false,
119            check_entire_repository: false,
120        }
121    }
122
123    /// Sets whether to use `cargo fix`-style `--allow-no-vcs` behavior.
124    ///
125    /// When enabled, this skips repository discovery and repository state
126    /// checks entirely, matching `cargo fix`. In other words, this does
127    /// more than only relax the "no repository found" case.
128    #[inline]
129    #[must_use]
130    pub const fn allow_no_vcs(mut self, enabled: bool) -> Self {
131        self.allow_no_vcs = enabled;
132        self
133    }
134
135    /// Sets whether to use `cargo fix`-style `--allow-dirty` behavior.
136    ///
137    /// When enabled, this still requires repository discovery unless
138    /// [`Self::allow_no_vcs`] is enabled, but it treats both dirty files and
139    /// staged changes as safe. This also implies [`Self::allow_staged`].
140    #[inline]
141    #[must_use]
142    pub const fn allow_dirty(mut self, enabled: bool) -> Self {
143        self.allow_dirty = enabled;
144        self
145    }
146
147    /// Sets whether to use `cargo fix`-style `--allow-staged` behavior.
148    ///
149    /// When enabled, this still requires repository discovery and change
150    /// queries unless [`Self::allow_no_vcs`] is enabled. Dirty files are still
151    /// considered unsafe.
152    #[inline]
153    #[must_use]
154    pub const fn allow_staged(mut self, enabled: bool) -> Self {
155        self.allow_staged = enabled;
156        self
157    }
158
159    /// Sets whether the safety check should cover the entire containing
160    /// repository rather than only the queried path.
161    #[inline]
162    #[must_use]
163    pub const fn check_entire_repository(mut self, enabled: bool) -> Self {
164        self.check_entire_repository = enabled;
165        self
166    }
167
168    fn find_changes<R>(
169        &self,
170        repo: &R,
171        path: &Path,
172    ) -> Result<Option<RepositoryChanges>, ModifyGuardError>
173    where
174        R: AllowOptionsRepository,
175    {
176        if self.check_entire_repository {
177            repo.repository_changes()
178        } else {
179            let wt_path = repo.resolve_path(path)?;
180            repo.path_changes(&wt_path)
181        }
182    }
183
184    /// Checks whether modification of `path` is considered safe under the
185    /// current `--allow-*` settings.
186    ///
187    /// Flag handling matches `cargo fix`:
188    ///
189    /// - [`Self::allow_no_vcs`] returns [`ModificationSafety::Safe`] and skips
190    ///   repository discovery and repository state checks
191    /// - [`Self::allow_dirty`] still requires repository discovery, but returns
192    ///   [`ModificationSafety::Safe`] and skips rejecting dirty or staged
193    ///   changes
194    /// - [`Self::allow_staged`] still requires repository discovery and change
195    ///   queries, but dirty files remain unsafe
196    ///
197    /// When [`Self::check_entire_repository`] is disabled, the safety check is
198    /// scoped to `path` after resolving it within the containing repository
199    /// worktree. When enabled, the entire containing repository is checked.
200    ///
201    /// # Errors
202    ///
203    /// Returns an error if repository discovery fails, if `path` cannot be
204    /// resolved for change queries, or if the backend fails to query the
205    /// relevant changes.
206    #[inline]
207    pub fn check_safe_to_modify<P>(&self, path: P) -> Result<ModificationSafety, ModifyGuardError>
208    where
209        P: AsRef<Path>,
210    {
211        self.check_safe_to_modify_with_backend(path, &RealBackend)
212    }
213
214    fn check_safe_to_modify_with_backend<P, B>(
215        &self,
216        path: P,
217        backend: &B,
218    ) -> Result<ModificationSafety, ModifyGuardError>
219    where
220        P: AsRef<Path>,
221        B: AllowOptionsBackend,
222    {
223        // Match `cargo fix` exactly:
224        // - `--allow-no-vcs` skips repository discovery and repository state
225        //   checks.
226        // - `--allow-dirty` still requires repository discovery, but skips
227        //   dirty and staged change checks.
228        // - `--allow-staged` still requires repository discovery and change
229        //   queries, but dirty files remain unsafe.
230
231        let path = path.as_ref();
232
233        if self.allow_no_vcs {
234            return Ok(ModificationSafety::Safe);
235        }
236
237        let Some(repo) = backend.discover(path)? else {
238            return Ok(UnsafeModificationReason::NoVcs.into());
239        };
240
241        if self.allow_dirty {
242            return Ok(ModificationSafety::Safe);
243        }
244
245        let Some(changes) = self.find_changes(&repo, path)? else {
246            return Ok(ModificationSafety::Safe);
247        };
248
249        let dirty_files = changes
250            .files()
251            .filter(|f| f.is_dirty())
252            .map(|f| f.wt_path().to_owned())
253            .collect::<Vec<_>>();
254
255        if self.allow_staged {
256            if !dirty_files.is_empty() {
257                return Ok(UnsafeModificationReason::Dirty {
258                    worktree: repo.worktree().to_owned(),
259                    dirty_files,
260                    staged_files: vec![],
261                }
262                .into());
263            }
264            return Ok(ModificationSafety::Safe);
265        }
266
267        let staged_files = changes
268            .files()
269            .filter(|f| f.is_staged())
270            .map(|f| f.wt_path().to_owned())
271            .collect::<Vec<_>>();
272
273        if dirty_files.is_empty() {
274            return Ok(UnsafeModificationReason::Staged {
275                worktree: repo.worktree().to_owned(),
276                staged_files,
277            }
278            .into());
279        }
280
281        Ok(UnsafeModificationReason::Dirty {
282            worktree: repo.worktree().to_owned(),
283            dirty_files,
284            staged_files,
285        }
286        .into())
287    }
288}
289
290trait AllowOptionsBackend {
291    type Repo: AllowOptionsRepository;
292
293    fn discover(&self, path: &Path) -> Result<Option<Self::Repo>, ModifyGuardError>;
294}
295
296trait AllowOptionsRepository {
297    fn worktree(&self) -> &Path;
298    fn resolve_path(&self, path: &Path) -> Result<PathBuf, ModifyGuardError>;
299    fn path_changes(&self, wt_path: &Path) -> Result<Option<RepositoryChanges>, ModifyGuardError>;
300    fn repository_changes(&self) -> Result<Option<RepositoryChanges>, ModifyGuardError>;
301}
302
303struct RealBackend;
304
305impl AllowOptionsBackend for RealBackend {
306    type Repo = Repository;
307
308    fn discover(&self, path: &Path) -> Result<Option<Self::Repo>, ModifyGuardError> {
309        Repository::discover(path)
310    }
311}
312
313impl AllowOptionsRepository for Repository {
314    fn worktree(&self) -> &Path {
315        Repository::worktree(self)
316    }
317    fn resolve_path(&self, path: &Path) -> Result<PathBuf, ModifyGuardError> {
318        Repository::resolve_path(self, path)
319    }
320    fn path_changes(&self, wt_path: &Path) -> Result<Option<RepositoryChanges>, ModifyGuardError> {
321        Repository::path_changes(self, wt_path)
322    }
323    fn repository_changes(&self) -> Result<Option<RepositoryChanges>, ModifyGuardError> {
324        Repository::repository_changes(self)
325    }
326}
327
328/// Whether modification of the queried target is considered safe under the
329/// current `--allow-*` policy.
330///
331/// This type describes the safety of modifying the queried target after
332/// applying the configured policy.
333#[expect(
334    clippy::exhaustive_enums,
335    reason = "Callers should exhaustively match the current outcomes; adding a new variant is an intentional breaking API change"
336)]
337#[derive(Debug)]
338pub enum ModificationSafety {
339    /// Modification of the queried target is considered safe.
340    Safe,
341    /// Modification of the queried target is considered unsafe.
342    ///
343    /// Contains the reason the modification is considered unsafe.
344    Unsafe(UnsafeModificationReason),
345}
346
347/// The reason modification of the queried target is considered unsafe under
348/// the current `--allow-*` policy.
349///
350// Use `#[doc = ...]` instead of a regular doc comment here because the
351// crate's `clippy::unnecessary_safety_comment` lint false-positively flags
352// this text in the usual `///` form.
353#[doc = "This type explains why [`ModificationSafety::Unsafe`] was returned."]
354#[derive(Debug)]
355#[non_exhaustive]
356pub enum UnsafeModificationReason {
357    /// Modification is considered unsafe because no supported VCS repository
358    /// was found for the queried target.
359    NoVcs,
360    /// Modification is considered unsafe because dirty files were found.
361    ///
362    /// `staged_files` is non-empty only when staged changes also make the
363    /// modification unsafe.
364    Dirty {
365        /// The root directory of the containing repository worktree.
366        worktree: PathBuf,
367        /// Worktree-relative paths of dirty files that make the modification
368        /// unsafe.
369        ///
370        /// This includes modified and untracked files.
371        dirty_files: Vec<PathBuf>,
372        /// Worktree-relative paths of staged files that also make the
373        /// modification unsafe.
374        staged_files: Vec<PathBuf>,
375    },
376    /// Modification is considered unsafe because staged changes were found.
377    Staged {
378        /// The root directory of the containing repository worktree.
379        worktree: PathBuf,
380        /// Worktree-relative paths of staged files that make the modification
381        /// unsafe.
382        staged_files: Vec<PathBuf>,
383    },
384}
385
386impl From<UnsafeModificationReason> for ModificationSafety {
387    #[inline]
388    fn from(reason: UnsafeModificationReason) -> Self {
389        Self::Unsafe(reason)
390    }
391}