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}