1use std::fs;
12use std::path::{Path, PathBuf};
13
14use memstead_schema::TypeDefinition;
15
16use super::Entity;
17use super::generator::generate_markdown;
18
19pub fn write_entity(
23 entity: &Entity,
24 mem_dir: &Path,
25 schema: &TypeDefinition,
26) -> Result<PathBuf, WriteError> {
27 if entity.file_path.is_empty() {
28 return Err(WriteError::NoFilePath(entity.id.to_string()));
29 }
30
31 let full_path = mem_dir.join(&entity.file_path);
32
33 let resolved = full_path
35 .canonicalize()
36 .unwrap_or_else(|_| full_path.clone());
37 let resolved_root = mem_dir
38 .canonicalize()
39 .unwrap_or_else(|_| mem_dir.to_path_buf());
40 if !resolved.starts_with(&resolved_root) && full_path != mem_dir.join(&entity.file_path) {
41 return Err(WriteError::PathTraversal(entity.file_path.clone()));
42 }
43
44 if let Some(parent) = full_path.parent() {
46 fs::create_dir_all(parent)?;
47 }
48
49 if let Some((section, fence)) = entity.sections.iter().find_map(|(k, v)| {
57 crate::markdown::closing_fence_if_unterminated(v.trim()).map(|f| (k.clone(), f))
58 }) {
59 return Err(WriteError::UnterminatedFence {
60 id: entity.id.to_string(),
61 section,
62 fence,
63 });
64 }
65
66 let content = generate_markdown(entity, schema);
68 fs::write(&full_path, content)?;
69
70 Ok(full_path)
71}
72
73#[derive(Debug, thiserror::Error)]
74pub enum WriteError {
75 #[error("io error: {0}")]
76 Io(#[from] std::io::Error),
77 #[error("path traversal detected: {0}")]
78 PathTraversal(String),
79 #[error("entity has no file_path: {0}")]
80 NoFilePath(String),
81 #[error(
86 "entity '{id}' section '{section}' ends inside an unterminated `{fence}` code fence — \
87 regenerating the file would bury the sections it absorbed. Repair it through the \
88 engine first: replace section '{section}' with a corrected body."
89 )]
90 UnterminatedFence {
91 id: String,
92 section: String,
93 fence: String,
94 },
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100 use crate::entity::{EntityId, MetadataValue};
101 use indexmap::IndexMap;
102 use memstead_schema::{builtin_names, type_by_name};
103 use tempfile::TempDir;
104
105 fn make_test_entity(name: &str) -> Entity {
106 let mut metadata = IndexMap::new();
107 metadata.insert("level".to_string(), MetadataValue::String("M0".to_string()));
108 metadata.insert(
109 "created_date".to_string(),
110 MetadataValue::String("2026-01-15".to_string()),
111 );
112 metadata.insert(
113 "last_modified".to_string(),
114 MetadataValue::String("2026-04-12".to_string()),
115 );
116 metadata.insert(
117 "type".to_string(),
118 MetadataValue::String("spec".to_string()),
119 );
120
121 let mut sections = IndexMap::new();
122 sections.insert("identity".to_string(), "Test identity.".to_string());
123 sections.insert("purpose".to_string(), "Test purpose.".to_string());
124
125 Entity {
126 id: EntityId::new("specs", name),
127 title: name.to_string(),
128 entity_type: "spec".to_string(),
129 mem: "specs".to_string(),
130 file_path: format!("{name}.md"),
131 metadata,
132 sections,
133 relationships: Vec::new(),
134 content_hash: String::new(),
135 stub: false,
136 stub_kind: None,
137 heading_spans: std::collections::HashMap::new(),
138 raw_section_headings: Vec::new(),
139 }
140 }
141
142 fn make_concept_entity(name: &str) -> Entity {
143 let mut metadata = IndexMap::new();
144 metadata.insert(
145 "maturity".to_string(),
146 MetadataValue::String("emerging".to_string()),
147 );
148 metadata.insert(
149 "abstraction_level".to_string(),
150 MetadataValue::String("concrete".to_string()),
151 );
152 metadata.insert(
153 "created_date".to_string(),
154 MetadataValue::String("2026-01-15".to_string()),
155 );
156 metadata.insert(
157 "last_modified".to_string(),
158 MetadataValue::String("2026-04-12".to_string()),
159 );
160 metadata.insert(
161 "type".to_string(),
162 MetadataValue::String("concept".to_string()),
163 );
164
165 let mut sections = IndexMap::new();
166 sections.insert(
167 "definition".to_string(),
168 "A precise mental model of X.".to_string(),
169 );
170 sections.insert(
171 "explanation".to_string(),
172 "How X operates in practice.".to_string(),
173 );
174 sections.insert("boundaries".to_string(), "Not Y, not Z.".to_string());
175 sections.insert(
176 "significance".to_string(),
177 "Foundational for understanding W.".to_string(),
178 );
179
180 Entity {
181 id: EntityId::new("concepts", name),
182 title: name.to_string(),
183 entity_type: "concept".to_string(),
184 mem: "concepts".to_string(),
185 file_path: format!("{name}.md"),
186 metadata,
187 sections,
188 relationships: Vec::new(),
189 content_hash: String::new(),
190 stub: false,
191 stub_kind: None,
192 heading_spans: std::collections::HashMap::new(),
193 raw_section_headings: Vec::new(),
194 }
195 }
196
197 #[test]
198 fn write_entity_creates_file() {
199 let dir = TempDir::new().unwrap();
200 let schema = type_by_name(builtin_names::SPEC).unwrap();
201 let entity = make_test_entity("test-entity");
202
203 let path = write_entity(&entity, dir.path(), &schema).unwrap();
204 assert!(path.exists());
205
206 let content = fs::read_to_string(&path).unwrap();
207 assert!(content.contains("# test-entity"));
208 }
209
210 #[test]
211 fn write_entity_concept_uses_schema_headings_and_order() {
212 let dir = TempDir::new().unwrap();
213 let schema = type_by_name(builtin_names::CONCEPT).unwrap();
214 let entity = make_concept_entity("clarity");
215
216 let path = write_entity(&entity, dir.path(), &schema).unwrap();
217 let content = fs::read_to_string(&path).unwrap();
218
219 assert!(content.contains("## Definition"));
221 assert!(content.contains("## Explanation"));
222 assert!(content.contains("## Boundaries"));
223 assert!(content.contains("## Significance"));
224 assert!(!content.contains("## Identity"));
225 assert!(!content.contains("## Purpose"));
226
227 let def_pos = content.find("## Definition").unwrap();
230 let exp_pos = content.find("## Explanation").unwrap();
231 let bnd_pos = content.find("## Boundaries").unwrap();
232 let sig_pos = content.find("## Significance").unwrap();
233 assert!(def_pos < exp_pos);
234 assert!(exp_pos < bnd_pos);
235 assert!(bnd_pos < sig_pos);
236
237 assert!(content.contains("type: concept"));
239 assert!(content.contains("maturity: emerging"));
240 }
241
242 #[test]
243 fn write_entity_creates_parent_dirs() {
244 let dir = TempDir::new().unwrap();
245 let schema = type_by_name(builtin_names::SPEC).unwrap();
246 let mut entity = make_test_entity("child");
247 entity.file_path = "parent/child.md".to_string();
248
249 let path = write_entity(&entity, dir.path(), &schema).unwrap();
250 assert!(path.exists());
251 assert!(dir.path().join("parent").exists());
252 }
253
254 #[test]
260 fn an_open_fence_is_declined_rather_than_frozen_on_export() {
261 let tmp = TempDir::new().unwrap();
262 let mut entity = make_test_entity("fenced");
263 entity.sections.insert(
264 "identity".to_string(),
265 "intro\n\n```rust\nfn main() {}".to_string(),
266 );
267 let schema = type_by_name(builtin_names::SPEC).unwrap();
268 let err = write_entity(&entity, tmp.path(), schema.as_ref())
269 .expect_err("regenerating this file would bury the absorbed sections");
270 match err {
271 WriteError::UnterminatedFence {
272 ref section,
273 ref fence,
274 ..
275 } => {
276 assert_eq!(section, "identity");
277 assert_eq!(fence, "```");
278 }
279 other => panic!("expected UnterminatedFence, got {other:?}"),
280 }
281 assert!(!tmp.path().join(&entity.file_path).exists());
283 }
284}