heroforge_core/artifact/
manifest.rs

1use std::collections::HashMap;
2
3use crate::error::{FossilError, Result};
4
5/// A file entry in a manifest
6#[derive(Debug, Clone)]
7pub struct FileEntry {
8    pub name: String,
9    pub hash: Option<String>, // None if deleted (delta manifest)
10    pub permissions: Option<String>,
11    pub old_name: Option<String>,
12}
13
14/// A parsed manifest (check-in)
15#[derive(Debug)]
16pub struct Manifest {
17    pub baseline: Option<String>, // B card - baseline manifest for deltas
18    pub comment: String,          // C card
19    pub timestamp: String,        // D card
20    pub files: Vec<FileEntry>,    // F cards
21    pub parents: Vec<String>,     // P card
22    pub user: String,             // U card
23    pub checksum: String,         // Z card
24    pub mimetype: Option<String>, // N card
25    pub repo_checksum: Option<String>, // R card
26}
27
28/// Decode fossilized string (unescape \s, \n, \\)
29fn 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
53/// Encode string to fossilized format
54pub fn encode_fossil_string(s: &str) -> String {
55    s.replace('\\', "\\\\")
56        .replace(' ', "\\s")
57        .replace('\n', "\\n")
58}
59
60/// Check if content looks like a manifest
61pub fn is_manifest(content: &[u8]) -> bool {
62    // Manifests are text and contain specific cards
63    if let Ok(text) = std::str::from_utf8(content) {
64        // Must have D (date), U (user), and Z (checksum) cards
65        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
73/// Parse a manifest artifact
74pub 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        // Handle PGP signature blocks
94        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                // F filename ?hash? ?permissions? ?old-name?
117                let parts: Vec<&str> = args.splitn(4, ' ').collect();
118                let name = decode_fossil_string(parts.first().unwrap_or(&""));
119
120                // Hash might be empty (file deleted in delta manifest)
121                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            // Ignore other cards: Q (cherry-pick), T (tags), etc.
166            _ => {}
167        }
168    }
169
170    // Validate required fields
171    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
196/// Get complete file list for a check-in (resolves delta manifests)
197///
198/// Uses a reference to the closure to avoid infinite type recursion.
199pub 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 this is a delta manifest, start with baseline
206    if let Some(ref baseline_hash) = manifest.baseline {
207        let baseline = get_baseline(baseline_hash)?;
208
209        // Recursively resolve if baseline is also a delta
210        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    // Apply changes from this manifest
224    for entry in &manifest.files {
225        if entry.hash.is_some() {
226            files.insert(entry.name.clone(), entry.clone());
227        } else {
228            // File deleted
229            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}