capsula_capture_file/
lib.rs1mod error;
2mod hash;
3
4use crate::error::FileHookError;
5use crate::hash::file_digest_sha256;
6use capsula_core::captured::Captured;
7use capsula_core::error::{CapsulaError, CapsulaResult};
8use capsula_core::hook::{Hook, PhaseMarker, RuntimeParams};
9use capsula_core::run::PreparedRun;
10use serde::{Deserialize, Serialize};
11use std::path::{Path, PathBuf};
12use tracing::debug;
13
14#[derive(Debug, Clone, Deserialize, Serialize)]
15pub struct FileHookConfig {
16 glob: String,
17 #[serde(default)]
18 mode: CaptureMode,
19 #[serde(default)]
20 hash: HashAlgorithm,
21}
22
23#[derive(Debug, Clone, Deserialize, Serialize, Default)]
24#[serde(rename_all = "lowercase")]
25pub enum CaptureMode {
26 #[default]
27 Copy,
28 Move,
29 None,
30}
31
32#[derive(Debug, Clone, Deserialize, Serialize, Default)]
33#[serde(rename_all = "lowercase")]
34pub enum HashAlgorithm {
35 #[default]
36 Sha256,
37 None,
39}
40
41#[derive(Debug)]
42pub struct FileHook {
43 config: FileHookConfig,
44}
45
46#[derive(Debug, Serialize)]
47pub struct FileCapturedPerFile {
48 path: PathBuf,
49 copied_path: Option<PathBuf>,
50 hash: Option<String>,
51}
52
53#[derive(Debug, Serialize)]
54pub struct FileCaptured {
55 files: Vec<FileCapturedPerFile>,
56}
57
58impl Captured for FileCaptured {
59 fn serialize_json(&self) -> Result<serde_json::Value, serde_json::Error> {
60 serde_json::to_value(self)
61 }
62}
63
64impl<P> Hook<P> for FileHook
65where
66 P: PhaseMarker,
67{
68 const ID: &'static str = "capture-file";
69
70 type Config = FileHookConfig;
71 type Output = FileCaptured;
72
73 fn from_config(
74 config: &serde_json::Value,
75 _project_root: &std::path::Path,
76 ) -> CapsulaResult<Self> {
77 let config: FileHookConfig = serde_json::from_value(config.clone())?;
78 Ok(Self { config })
79 }
80
81 fn config(&self) -> &Self::Config {
82 &self.config
83 }
84
85 fn run(
86 &self,
87 metadata: &PreparedRun,
88 params: &RuntimeParams<P>,
89 ) -> CapsulaResult<Self::Output> {
90 let artifact_dir = params
91 .artifact_dir
92 .as_deref()
93 .expect("capture-file hook requires an artifact directory");
94 self.run(metadata, artifact_dir).map_err(CapsulaError::from)
95 }
96
97 fn needs_artifact_dir(&self) -> bool {
98 true
99 }
100}
101
102impl FileHook {
103 fn build_glob_pattern(base: &Path, pattern: &str) -> String {
107 base.join(pattern).to_string_lossy().replace('\\', "/")
108 }
109
110 fn run(
111 &self,
112 metadata: &PreparedRun,
113 artifact_dir: &Path,
114 ) -> Result<FileCaptured, FileHookError> {
115 let pattern = Self::build_glob_pattern(&metadata.project_root, &self.config.glob);
116 debug!(
117 "FileHook: Searching for files matching pattern: {}",
118 pattern
119 );
120
121 let files: Vec<_> = glob::glob(&pattern)?
122 .filter_map(Result::ok) .filter(|path| path.is_file()) .map(|path| {
125 debug!("FileHook: Processing file: {}", path.display());
126 self.capture_file(&path, artifact_dir)
127 })
128 .collect::<Result<Vec<_>, FileHookError>>()?;
129
130 debug!("FileHook: Captured {} files", files.len());
131 Ok(FileCaptured { files })
132 }
133
134 fn capture_file(
135 &self,
136 path: &Path,
137 artifact_dir: &Path,
138 ) -> Result<FileCapturedPerFile, FileHookError> {
139 let hash = match self.config.hash {
141 HashAlgorithm::Sha256 => {
142 debug!("FileHook: Computing SHA256 hash for: {}", path.display());
143 Some(format!("sha256:{}", file_digest_sha256(path)?))
144 }
145 HashAlgorithm::None => None,
146 };
147
148 let copied_path = match self.config.mode {
150 CaptureMode::Copy | CaptureMode::Move => {
151 debug!(
152 "FileHook: {:?} file to artifact directory",
153 self.config.mode
154 );
155 let file_name = path
156 .file_name()
157 .ok_or_else(|| FileHookError::InvalidRunDir {
158 path: path.to_path_buf(),
159 })?
160 .to_os_string();
161 let dest_path = artifact_dir.join(file_name);
162 match self.config.mode {
163 CaptureMode::Copy => std::fs::copy(path, &dest_path).map(|_| ())?,
164 CaptureMode::Move => {
165 std::fs::rename(path, &dest_path)?;
166 }
167 CaptureMode::None => unreachable!(),
168 }
169 Some(dest_path)
170 }
171 CaptureMode::None => None,
172 };
173
174 Ok(FileCapturedPerFile {
175 path: path.to_path_buf(),
176 copied_path,
177 hash,
178 })
179 }
180}