1use anyhow::{Context, Result};
14use std::collections::BTreeSet;
15use std::path::{Path, PathBuf};
16use tokio::fs;
17
18use crate::config::Config;
19use crate::presets;
20
21pub(crate) fn collect_embedded_category_names(root: &str, filter: Option<&str>) -> Vec<String> {
24 let mut names = BTreeSet::new();
25 let prefix = format!("{root}/");
26 for asset_path in presets::asset_paths(root) {
27 let Some(rest) = asset_path.strip_prefix(&prefix) else {
28 continue;
29 };
30 let Some((category, _)) = rest.split_once('/') else {
31 continue;
32 };
33 if filter.is_none_or(|f| f == category) {
34 names.insert(category.to_string());
35 }
36 }
37 names.into_iter().collect()
38}
39
40pub(crate) async fn collect_fs_category_names(
44 root: &Path,
45 filter: Option<&str>,
46 what: &str,
47) -> Result<Vec<String>> {
48 if let Some(filter) = filter {
49 let path = root.join(filter);
50 if path.exists() {
51 return Ok(vec![filter.to_string()]);
52 }
53 return Ok(Vec::new());
54 }
55
56 if !root.exists() {
57 return Ok(Vec::new());
58 }
59
60 let mut names = BTreeSet::new();
61 let mut entries = fs::read_dir(root)
62 .await
63 .with_context(|| format!("reading {what}: {}", root.display()))?;
64 while let Some(entry) = entries.next_entry().await? {
65 if entry.file_type().await?.is_dir() {
66 names.insert(entry.file_name().to_string_lossy().to_string());
67 }
68 }
69 Ok(names.into_iter().collect())
70}
71
72pub(crate) fn platform_matches(
77 platforms: Option<&[String]>,
78 current: &str,
79 context: &str,
80) -> Result<bool> {
81 let Some(platforms) = platforms else {
82 return Ok(true);
83 };
84
85 let mut matches = false;
86 for platform in platforms {
87 let normalized = platform.trim().to_ascii_lowercase();
88 match normalized.as_str() {
89 "windows" | "unix" => matches |= normalized == current,
90 _ => anyhow::bail!(
91 "{context} has unsupported platform `{platform}`; expected `windows` or `unix`"
92 ),
93 }
94 }
95 Ok(matches)
96}
97
98pub(crate) async fn collect_fs_tree(
104 root: &Path,
105 what: &str,
106 keep: impl Fn(&Path) -> Result<Option<PathBuf>>,
107) -> Result<Vec<PathBuf>> {
108 let mut out = Vec::new();
109 let mut stack = vec![root.to_path_buf()];
110 while let Some(dir) = stack.pop() {
111 let mut entries = fs::read_dir(&dir)
112 .await
113 .with_context(|| format!("reading {what}: {}", dir.display()))?;
114 while let Some(entry) = entries.next_entry().await? {
115 let path = entry.path();
116 let ft = entry.file_type().await?;
117 if ft.is_dir() {
118 stack.push(path);
119 continue;
120 }
121 if !ft.is_file() {
122 continue;
123 }
124 let rel = path.strip_prefix(root).with_context(|| {
125 format!(
126 "failed to resolve {} relative to {}",
127 path.display(),
128 root.display()
129 )
130 })?;
131 if let Some(normalized) = keep(rel)? {
132 out.push(normalized);
133 }
134 }
135 }
136 out.sort();
137 Ok(out)
138}
139
140pub(crate) async fn merge_fs_tree(
146 config: &Config,
147 category_rel: &Path,
148 what: &str,
149 keep: impl Fn(&Path) -> Result<Option<PathBuf>> + Copy,
150) -> Result<Vec<PathBuf>> {
151 let base_category = config.presets_dir().join(category_rel);
152 let mut items: BTreeSet<PathBuf> = if base_category.is_dir() {
153 collect_fs_tree(&base_category, what, keep)
154 .await?
155 .into_iter()
156 .collect()
157 } else {
158 BTreeSet::new()
159 };
160 if let Some(overlay) = config.active_presets_overlay_dir() {
161 let overlay_category = overlay.join(category_rel);
162 if overlay_category.is_dir() {
163 items.extend(collect_fs_tree(&overlay_category, what, keep).await?);
164 }
165 }
166 Ok(items.into_iter().collect())
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[test]
174 fn platform_matches_defaults_to_true_when_unset() {
175 assert!(platform_matches(None, "windows", "ctx").unwrap());
176 }
177
178 #[test]
179 fn platform_matches_checks_current_platform() {
180 let platforms = vec!["windows".to_string()];
181 assert!(platform_matches(Some(&platforms), "windows", "ctx").unwrap());
182 assert!(!platform_matches(Some(&platforms), "unix", "ctx").unwrap());
183 }
184
185 #[test]
186 fn platform_matches_rejects_unknown_platform_with_context() {
187 let platforms = vec!["plan9".to_string()];
188 let err = platform_matches(Some(&platforms), "unix", "app/foo/shine.toml")
189 .unwrap_err()
190 .to_string();
191 assert!(err.contains("app/foo/shine.toml"));
192 assert!(err.contains("unsupported platform"));
193 }
194
195 #[test]
196 fn collect_embedded_category_names_filters_to_requested_name() {
197 let names = collect_embedded_category_names("shell", Some("proxy"));
198 assert_eq!(names, vec!["proxy".to_string()]);
199 }
200
201 #[tokio::test]
202 async fn collect_fs_tree_keeps_only_entries_the_predicate_returns_some_for() {
203 let dir = crate::test_support::make_temp_dir("shine-preset-meta").await;
204 tokio::fs::write(dir.join("keep.txt"), b"").await.unwrap();
205 tokio::fs::write(dir.join("skip.txt"), b"").await.unwrap();
206 tokio::fs::create_dir_all(dir.join("nested")).await.unwrap();
207 tokio::fs::write(dir.join("nested/keep2.txt"), b"")
208 .await
209 .unwrap();
210
211 let result = collect_fs_tree(&dir, "test dir", |rel| {
212 if rel.file_name().and_then(|n| n.to_str()) == Some("skip.txt") {
213 Ok(None)
214 } else {
215 Ok(Some(rel.to_path_buf()))
216 }
217 })
218 .await
219 .unwrap();
220
221 assert_eq!(
222 result,
223 vec![PathBuf::from("keep.txt"), PathBuf::from("nested/keep2.txt"),]
224 );
225
226 tokio::fs::remove_dir_all(&dir).await.unwrap();
227 }
228
229 #[tokio::test]
230 async fn collect_fs_tree_propagates_keep_errors() {
231 let dir = crate::test_support::make_temp_dir("shine-preset-meta").await;
232 tokio::fs::write(dir.join("bad.txt"), b"").await.unwrap();
233
234 let result = collect_fs_tree(&dir, "test dir", |_rel| anyhow::bail!("invalid entry")).await;
235
236 assert!(result.is_err());
237 tokio::fs::remove_dir_all(&dir).await.unwrap();
238 }
239
240 #[tokio::test]
241 async fn merge_fs_tree_combines_base_and_overlay() {
242 let dir = crate::test_support::make_temp_dir("shine-preset-meta").await;
243 let base = dir.join("presets/app/sample");
244 let overlay_root = dir.join("overlay");
245 let overlay = overlay_root.join("app/sample");
246 tokio::fs::create_dir_all(&base).await.unwrap();
247 tokio::fs::create_dir_all(&overlay).await.unwrap();
248 tokio::fs::write(base.join("a.txt"), b"").await.unwrap();
249 tokio::fs::write(overlay.join("b.txt"), b"").await.unwrap();
250
251 let mut config = crate::test_support::test_config(&dir);
252 config.presets_overlay_dir_override = Some(overlay_root);
253
254 let result = merge_fs_tree(&config, Path::new("app/sample"), "test dir", |rel| {
255 Ok(Some(rel.to_path_buf()))
256 })
257 .await
258 .unwrap();
259
260 assert_eq!(result, vec![PathBuf::from("a.txt"), PathBuf::from("b.txt")]);
261
262 tokio::fs::remove_dir_all(&dir).await.unwrap();
263 }
264}