heroforge_core/artifact/
manifest.rs1use std::collections::HashMap;
2
3use crate::error::{FossilError, Result};
4
5#[derive(Debug, Clone)]
7pub struct FileEntry {
8 pub name: String,
9 pub hash: Option<String>, pub permissions: Option<String>,
11 pub old_name: Option<String>,
12}
13
14#[derive(Debug)]
16pub struct Manifest {
17 pub baseline: Option<String>, pub comment: String, pub timestamp: String, pub files: Vec<FileEntry>, pub parents: Vec<String>, pub user: String, pub checksum: String, pub mimetype: Option<String>, pub repo_checksum: Option<String>, }
27
28fn decode_fossil_string(s: &str) -> String {
30 let mut result = String::with_capacity(s.len());
31 let mut chars = s.chars().peekable();
32
33 while let Some(c) = chars.next() {
34 if c == '\\' {
35 match chars.next() {
36 Some('s') => result.push(' '),
37 Some('n') => result.push('\n'),
38 Some('\\') => result.push('\\'),
39 Some(other) => {
40 result.push('\\');
41 result.push(other);
42 }
43 None => result.push('\\'),
44 }
45 } else {
46 result.push(c);
47 }
48 }
49
50 result
51}
52
53pub fn encode_fossil_string(s: &str) -> String {
55 s.replace('\\', "\\\\")
56 .replace(' ', "\\s")
57 .replace('\n', "\\n")
58}
59
60pub fn is_manifest(content: &[u8]) -> bool {
62 if let Ok(text) = std::str::from_utf8(content) {
64 text.lines().any(|l| l.starts_with("D "))
66 && text.lines().any(|l| l.starts_with("U "))
67 && text.lines().any(|l| l.starts_with("Z "))
68 } else {
69 false
70 }
71}
72
73pub fn parse_manifest(content: &[u8]) -> Result<Manifest> {
75 let text = std::str::from_utf8(content)
76 .map_err(|e| FossilError::InvalidArtifact(format!("Not valid UTF-8: {}", e)))?;
77
78 let mut baseline = None;
79 let mut comment = String::new();
80 let mut timestamp = String::new();
81 let mut files = Vec::new();
82 let mut parents = Vec::new();
83 let mut user = String::new();
84 let mut checksum = String::new();
85 let mut mimetype = None;
86 let mut repo_checksum = None;
87
88 for line in text.lines() {
89 if line.is_empty() {
90 continue;
91 }
92
93 if line.starts_with("-----") {
95 continue;
96 }
97 if line.starts_with("Hash:") {
98 continue;
99 }
100
101 let mut parts = line.splitn(2, ' ');
102 let card_type = parts.next().unwrap_or("");
103 let args = parts.next().unwrap_or("");
104
105 match card_type {
106 "B" => {
107 baseline = Some(args.to_string());
108 }
109 "C" => {
110 comment = decode_fossil_string(args);
111 }
112 "D" => {
113 timestamp = args.to_string();
114 }
115 "F" => {
116 let parts: Vec<&str> = args.splitn(4, ' ').collect();
118 let name = decode_fossil_string(parts.first().unwrap_or(&""));
119
120 let hash = parts.get(1).and_then(|s| {
122 if s.is_empty() {
123 None
124 } else {
125 Some(s.to_string())
126 }
127 });
128
129 let permissions = parts.get(2).and_then(|s| {
130 if s.is_empty() {
131 None
132 } else {
133 Some(s.to_string())
134 }
135 });
136
137 let old_name = parts.get(3).map(|s| decode_fossil_string(s));
138
139 files.push(FileEntry {
140 name,
141 hash,
142 permissions,
143 old_name,
144 });
145 }
146 "N" => {
147 mimetype = Some(args.to_string());
148 }
149 "P" => {
150 parents = args
151 .split(' ')
152 .filter(|s| !s.is_empty())
153 .map(|s| s.to_string())
154 .collect();
155 }
156 "R" => {
157 repo_checksum = Some(args.to_string());
158 }
159 "U" => {
160 user = decode_fossil_string(args);
161 }
162 "Z" => {
163 checksum = args.to_string();
164 }
165 _ => {}
167 }
168 }
169
170 if timestamp.is_empty() {
172 return Err(FossilError::InvalidArtifact("Missing D (date) card".into()));
173 }
174 if user.is_empty() {
175 return Err(FossilError::InvalidArtifact("Missing U (user) card".into()));
176 }
177 if checksum.is_empty() {
178 return Err(FossilError::InvalidArtifact(
179 "Missing Z (checksum) card".into(),
180 ));
181 }
182
183 Ok(Manifest {
184 baseline,
185 comment,
186 timestamp,
187 files,
188 parents,
189 user,
190 checksum,
191 mimetype,
192 repo_checksum,
193 })
194}
195
196pub fn get_full_file_list(
200 manifest: &Manifest,
201 get_baseline: &dyn Fn(&str) -> Result<Manifest>,
202) -> Result<HashMap<String, FileEntry>> {
203 let mut files: HashMap<String, FileEntry> = HashMap::new();
204
205 if let Some(ref baseline_hash) = manifest.baseline {
207 let baseline = get_baseline(baseline_hash)?;
208
209 let baseline_files = if baseline.baseline.is_some() {
211 get_full_file_list(&baseline, get_baseline)?
212 } else {
213 baseline
214 .files
215 .into_iter()
216 .map(|e| (e.name.clone(), e))
217 .collect()
218 };
219
220 files = baseline_files;
221 }
222
223 for entry in &manifest.files {
225 if entry.hash.is_some() {
226 files.insert(entry.name.clone(), entry.clone());
227 } else {
228 files.remove(&entry.name);
230 }
231 }
232
233 Ok(files)
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 #[test]
241 fn test_decode_fossil_string() {
242 assert_eq!(decode_fossil_string("hello"), "hello");
243 assert_eq!(decode_fossil_string(r"hello\sworld"), "hello world");
244 assert_eq!(decode_fossil_string(r"line1\nline2"), "line1\nline2");
245 assert_eq!(decode_fossil_string(r"back\\slash"), "back\\slash");
246 }
247
248 #[test]
249 fn test_encode_fossil_string() {
250 assert_eq!(encode_fossil_string("hello"), "hello");
251 assert_eq!(encode_fossil_string("hello world"), r"hello\sworld");
252 assert_eq!(encode_fossil_string("line1\nline2"), r"line1\nline2");
253 }
254
255 #[test]
256 fn test_parse_simple_manifest() {
257 let manifest_text = b"C Initial\\scommit\n\
258D 2024-01-15T10:30:00\n\
259F README.md abc123\n\
260F src/main.rs def456 x\n\
261P \n\
262U developer\n\
263Z 0123456789abcdef0123456789abcdef\n";
264
265 let manifest = parse_manifest(manifest_text).unwrap();
266
267 assert_eq!(manifest.comment, "Initial commit");
268 assert_eq!(manifest.timestamp, "2024-01-15T10:30:00");
269 assert_eq!(manifest.user, "developer");
270 assert_eq!(manifest.files.len(), 2);
271 assert_eq!(manifest.files[0].name, "README.md");
272 assert_eq!(manifest.files[0].hash, Some("abc123".to_string()));
273 assert_eq!(manifest.files[1].name, "src/main.rs");
274 assert_eq!(manifest.files[1].permissions, Some("x".to_string()));
275 }
276
277 #[test]
278 fn test_parse_delta_manifest() {
279 let manifest_text = b"B abc123def456\n\
280C Fix\\sbug\n\
281D 2024-01-16T11:00:00\n\
282F src/main.rs ghi789\n\
283P abc123\n\
284U developer\n\
285Z fedcba9876543210fedcba9876543210\n";
286
287 let manifest = parse_manifest(manifest_text).unwrap();
288
289 assert_eq!(manifest.baseline, Some("abc123def456".to_string()));
290 assert_eq!(manifest.comment, "Fix bug");
291 assert_eq!(manifest.parents, vec!["abc123"]);
292 }
293}