1use std::fs;
2use std::path::Path;
3
4use sha2::{Digest, Sha256};
5
6use crate::error::MarsError;
7use crate::types::ItemKind;
8
9pub fn compute_hash(path: &Path, kind: ItemKind) -> Result<String, MarsError> {
17 match kind {
18 ItemKind::Agent => {
19 let content = fs::read(path)?;
20 Ok(hash_bytes(&content))
21 }
22 ItemKind::Skill => compute_dir_hash(path),
23 }
24}
25
26pub fn compute_skill_hash_filtered(
28 dir: &Path,
29 excluded_top_level: &[&str],
30) -> Result<String, MarsError> {
31 compute_dir_hash_filtered(dir, excluded_top_level)
32}
33
34pub fn hash_bytes(content: &[u8]) -> String {
38 let mut hasher = Sha256::new();
39 hasher.update(content);
40 let digest = hasher.finalize();
41 format!("sha256:{:064x}", digest)
42}
43
44fn compute_dir_hash(dir: &Path) -> Result<String, MarsError> {
51 compute_dir_hash_filtered(dir, &[])
52}
53
54fn compute_dir_hash_filtered(dir: &Path, excluded_top_level: &[&str]) -> Result<String, MarsError> {
55 let mut entries: Vec<(String, String)> = Vec::new();
56 collect_file_hashes(dir, dir, &mut entries, excluded_top_level)?;
57 entries.sort_by(|a, b| a.0.cmp(&b.0));
58
59 let mut manifest = String::new();
60 for (rel_path, hash) in &entries {
61 manifest.push_str(rel_path);
62 manifest.push(':');
63 manifest.push_str(hash);
64 manifest.push('\n');
65 }
66
67 Ok(hash_bytes(manifest.as_bytes()))
68}
69
70fn collect_file_hashes(
72 root: &Path,
73 current: &Path,
74 entries: &mut Vec<(String, String)>,
75 excluded_top_level: &[&str],
76) -> Result<(), MarsError> {
77 for entry in fs::read_dir(current)? {
78 let entry = entry?;
79 let path = entry.path();
80 let file_type = entry.file_type()?;
81
82 let rel_path = path.strip_prefix(root).expect("path is always under root");
83 if is_excluded_top_level(rel_path, excluded_top_level) {
84 continue;
85 }
86
87 if file_type.is_dir() {
88 collect_file_hashes(root, &path, entries, excluded_top_level)?;
89 } else {
90 let rel_path: String = rel_path
92 .components()
93 .map(|c| c.as_os_str().to_string_lossy())
94 .collect::<Vec<_>>()
95 .join("/");
96 let content = fs::read(&path)?;
97 let hash = hash_bytes(&content);
98 entries.push((rel_path, hash));
99 }
100 }
101 Ok(())
102}
103
104fn is_excluded_top_level(path: &Path, excluded_top_level: &[&str]) -> bool {
105 let Some(first) = path.components().next().map(|c| c.as_os_str()) else {
106 return false;
107 };
108 excluded_top_level.iter().any(|excluded| first == *excluded)
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use std::fs;
115 use tempfile::TempDir;
116
117 #[test]
118 fn hash_bytes_returns_lowercase_hex() {
119 let hash = hash_bytes(b"test");
120 assert!(hash.starts_with("sha256:"));
121 let hex = &hash["sha256:".len()..];
122 assert_eq!(hex.len(), 64);
123 assert!(
124 hex.chars()
125 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
126 );
127 }
128
129 #[test]
130 fn compute_hash_skill_directory() {
131 let dir = TempDir::new().unwrap();
132 let skill_dir = dir.path().join("my-skill");
133 fs::create_dir_all(skill_dir.join("sub")).unwrap();
134 fs::write(skill_dir.join("main.md"), "main content").unwrap();
135 fs::write(skill_dir.join("sub").join("helper.md"), "helper content").unwrap();
136
137 let hash = compute_hash(&skill_dir, ItemKind::Skill).unwrap();
138 assert!(hash.starts_with("sha256:"));
139
140 let hash2 = compute_hash(&skill_dir, ItemKind::Skill).unwrap();
142 assert_eq!(hash, hash2);
143 }
144
145 #[test]
146 fn dir_hash_deterministic_regardless_of_creation_order() {
147 let dir1 = TempDir::new().unwrap();
148 let skill1 = dir1.path().join("skill");
149 fs::create_dir_all(&skill1).unwrap();
150 fs::write(skill1.join("a.md"), "content a").unwrap();
152 fs::write(skill1.join("b.md"), "content b").unwrap();
153
154 let dir2 = TempDir::new().unwrap();
155 let skill2 = dir2.path().join("skill");
156 fs::create_dir_all(&skill2).unwrap();
157 fs::write(skill2.join("b.md"), "content b").unwrap();
159 fs::write(skill2.join("a.md"), "content a").unwrap();
160
161 let hash1 = compute_hash(&skill1, ItemKind::Skill).unwrap();
162 let hash2 = compute_hash(&skill2, ItemKind::Skill).unwrap();
163 assert_eq!(hash1, hash2);
164 }
165
166 #[test]
167 fn dir_hash_changes_with_different_content() {
168 let dir = TempDir::new().unwrap();
169 let skill_dir = dir.path().join("skill");
170 fs::create_dir_all(&skill_dir).unwrap();
171 fs::write(skill_dir.join("file.md"), "version 1").unwrap();
172
173 let hash1 = compute_hash(&skill_dir, ItemKind::Skill).unwrap();
174
175 fs::write(skill_dir.join("file.md"), "version 2").unwrap();
176
177 let hash2 = compute_hash(&skill_dir, ItemKind::Skill).unwrap();
178 assert_ne!(hash1, hash2);
179 }
180
181 #[test]
182 fn dir_hash_changes_with_different_filename() {
183 let dir1 = TempDir::new().unwrap();
184 let skill1 = dir1.path().join("skill");
185 fs::create_dir_all(&skill1).unwrap();
186 fs::write(skill1.join("a.md"), "content").unwrap();
187
188 let dir2 = TempDir::new().unwrap();
189 let skill2 = dir2.path().join("skill");
190 fs::create_dir_all(&skill2).unwrap();
191 fs::write(skill2.join("b.md"), "content").unwrap();
192
193 let hash1 = compute_hash(&skill1, ItemKind::Skill).unwrap();
194 let hash2 = compute_hash(&skill2, ItemKind::Skill).unwrap();
195 assert_ne!(hash1, hash2);
196 }
197
198 #[test]
199 fn filtered_skill_hash_ignores_excluded_top_level_entries() {
200 let dir = TempDir::new().unwrap();
201 let skill_dir = dir.path().join("skill");
202 fs::create_dir_all(skill_dir.join(".git")).unwrap();
203 fs::write(skill_dir.join("SKILL.md"), "base").unwrap();
204 fs::write(skill_dir.join("mars.toml"), "v1").unwrap();
205 fs::write(skill_dir.join(".git").join("config"), "ignored").unwrap();
206
207 let hash1 =
208 compute_skill_hash_filtered(&skill_dir, crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL)
209 .unwrap();
210
211 fs::write(skill_dir.join("mars.toml"), "v2").unwrap();
212 fs::write(skill_dir.join(".git").join("config"), "changed").unwrap();
213
214 let hash2 =
215 compute_skill_hash_filtered(&skill_dir, crate::fs::FLAT_SKILL_EXCLUDED_TOP_LEVEL)
216 .unwrap();
217
218 assert_eq!(hash1, hash2);
219 }
220}