1pub mod format;
7pub mod index;
8pub mod patch;
9#[cfg(feature = "remote")]
10pub mod remote;
11mod validate;
12
13use serde::{Deserialize, Serialize};
14use std::path::Path;
15use thiserror::Error;
16
17#[derive(Debug, Error)]
20pub enum SkillError {
21 #[error("IO error: {0}")]
22 Io(String),
23 #[error("Parse error: {0}")]
24 Parse(String),
25 #[error("Validation error: {0}")]
26 Validation(String),
27 #[error("Skill not found: {0}")]
28 NotFound(String),
29 #[error("Skill already exists: {0}")]
30 AlreadyExists(String),
31 #[error("Patch failed: old text not found")]
32 PatchNotFound,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct SkillSummary {
37 pub name: String,
38 pub description: String,
39 pub version: u32,
40 pub tags: Vec<String>,
41 pub created: Option<String>,
42 pub last_used: Option<String>,
43}
44
45#[derive(Debug, Clone)]
46pub struct Skill {
47 pub summary: SkillSummary,
48 pub content: String,
49}
50
51pub struct CreateSkillInput {
52 pub name: String,
53 pub description: String,
54 pub content: String,
55 pub tags: Vec<String>,
56}
57
58pub fn list_skills(skills_dir: &Path) -> Result<Vec<SkillSummary>, SkillError> {
62 if !skills_dir.exists() {
63 return Ok(Vec::new());
64 }
65 let entries = std::fs::read_dir(skills_dir).map_err(|e| SkillError::Io(e.to_string()))?;
66 let mut summaries = Vec::new();
67 for entry in entries.flatten() {
68 let path = entry.path();
69 if !path.is_dir() {
70 continue;
71 }
72 let skill_md = path.join("SKILL.md");
73 if !skill_md.exists() {
74 continue;
75 }
76 match format::parse_file(&skill_md) {
77 Ok((summary, _body)) => summaries.push(summary),
78 Err(e) => eprintln!("[houston-skills] skipping {}: {e}", path.display()),
79 }
80 }
81 summaries.sort_by(|a, b| a.name.cmp(&b.name));
82 Ok(summaries)
83}
84
85pub fn load_skill(skills_dir: &Path, name: &str) -> Result<Skill, SkillError> {
87 let skill_dir = skills_dir.join(name);
88 let skill_md = skill_dir.join("SKILL.md");
89 if !skill_md.exists() {
90 return Err(SkillError::NotFound(name.to_string()));
91 }
92 let (mut summary, body) = format::parse_file(&skill_md)?;
93 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
94 summary.last_used = Some(today);
95 let updated = format::serialize(&summary, &body);
96 std::fs::write(&skill_md, &updated).map_err(|e| SkillError::Io(e.to_string()))?;
97 Ok(Skill {
98 summary,
99 content: body,
100 })
101}
102
103pub fn create_skill(skills_dir: &Path, input: CreateSkillInput) -> Result<(), SkillError> {
105 validate::name(&input.name)?;
106 validate::description(&input.description)?;
107 validate::content(&input.content)?;
108
109 let skill_dir = skills_dir.join(&input.name);
110 if skill_dir.exists() {
111 return Err(SkillError::AlreadyExists(input.name));
112 }
113 std::fs::create_dir_all(&skill_dir).map_err(|e| SkillError::Io(e.to_string()))?;
114
115 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
116 let summary = SkillSummary {
117 name: input.name,
118 description: input.description,
119 version: 1,
120 tags: input.tags,
121 created: Some(today.clone()),
122 last_used: Some(today),
123 };
124 let content = format::serialize(&summary, &input.content);
125 let skill_md = skill_dir.join("SKILL.md");
126 std::fs::write(&skill_md, &content).map_err(|e| SkillError::Io(e.to_string()))?;
127 Ok(())
128}
129
130pub fn patch_skill(
132 skills_dir: &Path,
133 name: &str,
134 old_text: &str,
135 new_text: &str,
136) -> Result<(), SkillError> {
137 let skill_md = skills_dir.join(name).join("SKILL.md");
138 if !skill_md.exists() {
139 return Err(SkillError::NotFound(name.to_string()));
140 }
141 let (mut summary, body) = format::parse_file(&skill_md)?;
142 let patched_body = patch::fuzzy_replace(&body, old_text, new_text)
143 .ok_or(SkillError::PatchNotFound)?;
144 validate::content(&patched_body)?;
145 summary.version += 1;
146 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
147 summary.last_used = Some(today);
148 let content = format::serialize(&summary, &patched_body);
149 std::fs::write(&skill_md, &content).map_err(|e| SkillError::Io(e.to_string()))?;
150 Ok(())
151}
152
153pub fn edit_skill(skills_dir: &Path, name: &str, new_content: &str) -> Result<(), SkillError> {
155 validate::content(new_content)?;
156 let skill_md = skills_dir.join(name).join("SKILL.md");
157 if !skill_md.exists() {
158 return Err(SkillError::NotFound(name.to_string()));
159 }
160 let (mut summary, _old_body) = format::parse_file(&skill_md)?;
161 summary.version += 1;
162 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
163 summary.last_used = Some(today);
164 let content = format::serialize(&summary, new_content);
165 std::fs::write(&skill_md, &content).map_err(|e| SkillError::Io(e.to_string()))?;
166 Ok(())
167}
168
169pub fn delete_skill(skills_dir: &Path, name: &str) -> Result<(), SkillError> {
171 let skill_dir = skills_dir.join(name);
172 if !skill_dir.exists() {
173 return Err(SkillError::NotFound(name.to_string()));
174 }
175 std::fs::remove_dir_all(&skill_dir).map_err(|e| SkillError::Io(e.to_string()))?;
176 Ok(())
177}
178
179pub fn build_skills_index(skills_dir: &Path) -> Result<String, SkillError> {
181 index::build(skills_dir)
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use tempfile::TempDir;
188
189 #[test]
190 fn list_empty_dir() {
191 let tmp = TempDir::new().unwrap();
192 let result = list_skills(tmp.path()).unwrap();
193 assert!(result.is_empty());
194 }
195
196 #[test]
197 fn list_nonexistent_dir() {
198 let result = list_skills(Path::new("/nonexistent/path/skills"));
199 assert!(result.is_ok());
200 assert!(result.unwrap().is_empty());
201 }
202
203 #[test]
204 fn create_and_list() {
205 let tmp = TempDir::new().unwrap();
206 let dir = tmp.path();
207 create_skill(dir, CreateSkillInput {
208 name: "my-skill".into(),
209 description: "Test skill".into(),
210 content: "## Procedure\n\n1. Do stuff\n".into(),
211 tags: vec!["test".into()],
212 }).unwrap();
213
214 let skills = list_skills(dir).unwrap();
215 assert_eq!(skills.len(), 1);
216 assert_eq!(skills[0].name, "my-skill");
217 assert_eq!(skills[0].version, 1);
218 }
219
220 #[test]
221 fn create_and_load() {
222 let tmp = TempDir::new().unwrap();
223 let dir = tmp.path();
224 create_skill(dir, CreateSkillInput {
225 name: "loader-test".into(),
226 description: "Load test".into(),
227 content: "## Procedure\nTest body".into(),
228 tags: vec![],
229 }).unwrap();
230
231 let skill = load_skill(dir, "loader-test").unwrap();
232 assert_eq!(skill.summary.name, "loader-test");
233 assert!(skill.content.contains("Test body"));
234 }
235
236 #[test]
237 fn create_duplicate_fails() {
238 let tmp = TempDir::new().unwrap();
239 let dir = tmp.path();
240 create_skill(dir, CreateSkillInput {
241 name: "dup".into(),
242 description: "".into(),
243 content: "body".into(),
244 tags: vec![],
245 }).unwrap();
246 let result = create_skill(dir, CreateSkillInput {
247 name: "dup".into(),
248 description: "".into(),
249 content: "body".into(),
250 tags: vec![],
251 });
252 assert!(result.is_err());
253 }
254
255 #[test]
256 fn edit_increments_version() {
257 let tmp = TempDir::new().unwrap();
258 let dir = tmp.path();
259 create_skill(dir, CreateSkillInput {
260 name: "editable".into(),
261 description: "Edit me".into(),
262 content: "v1 content".into(),
263 tags: vec![],
264 }).unwrap();
265
266 edit_skill(dir, "editable", "v2 content").unwrap();
267 let skill = load_skill(dir, "editable").unwrap();
268 assert_eq!(skill.summary.version, 2);
269 assert!(skill.content.contains("v2 content"));
270 }
271
272 #[test]
273 fn patch_fuzzy() {
274 let tmp = TempDir::new().unwrap();
275 let dir = tmp.path();
276 create_skill(dir, CreateSkillInput {
277 name: "patchable".into(),
278 description: "Patch me".into(),
279 content: "1. First step\n2. Second step\n".into(),
280 tags: vec![],
281 }).unwrap();
282
283 patch_skill(dir, "patchable", "Second step", "Updated step").unwrap();
284 let skill = load_skill(dir, "patchable").unwrap();
285 assert!(skill.content.contains("Updated step"));
286 assert!(!skill.content.contains("Second step"));
287 assert_eq!(skill.summary.version, 2);
288 }
289
290 #[test]
291 fn delete_removes_dir() {
292 let tmp = TempDir::new().unwrap();
293 let dir = tmp.path();
294 create_skill(dir, CreateSkillInput {
295 name: "deleteme".into(),
296 description: "".into(),
297 content: "body".into(),
298 tags: vec![],
299 }).unwrap();
300 assert!(dir.join("deleteme").exists());
301 delete_skill(dir, "deleteme").unwrap();
302 assert!(!dir.join("deleteme").exists());
303 }
304
305 #[test]
306 fn delete_nonexistent_fails() {
307 let tmp = TempDir::new().unwrap();
308 let result = delete_skill(tmp.path(), "nope");
309 assert!(result.is_err());
310 }
311}