use std::fs;
use std::path::{Path, PathBuf};
use memstead_schema::TypeDefinition;
use super::Entity;
use super::generator::generate_markdown;
pub fn write_entity(
entity: &Entity,
mem_dir: &Path,
schema: &TypeDefinition,
) -> Result<PathBuf, WriteError> {
if entity.file_path.is_empty() {
return Err(WriteError::NoFilePath(entity.id.to_string()));
}
let full_path = mem_dir.join(&entity.file_path);
let resolved = full_path
.canonicalize()
.unwrap_or_else(|_| full_path.clone());
let resolved_root = mem_dir
.canonicalize()
.unwrap_or_else(|_| mem_dir.to_path_buf());
if !resolved.starts_with(&resolved_root) && full_path != mem_dir.join(&entity.file_path) {
return Err(WriteError::PathTraversal(entity.file_path.clone()));
}
if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent)?;
}
if let Some((section, fence)) = entity.sections.iter().find_map(|(k, v)| {
crate::markdown::closing_fence_if_unterminated(v.trim()).map(|f| (k.clone(), f))
}) {
return Err(WriteError::UnterminatedFence {
id: entity.id.to_string(),
section,
fence,
});
}
let content = generate_markdown(entity, schema);
fs::write(&full_path, content)?;
Ok(full_path)
}
#[derive(Debug, thiserror::Error)]
pub enum WriteError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("path traversal detected: {0}")]
PathTraversal(String),
#[error("entity has no file_path: {0}")]
NoFilePath(String),
#[error(
"entity '{id}' section '{section}' ends inside an unterminated `{fence}` code fence — \
regenerating the file would bury the sections it absorbed. Repair it through the \
engine first: replace section '{section}' with a corrected body."
)]
UnterminatedFence {
id: String,
section: String,
fence: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entity::{EntityId, MetadataValue};
use indexmap::IndexMap;
use memstead_schema::{builtin_names, type_by_name};
use tempfile::TempDir;
fn make_test_entity(name: &str) -> Entity {
let mut metadata = IndexMap::new();
metadata.insert("level".to_string(), MetadataValue::String("M0".to_string()));
metadata.insert(
"created_date".to_string(),
MetadataValue::String("2026-01-15".to_string()),
);
metadata.insert(
"last_modified".to_string(),
MetadataValue::String("2026-04-12".to_string()),
);
metadata.insert(
"type".to_string(),
MetadataValue::String("spec".to_string()),
);
let mut sections = IndexMap::new();
sections.insert("identity".to_string(), "Test identity.".to_string());
sections.insert("purpose".to_string(), "Test purpose.".to_string());
Entity {
id: EntityId::new("specs", name),
title: name.to_string(),
entity_type: "spec".to_string(),
mem: "specs".to_string(),
file_path: format!("{name}.md"),
metadata,
sections,
relationships: Vec::new(),
content_hash: String::new(),
stub: false,
stub_kind: None,
heading_spans: std::collections::HashMap::new(),
raw_section_headings: Vec::new(),
}
}
fn make_concept_entity(name: &str) -> Entity {
let mut metadata = IndexMap::new();
metadata.insert(
"maturity".to_string(),
MetadataValue::String("emerging".to_string()),
);
metadata.insert(
"abstraction_level".to_string(),
MetadataValue::String("concrete".to_string()),
);
metadata.insert(
"created_date".to_string(),
MetadataValue::String("2026-01-15".to_string()),
);
metadata.insert(
"last_modified".to_string(),
MetadataValue::String("2026-04-12".to_string()),
);
metadata.insert(
"type".to_string(),
MetadataValue::String("concept".to_string()),
);
let mut sections = IndexMap::new();
sections.insert(
"definition".to_string(),
"A precise mental model of X.".to_string(),
);
sections.insert(
"explanation".to_string(),
"How X operates in practice.".to_string(),
);
sections.insert("boundaries".to_string(), "Not Y, not Z.".to_string());
sections.insert(
"significance".to_string(),
"Foundational for understanding W.".to_string(),
);
Entity {
id: EntityId::new("concepts", name),
title: name.to_string(),
entity_type: "concept".to_string(),
mem: "concepts".to_string(),
file_path: format!("{name}.md"),
metadata,
sections,
relationships: Vec::new(),
content_hash: String::new(),
stub: false,
stub_kind: None,
heading_spans: std::collections::HashMap::new(),
raw_section_headings: Vec::new(),
}
}
#[test]
fn write_entity_creates_file() {
let dir = TempDir::new().unwrap();
let schema = type_by_name(builtin_names::SPEC).unwrap();
let entity = make_test_entity("test-entity");
let path = write_entity(&entity, dir.path(), &schema).unwrap();
assert!(path.exists());
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("# test-entity"));
}
#[test]
fn write_entity_concept_uses_schema_headings_and_order() {
let dir = TempDir::new().unwrap();
let schema = type_by_name(builtin_names::CONCEPT).unwrap();
let entity = make_concept_entity("clarity");
let path = write_entity(&entity, dir.path(), &schema).unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("## Definition"));
assert!(content.contains("## Explanation"));
assert!(content.contains("## Boundaries"));
assert!(content.contains("## Significance"));
assert!(!content.contains("## Identity"));
assert!(!content.contains("## Purpose"));
let def_pos = content.find("## Definition").unwrap();
let exp_pos = content.find("## Explanation").unwrap();
let bnd_pos = content.find("## Boundaries").unwrap();
let sig_pos = content.find("## Significance").unwrap();
assert!(def_pos < exp_pos);
assert!(exp_pos < bnd_pos);
assert!(bnd_pos < sig_pos);
assert!(content.contains("type: concept"));
assert!(content.contains("maturity: emerging"));
}
#[test]
fn write_entity_creates_parent_dirs() {
let dir = TempDir::new().unwrap();
let schema = type_by_name(builtin_names::SPEC).unwrap();
let mut entity = make_test_entity("child");
entity.file_path = "parent/child.md".to_string();
let path = write_entity(&entity, dir.path(), &schema).unwrap();
assert!(path.exists());
assert!(dir.path().join("parent").exists());
}
#[test]
fn an_open_fence_is_declined_rather_than_frozen_on_export() {
let tmp = TempDir::new().unwrap();
let mut entity = make_test_entity("fenced");
entity.sections.insert(
"identity".to_string(),
"intro\n\n```rust\nfn main() {}".to_string(),
);
let schema = type_by_name(builtin_names::SPEC).unwrap();
let err = write_entity(&entity, tmp.path(), schema.as_ref())
.expect_err("regenerating this file would bury the absorbed sections");
match err {
WriteError::UnterminatedFence {
ref section,
ref fence,
..
} => {
assert_eq!(section, "identity");
assert_eq!(fence, "```");
}
other => panic!("expected UnterminatedFence, got {other:?}"),
}
assert!(!tmp.path().join(&entity.file_path).exists());
}
}