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 #[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#[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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(deny_unknown_fields)]
140pub struct EnvRef {
141 pub from_env: String,
142}
143
144#[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 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
171pub 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
446fn 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
484fn 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
519pub fn write_manifest(path: &Path, manifest: &CapabilityManifest) -> Result<()> {
525 std::fs::write(path, toml::to_string_pretty(manifest)?)?;
526 Ok(())
527}
528
529pub 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
542pub 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
575pub 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
589pub 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
613fn 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 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 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 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 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 assert_eq!(
793 frontmatter_description("---\nname: x\ndescription: >\n Long text here.\n---\n"),
794 None
795 );
796 assert_eq!(
799 frontmatter_description("---\nmetadata:\n description: Nested.\n---\n"),
800 None
801 );
802 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 #[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}