Skip to main content

aqc_filetree/
options.rs

1//! Walk configuration: symlink policy, skip presets, recovery rules, errors.
2
3use std::path::PathBuf;
4
5use crate::tree::FileKind;
6
7/// The only symlink control: traverse, record, or skip.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9pub enum SymlinkPolicy {
10    /// Default: symlinks neither traversed nor listed.
11    #[default]
12    Skip,
13    /// Listed as [`FileKind::Symlink`], not traversed.
14    Record,
15    /// Traversed; targets appear as normal entries.
16    Follow,
17}
18
19/// Skip-list presets; constants only, merged by the caller.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum SkipDirPreset {
22    /// `.git`.
23    Common,
24    /// `target`.
25    Rust,
26    /// `node_modules`, `dist`.
27    Node,
28    /// `__pycache__`, `.venv`, `venv`, `.pytest_cache`, `.mypy_cache`, `.tox`.
29    Python,
30    /// `bin`, `obj`.
31    DotNet,
32}
33
34impl SkipDirPreset {
35    /// The directory name components this preset adds.
36    #[must_use]
37    pub const fn names(self) -> &'static [&'static str] {
38        match self {
39            Self::Common => &[".git"],
40            Self::Rust => &["target"],
41            Self::Node => &["node_modules", "dist"],
42            Self::Python => &[
43                "__pycache__",
44                ".venv",
45                "venv",
46                ".pytest_cache",
47                ".mypy_cache",
48                ".tox",
49            ],
50            Self::DotNet => &["bin", "obj"],
51        }
52    }
53
54    /// Merge presets into one deduplicated skip list.
55    #[must_use]
56    pub fn merge(presets: &[Self]) -> Vec<String> {
57        let mut names: Vec<String> = presets
58            .iter()
59            .flat_map(|p| p.names().iter().map(|n| (*n).to_owned()))
60            .collect();
61        names.sort_unstable();
62        names.dedup();
63        names
64    }
65}
66
67/// Phase 2 path predicates; OR across fields. Product-specific filename
68/// lists live in callers, not in this crate.
69#[derive(Debug, Clone, Default)]
70pub struct RecoveryRules {
71    /// Match the file base name exactly.
72    pub exact_file_names: Vec<String>,
73    /// Match a file base name prefix.
74    pub file_name_prefixes: Vec<String>,
75    /// Match a directory base name (presence sentinel).
76    pub directory_names: Vec<String>,
77    /// Match a full `rel_path` suffix.
78    pub rel_path_suffixes: Vec<String>,
79}
80
81impl RecoveryRules {
82    /// True when the (`rel_path`, base name, kind) matches any rule.
83    pub(crate) fn matches(&self, rel_path: &str, name: &str, kind: FileKind) -> bool {
84        match kind {
85            FileKind::Directory => self.directory_names.iter().any(|d| d == name),
86            FileKind::File | FileKind::Symlink => {
87                self.exact_file_names.iter().any(|f| f == name)
88                    || self.file_name_prefixes.iter().any(|p| name.starts_with(p))
89                    || self
90                        .rel_path_suffixes
91                        .iter()
92                        .any(|sfx| rel_path.ends_with(sfx))
93            }
94        }
95    }
96}
97
98/// Walk configuration; defaults per `plan.md`.
99#[derive(Debug, Clone)]
100pub struct WalkOptions {
101    /// Phase 1 honors `.gitignore` / `.ignore`.
102    pub respect_gitignore: bool,
103    /// Dotfiles are not skipped for being hidden.
104    pub include_hidden: bool,
105    /// Symlink handling (both phases).
106    pub symlink_policy: SymlinkPolicy,
107    /// Never descend into directories with these final name components
108    /// (both phases; prunes descent by artifact-folder name, distinct from
109    /// gitignore's VCS rules).
110    pub skip_dir_names: Vec<String>,
111    /// Never enter these root-relative subtrees (both phases).
112    pub skip_path_prefixes: Vec<String>,
113    /// `None` = unlimited; `Some(n)` = max directory depth below root.
114    pub max_depth: Option<u32>,
115    /// Phase 2 rules; `None` = phase 2 off.
116    pub recovery: Option<RecoveryRules>,
117}
118
119impl Default for WalkOptions {
120    fn default() -> Self {
121        Self {
122            respect_gitignore: true,
123            include_hidden: true,
124            symlink_policy: SymlinkPolicy::Skip,
125            skip_dir_names: SkipDirPreset::merge(&[
126                SkipDirPreset::Common,
127                SkipDirPreset::Rust,
128                SkipDirPreset::Node,
129            ]),
130            skip_path_prefixes: Vec::new(),
131            max_depth: None,
132            recovery: None,
133        }
134    }
135}
136
137/// Why a walk failed.
138#[derive(Debug)]
139pub enum WalkError {
140    /// The root path does not exist.
141    RootNotFound,
142    /// The root path exists but is not a directory.
143    RootNotADirectory,
144    /// An OS error during the walk.
145    Io {
146        /// Where the error occurred (the root or an entry).
147        path: PathBuf,
148        /// The underlying error.
149        source: std::io::Error,
150    },
151}
152
153impl core::fmt::Display for WalkError {
154    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
155        match self {
156            Self::RootNotFound => write!(f, "walk root not found"),
157            Self::RootNotADirectory => write!(f, "walk root is not a directory"),
158            Self::Io { path, source } => write!(f, "io error on {}: {source}", path.display()),
159        }
160    }
161}
162
163impl core::error::Error for WalkError {}