Skip to main content

zeroclaw_config/
skill_bundles.rs

1//! Skill-bundle directory rules and helpers.
2//!
3//! Single source of truth for:
4//! - the `shared/skills/<alias>/` default
5//! - the inside-`shared/` containment rule
6//! - the per-config uniqueness rule
7//!
8//! Lives in `zeroclaw-config` (not `zeroclaw-runtime/skills/bundle.rs`) so
9//! [`crate::schema::Config::validate`] can call into it at load time.
10//! Runtime's `bundle.rs` re-exports these functions; there is no second
11//! implementation.
12
13use std::path::{Path, PathBuf};
14
15use crate::paths::normalize_lexical;
16use crate::schema::Config;
17
18/// Canonical default directory for a bundle: `<install>/shared/skills/<alias>/`.
19#[must_use]
20pub fn default_directory(install_root: &Path, alias: &str) -> PathBuf {
21    install_root.join("shared").join("skills").join(alias)
22}
23
24/// Resolve the on-disk directory for a configured bundle, applying the
25/// default when `[skill-bundles.<alias>].directory` is unset or empty.
26/// Absolute paths configured by the user pass through verbatim; relative
27/// paths are resolved against the install root.
28pub fn resolve_directory(
29    config: &Config,
30    install_root: &Path,
31    alias: &str,
32) -> Result<PathBuf, BundleDirectoryError> {
33    let bundle = config
34        .skill_bundles
35        .get(alias)
36        .ok_or_else(|| BundleDirectoryError::UnknownBundle(alias.to_string()))?;
37
38    let configured = bundle
39        .directory
40        .as_deref()
41        .map(str::trim)
42        .filter(|s| !s.is_empty());
43
44    let path = match configured {
45        Some(raw) => {
46            let candidate = PathBuf::from(raw);
47            if candidate.is_absolute() {
48                candidate
49            } else {
50                install_root.join(candidate)
51            }
52        }
53        None => default_directory(install_root, alias),
54    };
55    Ok(path)
56}
57
58/// Reject directories that escape `<install>/shared/`. Run at scaffold time
59/// and inside [`crate::schema::Config::validate`].
60pub fn validate_directory(path: &Path, install_root: &Path) -> Result<(), BundleDirectoryError> {
61    let shared = install_root.join("shared");
62    let normalized = normalize_lexical(path);
63    let shared_normalized = normalize_lexical(&shared);
64    if !normalized.starts_with(&shared_normalized) {
65        return Err(BundleDirectoryError::EscapesShared {
66            path: normalized.display().to_string(),
67            shared: shared_normalized.display().to_string(),
68        });
69    }
70    Ok(())
71}
72
73/// Reject configs where two bundles resolve to the same directory.
74pub fn validate_uniqueness(
75    config: &Config,
76    install_root: &Path,
77) -> Result<(), BundleDirectoryError> {
78    let mut seen: Vec<(String, PathBuf)> = Vec::with_capacity(config.skill_bundles.len());
79    for alias in config.skill_bundles.keys() {
80        let dir = resolve_directory(config, install_root, alias)?;
81        let normalized = normalize_lexical(&dir);
82        if let Some((other, _)) = seen.iter().find(|(_, p)| p == &normalized) {
83            return Err(BundleDirectoryError::DirectoryCollision {
84                path: normalized.display().to_string(),
85                first: other.clone(),
86                second: alias.clone(),
87            });
88        }
89        seen.push((alias.clone(), normalized));
90    }
91    Ok(())
92}
93
94#[derive(Debug, thiserror::Error)]
95pub enum BundleDirectoryError {
96    #[error("skill bundle '{0}' is not configured")]
97    UnknownBundle(String),
98
99    #[error(
100        "skill-bundle directory '{path}' escapes the shared workspace at '{shared}'; bundles must stay inside `<install>/shared/`"
101    )]
102    EscapesShared { path: String, shared: String },
103
104    #[error(
105        "skill-bundles '{first}' and '{second}' both resolve to directory '{path}'; each bundle must own a unique directory"
106    )]
107    DirectoryCollision {
108        path: String,
109        first: String,
110        second: String,
111    },
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::schema::SkillBundleConfig;
118
119    fn cfg_with_bundle(alias: &str, directory: Option<&str>) -> Config {
120        let mut cfg = Config::default();
121        cfg.skill_bundles.insert(
122            alias.to_string(),
123            SkillBundleConfig {
124                directory: directory.map(String::from),
125                ..Default::default()
126            },
127        );
128        cfg
129    }
130
131    #[test]
132    fn defaults_to_shared_skills_alias_when_unset() {
133        let cfg = cfg_with_bundle("alpha", None);
134        let root = Path::new("/tmp/install");
135        let resolved = resolve_directory(&cfg, root, "alpha").unwrap();
136        assert_eq!(resolved, root.join("shared/skills/alpha"));
137    }
138
139    #[test]
140    fn empty_directory_string_is_treated_as_unset() {
141        let cfg = cfg_with_bundle("alpha", Some("   "));
142        let root = Path::new("/tmp/install");
143        assert_eq!(
144            resolve_directory(&cfg, root, "alpha").unwrap(),
145            root.join("shared/skills/alpha"),
146        );
147    }
148
149    #[test]
150    fn validate_directory_rejects_dotdot_escape() {
151        let root = Path::new("/tmp/install");
152        let path = root.join("shared/../etc");
153        let err = validate_directory(&path, root).unwrap_err();
154        assert!(matches!(err, BundleDirectoryError::EscapesShared { .. }));
155    }
156
157    #[test]
158    fn uniqueness_rejects_two_bundles_pointing_at_same_dir() {
159        let mut cfg = Config::default();
160        cfg.skill_bundles.insert(
161            "alpha".into(),
162            SkillBundleConfig {
163                directory: Some("shared/skills/shared-pool".into()),
164                ..Default::default()
165            },
166        );
167        cfg.skill_bundles.insert(
168            "beta".into(),
169            SkillBundleConfig {
170                directory: Some("shared/skills/shared-pool".into()),
171                ..Default::default()
172            },
173        );
174        let err = validate_uniqueness(&cfg, Path::new("/tmp/install")).unwrap_err();
175        assert!(matches!(
176            err,
177            BundleDirectoryError::DirectoryCollision { .. }
178        ));
179    }
180
181    #[test]
182    fn uniqueness_passes_for_distinct_default_directories() {
183        let mut cfg = Config::default();
184        cfg.skill_bundles
185            .insert("alpha".into(), SkillBundleConfig::default());
186        cfg.skill_bundles
187            .insert("beta".into(), SkillBundleConfig::default());
188        validate_uniqueness(&cfg, Path::new("/tmp/install")).unwrap();
189    }
190}