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    // Generate markdown and write
50    let content = generate_markdown(entity, schema);
51    fs::write(&full_path, content)?;
52
53    Ok(full_path)
54}
55
56#[derive(Debug, thiserror::Error)]
57pub enum WriteError {
58    #[error("io error: {0}")]
59    Io(#[from] std::io::Error),
60    #[error("path traversal detected: {0}")]
61    PathTraversal(String),
62    #[error("entity has no file_path: {0}")]
63    NoFilePath(String),
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use crate::entity::{EntityId, MetadataValue};
70    use indexmap::IndexMap;
71    use memstead_schema::{builtin_names, type_by_name};
72    use tempfile::TempDir;
73
74    fn make_test_entity(name: &str) -> Entity {
75        let mut metadata = IndexMap::new();
76        metadata.insert("level".to_string(), MetadataValue::String("M0".to_string()));
77        metadata.insert(
78            "created_date".to_string(),
79            MetadataValue::String("2026-01-15".to_string()),
80        );
81        metadata.insert(
82            "last_modified".to_string(),
83            MetadataValue::String("2026-04-12".to_string()),
84        );
85        metadata.insert(
86            "type".to_string(),
87            MetadataValue::String("spec".to_string()),
88        );
89
90        let mut sections = IndexMap::new();
91        sections.insert("identity".to_string(), "Test identity.".to_string());
92        sections.insert("purpose".to_string(), "Test purpose.".to_string());
93
94        Entity {
95            id: EntityId::new("specs", name),
96            title: name.to_string(),
97            entity_type: "spec".to_string(),
98            mem: "specs".to_string(),
99            file_path: format!("{name}.md"),
100            metadata,
101            sections,
102            relationships: Vec::new(),
103            content_hash: String::new(),
104            stub: false,
105            stub_kind: None,
106            heading_spans: std::collections::HashMap::new(),
107            raw_section_headings: Vec::new(),
108        }
109    }
110
111    fn make_concept_entity(name: &str) -> Entity {
112        let mut metadata = IndexMap::new();
113        metadata.insert(
114            "maturity".to_string(),
115            MetadataValue::String("emerging".to_string()),
116        );
117        metadata.insert(
118            "abstraction_level".to_string(),
119            MetadataValue::String("concrete".to_string()),
120        );
121        metadata.insert(
122            "created_date".to_string(),
123            MetadataValue::String("2026-01-15".to_string()),
124        );
125        metadata.insert(
126            "last_modified".to_string(),
127            MetadataValue::String("2026-04-12".to_string()),
128        );
129        metadata.insert(
130            "type".to_string(),
131            MetadataValue::String("concept".to_string()),
132        );
133
134        let mut sections = IndexMap::new();
135        sections.insert(
136            "definition".to_string(),
137            "A precise mental model of X.".to_string(),
138        );
139        sections.insert(
140            "explanation".to_string(),
141            "How X operates in practice.".to_string(),
142        );
143        sections.insert("boundaries".to_string(), "Not Y, not Z.".to_string());
144        sections.insert(
145            "significance".to_string(),
146            "Foundational for understanding W.".to_string(),
147        );
148
149        Entity {
150            id: EntityId::new("concepts", name),
151            title: name.to_string(),
152            entity_type: "concept".to_string(),
153            mem: "concepts".to_string(),
154            file_path: format!("{name}.md"),
155            metadata,
156            sections,
157            relationships: Vec::new(),
158            content_hash: String::new(),
159            stub: false,
160            stub_kind: None,
161            heading_spans: std::collections::HashMap::new(),
162            raw_section_headings: Vec::new(),
163        }
164    }
165
166    #[test]
167    fn write_entity_creates_file() {
168        let dir = TempDir::new().unwrap();
169        let schema = type_by_name(builtin_names::SPEC).unwrap();
170        let entity = make_test_entity("test-entity");
171
172        let path = write_entity(&entity, dir.path(), &schema).unwrap();
173        assert!(path.exists());
174
175        let content = fs::read_to_string(&path).unwrap();
176        assert!(content.contains("# test-entity"));
177    }
178
179    #[test]
180    fn write_entity_concept_uses_schema_headings_and_order() {
181        let dir = TempDir::new().unwrap();
182        let schema = type_by_name(builtin_names::CONCEPT).unwrap();
183        let entity = make_concept_entity("clarity");
184
185        let path = write_entity(&entity, dir.path(), &schema).unwrap();
186        let content = fs::read_to_string(&path).unwrap();
187
188        // Concept headings, not spec headings
189        assert!(content.contains("## Definition"));
190        assert!(content.contains("## Explanation"));
191        assert!(content.contains("## Boundaries"));
192        assert!(content.contains("## Significance"));
193        assert!(!content.contains("## Identity"));
194        assert!(!content.contains("## Purpose"));
195
196        // Sections appear in schema-declared order: definition, explanation,
197        // boundaries, significance
198        let def_pos = content.find("## Definition").unwrap();
199        let exp_pos = content.find("## Explanation").unwrap();
200        let bnd_pos = content.find("## Boundaries").unwrap();
201        let sig_pos = content.find("## Significance").unwrap();
202        assert!(def_pos < exp_pos);
203        assert!(exp_pos < bnd_pos);
204        assert!(bnd_pos < sig_pos);
205
206        // Frontmatter uses concept type name
207        assert!(content.contains("type: concept"));
208        assert!(content.contains("maturity: emerging"));
209    }
210
211    #[test]
212    fn write_entity_creates_parent_dirs() {
213        let dir = TempDir::new().unwrap();
214        let schema = type_by_name(builtin_names::SPEC).unwrap();
215        let mut entity = make_test_entity("child");
216        entity.file_path = "parent/child.md".to_string();
217
218        let path = write_entity(&entity, dir.path(), &schema).unwrap();
219        assert!(path.exists());
220        assert!(dir.path().join("parent").exists());
221    }
222}