Skip to main content

mnemo_cma/
migrate.rs

1//! One-shot CMA tree import + export (v0.4.1 P0-2).
2
3use std::path::Path;
4
5use serde::{Deserialize, Serialize};
6use sha2::Digest;
7
8use crate::audit_bridge::{BridgedEvent, CmaSource, bridge_event};
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct ImportSummary {
12    pub files: u32,
13    pub memories: u32,
14    pub audit_events_bridged: u32,
15    pub hmac_chain_head: [u8; 32],
16}
17
18/// Walk a CMA `.memory/` tree and produce a deterministic
19/// [`ImportSummary`]. The actual engine ingestion is wired in by the
20/// caller (the binary in `mnemo-cli`); this fn is pure so the
21/// summary is byte-identical between two runs over the same tree.
22pub fn import_cma_tree(memory_dir: &Path) -> std::io::Result<(ImportSummary, Vec<BridgedEvent>)> {
23    let mut files = 0u32;
24    let mut memories = 0u32;
25    let mut bridged = Vec::new();
26    let mut head = [0u8; 32];
27
28    if !memory_dir.exists() {
29        return Ok((
30            ImportSummary {
31                files: 0,
32                memories: 0,
33                audit_events_bridged: 0,
34                hmac_chain_head: head,
35            },
36            bridged,
37        ));
38    }
39
40    // Sort entries so the output is stable (same tree → same head).
41    let mut entries: Vec<_> = walk(memory_dir)?;
42    entries.sort();
43
44    for path in entries {
45        if !path.is_file() {
46            continue;
47        }
48        files += 1;
49        let bytes = std::fs::metadata(&path)?.len();
50        let rel = path.strip_prefix(memory_dir).unwrap_or(&path);
51        let event = bridge_event(
52            CmaSource::CmaImport,
53            &rel.to_string_lossy(),
54            "import",
55            bytes,
56            head,
57        );
58        head = event.bridge_hash;
59        bridged.push(event);
60        if path.extension().and_then(|e| e.to_str()) == Some("md") {
61            memories += 1;
62        }
63    }
64
65    Ok((
66        ImportSummary {
67            files,
68            memories,
69            audit_events_bridged: bridged.len() as u32,
70            hmac_chain_head: head,
71        },
72        bridged,
73    ))
74}
75
76fn walk(dir: &Path) -> std::io::Result<Vec<std::path::PathBuf>> {
77    let mut out = Vec::new();
78    let mut stack = vec![dir.to_path_buf()];
79    while let Some(p) = stack.pop() {
80        if p.is_dir() {
81            for ent in std::fs::read_dir(&p)? {
82                let ent = ent?;
83                stack.push(ent.path());
84            }
85        } else if p.is_file() {
86            out.push(p);
87        }
88    }
89    Ok(out)
90}
91
92/// Export a synthesized CMA tree from a list of (path, body) pairs.
93/// Used by tests + by `mnemo cma export` so users can leave mnemo
94/// cleanly.
95pub fn export_to_tree(memory_dir: &Path, files: &[(String, String)]) -> std::io::Result<()> {
96    std::fs::create_dir_all(memory_dir)?;
97    for (rel, body) in files {
98        let path = memory_dir.join(rel);
99        if let Some(parent) = path.parent() {
100            std::fs::create_dir_all(parent)?;
101        }
102        std::fs::write(path, body)?;
103    }
104    Ok(())
105}
106
107/// Hash a CMA tree's body content into a 32-byte SHA-256 so two
108/// trees with the same files-and-bytes produce the same digest.
109/// Used by the round-trip test.
110pub fn tree_digest(memory_dir: &Path) -> std::io::Result<[u8; 32]> {
111    let mut entries = walk(memory_dir)?;
112    entries.sort();
113    let mut h = sha2::Sha256::new();
114    for p in entries {
115        let rel = p
116            .strip_prefix(memory_dir)
117            .unwrap_or(&p)
118            .to_string_lossy()
119            .to_string();
120        let body = std::fs::read(&p)?;
121        h.update(rel.as_bytes());
122        h.update(b"\n");
123        h.update(&body);
124        h.update(b"\n--\n");
125    }
126    Ok(h.finalize().into())
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    fn write_file(dir: &Path, rel: &str, body: &str) {
134        let p = dir.join(rel);
135        if let Some(parent) = p.parent() {
136            std::fs::create_dir_all(parent).unwrap();
137        }
138        std::fs::write(p, body).unwrap();
139    }
140
141    #[test]
142    fn import_empty_dir_is_empty_summary() {
143        let dir = tempfile::tempdir().unwrap();
144        let (sum, ev) = import_cma_tree(dir.path()).unwrap();
145        assert_eq!(sum.files, 0);
146        assert_eq!(sum.memories, 0);
147        assert_eq!(sum.audit_events_bridged, 0);
148        assert!(ev.is_empty());
149    }
150
151    #[test]
152    fn import_counts_md_files_and_chains_audit() {
153        let dir = tempfile::tempdir().unwrap();
154        write_file(dir.path(), "a.md", "alpha");
155        write_file(dir.path(), "b.md", "beta");
156        write_file(dir.path(), "notes/c.md", "gamma");
157        let (sum, ev) = import_cma_tree(dir.path()).unwrap();
158        assert_eq!(sum.files, 3);
159        assert_eq!(sum.memories, 3);
160        assert_eq!(ev.len(), 3);
161        assert_ne!(sum.hmac_chain_head, [0u8; 32]);
162        // Chain links — each next event's prev_hash matches the
163        // previous event's bridge_hash.
164        for w in ev.windows(2) {
165            assert_eq!(w[1].prev_hash, w[0].bridge_hash);
166        }
167    }
168
169    #[test]
170    fn import_is_idempotent() {
171        let dir = tempfile::tempdir().unwrap();
172        write_file(dir.path(), "a.md", "alpha");
173        write_file(dir.path(), "b.md", "beta");
174        let (s1, _) = import_cma_tree(dir.path()).unwrap();
175        let (s2, _) = import_cma_tree(dir.path()).unwrap();
176        assert_eq!(
177            s1, s2,
178            "running import twice must produce identical summary"
179        );
180    }
181
182    #[test]
183    fn export_round_trip_preserves_byte_content() {
184        let original = tempfile::tempdir().unwrap();
185        write_file(original.path(), "a.md", "alpha");
186        write_file(original.path(), "nested/b.md", "beta");
187        let original_digest = tree_digest(original.path()).unwrap();
188
189        // Read everything as (path, body) pairs.
190        let mut pairs = Vec::new();
191        for path in walk(original.path()).unwrap() {
192            let rel = path.strip_prefix(original.path()).unwrap();
193            let body = std::fs::read_to_string(&path).unwrap();
194            pairs.push((rel.to_string_lossy().to_string(), body));
195        }
196
197        let exported = tempfile::tempdir().unwrap();
198        export_to_tree(exported.path(), &pairs).unwrap();
199        let exported_digest = tree_digest(exported.path()).unwrap();
200        assert_eq!(original_digest, exported_digest);
201    }
202}