1use std::path::{Path, PathBuf};
5
6use super::bundle::{self, BundleError};
7use super::constants::{SKILL_MANIFEST_FILENAME, SKILL_SCAFFOLD_SUBDIRS};
8use super::document::SkillDocument;
9use super::frontmatter::SkillFrontmatter;
10use super::reference::SkillRef;
11use zeroclaw_config::schema::Config;
12
13#[derive(Debug, Clone)]
14pub struct ScaffoldOptions {
15 pub create_optional_subdirs: bool,
18 pub body: String,
21}
22
23impl Default for ScaffoldOptions {
24 fn default() -> Self {
25 Self {
26 create_optional_subdirs: true,
27 body: String::new(),
28 }
29 }
30}
31
32#[derive(Debug, thiserror::Error)]
33pub enum ScaffoldError {
34 #[error(transparent)]
35 Bundle(#[from] BundleError),
36
37 #[error("skill name '{0}' must use lowercase letters, digits, and hyphens only")]
38 InvalidName(String),
39
40 #[error("skill '{0}' already exists at {1}")]
41 AlreadyExists(String, PathBuf),
42
43 #[error("failed to write skill scaffold: {0}")]
44 Io(#[from] std::io::Error),
45}
46
47pub fn validate_name(name: &str) -> Result<(), ScaffoldError> {
49 let ok = !name.is_empty()
50 && name
51 .chars()
52 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
53 && !name.starts_with('-')
54 && !name.ends_with('-');
55 if ok {
56 Ok(())
57 } else {
58 Err(ScaffoldError::InvalidName(name.to_string()))
59 }
60}
61
62pub fn scaffold_skill(
65 config: &Config,
66 install_root: &Path,
67 target: &SkillRef,
68 frontmatter: SkillFrontmatter,
69 opts: ScaffoldOptions,
70) -> Result<PathBuf, ScaffoldError> {
71 validate_name(target.name())?;
72
73 let bundle_dir = bundle::resolve_directory(config, install_root, target.bundle())?;
74 bundle::validate_directory(&bundle_dir, install_root)?;
75
76 let skill_dir = bundle_dir.join(target.name());
77 if skill_dir.exists() {
78 return Err(ScaffoldError::AlreadyExists(target.to_string(), skill_dir));
79 }
80
81 std::fs::create_dir_all(&skill_dir)?;
82
83 let body = if opts.body.is_empty() {
84 format!("# {}\n", title_from_name(target.name()))
85 } else {
86 opts.body
87 };
88 let document = SkillDocument { frontmatter, body };
89 std::fs::write(
90 skill_dir.join(SKILL_MANIFEST_FILENAME),
91 document.serialize(),
92 )?;
93
94 if opts.create_optional_subdirs {
95 for sub in SKILL_SCAFFOLD_SUBDIRS {
96 std::fs::create_dir_all(skill_dir.join(sub))?;
97 }
98 }
99
100 Ok(skill_dir)
101}
102
103fn title_from_name(name: &str) -> String {
104 name.split('-')
105 .map(|w| {
106 let mut chars = w.chars();
107 match chars.next() {
108 Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
109 None => String::new(),
110 }
111 })
112 .collect::<Vec<_>>()
113 .join(" ")
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119 use tempfile::TempDir;
120 use zeroclaw_config::schema::SkillBundleConfig;
121
122 fn fixture() -> (TempDir, Config) {
123 let dir = tempfile::tempdir().unwrap();
124 let mut cfg = Config::default();
125 cfg.skill_bundles
126 .insert("alpha".to_string(), SkillBundleConfig::default());
127 (dir, cfg)
128 }
129
130 fn skill_ref(bundle: &str, name: &str) -> SkillRef {
131 SkillRef::new_unchecked(bundle.to_string(), name.to_string())
133 }
134
135 #[test]
136 fn rejects_invalid_skill_names() {
137 for bad in [
138 "",
139 "-leading",
140 "trailing-",
141 "Upper",
142 "with_underscore",
143 "has space",
144 ] {
145 assert!(
146 validate_name(bad).is_err(),
147 "expected '{bad}' to be rejected"
148 );
149 }
150 }
151
152 #[test]
153 fn accepts_valid_skill_names() {
154 for good in ["code-review", "x", "single-word-name", "version2", "abc123"] {
155 validate_name(good).unwrap();
156 }
157 }
158
159 #[test]
160 fn scaffold_creates_full_canonical_layout() {
161 let (dir, cfg) = fixture();
162 let frontmatter = SkillFrontmatter {
163 name: "code-review".into(),
164 description: "Reviews PRs.".into(),
165 ..Default::default()
166 };
167 let path = scaffold_skill(
168 &cfg,
169 dir.path(),
170 &skill_ref("alpha", "code-review"),
171 frontmatter.clone(),
172 ScaffoldOptions::default(),
173 )
174 .unwrap();
175
176 assert!(path.join(SKILL_MANIFEST_FILENAME).exists());
177 for sub in SKILL_SCAFFOLD_SUBDIRS {
178 assert!(path.join(sub).is_dir(), "missing optional subdir {sub}");
179 }
180
181 let written = std::fs::read_to_string(path.join(SKILL_MANIFEST_FILENAME)).unwrap();
182 let doc = SkillDocument::parse(&written).unwrap();
183 assert_eq!(doc.frontmatter, frontmatter);
184 assert!(doc.body.contains("# Code Review"));
185 }
186
187 #[test]
188 fn scaffold_skips_optional_subdirs_when_disabled() {
189 let (dir, cfg) = fixture();
190 let path = scaffold_skill(
191 &cfg,
192 dir.path(),
193 &skill_ref("alpha", "minimal"),
194 SkillFrontmatter {
195 name: "minimal".into(),
196 description: "d".into(),
197 ..Default::default()
198 },
199 ScaffoldOptions {
200 create_optional_subdirs: false,
201 body: String::new(),
202 },
203 )
204 .unwrap();
205 assert!(path.join(SKILL_MANIFEST_FILENAME).exists());
206 for sub in SKILL_SCAFFOLD_SUBDIRS {
207 assert!(!path.join(sub).exists());
208 }
209 }
210
211 #[test]
212 fn scaffold_errors_when_skill_already_exists() {
213 let (dir, cfg) = fixture();
214 let r = skill_ref("alpha", "dup");
215 let fm = SkillFrontmatter {
216 name: "dup".into(),
217 description: "d".into(),
218 ..Default::default()
219 };
220 scaffold_skill(&cfg, dir.path(), &r, fm.clone(), ScaffoldOptions::default()).unwrap();
221 let err = scaffold_skill(&cfg, dir.path(), &r, fm, ScaffoldOptions::default()).unwrap_err();
222 assert!(matches!(err, ScaffoldError::AlreadyExists(_, _)));
223 }
224
225 #[test]
226 fn scaffold_errors_when_bundle_unknown() {
227 let (dir, cfg) = fixture();
228 let r = skill_ref("missing-bundle", "x");
229 let fm = SkillFrontmatter {
230 name: "x".into(),
231 description: "d".into(),
232 ..Default::default()
233 };
234 let err = scaffold_skill(&cfg, dir.path(), &r, fm, ScaffoldOptions::default()).unwrap_err();
235 assert!(matches!(
236 err,
237 ScaffoldError::Bundle(BundleError::UnknownBundle(_))
238 ));
239 }
240
241 #[test]
242 fn title_from_name_capitalizes_hyphen_segments() {
243 assert_eq!(title_from_name("code-review"), "Code Review");
244 assert_eq!(title_from_name("x"), "X");
245 assert_eq!(
246 title_from_name("multi-word-skill-name"),
247 "Multi Word Skill Name"
248 );
249 }
250}