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::presets;
20
21/// Names of categories under `root` (e.g. `"shell"` or `"app"`) among the
22/// embedded assets, optionally filtered to a single name.
23pub(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
40/// Names of category subdirectories under `root` on disk, optionally
41/// filtered to a single name. `what` labels `root` in the read-directory
42/// error context (e.g. `"shell presets directory"`).
43pub(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
72/// Shared platform-filter logic for a `shine.toml` file entry's optional
73/// `platforms` list. `current` is `"windows"` or `"unix"`
74/// (`platform::current_platform()`). `context` labels the offending entry in
75/// the error message (e.g. `"shell/proxy/shine.toml"`).
76pub(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
98/// Recursively walks `root`, returning the sorted paths for which `keep`
99/// returns `Some`. `keep` receives each file's path relative to `root` and
100/// both filters (return `None` to skip) and normalizes/validates it (return
101/// `Err` to propagate a validation failure, e.g. an invalid file name).
102/// `what` labels `root`/subdirectories in the read-directory error context.
103pub(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
140/// [`collect_fs_tree`] over `config.presets_dir()`, merged with
141/// `config.active_presets_overlay_dir()` if set — the base/overlay merge
142/// shared by every "auto-collect files for a category with no explicit
143/// `[[files]]` list" path. `category_rel` is the category's path relative to
144/// the presets root, e.g. `Path::new("shell").join(name)`.
145pub(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}