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}