heroforge-core 0.2.2

Pure Rust core library for reading and writing Fossil SCM repositories
Documentation
use std::collections::HashMap;

use crate::error::{FossilError, Result};

/// A file entry in a manifest
#[derive(Debug, Clone)]
pub struct FileEntry {
    pub name: String,
    pub hash: Option<String>, // None if deleted (delta manifest)
    pub permissions: Option<String>,
    pub old_name: Option<String>,
}

/// A parsed manifest (check-in)
#[derive(Debug)]
pub struct Manifest {
    pub baseline: Option<String>, // B card - baseline manifest for deltas
    pub comment: String,          // C card
    pub timestamp: String,        // D card
    pub files: Vec<FileEntry>,    // F cards
    pub parents: Vec<String>,     // P card
    pub user: String,             // U card
    pub checksum: String,         // Z card
    pub mimetype: Option<String>, // N card
    pub repo_checksum: Option<String>, // R card
}

/// Decode fossilized string (unescape \s, \n, \\)
fn decode_fossil_string(s: &str) -> String {
    let mut result = String::with_capacity(s.len());
    let mut chars = s.chars().peekable();

    while let Some(c) = chars.next() {
        if c == '\\' {
            match chars.next() {
                Some('s') => result.push(' '),
                Some('n') => result.push('\n'),
                Some('\\') => result.push('\\'),
                Some(other) => {
                    result.push('\\');
                    result.push(other);
                }
                None => result.push('\\'),
            }
        } else {
            result.push(c);
        }
    }

    result
}

/// Encode string to fossilized format
pub fn encode_fossil_string(s: &str) -> String {
    s.replace('\\', "\\\\")
        .replace(' ', "\\s")
        .replace('\n', "\\n")
}

/// Check if content looks like a manifest
pub fn is_manifest(content: &[u8]) -> bool {
    // Manifests are text and contain specific cards
    if let Ok(text) = std::str::from_utf8(content) {
        // Must have D (date), U (user), and Z (checksum) cards
        text.lines().any(|l| l.starts_with("D "))
            && text.lines().any(|l| l.starts_with("U "))
            && text.lines().any(|l| l.starts_with("Z "))
    } else {
        false
    }
}

/// Parse a manifest artifact
pub fn parse_manifest(content: &[u8]) -> Result<Manifest> {
    let text = std::str::from_utf8(content)
        .map_err(|e| FossilError::InvalidArtifact(format!("Not valid UTF-8: {}", e)))?;

    let mut baseline = None;
    let mut comment = String::new();
    let mut timestamp = String::new();
    let mut files = Vec::new();
    let mut parents = Vec::new();
    let mut user = String::new();
    let mut checksum = String::new();
    let mut mimetype = None;
    let mut repo_checksum = None;

    for line in text.lines() {
        if line.is_empty() {
            continue;
        }

        // Handle PGP signature blocks
        if line.starts_with("-----") {
            continue;
        }
        if line.starts_with("Hash:") {
            continue;
        }

        let mut parts = line.splitn(2, ' ');
        let card_type = parts.next().unwrap_or("");
        let args = parts.next().unwrap_or("");

        match card_type {
            "B" => {
                baseline = Some(args.to_string());
            }
            "C" => {
                comment = decode_fossil_string(args);
            }
            "D" => {
                timestamp = args.to_string();
            }
            "F" => {
                // F filename ?hash? ?permissions? ?old-name?
                let parts: Vec<&str> = args.splitn(4, ' ').collect();
                let name = decode_fossil_string(parts.first().unwrap_or(&""));

                // Hash might be empty (file deleted in delta manifest)
                let hash = parts.get(1).and_then(|s| {
                    if s.is_empty() {
                        None
                    } else {
                        Some(s.to_string())
                    }
                });

                let permissions = parts.get(2).and_then(|s| {
                    if s.is_empty() {
                        None
                    } else {
                        Some(s.to_string())
                    }
                });

                let old_name = parts.get(3).map(|s| decode_fossil_string(s));

                files.push(FileEntry {
                    name,
                    hash,
                    permissions,
                    old_name,
                });
            }
            "N" => {
                mimetype = Some(args.to_string());
            }
            "P" => {
                parents = args
                    .split(' ')
                    .filter(|s| !s.is_empty())
                    .map(|s| s.to_string())
                    .collect();
            }
            "R" => {
                repo_checksum = Some(args.to_string());
            }
            "U" => {
                user = decode_fossil_string(args);
            }
            "Z" => {
                checksum = args.to_string();
            }
            // Ignore other cards: Q (cherry-pick), T (tags), etc.
            _ => {}
        }
    }

    // Validate required fields
    if timestamp.is_empty() {
        return Err(FossilError::InvalidArtifact("Missing D (date) card".into()));
    }
    if user.is_empty() {
        return Err(FossilError::InvalidArtifact("Missing U (user) card".into()));
    }
    if checksum.is_empty() {
        return Err(FossilError::InvalidArtifact(
            "Missing Z (checksum) card".into(),
        ));
    }

    Ok(Manifest {
        baseline,
        comment,
        timestamp,
        files,
        parents,
        user,
        checksum,
        mimetype,
        repo_checksum,
    })
}

/// Get complete file list for a check-in (resolves delta manifests)
///
/// Uses a reference to the closure to avoid infinite type recursion.
pub fn get_full_file_list(
    manifest: &Manifest,
    get_baseline: &dyn Fn(&str) -> Result<Manifest>,
) -> Result<HashMap<String, FileEntry>> {
    let mut files: HashMap<String, FileEntry> = HashMap::new();

    // If this is a delta manifest, start with baseline
    if let Some(ref baseline_hash) = manifest.baseline {
        let baseline = get_baseline(baseline_hash)?;

        // Recursively resolve if baseline is also a delta
        let baseline_files = if baseline.baseline.is_some() {
            get_full_file_list(&baseline, get_baseline)?
        } else {
            baseline
                .files
                .into_iter()
                .map(|e| (e.name.clone(), e))
                .collect()
        };

        files = baseline_files;
    }

    // Apply changes from this manifest
    for entry in &manifest.files {
        if entry.hash.is_some() {
            files.insert(entry.name.clone(), entry.clone());
        } else {
            // File deleted
            files.remove(&entry.name);
        }
    }

    Ok(files)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_decode_fossil_string() {
        assert_eq!(decode_fossil_string("hello"), "hello");
        assert_eq!(decode_fossil_string(r"hello\sworld"), "hello world");
        assert_eq!(decode_fossil_string(r"line1\nline2"), "line1\nline2");
        assert_eq!(decode_fossil_string(r"back\\slash"), "back\\slash");
    }

    #[test]
    fn test_encode_fossil_string() {
        assert_eq!(encode_fossil_string("hello"), "hello");
        assert_eq!(encode_fossil_string("hello world"), r"hello\sworld");
        assert_eq!(encode_fossil_string("line1\nline2"), r"line1\nline2");
    }

    #[test]
    fn test_parse_simple_manifest() {
        let manifest_text = b"C Initial\\scommit\n\
D 2024-01-15T10:30:00\n\
F README.md abc123\n\
F src/main.rs def456 x\n\
P \n\
U developer\n\
Z 0123456789abcdef0123456789abcdef\n";

        let manifest = parse_manifest(manifest_text).unwrap();

        assert_eq!(manifest.comment, "Initial commit");
        assert_eq!(manifest.timestamp, "2024-01-15T10:30:00");
        assert_eq!(manifest.user, "developer");
        assert_eq!(manifest.files.len(), 2);
        assert_eq!(manifest.files[0].name, "README.md");
        assert_eq!(manifest.files[0].hash, Some("abc123".to_string()));
        assert_eq!(manifest.files[1].name, "src/main.rs");
        assert_eq!(manifest.files[1].permissions, Some("x".to_string()));
    }

    #[test]
    fn test_parse_delta_manifest() {
        let manifest_text = b"B abc123def456\n\
C Fix\\sbug\n\
D 2024-01-16T11:00:00\n\
F src/main.rs ghi789\n\
P abc123\n\
U developer\n\
Z fedcba9876543210fedcba9876543210\n";

        let manifest = parse_manifest(manifest_text).unwrap();

        assert_eq!(manifest.baseline, Some("abc123def456".to_string()));
        assert_eq!(manifest.comment, "Fix bug");
        assert_eq!(manifest.parents, vec!["abc123"]);
    }
}