Skip to main content

cli/
preset_meta.rs

1//! Shared primitives for `apps::metadata` and `shells::metadata`'s
2//! shine.toml/category loaders: embedded-category-name discovery,
3//! filesystem-category-name discovery, platform filtering, and the
4//! filesystem tree walk + base/overlay merge used to auto-collect files for
5//! categories without an explicit `[[files]]` list.
6//!
7//! Deliberately scoped to just these primitives rather than a full generic
8//! loader: the two domains' leaf schemas (`AppCategory`/`ShellCategory`),
9//! per-file validation rules, and `Option`-vs-always-`Some` return shapes
10//! differ enough that forcing them onto one trait-parameterized loader would
11//! be harder to read than the duplication it removes.
12
13use 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
38/// Names of categories under `root` (e.g. `"shell"` or `"app"`) among the
39/// embedded assets, optionally filtered to a single name.
40pub(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
57/// Names of category subdirectories under `root` on disk, optionally
58/// filtered to a single name. `what` labels `root` in the read-directory
59/// error context (e.g. `"shell presets directory"`).
60pub(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
89/// Shared platform-filter logic for a `shine.toml` file entry's optional
90/// `platforms` list. Exact selectors are `macos`, `linux`, and `windows`;
91/// `unix` is the compatibility group for macOS and Linux. `context` labels the offending entry in
92/// the error message (e.g. `"shell/proxy/shine.toml"`).
93pub(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
121/// Recursively walks `root`, returning the sorted paths for which `keep`
122/// returns `Some`. `keep` receives each file's path relative to `root` and
123/// both filters (return `None` to skip) and normalizes/validates it (return
124/// `Err` to propagate a validation failure, e.g. an invalid file name).
125/// `what` labels `root`/subdirectories in the read-directory error context.
126pub(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
163/// [`collect_fs_tree`] over `config.presets_dir()`, merged with
164/// `config.active_presets_overlay_dir()` if set — the base/overlay merge
165/// shared by every "auto-collect files for a category with no explicit
166/// `[[files]]` list" path. `category_rel` is the category's path relative to
167/// the presets root, e.g. `Path::new("shell").join(name)`.
168pub(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            // Published crates intentionally exclude the documentation repositories.
285            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}