char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somdeslop(task): unify watchset + glob machinerya29d09f

main
5.5 KiB177 linesraw
1use std::collections::BTreeMap;
2use std::io::Read;
3use std::path::{Path, PathBuf};
4
5use glob::{MatchOptions, Pattern};
6use sha2::{Digest, Sha256};
7
8use crate::{Error, Result};
9
10pub(crate) const MATCH_OPTIONS: MatchOptions = MatchOptions {
11    case_sensitive: true,
12    require_literal_separator: true,
13    require_literal_leading_dot: false,
14};
15
16#[derive(Debug, thiserror::Error)]
17pub enum PatternError {
18    #[error("Glob patterns cannot be empty")]
19    Empty,
20    #[error("Glob patterns use '/' as the separator")]
21    Separator,
22    #[error(transparent)]
23    Syntax(#[from] glob::PatternError),
24    #[error("Task outputs must be package-relative glob patterns that stay inside the package")]
25    OutputOutsidePackage,
26}
27
28pub fn validate_pattern(pattern: &str) -> std::result::Result<(), PatternError> {
29    if pattern.is_empty() {
30        return Err(PatternError::Empty);
31    }
32    if pattern.contains('\\') {
33        return Err(PatternError::Separator);
34    }
35    Pattern::new(&normalize_pattern(pattern))?;
36    Ok(())
37}
38
39pub fn validate_output_pattern(pattern: &str) -> std::result::Result<(), PatternError> {
40    let normalized = pattern.strip_prefix("./").unwrap_or(pattern);
41    if normalized.is_empty()
42        || normalized == "."
43        || normalized.starts_with('/')
44        || normalized.split('/').any(|part| part == "..")
45    {
46        return Err(PatternError::OutputOutsidePackage);
47    }
48    Ok(())
49}
50
51/// Strips a leading `./` and trailing `/`s, which name the package root or
52/// a directory and carry no meaning for matching.
53pub fn normalize_pattern(pattern: &str) -> String {
54    let mut normalized = pattern;
55    while let Some(rest) = normalized.strip_prefix("./") {
56        normalized = rest;
57    }
58    normalized.trim_end_matches('/').to_owned()
59}
60
61/// Whether a pattern matches `relative` or a directory above it: a
62/// pattern naming a directory covers everything underneath it.
63pub fn covers(pattern: &Pattern, relative: &str) -> bool {
64    if pattern.matches_with(relative, MATCH_OPTIONS) {
65        return true;
66    }
67    relative
68        .match_indices('/')
69        .any(|(index, _)| pattern.matches_with(&relative[..index], MATCH_OPTIONS))
70}
71
72pub fn compile_pattern(task: &str, pattern: &str) -> Result<Pattern> {
73    Pattern::new(&normalize_pattern(pattern)).map_err(|error| Error::Pattern {
74        task: task.to_owned(),
75        pattern: pattern.to_owned(),
76        message: error.to_string(),
77    })
78}
79
80/// Expands `files` patterns against the tree under `root`. Every pattern
81/// must match at least one file.
82pub fn expand_files(
83    root: &Path,
84    task: &str,
85    patterns: &[String],
86) -> Result<BTreeMap<String, PathBuf>> {
87    let files = collect_paths(root, false);
88    let mut matched = BTreeMap::new();
89    for pattern in patterns {
90        let compiled = compile_pattern(task, pattern)?;
91        let mut any = false;
92        for relative in &files {
93            if covers(&compiled, relative) {
94                any = true;
95                matched.insert(relative.clone(), root.join(relative));
96            }
97        }
98        if !any {
99            return Err(Error::UnmatchedFiles {
100                task: task.to_owned(),
101                pattern: pattern.to_owned(),
102            });
103        }
104    }
105    Ok(matched)
106}
107
108/// Slash-separated paths of everything under `base`, relative to it.
109pub fn collect_paths(base: &Path, include_directories: bool) -> Vec<String> {
110    fn walk(directory: &Path, prefix: &str, include_directories: bool, paths: &mut Vec<String>) {
111        let Ok(entries) = std::fs::read_dir(directory) else {
112            return;
113        };
114        let mut entries: Vec<_> = entries.filter_map(std::result::Result::ok).collect();
115        entries.sort_by_key(|entry| entry.file_name());
116        for entry in entries {
117            let name = entry.file_name().to_string_lossy().into_owned();
118            let relative = if prefix.is_empty() {
119                name
120            } else {
121                format!("{prefix}/{name}")
122            };
123            let is_directory = entry.file_type().is_ok_and(|kind| kind.is_dir());
124            if is_directory {
125                if include_directories {
126                    paths.push(relative.clone());
127                }
128                walk(&entry.path(), &relative, include_directories, paths);
129            } else {
130                paths.push(relative);
131            }
132        }
133    }
134    let mut paths = Vec::new();
135    walk(base, "", include_directories, &mut paths);
136    paths
137}
138
139/// A file's cache identity: size and modification time, without reading it.
140pub(crate) fn file_id(path: &Path) -> std::io::Result<String> {
141    let metadata = std::fs::metadata(path)?;
142    let modified = match metadata.modified() {
143        Ok(mtime) => mtime.duration_since(std::time::UNIX_EPOCH).ok(),
144        Err(_) => return hash_id(path),
145    };
146    let Some(modified) = modified else {
147        return hash_id(path);
148    };
149    let mut id = format!(
150        "file:{}:{}:{}",
151        metadata.len(),
152        modified.as_secs(),
153        modified.subsec_nanos()
154    );
155    if path
156        .symlink_metadata()
157        .is_ok_and(|link| link.file_type().is_symlink())
158        && let Ok(target) = std::fs::read_link(path)
159    {
160        id.push_str(&format!("->{}", target.to_string_lossy()));
161    }
162    Ok(id)
163}
164
165fn hash_id(path: &Path) -> std::io::Result<String> {
166    let mut file = std::fs::File::open(path)?;
167    let mut hasher = Sha256::new();
168    let mut buffer = [0; 8192];
169    loop {
170        let read = file.read(&mut buffer)?;
171        if read == 0 {
172            break;
173        }
174        hasher.update(&buffer[..read]);
175    }
176    Ok(format!("file:hash:{:x}", hasher.finalize()))
177}