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 {
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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(deny_unknown_fields)]
143pub struct EnvRef {
144 pub from_env: String,
145}
146
147#[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 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
174pub 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
449fn 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
487fn 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
522pub fn write_manifest(path: &Path, manifest: &CapabilityManifest) -> Result<()> {
528 std::fs::write(path, toml::to_string_pretty(manifest)?)?;
529 Ok(())
530}
531
532pub 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
545pub 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
578pub 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
592pub 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
616fn 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 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 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 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 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 assert_eq!(
796 frontmatter_description("---\nname: x\ndescription: >\n Long text here.\n---\n"),
797 None
798 );
799 assert_eq!(
802 frontmatter_description("---\nmetadata:\n description: Nested.\n---\n"),
803 None
804 );
805 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 #[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}