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}