1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use tuff_hooks_spec::{CompatibilityMatrix, CoverageLevel};
6
7use crate::error::{Result, TuffError};
8use crate::manifest::{CapabilityManifest, CapabilityType, HookConfig};
9
10pub fn extend_hook_groups(existing: &mut Vec<serde_json::Value>, additions: &[serde_json::Value]) {
17 for addition in additions {
18 if !existing.iter().any(|group| group == addition) {
19 existing.push(addition.clone());
20 }
21 }
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct EmittedFile {
26 pub path: String,
27 pub hash: String,
28 #[serde(rename = "baselineHash")]
29 pub baseline_hash: String,
30}
31
32#[derive(Debug, Clone)]
33pub struct PlannedFile {
34 pub path: String,
35 pub content: Vec<u8>,
36 pub allow_existing: bool,
37}
38
39impl PlannedFile {
40 pub fn new(path: String, content: Vec<u8>) -> Self {
41 Self {
42 path,
43 content,
44 allow_existing: false,
45 }
46 }
47
48 pub fn mergeable(path: String, content: Vec<u8>) -> Self {
49 Self {
50 path,
51 content,
52 allow_existing: true,
53 }
54 }
55}
56
57#[derive(Debug, Clone)]
58pub struct NativeHookConfig {
59 pub fragment: serde_json::Value,
60 pub source_files: Vec<(String, Vec<u8>)>,
61}
62
63#[derive(Debug, Clone)]
64pub enum HookRenderDiagnosticLevel {
65 Warning,
66}
67
68#[derive(Debug, Clone)]
69pub struct HookRenderDiagnostic {
70 pub level: HookRenderDiagnosticLevel,
71 pub message: String,
72}
73
74#[derive(Debug, Clone)]
75pub struct HookRenderContext<'a> {
76 pub capability_id: &'a str,
77 pub hook: &'a HookConfig,
78 pub source_files: &'a [(String, Vec<u8>)],
79 pub repo_root: &'a Path,
80 pub track_managed_hooks: bool,
81}
82
83#[derive(Debug, Clone)]
84pub struct HookRenderPlan {
85 pub files: Vec<PlannedFile>,
86 pub managed_hooks: Vec<crate::lockfile::ManagedHook>,
87 pub diagnostics: Vec<HookRenderDiagnostic>,
88}
89
90#[derive(Debug, Clone)]
91pub enum HookDefinition {
92 Command(crate::manifest::HookConfig),
93 Native(NativeHookConfig),
94}
95
96#[derive(Debug, Clone)]
97pub enum CapabilityKind {
98 Skill,
99 Tool {
100 parameters: serde_json::Value,
101 implementation: crate::manifest::ImplementationConfig,
102 },
103 Hook {
104 hook: HookDefinition,
105 },
106 Workflow {
107 workflow: crate::manifest::WorkflowConfig,
108 },
109 McpServer {
110 server: crate::manifest::McpServerConfig,
111 },
112}
113
114impl CapabilityKind {
115 pub fn capability_type(&self) -> CapabilityType {
116 match self {
117 Self::Skill => CapabilityType::Skill,
118 Self::Tool { .. } => CapabilityType::Tool,
119 Self::Hook { .. } => CapabilityType::Hook,
120 Self::Workflow { .. } => CapabilityType::Workflow,
121 Self::McpServer { .. } => CapabilityType::McpServer,
122 }
123 }
124}
125
126pub struct ResolvedCapability {
127 pub id: String,
128 pub capability_type: CapabilityType,
129 pub version: String,
130 pub description: String,
131 pub source_files: Vec<(String, Vec<u8>)>,
132 pub source_dir: PathBuf,
133 pub kind: CapabilityKind,
134}
135
136#[derive(Serialize)]
137struct WorkflowDocument<'a> {
138 id: &'a str,
139 version: &'a str,
140 #[serde(rename = "type")]
141 capability_type: CapabilityType,
142 description: &'a str,
143 workflow: &'a crate::manifest::WorkflowConfig,
144}
145
146pub fn resolve_capability(manifest: &CapabilityManifest) -> Result<ResolvedCapability> {
147 let source_files = manifest.read_source_contents_with_names()?;
148 let kind =
149 match manifest.capability_type {
150 CapabilityType::Skill => CapabilityKind::Skill,
151 CapabilityType::Tool => CapabilityKind::Tool {
152 parameters: manifest.parameters.clone().ok_or_else(|| {
153 TuffError::usage("tool capability requires [parameters] section")
154 })?,
155 implementation: manifest.implementation.clone().ok_or_else(|| {
156 TuffError::usage("tool capability requires [implementation] section")
157 })?,
158 },
159 CapabilityType::Hook => {
160 CapabilityKind::Hook {
161 hook: HookDefinition::Command(manifest.hook.clone().ok_or_else(|| {
162 TuffError::usage("hook capability requires [hook] section")
163 })?),
164 }
165 }
166 CapabilityType::Workflow => CapabilityKind::Workflow {
167 workflow: manifest.workflow.clone().ok_or_else(|| {
168 TuffError::usage("workflow capability requires [workflow] section")
169 })?,
170 },
171 CapabilityType::Policy => {
172 return Err(TuffError::unsupported(
173 "policy capabilities are not installable yet",
174 ));
175 }
176 CapabilityType::McpServer => CapabilityKind::McpServer {
177 server: manifest.server.clone().ok_or_else(|| {
178 TuffError::usage("mcp-server capability requires [server] section")
179 })?,
180 },
181 };
182 Ok(ResolvedCapability {
183 id: manifest.id.clone(),
184 capability_type: manifest.capability_type,
185 version: manifest.version.clone(),
186 description: manifest.description.clone(),
187 source_files,
188 source_dir: manifest.root.clone(),
189 kind,
190 })
191}
192
193pub trait AgentAdapter {
194 fn id(&self) -> &'static str;
195 fn display_name(&self) -> &'static str;
196 fn dir_prefix(&self) -> &'static str;
197 fn mcp_config_relpath(&self) -> &'static str;
198 fn supported_agents(&self) -> &[&'static str];
199 fn hook_compatibility(&self) -> &'static CompatibilityMatrix;
200 fn hook_settings_relpath(&self) -> &'static str;
201 fn scaffold_hook_event(&self) -> &'static str;
202 fn hook_filename(&self) -> &'static str;
203 fn hook_file_content(&self, hook_cfg: &crate::manifest::HookConfig) -> Result<Vec<u8>> {
204 render_hook_script(hook_cfg)
205 }
206 fn render_standard_hook(&self, context: HookRenderContext<'_>) -> Result<HookRenderPlan> {
207 let matrix = self.hook_compatibility();
208 let Some(entry) = matrix.find_event(&context.hook.event) else {
209 return Err(TuffError::unsupported(format!(
210 "{} does not support hook event '{}'. Supported events: {}",
211 self.display_name(),
212 context.hook.event,
213 matrix.supported_native_events().join(", ")
214 )));
215 };
216 let Some(native_event) = entry.native_event_name() else {
217 let suffix = entry
218 .caveat
219 .map(|caveat| format!(": {caveat}"))
220 .unwrap_or_default();
221 return Err(TuffError::unsupported(format!(
222 "{} does not support hook event '{}'{}",
223 self.display_name(),
224 context.hook.event,
225 suffix
226 )));
227 };
228
229 let command = format!(
230 "sh {}/hooks/{}/{}",
231 self.dir_prefix(),
232 context.capability_id,
233 self.hook_filename()
234 );
235 let target_path = context
236 .repo_root
237 .join(self.dir_prefix())
238 .join("hooks")
239 .join(context.capability_id)
240 .join(self.hook_filename());
241 let script = self.hook_file_content(context.hook)?;
242 let settings_relpath = self.hook_settings_relpath();
243 let fragment = self.command_hook_fragment(native_event, &command);
244 let settings_path = context.repo_root.join(settings_relpath);
245 let existing = if settings_path.is_file() {
246 Some(std::fs::read(&settings_path)?)
247 } else {
248 None
249 };
250 let merged = self.merge_hook_fragment(existing.as_deref(), &fragment)?;
251
252 let mut files = vec![PlannedFile::new(
253 relative_or_absolute_fs(&target_path, context.repo_root),
254 script,
255 )];
256 for (relative, content) in context.source_files {
257 let path = context
258 .repo_root
259 .join(self.dir_prefix())
260 .join("hooks")
261 .join(context.capability_id)
262 .join(relative);
263 files.push(PlannedFile::new(
264 relative_or_absolute_fs(&path, context.repo_root),
265 content.clone(),
266 ));
267 }
268 files.push(PlannedFile::mergeable(
269 relative_or_absolute_fs(&settings_path, context.repo_root),
270 merged,
271 ));
272
273 let mut diagnostics = Vec::new();
274 if entry.coverage == CoverageLevel::Partial {
275 let scope = if entry.scope.is_empty() {
276 "partial coverage".to_string()
277 } else {
278 format!("scope: {}", entry.scope.join(", "))
279 };
280 let caveat = entry
281 .caveat
282 .map(|caveat| format!("; {caveat}"))
283 .unwrap_or_default();
284 diagnostics.push(HookRenderDiagnostic {
285 level: HookRenderDiagnosticLevel::Warning,
286 message: format!(
287 "{} renders '{}' with partial compatibility ({scope}{caveat})",
288 self.display_name(),
289 entry.event
290 ),
291 });
292 }
293
294 let managed_hooks = if context.track_managed_hooks {
295 crate::lockfile::managed_hooks_from_fragment_with_canonical(
296 context.repo_root,
297 settings_relpath,
298 &fragment,
299 Some(entry.event.as_str()),
300 )?
301 } else {
302 Vec::new()
303 };
304
305 Ok(HookRenderPlan {
306 files,
307 managed_hooks,
308 diagnostics,
309 })
310 }
311 fn command_hook_fragment(&self, native_event: &str, command: &str) -> serde_json::Value;
312 fn merge_hook_fragment(
313 &self,
314 existing: Option<&[u8]>,
315 fragment: &serde_json::Value,
316 ) -> Result<Vec<u8>>;
317 fn remove_hook_settings(
318 &self,
319 repo_root: &Path,
320 managed_hooks: &[crate::lockfile::ManagedHook],
321 ) -> Result<()>;
322 fn detect(&self, repo_root: &Path) -> bool;
323
324 fn kinds_supported(&self) -> &[CapabilityType];
325
326 fn supports(&self, capability_type: CapabilityType) -> bool {
327 self.kinds_supported().contains(&capability_type)
328 }
329
330 fn native_hook_event(&self, raw_event: &str) -> Result<&'static str> {
331 let matrix = self.hook_compatibility();
332 let Some(entry) = matrix.find_event(raw_event) else {
333 return Err(TuffError::unsupported(format!(
334 "{} does not support hook event '{}'. Supported events: {}",
335 self.display_name(),
336 raw_event,
337 matrix.supported_native_events().join(", ")
338 )));
339 };
340 entry.native_event_name().ok_or_else(|| {
341 let suffix = entry
342 .caveat
343 .map(|caveat| format!(": {caveat}"))
344 .unwrap_or_default();
345 TuffError::unsupported(format!(
346 "{} does not support hook event '{}'{}",
347 self.display_name(),
348 raw_event,
349 suffix
350 ))
351 })
352 }
353
354 fn canonical_hook_event(&self, raw_event: &str) -> Result<&'static str> {
355 let matrix = self.hook_compatibility();
356 let Some(entry) = matrix.find_event(raw_event) else {
357 return Err(TuffError::unsupported(format!(
358 "{} does not support hook event '{}'",
359 self.display_name(),
360 raw_event
361 )));
362 };
363 entry
364 .coverage
365 .is_supported()
366 .then_some(entry.event.as_str())
367 .ok_or_else(|| {
368 let suffix = entry
369 .caveat
370 .map(|caveat| format!(": {caveat}"))
371 .unwrap_or_default();
372 TuffError::unsupported(format!(
373 "{} does not support hook event '{}'{}",
374 self.display_name(),
375 raw_event,
376 suffix
377 ))
378 })
379 }
380
381 fn ensure_project_dir(&self, repo_root: &Path) -> std::io::Result<()> {
382 std::fs::create_dir_all(repo_root.join(self.dir_prefix()))
383 }
384
385 fn mcp_env_reference(&self, var: &str) -> String {
388 format!("${{{var}}}")
389 }
390
391 fn mcp_server_entry(&self, server: &crate::manifest::McpServerConfig) -> serde_json::Value {
394 use crate::manifest::McpTransport;
395 match server.transport {
396 McpTransport::Stdio => {
397 let mut entry = serde_json::json!({
398 "command": server.command.clone().unwrap_or_default(),
399 "args": server.args,
400 });
401 if !server.env.is_empty() {
402 let env: serde_json::Map<String, serde_json::Value> = server
403 .env
404 .iter()
405 .map(|(name, reference)| {
406 (
407 name.clone(),
408 serde_json::Value::String(
409 self.mcp_env_reference(&reference.from_env),
410 ),
411 )
412 })
413 .collect();
414 entry["env"] = serde_json::Value::Object(env);
415 }
416 entry
417 }
418 McpTransport::Http => serde_json::json!({
419 "type": "http",
420 "url": server.url.clone().unwrap_or_default(),
421 }),
422 }
423 }
424
425 fn plan(&self, capability: &ResolvedCapability, repo_root: &Path) -> Result<Vec<PlannedFile>> {
426 match capability.capability_type {
427 CapabilityType::Tool => self.plan_tool(capability, repo_root),
428 CapabilityType::Hook => self.plan_hook(capability, repo_root),
429 CapabilityType::Workflow => self.plan_workflow(capability, repo_root),
430 CapabilityType::McpServer => self.plan_mcp_server(capability, repo_root),
431 CapabilityType::Policy => Err(TuffError::unsupported(
432 "policy capabilities are not installable yet",
433 )),
434 CapabilityType::Skill => self.plan_skill(capability, repo_root),
435 }
436 }
437
438 fn remove(
439 &self,
440 primitive_id: &str,
441 repo_root: &Path,
442 managed_hooks: &[crate::lockfile::ManagedHook],
443 ) -> Result<()> {
444 let prefix = self.dir_prefix();
445 for kind in &["skills", "tools", "hooks", "workflows", "mcp-servers"] {
446 self.remove_dir(repo_root, prefix, kind, primitive_id)?;
447 }
448 crate::mcp::remove_tool(&repo_root.join(self.mcp_config_relpath()), primitive_id)?;
449 self.remove_hook_settings(repo_root, managed_hooks)?;
450 Ok(())
451 }
452
453 fn plan_skill(
456 &self,
457 capability: &ResolvedCapability,
458 repo_root: &Path,
459 ) -> Result<Vec<PlannedFile>> {
460 if capability.source_files.is_empty() {
461 return Err(TuffError::usage("no source files to emit"));
462 }
463
464 let mut files = Vec::new();
465 for (rel_path, content) in &capability.source_files {
466 let target_path = repo_root
467 .join(self.dir_prefix())
468 .join("skills")
469 .join(&capability.id)
470 .join(rel_path);
471
472 files.push(PlannedFile::new(
473 relative_or_absolute_fs(&target_path, repo_root),
474 content.clone(),
475 ));
476 }
477 Ok(files)
478 }
479
480 fn plan_tool(
481 &self,
482 capability: &ResolvedCapability,
483 repo_root: &Path,
484 ) -> Result<Vec<PlannedFile>> {
485 let mut files = Vec::new();
486
487 for (rel_path, content) in &capability.source_files {
488 let target_path = repo_root
489 .join(self.dir_prefix())
490 .join("tools")
491 .join(&capability.id)
492 .join(rel_path);
493
494 files.push(PlannedFile::new(
495 relative_or_absolute_fs(&target_path, repo_root),
496 content.clone(),
497 ));
498 }
499
500 if capability.source_files.is_empty() {
501 let placeholder = repo_root
502 .join(self.dir_prefix())
503 .join("tools")
504 .join(&capability.id)
505 .join(".gitkeep");
506 files.push(PlannedFile::new(
507 relative_or_absolute_fs(&placeholder, repo_root),
508 vec![],
509 ));
510 }
511
512 Ok(files)
513 }
514
515 fn plan_hook(
516 &self,
517 capability: &ResolvedCapability,
518 repo_root: &Path,
519 ) -> Result<Vec<PlannedFile>> {
520 let CapabilityKind::Hook { hook } = &capability.kind else {
521 return Err(TuffError::new("plan_hook called on non-hook capability"));
522 };
523
524 match hook {
525 HookDefinition::Command(hook_cfg) => {
526 let render = self.render_standard_hook(HookRenderContext {
527 capability_id: &capability.id,
528 hook: hook_cfg,
529 source_files: &capability.source_files,
530 repo_root,
531 track_managed_hooks: false,
532 })?;
533 Ok(render.files)
534 }
535 HookDefinition::Native(native) => self.plan_native_hook(capability, native, repo_root),
536 }
537 }
538
539 fn plan_native_hook(
540 &self,
541 capability: &ResolvedCapability,
542 native: &NativeHookConfig,
543 repo_root: &Path,
544 ) -> Result<Vec<PlannedFile>> {
545 let hook_root = repo_root
546 .join(self.dir_prefix())
547 .join("hooks")
548 .join(&capability.id);
549 let hook_root_rel = relative_or_absolute_fs(&hook_root, repo_root);
550 let in_harness_source =
551 path_is_under(&capability.source_dir, &repo_root.join(self.dir_prefix()));
552
553 let mut files = Vec::new();
554 if in_harness_source {
555 for (rel_path, content) in &native.source_files {
556 let target_path = capability.source_dir.join(rel_path);
557 files.push(PlannedFile::mergeable(
558 relative_or_absolute_fs(&target_path, repo_root),
559 content.clone(),
560 ));
561 }
562 } else {
563 for (rel_path, content) in &native.source_files {
564 let target_path = hook_root.join(rel_path);
565 files.push(PlannedFile::new(
566 relative_or_absolute_fs(&target_path, repo_root),
567 content.clone(),
568 ));
569 }
570 }
571
572 let fragment = replace_hook_dir_placeholder(native.fragment.clone(), &hook_root_rel);
573 let settings_relpath = self.hook_settings_relpath();
574 let settings_path = repo_root.join(settings_relpath);
575 let existing = if settings_path.is_file() {
576 Some(std::fs::read(&settings_path)?)
577 } else {
578 None
579 };
580 let merged = self.merge_hook_fragment(existing.as_deref(), &fragment)?;
581 files.push(PlannedFile::mergeable(
582 relative_or_absolute_fs(&settings_path, repo_root),
583 merged,
584 ));
585 Ok(files)
586 }
587
588 fn plan_workflow(
589 &self,
590 capability: &ResolvedCapability,
591 repo_root: &Path,
592 ) -> Result<Vec<PlannedFile>> {
593 let CapabilityKind::Workflow { workflow: wf } = &capability.kind else {
594 return Err(TuffError::new(
595 "plan_workflow called on non-workflow capability",
596 ));
597 };
598
599 let target_path = repo_root
600 .join(self.dir_prefix())
601 .join("workflows")
602 .join(&capability.id)
603 .join("workflow.toml");
604
605 let content = serialize_workflow(capability, wf)?;
606
607 Ok(vec![PlannedFile::new(
608 relative_or_absolute_fs(&target_path, repo_root),
609 content,
610 )])
611 }
612
613 fn plan_mcp_server(
618 &self,
619 capability: &ResolvedCapability,
620 repo_root: &Path,
621 ) -> Result<Vec<PlannedFile>> {
622 let CapabilityKind::McpServer { server } = &capability.kind else {
623 return Err(TuffError::new(
624 "plan_mcp_server called on non-mcp-server capability",
625 ));
626 };
627
628 let target_path = repo_root
629 .join(self.dir_prefix())
630 .join("mcp-servers")
631 .join(&capability.id)
632 .join("server.toml");
633
634 let content = serialize_mcp_server(capability, server)?;
635
636 Ok(vec![PlannedFile::new(
637 relative_or_absolute_fs(&target_path, repo_root),
638 content,
639 )])
640 }
641
642 fn remove_dir(
643 &self,
644 repo_root: &Path,
645 base: &str,
646 kind: &str,
647 primitive_id: &str,
648 ) -> Result<()> {
649 let dir = repo_root.join(base).join(kind).join(primitive_id);
650
651 if dir.exists() {
652 std::fs::remove_dir_all(&dir)?;
653 }
654
655 let kind_dir = dir.parent().expect("kind dir should have parent");
656 if kind_dir.exists() {
657 let mut rd = match std::fs::read_dir(kind_dir) {
658 Ok(rd) => rd,
659 Err(_) => return Ok(()),
660 };
661 if rd.next().is_none() {
662 std::fs::remove_dir(kind_dir)?;
663 }
664 }
665
666 let base_dir = kind_dir.parent().expect("base dir should have parent");
667 if base_dir.exists() {
668 let mut rd = match std::fs::read_dir(base_dir) {
669 Ok(rd) => rd,
670 Err(_) => return Ok(()),
671 };
672 if rd.next().is_none() {
673 std::fs::remove_dir(base_dir)?;
674 }
675 }
676
677 Ok(())
678 }
679}
680
681fn render_hook_script(hook_cfg: &HookConfig) -> Result<Vec<u8>> {
682 let working_directory = shell_single_quote(&hook_cfg.working_directory)?;
683 let command = shell_single_quote(&hook_cfg.command)?;
684 Ok(format!(
685 "#!/usr/bin/env bash\nset -euo pipefail\ncd -- {working_directory}\nexec bash -euo pipefail -c {command}\n"
686 )
687 .into_bytes())
688}
689
690fn shell_single_quote(value: &str) -> Result<String> {
691 if value.contains('\0') {
692 return Err(TuffError::usage(
693 "hook working directory and command cannot contain NUL bytes",
694 ));
695 }
696 Ok(format!("'{}'", value.replace('\'', "'\"'\"'")))
697}
698
699fn serialize_workflow(
700 capability: &ResolvedCapability,
701 workflow: &crate::manifest::WorkflowConfig,
702) -> Result<Vec<u8>> {
703 let document = WorkflowDocument {
704 id: &capability.id,
705 version: &capability.version,
706 capability_type: capability.capability_type,
707 description: &capability.description,
708 workflow,
709 };
710 let mut content = toml::to_string_pretty(&document)?;
711 if !content.ends_with('\n') {
712 content.push('\n');
713 }
714 Ok(content.into_bytes())
715}
716
717#[derive(Serialize)]
718struct McpServerDocument<'a> {
719 id: &'a str,
720 version: &'a str,
721 #[serde(rename = "type")]
722 capability_type: CapabilityType,
723 description: &'a str,
724 server: &'a crate::manifest::McpServerConfig,
725}
726
727fn serialize_mcp_server(
728 capability: &ResolvedCapability,
729 server: &crate::manifest::McpServerConfig,
730) -> Result<Vec<u8>> {
731 let document = McpServerDocument {
732 id: &capability.id,
733 version: &capability.version,
734 capability_type: capability.capability_type,
735 description: &capability.description,
736 server,
737 };
738 let mut content = toml::to_string_pretty(&document)?;
739 if !content.ends_with('\n') {
740 content.push('\n');
741 }
742 Ok(content.into_bytes())
743}
744
745fn path_is_under(path: &Path, root: &Path) -> bool {
746 let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
747 let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
748 canonical_path.starts_with(canonical_root)
749}
750
751pub fn replace_hook_dir_placeholder(
752 mut value: serde_json::Value,
753 hook_dir: &str,
754) -> serde_json::Value {
755 match &mut value {
756 serde_json::Value::String(s) => {
757 *s = s.replace("{{hook_dir}}", hook_dir);
758 }
759 serde_json::Value::Array(items) => {
760 for item in items {
761 *item = replace_hook_dir_placeholder(item.take(), hook_dir);
762 }
763 }
764 serde_json::Value::Object(map) => {
765 for item in map.values_mut() {
766 *item = replace_hook_dir_placeholder(item.take(), hook_dir);
767 }
768 }
769 _ => {}
770 }
771 value
772}
773
774fn relative_or_absolute_fs(path: &Path, repo_root: &Path) -> String {
775 crate::lockfile::relative_or_absolute_fs(path, repo_root)
776}
777
778#[cfg(test)]
779mod tests {
780 use super::*;
781 use crate::manifest::{Requirement, WorkflowConfig};
782
783 #[cfg(unix)]
784 #[test]
785 fn hook_script_preserves_shell_sensitive_values() {
786 use std::process::Command;
787
788 let temp = tempfile::tempdir().expect("tempdir");
789 let working_directory = temp.path().join("directory with ' quote");
790 std::fs::create_dir(&working_directory).expect("create working directory");
791 let hook = HookConfig {
792 event: "stop".to_string(),
793 command: "printf '%s\\n' 'safe; $HOME `literal`' > result.txt".to_string(),
794 working_directory: working_directory.to_string_lossy().into_owned(),
795 };
796 let script_path = temp.path().join("run.sh");
797 std::fs::write(
798 &script_path,
799 render_hook_script(&hook).expect("render script"),
800 )
801 .expect("write script");
802
803 let syntax = Command::new("bash")
804 .arg("-n")
805 .arg(&script_path)
806 .status()
807 .expect("check script syntax");
808 assert!(syntax.success());
809 let executed = Command::new("bash")
810 .arg(&script_path)
811 .status()
812 .expect("execute script");
813 assert!(executed.success());
814 assert_eq!(
815 std::fs::read_to_string(working_directory.join("result.txt"))
816 .expect("read command output"),
817 "safe; $HOME `literal`\n"
818 );
819 }
820
821 #[test]
822 fn hook_script_rejects_nul_bytes() {
823 let hook = HookConfig {
824 event: "stop".to_string(),
825 command: "printf '\0'".to_string(),
826 working_directory: ".".to_string(),
827 };
828
829 assert!(render_hook_script(&hook).is_err());
830 }
831
832 #[test]
833 fn workflow_serialization_escapes_manifest_values() {
834 let workflow = WorkflowConfig {
835 requires: vec![Requirement {
836 id: "dependency\"\\name".to_string(),
837 capability_type: CapabilityType::Skill,
838 }],
839 };
840 let capability = ResolvedCapability {
841 id: "workflow\"id".to_string(),
842 capability_type: CapabilityType::Workflow,
843 version: "1.0.0".to_string(),
844 description: "first line\nsecond \"line\" \\ value".to_string(),
845 source_files: Vec::new(),
846 source_dir: PathBuf::new(),
847 kind: CapabilityKind::Workflow {
848 workflow: workflow.clone(),
849 },
850 };
851
852 let bytes = serialize_workflow(&capability, &workflow).expect("serialize workflow");
853 let parsed: toml::Value = toml::from_slice(&bytes).expect("parse emitted workflow");
854
855 assert_eq!(parsed["id"].as_str(), Some("workflow\"id"));
856 assert_eq!(
857 parsed["description"].as_str(),
858 Some("first line\nsecond \"line\" \\ value")
859 );
860 assert_eq!(
861 parsed["workflow"]["requires"][0]["id"].as_str(),
862 Some("dependency\"\\name")
863 );
864 }
865}