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 => {
419 let mut entry = serde_json::Map::new();
420 if self.mcp_http_declares_type() {
421 entry.insert("type".into(), serde_json::Value::String("http".into()));
422 }
423 entry.insert(
424 "url".into(),
425 serde_json::Value::String(server.url.clone().unwrap_or_default()),
426 );
427 if !server.headers.is_empty() {
428 let headers: serde_json::Map<String, serde_json::Value> = server
429 .headers
430 .iter()
431 .map(|(name, reference)| {
432 let value =
433 reference.render(&self.mcp_env_reference(&reference.from_env));
434 (name.clone(), serde_json::Value::String(value))
435 })
436 .collect();
437 entry.insert("headers".into(), serde_json::Value::Object(headers));
438 }
439 serde_json::Value::Object(entry)
440 }
441 }
442 }
443
444 fn mcp_http_declares_type(&self) -> bool {
448 true
449 }
450
451 fn plan(&self, capability: &ResolvedCapability, repo_root: &Path) -> Result<Vec<PlannedFile>> {
452 match capability.capability_type {
453 CapabilityType::Tool => self.plan_tool(capability, repo_root),
454 CapabilityType::Hook => self.plan_hook(capability, repo_root),
455 CapabilityType::Workflow => self.plan_workflow(capability, repo_root),
456 CapabilityType::McpServer => self.plan_mcp_server(capability, repo_root),
457 CapabilityType::Policy => Err(TuffError::unsupported(
458 "policy capabilities are not installable yet",
459 )),
460 CapabilityType::Skill => self.plan_skill(capability, repo_root),
461 }
462 }
463
464 fn remove(
465 &self,
466 primitive_id: &str,
467 repo_root: &Path,
468 managed_hooks: &[crate::lockfile::ManagedHook],
469 ) -> Result<()> {
470 let prefix = self.dir_prefix();
471 for kind in &["skills", "tools", "hooks", "workflows", "mcp-servers"] {
472 self.remove_dir(repo_root, prefix, kind, primitive_id)?;
473 }
474 crate::mcp::remove_tool(&repo_root.join(self.mcp_config_relpath()), primitive_id)?;
475 self.remove_hook_settings(repo_root, managed_hooks)?;
476 Ok(())
477 }
478
479 fn plan_skill(
482 &self,
483 capability: &ResolvedCapability,
484 repo_root: &Path,
485 ) -> Result<Vec<PlannedFile>> {
486 if capability.source_files.is_empty() {
487 return Err(TuffError::usage("no source files to emit"));
488 }
489
490 let mut files = Vec::new();
491 for (rel_path, content) in &capability.source_files {
492 let target_path = repo_root
493 .join(self.dir_prefix())
494 .join("skills")
495 .join(&capability.id)
496 .join(rel_path);
497
498 files.push(PlannedFile::new(
499 relative_or_absolute_fs(&target_path, repo_root),
500 content.clone(),
501 ));
502 }
503 Ok(files)
504 }
505
506 fn plan_tool(
507 &self,
508 capability: &ResolvedCapability,
509 repo_root: &Path,
510 ) -> Result<Vec<PlannedFile>> {
511 let mut files = Vec::new();
512
513 for (rel_path, content) in &capability.source_files {
514 let target_path = repo_root
515 .join(self.dir_prefix())
516 .join("tools")
517 .join(&capability.id)
518 .join(rel_path);
519
520 files.push(PlannedFile::new(
521 relative_or_absolute_fs(&target_path, repo_root),
522 content.clone(),
523 ));
524 }
525
526 if capability.source_files.is_empty() {
527 let placeholder = repo_root
528 .join(self.dir_prefix())
529 .join("tools")
530 .join(&capability.id)
531 .join(".gitkeep");
532 files.push(PlannedFile::new(
533 relative_or_absolute_fs(&placeholder, repo_root),
534 vec![],
535 ));
536 }
537
538 Ok(files)
539 }
540
541 fn plan_hook(
542 &self,
543 capability: &ResolvedCapability,
544 repo_root: &Path,
545 ) -> Result<Vec<PlannedFile>> {
546 let CapabilityKind::Hook { hook } = &capability.kind else {
547 return Err(TuffError::new("plan_hook called on non-hook capability"));
548 };
549
550 match hook {
551 HookDefinition::Command(hook_cfg) => {
552 let render = self.render_standard_hook(HookRenderContext {
553 capability_id: &capability.id,
554 hook: hook_cfg,
555 source_files: &capability.source_files,
556 repo_root,
557 track_managed_hooks: false,
558 })?;
559 Ok(render.files)
560 }
561 HookDefinition::Native(native) => self.plan_native_hook(capability, native, repo_root),
562 }
563 }
564
565 fn plan_native_hook(
566 &self,
567 capability: &ResolvedCapability,
568 native: &NativeHookConfig,
569 repo_root: &Path,
570 ) -> Result<Vec<PlannedFile>> {
571 let hook_root = repo_root
572 .join(self.dir_prefix())
573 .join("hooks")
574 .join(&capability.id);
575 let hook_root_rel = relative_or_absolute_fs(&hook_root, repo_root);
576 let in_harness_source =
577 path_is_under(&capability.source_dir, &repo_root.join(self.dir_prefix()));
578
579 let mut files = Vec::new();
580 if in_harness_source {
581 for (rel_path, content) in &native.source_files {
582 let target_path = capability.source_dir.join(rel_path);
583 files.push(PlannedFile::mergeable(
584 relative_or_absolute_fs(&target_path, repo_root),
585 content.clone(),
586 ));
587 }
588 } else {
589 for (rel_path, content) in &native.source_files {
590 let target_path = hook_root.join(rel_path);
591 files.push(PlannedFile::new(
592 relative_or_absolute_fs(&target_path, repo_root),
593 content.clone(),
594 ));
595 }
596 }
597
598 let fragment = replace_hook_dir_placeholder(native.fragment.clone(), &hook_root_rel);
599 let settings_relpath = self.hook_settings_relpath();
600 let settings_path = repo_root.join(settings_relpath);
601 let existing = if settings_path.is_file() {
602 Some(std::fs::read(&settings_path)?)
603 } else {
604 None
605 };
606 let merged = self.merge_hook_fragment(existing.as_deref(), &fragment)?;
607 files.push(PlannedFile::mergeable(
608 relative_or_absolute_fs(&settings_path, repo_root),
609 merged,
610 ));
611 Ok(files)
612 }
613
614 fn plan_workflow(
615 &self,
616 capability: &ResolvedCapability,
617 repo_root: &Path,
618 ) -> Result<Vec<PlannedFile>> {
619 let CapabilityKind::Workflow { workflow: wf } = &capability.kind else {
620 return Err(TuffError::new(
621 "plan_workflow called on non-workflow capability",
622 ));
623 };
624
625 let target_path = repo_root
626 .join(self.dir_prefix())
627 .join("workflows")
628 .join(&capability.id)
629 .join("workflow.toml");
630
631 let content = serialize_workflow(capability, wf)?;
632
633 Ok(vec![PlannedFile::new(
634 relative_or_absolute_fs(&target_path, repo_root),
635 content,
636 )])
637 }
638
639 fn plan_mcp_server(
644 &self,
645 capability: &ResolvedCapability,
646 repo_root: &Path,
647 ) -> Result<Vec<PlannedFile>> {
648 let CapabilityKind::McpServer { server } = &capability.kind else {
649 return Err(TuffError::new(
650 "plan_mcp_server called on non-mcp-server capability",
651 ));
652 };
653
654 let target_path = repo_root
655 .join(self.dir_prefix())
656 .join("mcp-servers")
657 .join(&capability.id)
658 .join("server.toml");
659
660 let content = serialize_mcp_server(capability, server)?;
661
662 Ok(vec![PlannedFile::new(
663 relative_or_absolute_fs(&target_path, repo_root),
664 content,
665 )])
666 }
667
668 fn remove_dir(
669 &self,
670 repo_root: &Path,
671 base: &str,
672 kind: &str,
673 primitive_id: &str,
674 ) -> Result<()> {
675 let dir = repo_root.join(base).join(kind).join(primitive_id);
676
677 if dir.exists() {
678 std::fs::remove_dir_all(&dir)?;
679 }
680
681 let kind_dir = dir.parent().expect("kind dir should have parent");
682 if kind_dir.exists() {
683 let mut rd = match std::fs::read_dir(kind_dir) {
684 Ok(rd) => rd,
685 Err(_) => return Ok(()),
686 };
687 if rd.next().is_none() {
688 std::fs::remove_dir(kind_dir)?;
689 }
690 }
691
692 let base_dir = kind_dir.parent().expect("base dir should have parent");
693 if base_dir.exists() {
694 let mut rd = match std::fs::read_dir(base_dir) {
695 Ok(rd) => rd,
696 Err(_) => return Ok(()),
697 };
698 if rd.next().is_none() {
699 std::fs::remove_dir(base_dir)?;
700 }
701 }
702
703 Ok(())
704 }
705}
706
707fn render_hook_script(hook_cfg: &HookConfig) -> Result<Vec<u8>> {
708 let working_directory = shell_single_quote(&hook_cfg.working_directory)?;
709 let command = shell_single_quote(&hook_cfg.command)?;
710 Ok(format!(
711 "#!/usr/bin/env bash\nset -euo pipefail\ncd -- {working_directory}\nexec bash -euo pipefail -c {command}\n"
712 )
713 .into_bytes())
714}
715
716fn shell_single_quote(value: &str) -> Result<String> {
717 if value.contains('\0') {
718 return Err(TuffError::usage(
719 "hook working directory and command cannot contain NUL bytes",
720 ));
721 }
722 Ok(format!("'{}'", value.replace('\'', "'\"'\"'")))
723}
724
725fn serialize_workflow(
726 capability: &ResolvedCapability,
727 workflow: &crate::manifest::WorkflowConfig,
728) -> Result<Vec<u8>> {
729 let document = WorkflowDocument {
730 id: &capability.id,
731 version: &capability.version,
732 capability_type: capability.capability_type,
733 description: &capability.description,
734 workflow,
735 };
736 let mut content = toml::to_string_pretty(&document)?;
737 if !content.ends_with('\n') {
738 content.push('\n');
739 }
740 Ok(content.into_bytes())
741}
742
743#[derive(Serialize)]
744struct McpServerDocument<'a> {
745 id: &'a str,
746 version: &'a str,
747 #[serde(rename = "type")]
748 capability_type: CapabilityType,
749 description: &'a str,
750 server: &'a crate::manifest::McpServerConfig,
751}
752
753fn serialize_mcp_server(
754 capability: &ResolvedCapability,
755 server: &crate::manifest::McpServerConfig,
756) -> Result<Vec<u8>> {
757 let document = McpServerDocument {
758 id: &capability.id,
759 version: &capability.version,
760 capability_type: capability.capability_type,
761 description: &capability.description,
762 server,
763 };
764 let mut content = toml::to_string_pretty(&document)?;
765 if !content.ends_with('\n') {
766 content.push('\n');
767 }
768 Ok(content.into_bytes())
769}
770
771fn path_is_under(path: &Path, root: &Path) -> bool {
772 let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
773 let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
774 canonical_path.starts_with(canonical_root)
775}
776
777pub fn replace_hook_dir_placeholder(
778 mut value: serde_json::Value,
779 hook_dir: &str,
780) -> serde_json::Value {
781 match &mut value {
782 serde_json::Value::String(s) => {
783 *s = s.replace("{{hook_dir}}", hook_dir);
784 }
785 serde_json::Value::Array(items) => {
786 for item in items {
787 *item = replace_hook_dir_placeholder(item.take(), hook_dir);
788 }
789 }
790 serde_json::Value::Object(map) => {
791 for item in map.values_mut() {
792 *item = replace_hook_dir_placeholder(item.take(), hook_dir);
793 }
794 }
795 _ => {}
796 }
797 value
798}
799
800fn relative_or_absolute_fs(path: &Path, repo_root: &Path) -> String {
801 crate::lockfile::relative_or_absolute_fs(path, repo_root)
802}
803
804#[cfg(test)]
805mod tests {
806 use super::*;
807 use crate::manifest::{Requirement, WorkflowConfig};
808
809 #[cfg(unix)]
810 #[test]
811 fn hook_script_preserves_shell_sensitive_values() {
812 use std::process::Command;
813
814 let temp = tempfile::tempdir().expect("tempdir");
815 let working_directory = temp.path().join("directory with ' quote");
816 std::fs::create_dir(&working_directory).expect("create working directory");
817 let hook = HookConfig {
818 event: "stop".to_string(),
819 command: "printf '%s\\n' 'safe; $HOME `literal`' > result.txt".to_string(),
820 working_directory: working_directory.to_string_lossy().into_owned(),
821 };
822 let script_path = temp.path().join("run.sh");
823 std::fs::write(
824 &script_path,
825 render_hook_script(&hook).expect("render script"),
826 )
827 .expect("write script");
828
829 let syntax = Command::new("bash")
830 .arg("-n")
831 .arg(&script_path)
832 .status()
833 .expect("check script syntax");
834 assert!(syntax.success());
835 let executed = Command::new("bash")
836 .arg(&script_path)
837 .status()
838 .expect("execute script");
839 assert!(executed.success());
840 assert_eq!(
841 std::fs::read_to_string(working_directory.join("result.txt"))
842 .expect("read command output"),
843 "safe; $HOME `literal`\n"
844 );
845 }
846
847 #[test]
848 fn hook_script_rejects_nul_bytes() {
849 let hook = HookConfig {
850 event: "stop".to_string(),
851 command: "printf '\0'".to_string(),
852 working_directory: ".".to_string(),
853 };
854
855 assert!(render_hook_script(&hook).is_err());
856 }
857
858 #[test]
859 fn workflow_serialization_escapes_manifest_values() {
860 let workflow = WorkflowConfig {
861 requires: vec![Requirement {
862 id: "dependency\"\\name".to_string(),
863 capability_type: CapabilityType::Skill,
864 }],
865 };
866 let capability = ResolvedCapability {
867 id: "workflow\"id".to_string(),
868 capability_type: CapabilityType::Workflow,
869 version: "1.0.0".to_string(),
870 description: "first line\nsecond \"line\" \\ value".to_string(),
871 source_files: Vec::new(),
872 source_dir: PathBuf::new(),
873 kind: CapabilityKind::Workflow {
874 workflow: workflow.clone(),
875 },
876 };
877
878 let bytes = serialize_workflow(&capability, &workflow).expect("serialize workflow");
879 let parsed: toml::Value = toml::from_slice(&bytes).expect("parse emitted workflow");
880
881 assert_eq!(parsed["id"].as_str(), Some("workflow\"id"));
882 assert_eq!(
883 parsed["description"].as_str(),
884 Some("first line\nsecond \"line\" \\ value")
885 );
886 assert_eq!(
887 parsed["workflow"]["requires"][0]["id"].as_str(),
888 Some("dependency\"\\name")
889 );
890 }
891}