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        }
108    }
109
110    fn make_concept_entity(name: &str) -> Entity {
111        let mut metadata = IndexMap::new();
112        metadata.insert(
113            "maturity".to_string(),
114            MetadataValue::String("emerging".to_string()),
115        );
116        metadata.insert(
117            "abstraction_level".to_string(),
118            MetadataValue::String("concrete".to_string()),
119        );
120        metadata.insert(
121            "created_date".to_string(),
122            MetadataValue::String("2026-01-15".to_string()),
123        );
124        metadata.insert(
125            "last_modified".to_string(),
126            MetadataValue::String("2026-04-12".to_string()),
127        );
128        metadata.insert(
129            "type".to_string(),
130            MetadataValue::String("concept".to_string()),
131        );
132
133        let mut sections = IndexMap::new();
134        sections.insert(
135            "definition".to_string(),
136            "A precise mental model of X.".to_string(),
137        );
138        sections.insert(
139            "explanation".to_string(),
140            "How X operates in practice.".to_string(),
141        );
142        sections.insert("boundaries".to_string(), "Not Y, not Z.".to_string());
143        sections.insert(
144            "significance".to_string(),
145            "Foundational for understanding W.".to_string(),
146        );
147
148        Entity {
149            id: EntityId::new("concepts", name),
150            title: name.to_string(),
151            entity_type: "concept".to_string(),
152            mem: "concepts".to_string(),
153            file_path: format!("{name}.md"),
154            metadata,
155            sections,
156            relationships: Vec::new(),
157            content_hash: String::new(),
158            stub: false,
159            stub_kind: None,
160            heading_spans: std::collections::HashMap::new(),
161        }
162    }
163
164    #[test]
165    fn write_entity_creates_file() {
166        let dir = TempDir::new().unwrap();
167        let schema = type_by_name(builtin_names::SPEC).unwrap();
168        let entity = make_test_entity("test-entity");
169
170        let path = write_entity(&entity, dir.path(), &schema).unwrap();
171        assert!(path.exists());
172
173        let content = fs::read_to_string(&path).unwrap();
174        assert!(content.contains("# test-entity"));
175    }
176
177    #[test]
178    fn write_entity_concept_uses_schema_headings_and_order() {
179        let dir = TempDir::new().unwrap();
180        let schema = type_by_name(builtin_names::CONCEPT).unwrap();
181        let entity = make_concept_entity("clarity");
182
183        let path = write_entity(&entity, dir.path(), &schema).unwrap();
184        let content = fs::read_to_string(&path).unwrap();
185
186        // Concept headings, not spec headings
187        assert!(content.contains("## Definition"));
188        assert!(content.contains("## Explanation"));
189        assert!(content.contains("## Boundaries"));
190        assert!(content.contains("## Significance"));
191        assert!(!content.contains("## Identity"));
192        assert!(!content.contains("## Purpose"));
193
194        // Sections appear in schema-declared order: definition, explanation,
195        // boundaries, significance
196        let def_pos = content.find("## Definition").unwrap();
197        let exp_pos = content.find("## Explanation").unwrap();
198        let bnd_pos = content.find("## Boundaries").unwrap();
199        let sig_pos = content.find("## Significance").unwrap();
200        assert!(def_pos < exp_pos);
201        assert!(exp_pos < bnd_pos);
202        assert!(bnd_pos < sig_pos);
203
204        // Frontmatter uses concept type name
205        assert!(content.contains("type: concept"));
206        assert!(content.contains("maturity: emerging"));
207    }
208
209    #[test]
210    fn write_entity_creates_parent_dirs() {
211        let dir = TempDir::new().unwrap();
212        let schema = type_by_name(builtin_names::SPEC).unwrap();
213        let mut entity = make_test_entity("child");
214        entity.file_path = "parent/child.md".to_string();
215
216        let path = write_entity(&entity, dir.path(), &schema).unwrap();
217        assert!(path.exists());
218        assert!(dir.path().join("parent").exists());
219    }
220}