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    /// An external MCP server Tuff wires into each harness's native MCP
16    /// config. Distinct from a `tool` with `implementation.mcp = true`,
17    /// whose server code Tuff ships itself.
18    #[serde(rename = "mcp-server")]
19    McpServer,
20}
21
22impl CapabilityType {
23    pub fn plural_dir(&self) -> &'static str {
24        match self {
25            Self::Skill => "skills",
26            Self::Tool => "tools",
27            Self::Hook => "hooks",
28            Self::Workflow => "workflows",
29            Self::Policy => "policies",
30            Self::McpServer => "mcp-servers",
31        }
32    }
33
34    pub fn as_str(&self) -> &'static str {
35        match self {
36            Self::Skill => "skill",
37            Self::Tool => "tool",
38            Self::Hook => "hook",
39            Self::Workflow => "workflow",
40            Self::Policy => "policy",
41            Self::McpServer => "mcp-server",
42        }
43    }
44
45    pub fn parse(s: &str) -> Option<Self> {
46        match s {
47            "skill" => Some(Self::Skill),
48            "tool" => Some(Self::Tool),
49            "hook" => Some(Self::Hook),
50            "workflow" => Some(Self::Workflow),
51            "policy" => Some(Self::Policy),
52            "mcp-server" | "mcp" => Some(Self::McpServer),
53            _ => None,
54        }
55    }
56}
57
58impl std::fmt::Display for CapabilityType {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        f.write_str(self.as_str())
61    }
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct CapabilityManifest {
66    pub id: String,
67    pub version: String,
68    #[serde(rename = "type")]
69    pub capability_type: CapabilityType,
70    pub description: String,
71    #[serde(default)]
72    pub files: Vec<String>,
73    #[serde(default)]
74    pub parameters: Option<serde_json::Value>,
75    #[serde(default)]
76    pub implementation: Option<ImplementationConfig>,
77    #[serde(default)]
78    pub hook: Option<HookConfig>,
79    #[serde(default)]
80    pub workflow: Option<WorkflowConfig>,
81    #[serde(default)]
82    pub server: Option<McpServerConfig>,
83    #[serde(default)]
84    #[allow(dead_code)]
85    pub targets: Vec<String>,
86
87    #[serde(skip)]
88    pub root: PathBuf,
89}
90
91/// Declaration of an external MCP server (`type = "mcp-server"`).
92///
93/// Secrets never appear here: every `[server.env]` value must be an
94/// [`EnvRef`], and every `[server.headers]` value a [`HeaderRef`], naming
95/// the variable to read on the developer's machine, so a manifest can be
96/// committed and shared without leaking anything.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98// The `Option` fields are skipped when absent. TOML has no null and its
99// serializer drops them anyway; JSON has one, and the lockfile is JSON, so
100// without the skip an absent `url` would be written as `"url": null`.
101pub struct McpServerConfig {
102    #[serde(default)]
103    pub transport: McpTransport,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub command: Option<String>,
106    #[serde(default)]
107    pub args: Vec<String>,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub url: Option<String>,
110    #[serde(default)]
111    pub env: std::collections::BTreeMap<String, EnvRef>,
112    /// HTTP request headers, keyed by header name. Skipped when empty so a
113    /// server that declares none serializes byte-for-byte as it did before
114    /// headers existed, and no installed record drifts on upgrade.
115    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
116    pub headers: std::collections::BTreeMap<String, HeaderRef>,
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub metadata: Option<McpServerMetadata>,
119}
120
121#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "lowercase")]
123pub enum McpTransport {
124    #[default]
125    Stdio,
126    Http,
127}
128
129impl McpTransport {
130    pub fn as_str(&self) -> &'static str {
131        match self {
132            Self::Stdio => "stdio",
133            Self::Http => "http",
134        }
135    }
136}
137
138/// A reference to an environment variable on the machine running the
139/// harness. Deliberately the only shape an env value can take — a bare
140/// string literal is rejected at parse time.
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(deny_unknown_fields)]
143pub struct EnvRef {
144    pub from_env: String,
145}
146
147/// A reference to the environment variable holding one HTTP header's value.
148///
149/// Headers carry secrets at least as often as environment variables do, so
150/// they take the same reference-only shape: a literal string is rejected at
151/// parse time. `format` wraps the value, with `{}` standing for it, which
152/// is what `Authorization = "Bearer <token>"` needs; it defaults to the
153/// bare value.
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(deny_unknown_fields)]
156pub struct HeaderRef {
157    pub from_env: String,
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub format: Option<String>,
160}
161
162impl HeaderRef {
163    /// The header value to emit, given how this harness spells a reference
164    /// to the variable. The reference is substituted into `format`, so the
165    /// harness expands the variable and Tuff never sees the secret.
166    pub fn render(&self, value_reference: &str) -> String {
167        match &self.format {
168            Some(format) => format.replacen(FORMAT_PLACEHOLDER, value_reference, 1),
169            None => value_reference.to_string(),
170        }
171    }
172}
173
174/// The one substitution `format` understands.
175pub const FORMAT_PLACEHOLDER: &str = "{}";
176
177#[derive(Debug, Clone, Default, Serialize, Deserialize)]
178pub struct McpServerMetadata {
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub tools_summary: Option<String>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct ImplementationConfig {
185    pub language: String,
186    pub entrypoint: String,
187    #[serde(default)]
188    pub mcp: bool,
189    #[serde(default)]
190    pub runtime_deps: Vec<String>,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct HookConfig {
195    pub event: String,
196    pub command: String,
197    #[serde(default = "default_cwd")]
198    pub working_directory: String,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct WorkflowConfig {
203    pub requires: Vec<Requirement>,
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct Requirement {
208    pub id: String,
209    #[serde(rename = "type")]
210    pub capability_type: CapabilityType,
211}
212
213fn default_cwd() -> String {
214    ".".to_string()
215}
216
217impl CapabilityManifest {
218    pub fn source_files(&self) -> Result<Vec<PathBuf>> {
219        let mut paths = Vec::new();
220
221        for f in &self.files {
222            let clean = f.trim_start_matches("./");
223            let path = self.root.join(clean);
224            if !path.exists() {
225                return Err(TuffError::not_found(format!(
226                    "capability source file not found: {}",
227                    path.display()
228                )));
229            }
230            paths.push(path);
231        }
232
233        if self.capability_type == CapabilityType::Tool
234            && let Some(ref imp) = self.implementation
235        {
236            let ep_path = self.root.join(&imp.entrypoint);
237            if !paths.contains(&ep_path) && ep_path.exists() {
238                paths.push(ep_path);
239            }
240        }
241
242        Ok(paths)
243    }
244
245    pub fn read_source_contents_with_names(&self) -> Result<Vec<(String, Vec<u8>)>> {
246        self.source_files()?
247            .iter()
248            .map(|p| {
249                let rel = p
250                    .strip_prefix(&self.root)
251                    .unwrap_or(p)
252                    .to_string_lossy()
253                    .replace('\\', "/");
254                let rel = rel.strip_prefix("src/").unwrap_or(&rel).to_string();
255                let content = std::fs::read(p)?;
256                Ok((rel, content))
257            })
258            .collect()
259    }
260}
261
262fn validate_non_empty(field: &str, value: &str) -> Result<()> {
263    if value.is_empty() {
264        return Err(TuffError::usage(format!(
265            "capability manifest field '{field}' must be a non-empty string"
266        )));
267    }
268    Ok(())
269}
270
271pub fn load_manifest(capability_dir: &Path) -> Result<CapabilityManifest> {
272    let manifest_path = capability_dir.join("tuff.toml");
273    if !manifest_path.exists() {
274        return Err(TuffError::not_found(format!(
275            "capability manifest not found: {}",
276            manifest_path.display()
277        )));
278    }
279
280    let raw = std::fs::read_to_string(&manifest_path)?;
281    let mut manifest = parse_manifest(&raw, &manifest_path)?;
282    manifest.root = capability_dir.to_path_buf();
283
284    validate_non_empty("id", &manifest.id)?;
285    validate_non_empty("version", &manifest.version)?;
286    validate_non_empty("type", &manifest.capability_type.to_string())?;
287    validate_non_empty("description", &manifest.description)?;
288
289    match manifest.capability_type {
290        CapabilityType::Skill => {
291            if manifest.files.is_empty() {
292                return Err(TuffError::usage(
293                    "skill capability 'files' must not be empty",
294                ));
295            }
296            manifest.source_files()?;
297        }
298        CapabilityType::Tool => {
299            if manifest.parameters.is_none() {
300                return Err(TuffError::usage(
301                    "tool capability requires a [parameters] section with JSON Schema",
302                ));
303            }
304            if manifest.implementation.is_none() {
305                return Err(TuffError::usage(
306                    "tool capability requires an [implementation] section",
307                ));
308            }
309
310            let params = manifest.parameters.as_ref().unwrap();
311            crate::tool::validate_json_schema(params)?;
312
313            let impl_cfg = manifest.implementation.as_ref().unwrap();
314            crate::tool::validate_entrypoint(&manifest.root, &impl_cfg.entrypoint)?;
315
316            if !impl_cfg.runtime_deps.is_empty() {
317                eprintln!(
318                    "note: this tool requires runtime dependencies: {}",
319                    impl_cfg.runtime_deps.join(", ")
320                );
321            }
322
323            if !manifest.files.is_empty() {
324                manifest.source_files()?;
325            }
326        }
327        CapabilityType::Hook => {
328            let hook_cfg = manifest
329                .hook
330                .as_ref()
331                .ok_or_else(|| TuffError::usage("hook capability requires a [hook] section"))?;
332
333            if hook_cfg.event.trim().is_empty() {
334                return Err(TuffError::usage("hook 'event' must be a non-empty string"));
335            }
336            if hook_cfg.command.trim().is_empty() {
337                return Err(TuffError::usage(
338                    "hook 'command' must be a non-empty string",
339                ));
340            }
341
342            crate::tool::check_path_traversal(&hook_cfg.working_directory)?;
343
344            eprintln!(
345                "note: this hook runs '{}' on event '{}' — it will not be executed during install",
346                hook_cfg.command, hook_cfg.event
347            );
348
349            if !manifest.files.is_empty() {
350                manifest.source_files()?;
351            }
352        }
353        CapabilityType::Workflow => {
354            let wf = manifest.workflow.as_ref().ok_or_else(|| {
355                TuffError::usage("workflow capability requires a [[workflow.requires]] section")
356            })?;
357
358            if wf.requires.is_empty() {
359                return Err(TuffError::usage(
360                    "workflow 'requires' must have at least one entry",
361                ));
362            }
363
364            let mut seen = std::collections::HashSet::new();
365            for req in &wf.requires {
366                if req.id.trim().is_empty() {
367                    return Err(TuffError::usage(
368                        "workflow requirement 'id' must not be empty",
369                    ));
370                }
371                if req.id == manifest.id {
372                    return Err(TuffError::usage("workflow cannot require itself"));
373                }
374                if !seen.insert(&req.id) {
375                    return Err(TuffError::usage(format!(
376                        "duplicate requirement '{}' in workflow",
377                        req.id
378                    )));
379                }
380            }
381
382            let names: Vec<_> = wf
383                .requires
384                .iter()
385                .map(|r| format!("{} ({})", r.id, r.capability_type))
386                .collect();
387            eprintln!(
388                "note: workflow '{}' requires {} capabilities: {}",
389                manifest.id,
390                names.len(),
391                names.join(", ")
392            );
393        }
394        CapabilityType::Policy => {
395            return Err(TuffError::unsupported(
396                "policy capabilities are not supported yet",
397            ));
398        }
399        CapabilityType::McpServer => {
400            let server = manifest.server.as_ref().ok_or_else(|| {
401                TuffError::usage("mcp-server capability requires a [server] section")
402            })?;
403            validate_mcp_server(server)?;
404
405            if !manifest.files.is_empty() {
406                manifest.source_files()?;
407            }
408        }
409    }
410
411    Ok(manifest)
412}
413
414pub fn validate_mcp_server(server: &McpServerConfig) -> Result<()> {
415    match server.transport {
416        McpTransport::Stdio => {
417            if server
418                .command
419                .as_deref()
420                .is_none_or(|c| c.trim().is_empty())
421            {
422                return Err(TuffError::usage(
423                    "mcp-server with transport = \"stdio\" requires a non-empty 'command'",
424                ));
425            }
426        }
427        McpTransport::Http => {
428            if server.url.as_deref().is_none_or(|u| u.trim().is_empty()) {
429                return Err(TuffError::usage(
430                    "mcp-server with transport = \"http\" requires a non-empty 'url'",
431                ));
432            }
433        }
434    }
435    for (name, reference) in &server.env {
436        if name.trim().is_empty() {
437            return Err(TuffError::usage("[server.env] keys must be non-empty"));
438        }
439        if reference.from_env.trim().is_empty() {
440            return Err(TuffError::usage(format!(
441                "[server.env] {name} must reference a variable: {name} = {{ from_env = \"VAR\" }}"
442            )));
443        }
444    }
445    validate_mcp_headers(server)?;
446    Ok(())
447}
448
449/// Headers belong to the request a harness makes, so they are meaningful
450/// only over HTTP; on a stdio server they would be silently dropped, which
451/// is exactly the quiet-success failure RFC-106 exists to remove.
452fn validate_mcp_headers(server: &McpServerConfig) -> Result<()> {
453    if !server.headers.is_empty() && server.transport != McpTransport::Http {
454        return Err(TuffError::usage(
455            "[server.headers] applies to transport = \"http\"; a stdio server passes \
456             secrets through [server.env]",
457        ));
458    }
459    for (name, reference) in &server.headers {
460        if name.trim().is_empty() {
461            return Err(TuffError::usage("[server.headers] keys must be non-empty"));
462        }
463        if reference.from_env.trim().is_empty() {
464            return Err(TuffError::usage(format!(
465                "[server.headers] {name} must reference a variable: \
466                 {name} = {{ from_env = \"VAR\" }}"
467            )));
468        }
469        if let Some(format) = &reference.format {
470            let placeholders = format.matches(FORMAT_PLACEHOLDER).count();
471            if placeholders != 1 {
472                let problem = if placeholders == 0 {
473                    "would discard the value"
474                } else {
475                    "would repeat the value"
476                };
477                return Err(TuffError::usage(format!(
478                    "[server.headers] {name}: format \"{format}\" {problem}"
479                ))
480                .with_hint("format must contain exactly one {} placeholder, as in \"Bearer {}\""));
481            }
482        }
483    }
484    Ok(())
485}
486
487/// Parse a manifest, turning serde's opaque "invalid type: string" failure
488/// for a literal `[server.env]` value into an error that says what to write
489/// instead.
490fn parse_manifest(raw: &str, manifest_path: &Path) -> Result<CapabilityManifest> {
491    toml::from_str(raw).map_err(|error: toml::de::Error| {
492        let message = error.to_string();
493        let looks_literal =
494            message.contains("invalid type: string") || message.contains("expected a table");
495        let literal_table = looks_literal
496            .then(|| {
497                ["[server.env]", "[server.headers]"]
498                    .into_iter()
499                    .find(|table| raw.contains(table))
500            })
501            .flatten();
502        if let Some(table) = literal_table {
503            let example = if table == "[server.headers]" {
504                "Authorization = { from_env = \"TOKEN\", format = \"Bearer {}\" }"
505            } else {
506                "NAME = { from_env = \"NAME\" }"
507            };
508            TuffError::usage(format!(
509                "invalid manifest at {}: {} values must be references, never \
510                 literals — write {} ({})",
511                manifest_path.display(),
512                table,
513                example,
514                message.trim()
515            ))
516        } else {
517            TuffError::from(error)
518        }
519    })
520}
521
522/// Writes a capability manifest as deterministic TOML.
523///
524/// # Errors
525///
526/// Returns an error when serialization or filesystem writing fails.
527pub fn write_manifest(path: &Path, manifest: &CapabilityManifest) -> Result<()> {
528    std::fs::write(path, toml::to_string_pretty(manifest)?)?;
529    Ok(())
530}
531
532/// The version a capability source declares for itself, if any: `version`
533/// in `tuff.toml`, else `version:` or `metadata.version:` in the `SKILL.md`
534/// frontmatter (RFC-101 tier 2). It is what the author wrote, not what was
535/// released: it may not change when the content does, which is why a
536/// release tag outranks it and the lockfile records which one it holds.
537pub fn declared_version(dir: &Path) -> Option<String> {
538    if dir.join("tuff.toml").is_file() {
539        return load_manifest(dir).ok().map(|manifest| manifest.version);
540    }
541    let skill = std::fs::read_to_string(dir.join("SKILL.md")).ok()?;
542    frontmatter_version(&skill)
543}
544
545/// Read a version out of `SKILL.md` frontmatter without a YAML parser: a
546/// top-level `version:` line, else `version:` indented under `metadata:`,
547/// which is where the Agent Skills specification puts it. Quotes are
548/// stripped; anything else is taken as written.
549pub fn frontmatter_version(skill: &str) -> Option<String> {
550    let mut lines = skill.lines().map(|line| line.trim_end_matches('\r'));
551    if lines.next()?.trim() != "---" {
552        return None;
553    }
554    let mut in_metadata = false;
555    let mut nested = None;
556    for line in lines {
557        if line.trim() == "---" {
558            break;
559        }
560        let indented = line.starts_with([' ', '\t']);
561        if !indented {
562            in_metadata = line.trim_end() == "metadata:";
563            if let Some(value) = line.strip_prefix("version:") {
564                return frontmatter_scalar(value);
565            }
566            continue;
567        }
568        if in_metadata
569            && nested.is_none()
570            && let Some(value) = line.trim_start().strip_prefix("version:")
571        {
572            nested = frontmatter_scalar(value);
573        }
574    }
575    nested
576}
577
578/// The description a capability source declares for itself, if any:
579/// `description` in `tuff.toml`, else `description:` in the `SKILL.md`
580/// frontmatter, which is where the Agent Skills specification puts it.
581///
582/// Unlike a version, a description is prose that no command depends on, so
583/// an absent one is an empty line in a report rather than an error.
584pub fn declared_description(dir: &Path) -> Option<String> {
585    if dir.join("tuff.toml").is_file() {
586        return load_manifest(dir).ok().map(|manifest| manifest.description);
587    }
588    let skill = std::fs::read_to_string(dir.join("SKILL.md")).ok()?;
589    frontmatter_description(&skill)
590}
591
592/// Read a top-level `description:` out of `SKILL.md` frontmatter.
593///
594/// Only the top level, and only a single line: a folded or block scalar is
595/// left alone rather than half-read, because a description this misses is a
596/// blank cell, while one it mangles is a wrong cell.
597pub fn frontmatter_description(skill: &str) -> Option<String> {
598    let mut lines = skill.lines().map(|line| line.trim_end_matches('\r'));
599    if lines.next()?.trim() != "---" {
600        return None;
601    }
602    for line in lines {
603        if line.trim() == "---" {
604            break;
605        }
606        if line.starts_with([' ', '\t']) {
607            continue;
608        }
609        if let Some(value) = line.strip_prefix("description:") {
610            return frontmatter_text(value);
611        }
612    }
613    None
614}
615
616/// A frontmatter value that is allowed to contain spaces, unlike a version.
617fn frontmatter_text(value: &str) -> Option<String> {
618    let value = value.trim();
619    let value = value
620        .strip_prefix('"')
621        .and_then(|rest| rest.strip_suffix('"'))
622        .or_else(|| {
623            value
624                .strip_prefix('\'')
625                .and_then(|rest| rest.strip_suffix('\''))
626        })
627        .unwrap_or(value)
628        .trim();
629    // `>` and `|` open a multi-line scalar whose body is on the next lines.
630    if value.is_empty() || value.starts_with(['>', '|']) {
631        return None;
632    }
633    Some(value.to_string())
634}
635
636fn frontmatter_scalar(value: &str) -> Option<String> {
637    let value = value.trim();
638    let value = value
639        .strip_prefix('"')
640        .and_then(|rest| rest.strip_suffix('"'))
641        .or_else(|| {
642            value
643                .strip_prefix('\'')
644                .and_then(|rest| rest.strip_suffix('\''))
645        })
646        .unwrap_or(value)
647        .trim();
648    (!value.is_empty() && !value.contains(char::is_whitespace)).then(|| value.to_string())
649}
650
651pub fn synthetic_manifest(
652    skill_dir: &Path,
653    name: &str,
654    version: &str,
655) -> Result<CapabilityManifest> {
656    let skill_file = skill_dir.join("SKILL.md");
657    if !skill_file.exists() {
658        return Err(TuffError::not_found(format!(
659            "skill entrypoint not found: {}",
660            skill_file.display()
661        )));
662    }
663    let mut files = Vec::new();
664    walk_skill_dir(skill_dir, "", &mut files)?;
665    files.sort();
666
667    Ok(CapabilityManifest {
668        id: name.to_string(),
669        version: version.to_string(),
670        capability_type: CapabilityType::Skill,
671        description: "Installed from git source.".to_string(),
672        files,
673        parameters: None,
674        implementation: None,
675        hook: None,
676        workflow: None,
677        server: None,
678        targets: Vec::new(),
679        root: skill_dir.to_path_buf(),
680    })
681}
682
683fn walk_skill_dir(base: &Path, prefix: &str, files: &mut Vec<String>) -> Result<()> {
684    for entry in std::fs::read_dir(base)? {
685        let entry = entry?;
686        let path = entry.path();
687        let metadata = std::fs::symlink_metadata(&path)?;
688        if metadata.file_type().is_symlink() {
689            return Err(TuffError::refused(format!(
690                "symbolic links are not allowed in capability sources: {}",
691                path.display()
692            )));
693        }
694        let rel = if prefix.is_empty() {
695            entry.file_name().to_string_lossy().to_string()
696        } else {
697            format!("{}/{}", prefix, entry.file_name().to_string_lossy())
698        };
699        if metadata.is_dir() {
700            walk_skill_dir(&path, &rel, files)?;
701        } else if metadata.is_file() && rel != "tuff.toml" {
702            files.push(rel);
703        }
704    }
705    Ok(())
706}
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711    use std::fs;
712    use tempfile::TempDir;
713
714    fn write_manifest(dir: &std::path::Path, content: &str) {
715        fs::write(dir.join("tuff.toml"), content).unwrap();
716    }
717
718    #[test]
719    fn frontmatter_version_reads_top_level_then_metadata() {
720        assert_eq!(
721            frontmatter_version("---\nname: x\nversion: 1.2.0\n---\n# X\n").as_deref(),
722            Some("1.2.0")
723        );
724        assert_eq!(
725            frontmatter_version("---\nname: x\nversion: \"1.2.0\"\n---\n").as_deref(),
726            Some("1.2.0")
727        );
728        // The Agent Skills specification nests it under `metadata`.
729        assert_eq!(
730            frontmatter_version(
731                "---\nname: x\nmetadata:\n  author: org\n  version: \"1.0\"\n---\n"
732            )
733            .as_deref(),
734            Some("1.0")
735        );
736        // Top level wins over nested when both are present.
737        assert_eq!(
738            frontmatter_version("---\nmetadata:\n  version: 0.9.0\nversion: 1.2.0\n---\n")
739                .as_deref(),
740            Some("1.2.0")
741        );
742        // A `version:` nested under some other key is not the skill's.
743        assert_eq!(
744            frontmatter_version("---\nname: x\nextra:\n  version: 3.0.0\n---\n"),
745            None
746        );
747        assert_eq!(
748            frontmatter_version("# no frontmatter\nversion: 1.0.0\n"),
749            None
750        );
751        assert_eq!(
752            frontmatter_version("---\nname: x\n---\nversion: 9.9.9\n"),
753            None
754        );
755        assert_eq!(frontmatter_version("---\nversion:\n---\n"), None);
756        assert_eq!(frontmatter_version("---\nversion: 1.2.0 beta\n---\n"), None);
757        assert_eq!(
758            frontmatter_version("---\r\nname: x\r\nversion: 2.0.0\r\n---\r\n").as_deref(),
759            Some("2.0.0")
760        );
761    }
762
763    #[test]
764    fn declared_description_reads_the_frontmatter_and_prefers_the_manifest() {
765        let tmp = TempDir::new().unwrap();
766        fs::write(
767            tmp.path().join("SKILL.md"),
768            "---\nname: x\ndescription: Reviews a diff for security problems.\n---\n# X\n",
769        )
770        .unwrap();
771        assert_eq!(
772            declared_description(tmp.path()).as_deref(),
773            Some("Reviews a diff for security problems.")
774        );
775
776        write_manifest(
777            tmp.path(),
778            r#"id = "x"
779version = "1.0.0"
780type = "skill"
781description = "From the manifest"
782files = ["SKILL.md"]
783"#,
784        );
785        assert_eq!(
786            declared_description(tmp.path()).as_deref(),
787            Some("From the manifest")
788        );
789    }
790
791    #[test]
792    fn a_multi_line_description_is_left_alone_rather_than_half_read() {
793        // A folded scalar's text is on the following lines, so reading the
794        // marker would record ">" as the description.
795        assert_eq!(
796            frontmatter_description("---\nname: x\ndescription: >\n  Long text here.\n---\n"),
797            None
798        );
799        // An indented `description:` belongs to some nested mapping, not to
800        // the skill.
801        assert_eq!(
802            frontmatter_description("---\nmetadata:\n  description: Nested.\n---\n"),
803            None
804        );
805        // No frontmatter at all is not an error.
806        assert_eq!(frontmatter_description("# Just a heading\n"), None);
807    }
808
809    #[test]
810    fn declared_version_prefers_the_manifest_over_the_frontmatter() {
811        let tmp = TempDir::new().unwrap();
812        fs::write(
813            tmp.path().join("SKILL.md"),
814            "---\nname: x\nversion: 2.0.0\n---\n# X\n",
815        )
816        .unwrap();
817        assert_eq!(declared_version(tmp.path()).as_deref(), Some("2.0.0"));
818        fs::write(
819            tmp.path().join("tuff.toml"),
820            "id = \"x\"\nversion = \"1.0.0\"\ntype = \"skill\"\ndescription = \"d\"\nfiles = [\"SKILL.md\"]\n",
821        )
822        .unwrap();
823        assert_eq!(declared_version(tmp.path()).as_deref(), Some("1.0.0"));
824
825        let bare = TempDir::new().unwrap();
826        fs::write(bare.path().join("SKILL.md"), "# no version\n").unwrap();
827        assert_eq!(declared_version(bare.path()), None);
828    }
829
830    #[test]
831    fn load_skill_manifest_succeeds() {
832        let tmp = TempDir::new().unwrap();
833        fs::create_dir_all(tmp.path().join("src")).unwrap();
834        fs::write(tmp.path().join("src").join("SKILL.md"), "# Skill").unwrap();
835        write_manifest(
836            tmp.path(),
837            r#"id = "test"
838version = "1.0.0"
839type = "skill"
840description = "A test skill"
841files = ["src/SKILL.md"]
842"#,
843        );
844        let m = load_manifest(tmp.path()).unwrap();
845        assert_eq!(m.id, "test");
846        assert_eq!(m.capability_type, CapabilityType::Skill);
847    }
848
849    #[test]
850    fn load_tool_manifest_succeeds() {
851        let tmp = TempDir::new().unwrap();
852        fs::write(tmp.path().join("run.sh"), "echo ok").unwrap();
853        write_manifest(
854            tmp.path(),
855            r#"id = "tool1"
856version = "1.0.0"
857type = "tool"
858description = "A test tool"
859files = ["run.sh"]
860
861[parameters]
862type = "object"
863required = ["x"]
864[parameters.properties.x]
865type = "string"
866description = "x"
867
868[implementation]
869language = "bash"
870entrypoint = "run.sh"
871"#,
872        );
873        let m = load_manifest(tmp.path()).unwrap();
874        assert_eq!(m.capability_type, CapabilityType::Tool);
875        assert!(m.implementation.is_some());
876    }
877
878    #[test]
879    fn load_hook_manifest_succeeds() {
880        let tmp = TempDir::new().unwrap();
881        write_manifest(
882            tmp.path(),
883            r#"id = "hook1"
884version = "1.0.0"
885type = "hook"
886description = "A test hook"
887
888[hook]
889event = "before_finish"
890command = "cargo test"
891"#,
892        );
893        let m = load_manifest(tmp.path()).unwrap();
894        assert_eq!(m.capability_type, CapabilityType::Hook);
895        assert!(m.hook.is_some());
896    }
897
898    #[test]
899    fn load_rejects_unsupported_type() {
900        let tmp = TempDir::new().unwrap();
901        write_manifest(
902            tmp.path(),
903            r#"id = "bad"
904version = "1.0.0"
905type = "unknown"
906description = "Bad"
907files = ["SKILL.md"]
908"#,
909        );
910        assert!(load_manifest(tmp.path()).is_err());
911    }
912
913    #[test]
914    fn load_rejects_missing_manifest() {
915        let tmp = TempDir::new().unwrap();
916        assert!(load_manifest(tmp.path()).is_err());
917    }
918
919    #[test]
920    fn source_files_resolves_paths() {
921        let tmp = TempDir::new().unwrap();
922        fs::create_dir_all(tmp.path().join("src")).unwrap();
923        fs::write(tmp.path().join("src").join("SKILL.md"), "skill").unwrap();
924        let m = CapabilityManifest {
925            id: "t".into(),
926            version: "1.0".into(),
927            capability_type: CapabilityType::Skill,
928            description: "desc".into(),
929            files: vec!["src/SKILL.md".into()],
930            parameters: None,
931            implementation: None,
932            hook: None,
933            workflow: None,
934            server: None,
935            targets: vec![],
936            root: tmp.path().to_path_buf(),
937        };
938        let files = m.source_files().unwrap();
939        assert_eq!(files.len(), 1);
940        assert!(files[0].ends_with("SKILL.md"));
941    }
942
943    #[test]
944    fn source_files_rejects_missing_file() {
945        let tmp = TempDir::new().unwrap();
946        let m = CapabilityManifest {
947            id: "t".into(),
948            version: "1.0".into(),
949            capability_type: CapabilityType::Skill,
950            description: "desc".into(),
951            files: vec!["src/MISSING.md".into()],
952            parameters: None,
953            implementation: None,
954            hook: None,
955            workflow: None,
956            server: None,
957            targets: vec![],
958            root: tmp.path().to_path_buf(),
959        };
960        assert!(m.source_files().is_err());
961    }
962
963    #[test]
964    fn validate_non_empty_rejects_empty() {
965        assert!(validate_non_empty("id", "").is_err());
966        assert!(validate_non_empty("id", "ok").is_ok());
967    }
968
969    fn load_mcp(toml_body: &str) -> Result<CapabilityManifest> {
970        let tmp = TempDir::new().unwrap();
971        fs::write(tmp.path().join("tuff.toml"), toml_body).unwrap();
972        load_manifest(tmp.path())
973    }
974
975    const MCP_HEAD: &str =
976        "id = \"srv\"\nversion = \"1.0.0\"\ntype = \"mcp-server\"\ndescription = \"d\"\n";
977
978    #[test]
979    fn mcp_server_requires_server_section() {
980        let error = load_mcp(MCP_HEAD).unwrap_err().to_string();
981        assert!(error.contains("requires a [server] section"), "{error}");
982    }
983
984    #[test]
985    fn mcp_server_stdio_requires_command_and_http_requires_url() {
986        let error = load_mcp(&format!("{MCP_HEAD}[server]\ntransport = \"stdio\"\n"))
987            .unwrap_err()
988            .to_string();
989        assert!(error.contains("requires a non-empty 'command'"), "{error}");
990        let error = load_mcp(&format!("{MCP_HEAD}[server]\ntransport = \"http\"\n"))
991            .unwrap_err()
992            .to_string();
993        assert!(error.contains("requires a non-empty 'url'"), "{error}");
994        let ok = load_mcp(&format!(
995            "{MCP_HEAD}[server]\ntransport = \"http\"\nurl = \"https://example.test/mcp\"\n"
996        ))
997        .unwrap();
998        assert_eq!(ok.server.unwrap().transport, McpTransport::Http);
999    }
1000
1001    #[test]
1002    fn mcp_server_env_must_be_a_reference_not_a_literal() {
1003        let error = load_mcp(&format!(
1004            "{MCP_HEAD}[server]\ncommand = \"npx\"\n[server.env]\nTOKEN = \"literal\"\n"
1005        ))
1006        .unwrap_err()
1007        .to_string();
1008        assert!(error.contains("from_env"), "{error}");
1009
1010        let ok = load_mcp(&format!(
1011            "{MCP_HEAD}[server]\ncommand = \"npx\"\n[server.env]\nTOKEN = {{ from_env = \"MY_TOKEN\" }}\n"
1012        ))
1013        .unwrap();
1014        assert_eq!(ok.server.unwrap().env["TOKEN"].from_env, "MY_TOKEN");
1015    }
1016
1017    #[test]
1018    fn mcp_server_headers_must_be_references_not_literals() {
1019        let error = load_mcp(&format!(
1020            "{MCP_HEAD}[server]\ntransport = \"http\"\nurl = \"https://example.test/mcp\"\n\
1021             [server.headers]\nAuthorization = \"Bearer secret\"\n"
1022        ))
1023        .unwrap_err()
1024        .to_string();
1025        assert!(error.contains("[server.headers]"), "{error}");
1026        assert!(error.contains("from_env"), "{error}");
1027    }
1028
1029    #[test]
1030    fn mcp_server_header_reference_carries_an_optional_format() {
1031        let server = load_mcp(&format!(
1032            "{MCP_HEAD}[server]\ntransport = \"http\"\nurl = \"https://example.test/mcp\"\n\
1033             [server.headers]\n\
1034             Authorization = {{ from_env = \"NOTION_TOKEN\", format = \"Bearer {{}}\" }}\n\
1035             X-Api-Key = {{ from_env = \"API_KEY\" }}\n"
1036        ))
1037        .unwrap()
1038        .server
1039        .unwrap();
1040        assert_eq!(server.headers["Authorization"].from_env, "NOTION_TOKEN");
1041        assert_eq!(
1042            server.headers["Authorization"].render("${NOTION_TOKEN}"),
1043            "Bearer ${NOTION_TOKEN}"
1044        );
1045        assert_eq!(server.headers["X-Api-Key"].format, None);
1046        assert_eq!(
1047            server.headers["X-Api-Key"].render("${API_KEY}"),
1048            "${API_KEY}"
1049        );
1050    }
1051
1052    #[test]
1053    fn mcp_server_header_format_needs_exactly_one_placeholder() {
1054        for format in ["Bearer", "Bearer {} {}"] {
1055            let error = load_mcp(&format!(
1056                "{MCP_HEAD}[server]\ntransport = \"http\"\nurl = \"https://example.test/mcp\"\n\
1057                 [server.headers]\n\
1058                 Authorization = {{ from_env = \"TOKEN\", format = \"{format}\" }}\n"
1059            ))
1060            .unwrap_err()
1061            .to_string();
1062            assert!(error.contains("Authorization"), "{format}: {error}");
1063        }
1064    }
1065
1066    #[test]
1067    fn mcp_server_headers_are_refused_on_stdio() {
1068        let error = load_mcp(&format!(
1069            "{MCP_HEAD}[server]\ncommand = \"npx\"\n\
1070             [server.headers]\nAuthorization = {{ from_env = \"TOKEN\" }}\n"
1071        ))
1072        .unwrap_err()
1073        .to_string();
1074        assert!(error.contains("http"), "{error}");
1075    }
1076
1077    /// A server without headers has to serialize exactly as it did before
1078    /// the field existed, or every installed record drifts on upgrade.
1079    #[test]
1080    fn a_server_without_headers_serializes_without_the_table() {
1081        let server = load_mcp(&format!("{MCP_HEAD}[server]\ncommand = \"npx\"\n"))
1082            .unwrap()
1083            .server
1084            .unwrap();
1085        let wire = toml::to_string_pretty(&server).unwrap();
1086        assert!(!wire.contains("headers"), "{wire}");
1087    }
1088
1089    #[test]
1090    fn capability_type_round_trips_the_hyphenated_name() {
1091        assert_eq!(CapabilityType::McpServer.as_str(), "mcp-server");
1092        assert_eq!(
1093            CapabilityType::parse("mcp-server"),
1094            Some(CapabilityType::McpServer)
1095        );
1096        assert_eq!(
1097            CapabilityType::parse("mcp"),
1098            Some(CapabilityType::McpServer)
1099        );
1100        let wire = toml::to_string(&Requirement {
1101            id: "x".into(),
1102            capability_type: CapabilityType::McpServer,
1103        })
1104        .unwrap();
1105        assert!(wire.contains("type = \"mcp-server\""), "{wire}");
1106    }
1107}