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 }
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 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 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 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}