mdvault_core/domain/
creator.rs1use std::collections::HashMap;
7use std::fs;
8use std::path::PathBuf;
9
10use chrono::Local;
11
12use super::NoteType;
13use super::context::CreationContext;
14use super::traits::{DomainError, DomainResult, NoteBehavior};
15use crate::frontmatter::{Frontmatter, ParsedDocument, serialize_with_order};
16use crate::templates::engine::render_with_ref_date as render_template;
17use crate::types::scaffolding::generate_scaffolding;
18
19#[derive(Debug)]
21pub struct CreationResult {
22 pub path: PathBuf,
24 pub generated_id: Option<String>,
26 pub type_name: String,
28}
29
30pub struct NoteCreator {
32 note_type: NoteType,
33}
34
35impl NoteCreator {
36 pub fn new(note_type: NoteType) -> Self {
38 Self { note_type }
39 }
40
41 pub fn behavior(&self) -> &dyn NoteBehavior {
43 self.note_type.behavior()
44 }
45
46 pub fn create(&self, ctx: &mut CreationContext) -> DomainResult<CreationResult> {
58 let behavior = self.note_type.behavior();
59
60 behavior.before_create(ctx)?;
65
66 let output_path = if let Some(ref path) = ctx.output_path {
68 path.clone()
69 } else {
70 let path = behavior.output_path(ctx)?;
71 ctx.output_path = Some(path.clone());
72 path
73 };
74
75 if output_path.exists() {
77 return Err(DomainError::Other(format!(
78 "Refusing to overwrite existing file: {}",
79 output_path.display()
80 )));
81 }
82
83 let content = self.generate_content(ctx)?;
85
86 let content = ensure_core_metadata(&content, ctx)?;
88
89 if let Some(parent) = output_path.parent() {
94 fs::create_dir_all(parent).map_err(DomainError::Io)?;
95 }
96 fs::write(&output_path, &content).map_err(DomainError::Io)?;
97
98 behavior.after_create(ctx, &content)?;
100
101 let generated_id = ctx
103 .core_metadata
104 .task_id
105 .clone()
106 .or_else(|| ctx.core_metadata.project_id.clone());
107
108 Ok(CreationResult {
109 path: output_path,
110 generated_id,
111 type_name: ctx.type_name.clone(),
112 })
113 }
114
115 fn generate_content(&self, ctx: &CreationContext) -> DomainResult<String> {
120 if let Some(ref template) = ctx.template {
121 let render_ctx = self.build_render_context(ctx);
123 render_template(template, &render_ctx, ctx.reference_date).map_err(|e| {
124 DomainError::Other(format!("Failed to render template: {}", e))
125 })
126 } else {
127 let title_for_scaffolding =
131 ctx.core_metadata.title.as_ref().unwrap_or(&ctx.title);
132 Ok(generate_scaffolding(
133 &ctx.type_name,
134 ctx.typedef.as_deref(),
135 title_for_scaffolding,
136 &ctx.vars,
137 ))
138 }
139 }
140
141 fn build_render_context(&self, ctx: &CreationContext) -> HashMap<String, String> {
143 let mut render_ctx = HashMap::new();
144
145 let now = Local::now();
147 render_ctx.insert("date".into(), now.format("%Y-%m-%d").to_string());
148 render_ctx.insert("time".into(), now.format("%H:%M").to_string());
149 render_ctx.insert("datetime".into(), now.to_rfc3339());
150 render_ctx.insert("today".into(), now.format("%Y-%m-%d").to_string());
151 render_ctx.insert("now".into(), now.to_rfc3339());
152
153 render_ctx.extend(ctx.vars.clone());
155
156 render_ctx.insert(
158 "vault_root".into(),
159 ctx.config.vault_root.to_string_lossy().to_string(),
160 );
161 render_ctx.insert(
162 "templates_dir".into(),
163 ctx.config.templates_dir.to_string_lossy().to_string(),
164 );
165
166 if let Some(ref template) = ctx.template {
168 render_ctx.insert("template_name".into(), template.logical_name.clone());
169 render_ctx.insert(
170 "template_path".into(),
171 template.path.to_string_lossy().to_string(),
172 );
173 }
174
175 if let Some(ref output_path) = ctx.output_path {
177 render_ctx
178 .insert("output_path".into(), output_path.to_string_lossy().to_string());
179 if let Some(name) = output_path.file_name().and_then(|s| s.to_str()) {
180 render_ctx.insert("output_filename".into(), name.to_string());
181 }
182 if let Some(parent) = output_path.parent() {
183 render_ctx
184 .insert("output_dir".into(), parent.to_string_lossy().to_string());
185 }
186 }
187
188 if let Some(ref id) = ctx.core_metadata.task_id {
190 render_ctx.insert("task-id".into(), id.clone());
191 }
192 if let Some(ref id) = ctx.core_metadata.project_id {
193 render_ctx.insert("project-id".into(), id.clone());
194 }
195 if let Some(ref project) = ctx.core_metadata.project {
196 render_ctx.insert("project".into(), project.clone());
197 }
198
199 render_ctx
200 }
201}
202
203fn ensure_core_metadata(content: &str, ctx: &CreationContext) -> DomainResult<String> {
209 use crate::frontmatter::parse;
210
211 let parsed = parse(content)
212 .map_err(|e| DomainError::Other(format!("Failed to parse frontmatter: {}", e)))?;
213
214 let mut fields = parsed.frontmatter.map(|fm| fm.fields).unwrap_or_default();
215
216 let core = &ctx.core_metadata;
218
219 if let Some(ref t) = core.note_type {
220 fields.insert("type".into(), serde_yaml::Value::String(t.clone()));
221 }
222 if let Some(ref t) = core.title {
223 fields.insert("title".into(), serde_yaml::Value::String(t.clone()));
224 }
225 if let Some(ref id) = core.project_id {
226 fields.insert("project-id".into(), serde_yaml::Value::String(id.clone()));
227 }
228 if let Some(ref id) = core.task_id {
229 fields.insert("task-id".into(), serde_yaml::Value::String(id.clone()));
230 }
231 if let Some(counter) = core.task_counter {
232 fields.insert("task_counter".into(), serde_yaml::Value::Number(counter.into()));
233 }
234 if let Some(ref p) = core.project {
235 fields.insert("project".into(), serde_yaml::Value::String(p.clone()));
236 }
237 if let Some(ref d) = core.date {
238 fields.insert("date".into(), serde_yaml::Value::String(d.clone()));
239 }
240 if let Some(ref w) = core.week {
241 fields.insert("week".into(), serde_yaml::Value::String(w.clone()));
242 }
243
244 let doc =
246 ParsedDocument { frontmatter: Some(Frontmatter { fields }), body: parsed.body };
247
248 let order = ctx.typedef.as_ref().and_then(|td| td.frontmatter_order.as_deref());
249 Ok(serialize_with_order(&doc, order))
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use crate::config::types::ResolvedConfig;
256 use crate::types::TypeRegistry;
257 use std::path::PathBuf;
258 use tempfile::tempdir;
259
260 fn make_test_config(vault_root: PathBuf) -> ResolvedConfig {
261 ResolvedConfig {
262 active_profile: "test".into(),
263 vault_root: vault_root.clone(),
264 templates_dir: vault_root.join(".mdvault/templates"),
265 captures_dir: vault_root.join(".mdvault/captures"),
266 macros_dir: vault_root.join(".mdvault/macros"),
267 typedefs_dir: vault_root.join(".mdvault/typedefs"),
268 excluded_folders: vec![],
269 security: Default::default(),
270 logging: Default::default(),
271 activity: Default::default(),
272 }
273 }
274
275 #[test]
276 fn test_ensure_core_metadata() {
277 let content =
278 "---\ntype: wrong\ntitle: Wrong Title\ncustom: value\n---\n# Body\n";
279
280 let tmp = tempdir().unwrap();
281 let config = make_test_config(tmp.path().to_path_buf());
282 let registry = TypeRegistry::new();
283
284 let mut ctx = CreationContext::new("task", "Correct Title", &config, ®istry);
285 ctx.core_metadata.task_id = Some("TST-001".into());
286 ctx.core_metadata.project = Some("TST".into());
287
288 let result = ensure_core_metadata(content, &ctx).unwrap();
289
290 assert!(result.contains("type: task"));
291 assert!(result.contains("title: Correct Title"));
292 assert!(result.contains("task-id: TST-001"));
293 assert!(result.contains("project: TST"));
294 assert!(result.contains("custom: value")); }
296}