anodizer_core/
extrafiles.rs1use std::collections::HashSet;
19use std::path::PathBuf;
20
21use anyhow::{Context, Result, bail};
22
23use crate::config::ExtraFileSpec;
24use crate::log::StageLogger;
25
26#[derive(Debug, Clone)]
33pub struct ResolvedExtraFile {
34 pub path: PathBuf,
35 pub name_template: Option<String>,
36}
37
38pub fn resolve(specs: &[ExtraFileSpec], log: &StageLogger) -> Result<Vec<ResolvedExtraFile>> {
41 let mut seen: HashSet<PathBuf> = HashSet::new();
42 let mut out: Vec<ResolvedExtraFile> = Vec::new();
43
44 for spec in specs {
45 let pattern = spec.glob();
46 let name_tmpl = spec.name_template().map(str::to_owned);
47
48 if pattern.is_empty() {
49 log.warn("extra_files: ignoring empty glob");
50 continue;
51 }
52
53 let matches: Vec<PathBuf> = glob::glob(pattern)
54 .with_context(|| format!("extra_files: invalid glob '{pattern}'"))?
55 .collect::<std::result::Result<Vec<_>, _>>()
56 .with_context(|| format!("extra_files: error expanding glob '{pattern}'"))?;
57
58 if matches.is_empty() {
59 log.warn(&format!(
60 "extra_files: glob '{pattern}' matched no files, skipping"
61 ));
62 continue;
63 }
64
65 if name_tmpl.is_some() && matches.len() > 1 {
66 bail!(
67 "extra_files: glob '{}' with name_template matched {} files (must match exactly one)",
68 pattern,
69 matches.len()
70 );
71 }
72
73 for path in matches.into_iter().filter(|p| p.is_file()) {
74 if seen.insert(path.clone()) {
75 out.push(ResolvedExtraFile {
76 path,
77 name_template: name_tmpl.clone(),
78 });
79 }
80 }
81 }
82
83 out.sort_by(|a, b| a.path.cmp(&b.path));
84 Ok(out)
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90 use tempfile::TempDir;
91
92 fn log() -> StageLogger {
93 StageLogger::new("test", crate::log::Verbosity::Quiet)
94 }
95
96 #[test]
97 fn empty_specs_returns_empty() {
98 let result = resolve(&[], &log()).unwrap();
99 assert!(result.is_empty());
100 }
101
102 #[test]
103 fn empty_glob_is_skipped() {
104 let specs = vec![ExtraFileSpec::Glob(String::new())];
105 let result = resolve(&specs, &log()).unwrap();
106 assert!(result.is_empty());
107 }
108
109 #[test]
110 fn no_match_is_skipped_not_error() {
111 let specs = vec![ExtraFileSpec::Glob(
112 "/tmp/nonexistent-prefix-xyz-*.bin".to_string(),
113 )];
114 let result = resolve(&specs, &log()).unwrap();
115 assert!(result.is_empty());
116 }
117
118 #[test]
119 fn multi_match_with_name_template_errors() {
120 let tmp = TempDir::new().unwrap();
121 std::fs::write(tmp.path().join("a.bin"), b"a").unwrap();
122 std::fs::write(tmp.path().join("b.bin"), b"b").unwrap();
123
124 let glob_pattern = format!("{}/*.bin", tmp.path().display());
125 let specs = vec![ExtraFileSpec::Detailed {
126 glob: glob_pattern,
127 name_template: Some("collapsed.bin".to_string()),
128 allow_empty: false,
129 }];
130
131 let err = resolve(&specs, &log()).unwrap_err();
132 assert!(err.to_string().contains("must match exactly one"));
133 }
134
135 #[test]
136 fn dedupes_across_specs() {
137 let tmp = TempDir::new().unwrap();
138 std::fs::write(tmp.path().join("a.bin"), b"a").unwrap();
139
140 let glob1 = format!("{}/*.bin", tmp.path().display());
141 let glob2 = format!("{}/a.bin", tmp.path().display());
142 let specs = vec![ExtraFileSpec::Glob(glob1), ExtraFileSpec::Glob(glob2)];
143
144 let result = resolve(&specs, &log()).unwrap();
145 assert_eq!(result.len(), 1);
146 }
147
148 #[test]
149 fn results_sorted_by_path() {
150 let tmp = TempDir::new().unwrap();
151 std::fs::write(tmp.path().join("c.bin"), b"c").unwrap();
152 std::fs::write(tmp.path().join("a.bin"), b"a").unwrap();
153 std::fs::write(tmp.path().join("b.bin"), b"b").unwrap();
154
155 let specs = vec![ExtraFileSpec::Glob(format!(
156 "{}/*.bin",
157 tmp.path().display()
158 ))];
159 let result = resolve(&specs, &log()).unwrap();
160 assert_eq!(result.len(), 3);
161 assert!(result[0].path.to_string_lossy().ends_with("a.bin"));
162 assert!(result[1].path.to_string_lossy().ends_with("b.bin"));
163 assert!(result[2].path.to_string_lossy().ends_with("c.bin"));
164 }
165
166 #[test]
167 fn directories_filtered_out() {
168 let tmp = TempDir::new().unwrap();
169 std::fs::create_dir(tmp.path().join("subdir")).unwrap();
170 std::fs::write(tmp.path().join("real.bin"), b"x").unwrap();
171
172 let specs = vec![ExtraFileSpec::Glob(format!("{}/*", tmp.path().display()))];
173 let result = resolve(&specs, &log()).unwrap();
174 assert_eq!(result.len(), 1);
175 assert!(result[0].path.to_string_lossy().ends_with("real.bin"));
176 }
177}