Skip to main content

vyn_core/
manifest.rs

1use crate::ignore::IgnoreMatcher;
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4use std::fs;
5use std::path::{Path, PathBuf};
6use thiserror::Error;
7use walkdir::WalkDir;
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10pub struct FileEntry {
11    pub path: String,
12    pub sha256: String,
13    pub size: u64,
14    pub mode: u32,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18pub struct Manifest {
19    pub version: u64,
20    pub files: Vec<FileEntry>,
21}
22
23#[derive(Debug, Error)]
24pub enum ManifestError {
25    #[error("failed to walk directory: {0}")]
26    Walk(#[from] walkdir::Error),
27    #[error("failed to read file metadata: {0}")]
28    Io(#[from] std::io::Error),
29    #[error("invalid relative path")]
30    InvalidPath,
31}
32
33impl Manifest {
34    pub fn empty() -> Self {
35        Self {
36            version: 1,
37            files: Vec::new(),
38        }
39    }
40}
41
42pub fn capture_manifest(root: &Path, matcher: &IgnoreMatcher) -> Result<Manifest, ManifestError> {
43    let mut files = Vec::new();
44
45    for entry in WalkDir::new(root) {
46        let entry = entry?;
47        let path = entry.path();
48        let is_dir = entry.file_type().is_dir();
49
50        if matcher.should_ignore(path, is_dir) {
51            continue;
52        }
53
54        if is_dir {
55            continue;
56        }
57
58        let rel_path = to_relative_path(root, path)?;
59        let metadata = fs::metadata(path)?;
60        let data = fs::read(path)?;
61
62        files.push(FileEntry {
63            path: rel_path,
64            sha256: sha256_hex(&data),
65            size: metadata.len(),
66            mode: file_mode(&metadata),
67        });
68    }
69
70    files.sort_by(|a, b| a.path.cmp(&b.path));
71
72    Ok(Manifest { version: 1, files })
73}
74
75fn to_relative_path(root: &Path, path: &Path) -> Result<String, ManifestError> {
76    let rel = path
77        .strip_prefix(root)
78        .map_err(|_| ManifestError::InvalidPath)?;
79    Ok(path_to_unix(rel))
80}
81
82fn path_to_unix(path: &Path) -> String {
83    let mut out = PathBuf::new();
84    for part in path.components() {
85        out.push(part.as_os_str());
86    }
87    out.to_string_lossy().replace('\\', "/")
88}
89
90fn sha256_hex(data: &[u8]) -> String {
91    let digest = Sha256::digest(data);
92    let mut output = String::with_capacity(digest.len() * 2);
93    const HEX: &[u8; 16] = b"0123456789abcdef";
94
95    for byte in digest {
96        output.push(HEX[(byte >> 4) as usize] as char);
97        output.push(HEX[(byte & 0x0f) as usize] as char);
98    }
99
100    output
101}
102
103#[cfg(unix)]
104fn file_mode(metadata: &std::fs::Metadata) -> u32 {
105    use std::os::unix::fs::PermissionsExt;
106    metadata.permissions().mode()
107}
108
109#[cfg(not(unix))]
110fn file_mode(_metadata: &std::fs::Metadata) -> u32 {
111    0
112}
113
114#[cfg(test)]
115mod tests {
116    use super::capture_manifest;
117    use crate::ignore::load_ignore_matcher;
118    use std::fs;
119    use uuid::Uuid;
120
121    #[test]
122    fn manifest_integrity() {
123        let tmp = std::env::temp_dir().join(format!("vyn-manifest-{}", Uuid::new_v4()));
124        fs::create_dir_all(tmp.join("dir")).expect("test directories should be created");
125        fs::write(tmp.join("dir").join("a.env"), "A=1\nB=2\n")
126            .expect("file a.env should be written");
127        fs::write(tmp.join("b.yaml"), "name: vyn\n").expect("file b.yaml should be written");
128
129        let matcher = load_ignore_matcher(&tmp).expect("matcher should load");
130        let manifest = capture_manifest(&tmp, &matcher).expect("manifest capture should succeed");
131
132        assert_eq!(manifest.version, 1);
133        assert_eq!(manifest.files.len(), 2);
134        assert_eq!(manifest.files[0].path, "b.yaml");
135        assert_eq!(manifest.files[1].path, "dir/a.env");
136        assert!(manifest.files.iter().all(|f| f.size > 0));
137
138        fs::remove_dir_all(tmp).expect("temp directory should be removed");
139    }
140}