Skip to main content

lovely/
fsutil.rs

1use crate::{LovelyError, Result};
2use std::fs;
3use std::path::{Component, Path, PathBuf};
4
5pub fn ensure_dir(path: &Path) -> Result<()> {
6    fs::create_dir_all(path).map_err(|err| LovelyError::io(path, err))
7}
8
9pub fn read_to_string(path: &Path) -> Result<String> {
10    fs::read_to_string(path).map_err(|err| LovelyError::io(path, err))
11}
12
13pub fn write_string(path: &Path, contents: &str) -> Result<()> {
14    if let Some(parent) = path.parent() {
15        ensure_dir(parent)?;
16    }
17    fs::write(path, contents).map_err(|err| LovelyError::io(path, err))
18}
19
20pub fn copy_file(from: &Path, to: &Path) -> Result<u64> {
21    if let Some(parent) = to.parent() {
22        ensure_dir(parent)?;
23    }
24    fs::copy(from, to).map_err(|err| LovelyError::io(to, err))
25}
26
27pub fn copy_dir_contents(from: &Path, to: &Path) -> Result<()> {
28    ensure_dir(to)?;
29    for file in collect_files(from)? {
30        let rel = relative_path(from, &file)?;
31        copy_file(&file, &to.join(rel))?;
32    }
33    Ok(())
34}
35
36pub fn normalize_slashes(path: &Path) -> String {
37    let normalized = path
38        .components()
39        .filter_map(|component| match component {
40            Component::Normal(part) => Some(part.to_string_lossy().to_string()),
41            _ => None,
42        })
43        .collect::<Vec<_>>()
44        .join("/");
45    if normalized.is_empty() && path == Path::new(".") {
46        ".".to_string()
47    } else {
48        normalized
49    }
50}
51
52pub fn relative_path(base: &Path, path: &Path) -> Result<PathBuf> {
53    path.strip_prefix(base).map(Path::to_path_buf).map_err(|_| {
54        LovelyError::Archive(format!("{} is outside {}", path.display(), base.display()))
55    })
56}
57
58pub fn executable_in_path(name: &str) -> bool {
59    let Some(path_var) = std::env::var_os("PATH") else {
60        return false;
61    };
62
63    std::env::split_paths(&path_var).any(|dir| {
64        let candidate = dir.join(name);
65        if candidate.is_file() {
66            return true;
67        }
68
69        #[cfg(windows)]
70        {
71            let candidate = dir.join(format!("{name}.exe"));
72            candidate.is_file()
73        }
74
75        #[cfg(not(windows))]
76        {
77            false
78        }
79    })
80}
81
82pub fn collect_files(root: &Path) -> Result<Vec<PathBuf>> {
83    fn visit(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
84        for entry in fs::read_dir(dir).map_err(|err| LovelyError::io(dir, err))? {
85            let entry = entry.map_err(LovelyError::plain_io)?;
86            let path = entry.path();
87            let rel = relative_path(root, &path)?;
88            if should_skip(&rel) {
89                continue;
90            }
91            let kind = entry.file_type().map_err(LovelyError::plain_io)?;
92            if kind.is_dir() {
93                visit(root, &path, out)?;
94            } else if kind.is_file() {
95                out.push(path);
96            }
97        }
98        Ok(())
99    }
100
101    let mut files = Vec::new();
102    visit(root, root, &mut files)?;
103    files.sort_by_key(|path| normalize_slashes(&relative_path(root, path).unwrap_or_default()));
104    Ok(files)
105}
106
107pub fn collect_included_files(
108    root: &Path,
109    includes: &[String],
110    excludes: &[String],
111) -> Result<Vec<PathBuf>> {
112    let files = collect_files(root)?;
113    if includes.is_empty() {
114        return Ok(Vec::new());
115    }
116
117    let mut included = files
118        .into_iter()
119        .filter(|file| {
120            relative_path(root, file)
121                .map(|rel| {
122                    let rel = normalize_slashes(&rel);
123                    matches_any_pattern(&rel, includes) && !matches_any_pattern(&rel, excludes)
124                })
125                .unwrap_or(false)
126        })
127        .collect::<Vec<_>>();
128    included.sort_by_key(|path| normalize_slashes(&relative_path(root, path).unwrap_or_default()));
129    Ok(included)
130}
131
132fn should_skip(rel: &Path) -> bool {
133    let first = rel
134        .components()
135        .next()
136        .and_then(|component| match component {
137            Component::Normal(part) => Some(part.to_string_lossy()),
138            _ => None,
139        });
140
141    matches!(
142        first.as_deref(),
143        Some(".git" | ".lovely" | "target" | "dist" | "build")
144    )
145}
146
147fn matches_any_pattern(rel: &str, patterns: &[String]) -> bool {
148    patterns
149        .iter()
150        .map(|pattern| normalize_pattern(pattern))
151        .any(|pattern| glob_match(&pattern, rel))
152}
153
154fn normalize_pattern(pattern: &str) -> String {
155    pattern
156        .trim()
157        .trim_start_matches("./")
158        .trim_matches('/')
159        .replace('\\', "/")
160}
161
162fn glob_match(pattern: &str, rel: &str) -> bool {
163    if pattern.is_empty() {
164        return false;
165    }
166    if pattern == "**" || pattern == "**/*" {
167        return true;
168    }
169
170    let pattern_parts = pattern.split('/').collect::<Vec<_>>();
171    let rel_parts = rel.split('/').collect::<Vec<_>>();
172    glob_match_parts(&pattern_parts, &rel_parts)
173}
174
175fn glob_match_parts(pattern: &[&str], rel: &[&str]) -> bool {
176    match (pattern.first(), rel.first()) {
177        (None, None) => true,
178        (None, Some(_)) => false,
179        (Some(&"**"), _) => {
180            glob_match_parts(&pattern[1..], rel)
181                || (!rel.is_empty() && glob_match_parts(pattern, &rel[1..]))
182        }
183        (Some(_), None) => false,
184        (Some(pattern_part), Some(rel_part)) => {
185            segment_match(pattern_part, rel_part) && glob_match_parts(&pattern[1..], &rel[1..])
186        }
187    }
188}
189
190fn segment_match(pattern: &str, text: &str) -> bool {
191    if pattern == "*" {
192        return true;
193    }
194    if !pattern.contains('*') {
195        return pattern == text;
196    }
197
198    let mut remainder = text;
199    let mut first = true;
200    for part in pattern.split('*') {
201        if part.is_empty() {
202            continue;
203        }
204        if first && !pattern.starts_with('*') {
205            let Some(next) = remainder.strip_prefix(part) else {
206                return false;
207            };
208            remainder = next;
209        } else if let Some(index) = remainder.find(part) {
210            remainder = &remainder[index + part.len()..];
211        } else {
212            return false;
213        }
214        first = false;
215    }
216
217    pattern.ends_with('*') || remainder.is_empty()
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn glob_include_patterns_match_expected_paths() {
226        let includes = vec![
227            "main.lua".to_string(),
228            "conf.lua".to_string(),
229            "src/**".to_string(),
230            "assets/**/*".to_string(),
231            "*.md".to_string(),
232        ];
233
234        assert!(matches_any_pattern("main.lua", &includes));
235        assert!(matches_any_pattern("src/game/state.lua", &includes));
236        assert!(matches_any_pattern("assets/sprites/boat.png", &includes));
237        assert!(matches_any_pattern("README.md", &includes));
238        assert!(!matches_any_pattern("scripts/release.sh", &includes));
239        assert!(!matches_any_pattern("node_modules/pkg/index.js", &includes));
240    }
241}