Skip to main content

tuff_core/
manifest.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::{Result, TuffError};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "lowercase")]
9pub enum CapabilityType {
10    Skill,
11    Tool,
12    Hook,
13    Workflow,
14    Policy,
15}
16
17impl CapabilityType {
18    pub fn plural_dir(&self) -> &'static str {
19        match self {
20            Self::Skill => "skills",
21            Self::Tool => "tools",
22            Self::Hook => "hooks",
23            Self::Workflow => "workflows",
24            Self::Policy => "policies",
25        }
26    }
27
28    pub fn as_str(&self) -> &'static str {
29        match self {
30            Self::Skill => "skill",
31            Self::Tool => "tool",
32            Self::Hook => "hook",
33            Self::Workflow => "workflow",
34            Self::Policy => "policy",
35        }
36    }
37
38    pub fn parse(s: &str) -> Option<Self> {
39        match s {
40            "skill" => Some(Self::Skill),
41            "tool" => Some(Self::Tool),
42            "hook" => Some(Self::Hook),
43            "workflow" => Some(Self::Workflow),
44            "policy" => Some(Self::Policy),
45            _ => None,
46        }
47    }
48}
49
50impl std::fmt::Display for CapabilityType {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        f.write_str(self.as_str())
53    }
54}
55
56#[derive(Debug, Deserialize)]
57pub struct CapabilityManifest {
58    pub id: String,
59    pub version: String,
60    #[serde(rename = "type")]
61    pub capability_type: CapabilityType,
62    pub description: String,
63    #[serde(default)]
64    pub files: Vec<String>,
65    #[serde(default)]
66    pub parameters: Option<serde_json::Value>,
67    #[serde(default)]
68    pub implementation: Option<ImplementationConfig>,
69    #[serde(default)]
70    pub hook: Option<HookConfig>,
71    #[serde(default)]
72    pub workflow: Option<WorkflowConfig>,
73    #[serde(default)]
74    #[allow(dead_code)]
75    pub targets: Vec<String>,
76
77    #[serde(skip)]
78    pub root: PathBuf,
79}
80
81#[derive(Debug, Clone, Deserialize)]
82pub struct ImplementationConfig {
83    pub language: String,
84    pub entrypoint: String,
85    #[serde(default)]
86    pub mcp: bool,
87    #[serde(default)]
88    pub runtime_deps: Vec<String>,
89}
90
91#[derive(Debug, Clone, Deserialize)]
92pub struct HookConfig {
93    pub event: String,
94    pub command: String,
95    #[serde(default = "default_cwd")]
96    pub working_directory: String,
97}
98
99#[derive(Debug, Clone, Deserialize)]
100pub struct WorkflowConfig {
101    pub requires: Vec<Requirement>,
102}
103
104#[derive(Debug, Clone, Deserialize)]
105pub struct Requirement {
106    pub id: String,
107    #[serde(rename = "type")]
108    pub capability_type: CapabilityType,
109}
110
111fn default_cwd() -> String {
112    ".".to_string()
113}
114
115impl CapabilityManifest {
116    pub fn source_files(&self) -> Result<Vec<PathBuf>> {
117        let mut paths = Vec::new();
118
119        for f in &self.files {
120            let clean = f.trim_start_matches("./");
121            let path = self.root.join(clean);
122            if !path.exists() {
123                return Err(TuffError::new(format!(
124                    "capability source file not found: {}",
125                    path.display()
126                )));
127            }
128            paths.push(path);
129        }
130
131        if self.capability_type == CapabilityType::Tool
132            && let Some(ref imp) = self.implementation
133        {
134            let ep_path = self.root.join(&imp.entrypoint);
135            if !paths.contains(&ep_path) && ep_path.exists() {
136                paths.push(ep_path);
137            }
138        }
139
140        Ok(paths)
141    }
142
143    pub fn read_source_contents_with_names(&self) -> Result<Vec<(String, Vec<u8>)>> {
144        self.source_files()?
145            .iter()
146            .map(|p| {
147                let rel = p
148                    .strip_prefix(&self.root)
149                    .unwrap_or(p)
150                    .to_string_lossy()
151                    .replace('\\', "/");
152                let rel = rel.strip_prefix("src/").unwrap_or(&rel).to_string();
153                let content = std::fs::read(p)?;
154                Ok((rel, content))
155            })
156            .collect()
157    }
158}
159
160fn validate_non_empty(field: &str, value: &str) -> Result<()> {
161    if value.is_empty() {
162        return Err(TuffError::new(format!(
163            "capability manifest field '{field}' must be a non-empty string"
164        )));
165    }
166    Ok(())
167}
168
169pub fn load_manifest(capability_dir: &Path) -> Result<CapabilityManifest> {
170    let manifest_path = capability_dir.join("tuff.toml");
171    if !manifest_path.exists() {
172        return Err(TuffError::new(format!(
173            "capability manifest not found: {}",
174            manifest_path.display()
175        )));
176    }
177
178    let mut manifest: CapabilityManifest =
179        toml::from_str(&std::fs::read_to_string(&manifest_path)?)?;
180    manifest.root = capability_dir.to_path_buf();
181
182    validate_non_empty("id", &manifest.id)?;
183    validate_non_empty("version", &manifest.version)?;
184    validate_non_empty("type", &manifest.capability_type.to_string())?;
185    validate_non_empty("description", &manifest.description)?;
186
187    match manifest.capability_type {
188        CapabilityType::Skill => {
189            if manifest.files.is_empty() {
190                return Err(TuffError::new("skill capability 'files' must not be empty"));
191            }
192            manifest.source_files()?;
193        }
194        CapabilityType::Tool => {
195            if manifest.parameters.is_none() {
196                return Err(TuffError::new(
197                    "tool capability requires a [parameters] section with JSON Schema",
198                ));
199            }
200            if manifest.implementation.is_none() {
201                return Err(TuffError::new(
202                    "tool capability requires an [implementation] section",
203                ));
204            }
205
206            let params = manifest.parameters.as_ref().unwrap();
207            crate::tool::validate_json_schema(params)?;
208
209            let impl_cfg = manifest.implementation.as_ref().unwrap();
210            crate::tool::validate_entrypoint(&manifest.root, &impl_cfg.entrypoint)?;
211
212            if !impl_cfg.runtime_deps.is_empty() {
213                eprintln!(
214                    "note: this tool requires runtime dependencies: {}",
215                    impl_cfg.runtime_deps.join(", ")
216                );
217            }
218
219            if !manifest.files.is_empty() {
220                manifest.source_files()?;
221            }
222        }
223        CapabilityType::Hook => {
224            let hook_cfg = manifest
225                .hook
226                .as_ref()
227                .ok_or_else(|| TuffError::new("hook capability requires a [hook] section"))?;
228
229            if hook_cfg.event.trim().is_empty() {
230                return Err(TuffError::new("hook 'event' must be a non-empty string"));
231            }
232            if hook_cfg.command.trim().is_empty() {
233                return Err(TuffError::new("hook 'command' must be a non-empty string"));
234            }
235
236            crate::tool::check_path_traversal(&hook_cfg.working_directory)?;
237
238            eprintln!(
239                "note: this hook runs '{}' on event '{}' — it will not be executed during install",
240                hook_cfg.command, hook_cfg.event
241            );
242
243            if !manifest.files.is_empty() {
244                manifest.source_files()?;
245            }
246        }
247        CapabilityType::Workflow => {
248            let wf = manifest.workflow.as_ref().ok_or_else(|| {
249                TuffError::new("workflow capability requires a [[workflow.requires]] section")
250            })?;
251
252            if wf.requires.is_empty() {
253                return Err(TuffError::new(
254                    "workflow 'requires' must have at least one entry",
255                ));
256            }
257
258            let mut seen = std::collections::HashSet::new();
259            for req in &wf.requires {
260                if req.id.trim().is_empty() {
261                    return Err(TuffError::new(
262                        "workflow requirement 'id' must not be empty",
263                    ));
264                }
265                if req.id == manifest.id {
266                    return Err(TuffError::new("workflow cannot require itself"));
267                }
268                if !seen.insert(&req.id) {
269                    return Err(TuffError::new(format!(
270                        "duplicate requirement '{}' in workflow",
271                        req.id
272                    )));
273                }
274            }
275
276            let names: Vec<_> = wf
277                .requires
278                .iter()
279                .map(|r| format!("{} ({})", r.id, r.capability_type))
280                .collect();
281            eprintln!(
282                "note: workflow '{}' requires {} capabilities: {}",
283                manifest.id,
284                names.len(),
285                names.join(", ")
286            );
287        }
288        CapabilityType::Policy => {
289            return Err(TuffError::new("policy capabilities are not supported yet"));
290        }
291    }
292
293    Ok(manifest)
294}
295
296pub fn synthetic_manifest(
297    skill_dir: &Path,
298    name: &str,
299    version: &str,
300) -> Result<CapabilityManifest> {
301    let skill_file = skill_dir.join("SKILL.md");
302    if !skill_file.exists() {
303        return Err(TuffError::new(format!(
304            "skill entrypoint not found: {}",
305            skill_file.display()
306        )));
307    }
308    let mut files = Vec::new();
309    walk_skill_dir(skill_dir, "", &mut files)?;
310    files.sort();
311
312    Ok(CapabilityManifest {
313        id: name.to_string(),
314        version: version.to_string(),
315        capability_type: CapabilityType::Skill,
316        description: "Installed from git source.".to_string(),
317        files,
318        parameters: None,
319        implementation: None,
320        hook: None,
321        workflow: None,
322        targets: Vec::new(),
323        root: skill_dir.to_path_buf(),
324    })
325}
326
327fn walk_skill_dir(base: &Path, prefix: &str, files: &mut Vec<String>) -> Result<()> {
328    for entry in std::fs::read_dir(base)? {
329        let entry = entry?;
330        let path = entry.path();
331        let rel = if prefix.is_empty() {
332            entry.file_name().to_string_lossy().to_string()
333        } else {
334            format!("{}/{}", prefix, entry.file_name().to_string_lossy())
335        };
336        if path.is_dir() {
337            walk_skill_dir(&path, &rel, files)?;
338        } else if rel != "tuff.toml" {
339            files.push(rel);
340        }
341    }
342    Ok(())
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use std::fs;
349    use tempfile::TempDir;
350
351    fn write_manifest(dir: &std::path::Path, content: &str) {
352        fs::write(dir.join("tuff.toml"), content).unwrap();
353    }
354
355    #[test]
356    fn load_skill_manifest_succeeds() {
357        let tmp = TempDir::new().unwrap();
358        fs::create_dir_all(tmp.path().join("src")).unwrap();
359        fs::write(tmp.path().join("src").join("SKILL.md"), "# Skill").unwrap();
360        write_manifest(
361            tmp.path(),
362            r#"id = "test"
363version = "1.0.0"
364type = "skill"
365description = "A test skill"
366files = ["src/SKILL.md"]
367"#,
368        );
369        let m = load_manifest(tmp.path()).unwrap();
370        assert_eq!(m.id, "test");
371        assert_eq!(m.capability_type, CapabilityType::Skill);
372    }
373
374    #[test]
375    fn load_tool_manifest_succeeds() {
376        let tmp = TempDir::new().unwrap();
377        fs::write(tmp.path().join("run.sh"), "echo ok").unwrap();
378        write_manifest(
379            tmp.path(),
380            r#"id = "tool1"
381version = "1.0.0"
382type = "tool"
383description = "A test tool"
384files = ["run.sh"]
385
386[parameters]
387type = "object"
388required = ["x"]
389[parameters.properties.x]
390type = "string"
391description = "x"
392
393[implementation]
394language = "bash"
395entrypoint = "run.sh"
396"#,
397        );
398        let m = load_manifest(tmp.path()).unwrap();
399        assert_eq!(m.capability_type, CapabilityType::Tool);
400        assert!(m.implementation.is_some());
401    }
402
403    #[test]
404    fn load_hook_manifest_succeeds() {
405        let tmp = TempDir::new().unwrap();
406        write_manifest(
407            tmp.path(),
408            r#"id = "hook1"
409version = "1.0.0"
410type = "hook"
411description = "A test hook"
412
413[hook]
414event = "before_finish"
415command = "cargo test"
416"#,
417        );
418        let m = load_manifest(tmp.path()).unwrap();
419        assert_eq!(m.capability_type, CapabilityType::Hook);
420        assert!(m.hook.is_some());
421    }
422
423    #[test]
424    fn load_rejects_unsupported_type() {
425        let tmp = TempDir::new().unwrap();
426        write_manifest(
427            tmp.path(),
428            r#"id = "bad"
429version = "1.0.0"
430type = "unknown"
431description = "Bad"
432files = ["SKILL.md"]
433"#,
434        );
435        assert!(load_manifest(tmp.path()).is_err());
436    }
437
438    #[test]
439    fn load_rejects_missing_manifest() {
440        let tmp = TempDir::new().unwrap();
441        assert!(load_manifest(tmp.path()).is_err());
442    }
443
444    #[test]
445    fn source_files_resolves_paths() {
446        let tmp = TempDir::new().unwrap();
447        fs::create_dir_all(tmp.path().join("src")).unwrap();
448        fs::write(tmp.path().join("src").join("SKILL.md"), "skill").unwrap();
449        let m = CapabilityManifest {
450            id: "t".into(),
451            version: "1.0".into(),
452            capability_type: CapabilityType::Skill,
453            description: "desc".into(),
454            files: vec!["src/SKILL.md".into()],
455            parameters: None,
456            implementation: None,
457            hook: None,
458            workflow: None,
459            targets: vec![],
460            root: tmp.path().to_path_buf(),
461        };
462        let files = m.source_files().unwrap();
463        assert_eq!(files.len(), 1);
464        assert!(files[0].ends_with("SKILL.md"));
465    }
466
467    #[test]
468    fn source_files_rejects_missing_file() {
469        let tmp = TempDir::new().unwrap();
470        let m = CapabilityManifest {
471            id: "t".into(),
472            version: "1.0".into(),
473            capability_type: CapabilityType::Skill,
474            description: "desc".into(),
475            files: vec!["src/MISSING.md".into()],
476            parameters: None,
477            implementation: None,
478            hook: None,
479            workflow: None,
480            targets: vec![],
481            root: tmp.path().to_path_buf(),
482        };
483        assert!(m.source_files().is_err());
484    }
485
486    #[test]
487    fn validate_non_empty_rejects_empty() {
488        assert!(validate_non_empty("id", "").is_err());
489        assert!(validate_non_empty("id", "ok").is_ok());
490    }
491}