1use 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
18pub 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 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
92pub 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
107pub 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 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 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}