1use anyhow::{Context, Result};
14use std::collections::BTreeSet;
15use std::path::{Path, PathBuf};
16use tokio::fs;
17
18use crate::config::Config;
19use crate::platform::OperatingSystem;
20use crate::presets;
21
22#[cfg(test)]
23pub(crate) fn collect_pristine_embedded_category_names(root: &str) -> Vec<String> {
24 let mut names = BTreeSet::new();
25 let prefix = format!("{root}/");
26 for asset_path in presets::embedded_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 names.insert(category.to_string());
34 }
35 names.into_iter().collect()
36}
37
38pub(crate) fn collect_embedded_category_names(root: &str, filter: Option<&str>) -> Vec<String> {
41 let mut names = BTreeSet::new();
42 let prefix = format!("{root}/");
43 for asset_path in presets::asset_paths(root) {
44 let Some(rest) = asset_path.strip_prefix(&prefix) else {
45 continue;
46 };
47 let Some((category, _)) = rest.split_once('/') else {
48 continue;
49 };
50 if filter.is_none_or(|f| f == category) {
51 names.insert(category.to_string());
52 }
53 }
54 names.into_iter().collect()
55}
56
57pub(crate) async fn collect_fs_category_names(
61 root: &Path,
62 filter: Option<&str>,
63 what: &str,
64) -> Result<Vec<String>> {
65 if let Some(filter) = filter {
66 let path = root.join(filter);
67 if path.exists() {
68 return Ok(vec![filter.to_string()]);
69 }
70 return Ok(Vec::new());
71 }
72
73 if !root.exists() {
74 return Ok(Vec::new());
75 }
76
77 let mut names = BTreeSet::new();
78 let mut entries = fs::read_dir(root)
79 .await
80 .with_context(|| format!("reading {what}: {}", root.display()))?;
81 while let Some(entry) = entries.next_entry().await? {
82 if entry.file_type().await?.is_dir() {
83 names.insert(entry.file_name().to_string_lossy().to_string());
84 }
85 }
86 Ok(names.into_iter().collect())
87}
88
89pub(crate) fn platform_matches(
94 platforms: Option<&[String]>,
95 current: OperatingSystem,
96 context: &str,
97) -> Result<bool> {
98 let Some(platforms) = platforms else {
99 return Ok(true);
100 };
101 if platforms.is_empty() {
102 anyhow::bail!(
103 "{context} platforms must not be empty; expected `macos`, `linux`, `windows`, or `unix`"
104 );
105 }
106
107 let mut matches = false;
108 for platform in platforms {
109 let normalized = platform.trim().to_ascii_lowercase();
110 match normalized.as_str() {
111 "macos" | "linux" | "windows" => matches |= normalized == current.as_str(),
112 "unix" => matches |= current.is_unix(),
113 _ => anyhow::bail!(
114 "{context} has unsupported platform `{platform}`; expected `macos`, `linux`, `windows`, or `unix`"
115 ),
116 }
117 }
118 Ok(matches)
119}
120
121pub(crate) async fn collect_fs_tree(
127 root: &Path,
128 what: &str,
129 keep: impl Fn(&Path) -> Result<Option<PathBuf>>,
130) -> Result<Vec<PathBuf>> {
131 let mut out = Vec::new();
132 let mut stack = vec![root.to_path_buf()];
133 while let Some(dir) = stack.pop() {
134 let mut entries = fs::read_dir(&dir)
135 .await
136 .with_context(|| format!("reading {what}: {}", dir.display()))?;
137 while let Some(entry) = entries.next_entry().await? {
138 let path = entry.path();
139 let ft = entry.file_type().await?;
140 if ft.is_dir() {
141 stack.push(path);
142 continue;
143 }
144 if !ft.is_file() {
145 continue;
146 }
147 let rel = path.strip_prefix(root).with_context(|| {
148 format!(
149 "failed to resolve {} relative to {}",
150 path.display(),
151 root.display()
152 )
153 })?;
154 if let Some(normalized) = keep(rel)? {
155 out.push(normalized);
156 }
157 }
158 }
159 out.sort();
160 Ok(out)
161}
162
163pub(crate) async fn merge_fs_tree(
169 config: &Config,
170 category_rel: &Path,
171 what: &str,
172 keep: impl Fn(&Path) -> Result<Option<PathBuf>> + Copy,
173) -> Result<Vec<PathBuf>> {
174 let base_category = config.presets_dir().join(category_rel);
175 let mut items: BTreeSet<PathBuf> = if base_category.is_dir() {
176 collect_fs_tree(&base_category, what, keep)
177 .await?
178 .into_iter()
179 .collect()
180 } else {
181 BTreeSet::new()
182 };
183 if let Some(overlay) = config.active_presets_overlay_dir() {
184 let overlay_category = overlay.join(category_rel);
185 if overlay_category.is_dir() {
186 items.extend(collect_fs_tree(&overlay_category, what, keep).await?);
187 }
188 }
189 Ok(items.into_iter().collect())
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 fn normalize_generated_block(block: &str) -> String {
197 String::from_utf8(crate::install_core::normalize_eol(block.as_bytes())).unwrap()
198 }
199
200 fn generated_block_replacement(current: &str, expected: &str) -> String {
201 if current.contains("\r\n") {
202 expected.replace('\n', "\r\n")
203 } else {
204 expected.to_string()
205 }
206 }
207
208 #[test]
209 fn platform_matches_defaults_to_true_when_unset() {
210 assert!(platform_matches(None, OperatingSystem::Windows, "ctx").unwrap());
211 }
212
213 #[test]
214 fn platform_matches_exact_os_and_unix_group() {
215 let macos = vec!["macos".to_string()];
216 assert!(platform_matches(Some(&macos), OperatingSystem::Macos, "ctx").unwrap());
217 assert!(!platform_matches(Some(&macos), OperatingSystem::Linux, "ctx").unwrap());
218
219 let unix = vec!["unix".to_string()];
220 assert!(platform_matches(Some(&unix), OperatingSystem::Macos, "ctx").unwrap());
221 assert!(platform_matches(Some(&unix), OperatingSystem::Linux, "ctx").unwrap());
222 assert!(!platform_matches(Some(&unix), OperatingSystem::Windows, "ctx").unwrap());
223 }
224
225 #[test]
226 fn platform_matches_rejects_empty_platforms() {
227 let platforms = Vec::new();
228 let err = platform_matches(Some(&platforms), OperatingSystem::Linux, "ctx")
229 .unwrap_err()
230 .to_string();
231 assert!(err.contains("must not be empty"));
232 }
233
234 #[test]
235 fn platform_matches_rejects_unknown_platform_with_context() {
236 let platforms = vec!["plan9".to_string()];
237 let err = platform_matches(
238 Some(&platforms),
239 OperatingSystem::Linux,
240 "app/foo/shine.toml",
241 )
242 .unwrap_err()
243 .to_string();
244 assert!(err.contains("app/foo/shine.toml"));
245 assert!(err.contains("unsupported platform"));
246 }
247
248 #[test]
249 fn collect_embedded_category_names_filters_to_requested_name() {
250 let names = collect_embedded_category_names("shell", Some("proxy"));
251 assert_eq!(names, vec!["proxy".to_string()]);
252 }
253
254 #[test]
255 fn built_in_preset_platform_capability_docs_are_current() {
256 const START: &str = "<!-- BEGIN GENERATED PRESET PLATFORM CAPABILITIES -->";
257 const END: &str = "<!-- END GENERATED PRESET PLATFORM CAPABILITIES -->";
258
259 let mut capabilities = crate::apps::built_in_platform_availability().unwrap();
260 capabilities.extend(crate::shells::metadata::built_in_platform_availability().unwrap());
261
262 let mut expected = String::from(START);
263 expected.push_str("\n| Preset capability | macOS | Linux | Windows |\n");
264 expected.push_str("| --- | --- | --- | --- |\n");
265 for (target, platforms) in capabilities {
266 let supported = |platform| {
267 if platforms.contains(&platform) {
268 "✓"
269 } else {
270 "—"
271 }
272 };
273 expected.push_str(&format!(
274 "| `{target}` | {} | {} | {} |\n",
275 supported(OperatingSystem::Macos),
276 supported(OperatingSystem::Linux),
277 supported(OperatingSystem::Windows),
278 ));
279 }
280 expected.push_str(END);
281
282 let repository_root = Path::new(env!("CARGO_MANIFEST_DIR"));
283 if !repository_root.join("docs/manual").is_dir() {
284 return;
286 }
287 let update = std::env::var_os("SHINE_UPDATE_PRESET_CAPABILITIES").as_deref()
288 == Some(std::ffi::OsStr::new("1"));
289 for relative in [
290 "docs/manual/reference/built-in-presets.md",
291 "website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/built-in-presets.md",
292 ] {
293 let path = repository_root.join(relative);
294 let document = std::fs::read_to_string(&path).unwrap();
295 let start = document
296 .find(START)
297 .unwrap_or_else(|| panic!("{} is missing {START}", path.display()));
298 let end = document[start..]
299 .find(END)
300 .map(|offset| start + offset + END.len())
301 .unwrap_or_else(|| panic!("{} is missing {END}", path.display()));
302 if update {
303 let replacement = generated_block_replacement(&document[start..end], &expected);
304 let mut updated = document;
305 updated.replace_range(start..end, &replacement);
306 std::fs::write(&path, updated).unwrap();
307 continue;
308 }
309 assert_eq!(
310 normalize_generated_block(&document[start..end]),
311 expected,
312 "{} has a stale built-in preset platform capability list; replace its generated block with the right-hand value",
313 path.display()
314 );
315 }
316 }
317
318 #[test]
319 fn generated_capability_blocks_accept_and_preserve_crlf() {
320 let expected = "<!-- start -->\n| row |\n<!-- end -->";
321 let checked_out = "<!-- start -->\r\n| row |\r\n<!-- end -->";
322
323 assert_eq!(normalize_generated_block(checked_out), expected);
324 assert_eq!(
325 generated_block_replacement(checked_out, expected),
326 checked_out
327 );
328 assert_eq!(generated_block_replacement(expected, expected), expected);
329 }
330
331 #[tokio::test]
332 async fn collect_fs_tree_keeps_only_entries_the_predicate_returns_some_for() {
333 let dir = crate::test_support::make_temp_dir("shine-preset-meta").await;
334 tokio::fs::write(dir.join("keep.txt"), b"").await.unwrap();
335 tokio::fs::write(dir.join("skip.txt"), b"").await.unwrap();
336 tokio::fs::create_dir_all(dir.join("nested")).await.unwrap();
337 tokio::fs::write(dir.join("nested/keep2.txt"), b"")
338 .await
339 .unwrap();
340
341 let result = collect_fs_tree(&dir, "test dir", |rel| {
342 if rel.file_name().and_then(|n| n.to_str()) == Some("skip.txt") {
343 Ok(None)
344 } else {
345 Ok(Some(rel.to_path_buf()))
346 }
347 })
348 .await
349 .unwrap();
350
351 assert_eq!(
352 result,
353 vec![PathBuf::from("keep.txt"), PathBuf::from("nested/keep2.txt"),]
354 );
355
356 tokio::fs::remove_dir_all(&dir).await.unwrap();
357 }
358
359 #[tokio::test]
360 async fn collect_fs_tree_propagates_keep_errors() {
361 let dir = crate::test_support::make_temp_dir("shine-preset-meta").await;
362 tokio::fs::write(dir.join("bad.txt"), b"").await.unwrap();
363
364 let result = collect_fs_tree(&dir, "test dir", |_rel| anyhow::bail!("invalid entry")).await;
365
366 assert!(result.is_err());
367 tokio::fs::remove_dir_all(&dir).await.unwrap();
368 }
369
370 #[tokio::test]
371 async fn merge_fs_tree_combines_base_and_overlay() {
372 let dir = crate::test_support::make_temp_dir("shine-preset-meta").await;
373 let base = dir.join("presets/app/sample");
374 let overlay_root = dir.join("overlay");
375 let overlay = overlay_root.join("app/sample");
376 tokio::fs::create_dir_all(&base).await.unwrap();
377 tokio::fs::create_dir_all(&overlay).await.unwrap();
378 tokio::fs::write(base.join("a.txt"), b"").await.unwrap();
379 tokio::fs::write(overlay.join("b.txt"), b"").await.unwrap();
380
381 let mut config = crate::test_support::test_config(&dir);
382 config.presets_overlay_dir_override = Some(overlay_root);
383
384 let result = merge_fs_tree(&config, Path::new("app/sample"), "test dir", |rel| {
385 Ok(Some(rel.to_path_buf()))
386 })
387 .await
388 .unwrap();
389
390 assert_eq!(result, vec![PathBuf::from("a.txt"), PathBuf::from("b.txt")]);
391
392 tokio::fs::remove_dir_all(&dir).await.unwrap();
393 }
394}