Skip to main content

knf/
fs.rs

1//! Discover ordered configuration inputs and filter path lists without loading them.
2
3use std::fs;
4use std::io;
5use std::path::{Component, Path, PathBuf};
6
7use crate::{Format, STDIN};
8
9/// An invalid glob pattern.
10pub use fast_glob::Error as GlobError;
11
12/// A validated relative JSON or TOML target, with `.` components removed.
13#[derive(Debug, Clone)]
14pub struct AccumulateTarget {
15    path: PathBuf,
16    format: Format,
17}
18
19/// Why a target cannot be used for discovery.
20#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
21pub enum AccumulateTargetError {
22    /// Standard input cannot be discovered.
23    #[error("discovery does not accept stdin")]
24    Stdin,
25    /// Targets must be relative and must not traverse parents.
26    #[error("discovery requires a relative target path without .. components")]
27    InvalidPath,
28    /// The target extension must select JSON or TOML.
29    #[error("discovery requires a target with a JSON or TOML extension")]
30    UnknownExtension,
31}
32
33impl TryFrom<PathBuf> for AccumulateTarget {
34    type Error = AccumulateTargetError;
35
36    fn try_from(raw: PathBuf) -> Result<Self, Self::Error> {
37        if raw.as_os_str() == STDIN {
38            return Err(AccumulateTargetError::Stdin);
39        }
40        if raw.components().any(|component| {
41            matches!(
42                component,
43                Component::RootDir | Component::Prefix(_) | Component::ParentDir
44            )
45        }) {
46            return Err(AccumulateTargetError::InvalidPath);
47        }
48        let path: PathBuf = raw
49            .components()
50            .filter(|component| !matches!(component, Component::CurDir))
51            .collect();
52        let format = Format::from_path(&path).ok_or(AccumulateTargetError::UnknownExtension)?;
53        Ok(Self { path, format })
54    }
55}
56
57/// A filesystem failure during discovery. Paths are structured diagnostic data.
58#[derive(Debug, thiserror::Error)]
59pub enum AccumulateError {
60    /// The working directory could not be obtained for a relative base.
61    #[error("cannot determine the working directory")]
62    CurrentDirectory(#[source] io::Error),
63    /// Metadata could not be read (symlinks are followed).
64    #[error("cannot inspect a discovery path")]
65    Inspect {
66        /// The path whose metadata failed.
67        path: PathBuf,
68        /// The underlying filesystem error.
69        #[source]
70        source: io::Error,
71    },
72    /// A directory or one of its entries could not be read.
73    #[error("cannot list a discovery directory")]
74    List {
75        /// The directory being listed.
76        path: PathBuf,
77        /// The underlying filesystem error.
78        #[source]
79        source: io::Error,
80    },
81    /// The named target is a directory.
82    #[error("discovery target is a directory")]
83    Directory {
84        /// The target path.
85        path: PathBuf,
86    },
87    /// The named target is neither a directory nor a regular file.
88    #[error("discovery target is not a regular file")]
89    NonRegular {
90        /// The target path.
91        path: PathBuf,
92    },
93}
94
95/// Discover same-format files along a target's directories, then append the target.
96///
97/// The base directory itself is excluded. Each visited directory contributes its
98/// regular files in native filename order. Symlinks are followed without
99/// canonicalization or deduplication. Discovery never reads configuration contents.
100///
101/// With no base, paths are relative to the working directory. An explicit base
102/// is made absolute once and results are absolute, without changing the working
103/// directory. Any inspection failure aborts discovery; no partial list is returned.
104pub fn accumulate(
105    target: &AccumulateTarget,
106    base_dir: Option<&Path>,
107) -> Result<Vec<PathBuf>, AccumulateError> {
108    let base = match base_dir {
109        None => PathBuf::new(),
110        Some(base) if base.is_absolute() => base.to_path_buf(),
111        // Use the platform's absolute-path rules, including Windows drive-relative
112        // and root-relative bases. An empty base denotes the working directory.
113        Some(base) => std::path::absolute(if base.as_os_str().is_empty() {
114            Path::new(".")
115        } else {
116            base
117        })
118        .map_err(AccumulateError::CurrentDirectory)?,
119    };
120    let target_path = base.join(&target.path);
121    let metadata = fs::metadata(&target_path).map_err(|source| AccumulateError::Inspect {
122        path: target_path.clone(),
123        source,
124    })?;
125    if metadata.is_dir() {
126        return Err(AccumulateError::Directory { path: target_path });
127    }
128    if !metadata.is_file() {
129        return Err(AccumulateError::NonRegular { path: target_path });
130    }
131
132    let mut files = Vec::new();
133    let mut directory = base;
134    if let Some(parent) = target.path.parent() {
135        for component in parent.components() {
136            directory.push(component);
137            let listing_error = |source| AccumulateError::List {
138                path: directory.clone(),
139                source,
140            };
141            let entries = fs::read_dir(&directory).map_err(listing_error)?;
142            let mut matching = Vec::new();
143            for entry in entries {
144                let entry = entry.map_err(listing_error)?;
145                let path = entry.path();
146                if path == target_path || Format::from_path(&path) != Some(target.format) {
147                    continue;
148                }
149                let metadata = fs::metadata(&path).map_err(|source| AccumulateError::Inspect {
150                    path: path.clone(),
151                    source,
152                })?;
153                if metadata.is_file() {
154                    matching.push((entry.file_name(), path));
155                }
156            }
157            matching.sort_by(|left, right| left.0.cmp(&right.0));
158            files.extend(matching.into_iter().map(|(_, path)| path));
159        }
160    }
161    files.push(target_path);
162    Ok(files)
163}
164
165/// A case-sensitive glob predicate, validated before any filesystem access.
166#[derive(Debug, Clone)]
167pub struct GlobPattern(String);
168
169impl std::str::FromStr for GlobPattern {
170    type Err = GlobError;
171
172    fn from_str(pattern: &str) -> Result<Self, Self::Err> {
173        fast_glob::validate(pattern)?;
174        Ok(Self(pattern.to_owned()))
175    }
176}
177
178impl GlobPattern {
179    /// Match the entire supplied spelling, normalizing Windows separators only.
180    pub fn matches_path(&self, path: &Path) -> bool {
181        let bytes = path.as_os_str().as_encoded_bytes();
182        #[cfg(windows)]
183        let bytes = bytes
184            .iter()
185            .map(|&byte| if byte == b'\\' { b'/' } else { byte })
186            .collect::<Vec<_>>();
187        fast_glob::glob_match(&self.0, bytes)
188    }
189
190    /// Match only the filename; paths without one do not match.
191    pub fn matches_filename(&self, path: &Path) -> bool {
192        path.file_name()
193            .is_some_and(|name| fast_glob::glob_match(&self.0, name.as_encoded_bytes()))
194    }
195}
196
197/// Filter existing candidates without I/O, preserving spelling, order and duplicates.
198///
199/// Matching uses native encoded bytes, so `?` matches one byte. An empty
200/// selection is allowed. Set `filename_only` to ignore directory components.
201pub fn filter_paths<P: AsRef<Path>>(
202    files: &[P],
203    pattern: &GlobPattern,
204    filename_only: bool,
205) -> Vec<PathBuf> {
206    files
207        .iter()
208        .map(AsRef::as_ref)
209        .filter(|path| {
210            if filename_only {
211                pattern.matches_filename(path)
212            } else {
213                pattern.matches_path(path)
214            }
215        })
216        .map(Path::to_path_buf)
217        .collect()
218}