memstead_base/entity/
writer.rs1use 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 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 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 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 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}