Skip to main content

memstead_base/entity/
writer.rs

1//! Entity → markdown projection writer for the export paths.
2//!
3//! [`write_entity`] renders an entity to its `{slug}.md` file under a
4//! mem directory. It is used by the disk-export and working-tree
5//! export paths (`Engine`'s archive export and the git-branch
6//! `ops::export`), not by the store-mutation pipeline — live mutations
7//! persist through the storage backend's `write_entity`, and entities
8//! live flat at `{mem}/{slug}.md` (no PART_OF-hierarchy path
9//! computation or file moves).
10
11use std::fs;
12use std::path::{Path, PathBuf};
13
14use memstead_schema::TypeDefinition;
15
16use super::Entity;
17use super::generator::generate_markdown;
18
19/// Write an entity to its file path under the mem directory.
20/// Creates parent directories as needed.
21/// Returns the absolute path where the file was written.
22pub 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    // Verify path doesn't escape mem dir
34    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    // Create parent directories
45    if let Some(parent) = full_path.parent() {
46        fs::create_dir_all(parent)?;
47    }
48
49    // The export twin of the mutation path's `render_for_write` guard. An
50    // entity whose stored body ends inside an unterminated fence has already
51    // absorbed the sections after it, and the generator appends its closer
52    // AFTER those bytes: regenerating the file here would seal them inside a
53    // legitimately fenced block, which is the same unrecoverable freeze the
54    // mutation verbs refuse (04/02, criterion 5). Export declines the entity
55    // and says so rather than performing that write.
56    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    // Generate markdown and write
67    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    /// The entity's stored body ends inside an unterminated code fence, so
82    /// regenerating its file would bury the sections that fence absorbed.
83    /// Shares its condition (and its recovery: replace the named section
84    /// through the engine) with `UNTERMINATED_FENCE_IN_STORED_BODY`.
85    #[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        // Concept headings, not spec headings
220        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        // Sections appear in schema-declared order: definition, explanation,
228        // boundaries, significance
229        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        // Frontmatter uses concept type name
238        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    /// The export twin of the mutation guard. The grade that closed 04/02's
255    /// criterion 5 found this path still unguarded: the mutation verbs all
256    /// refused, and `export --format markdown` walked past them to the same
257    /// freeze. Both export loops reach bytes through this function, so the
258    /// guard belongs here rather than in each loop.
259    #[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        // And nothing was written: a declined export leaves no file behind.
282        assert!(!tmp.path().join(&entity.file_path).exists());
283    }
284}