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