Skip to main content

capsula_capture_file/
lib.rs

1mod 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    // Md5,
38    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    /// Builds a complete glob pattern from base path and user pattern.
104    /// Users must use forward slashes (/) in patterns.
105    /// On Windows, this normalizes path separators from backslashes to forward slashes.
106    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 out GlobErrors
123            .filter(|path| path.is_file()) // Only files, not directories
124            .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        // Compute hash if needed
140        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        // Copy or move file if needed
149        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}