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::templates::engine::render_with_ref_date as render_template;
16use crate::types::scaffolding::generate_scaffolding;
17
18#[derive(Debug)]
20pub struct CreationResult {
21 pub path: PathBuf,
23 pub generated_id: Option<String>,
25 pub type_name: String,
27 pub content: String,
29}
30
31pub struct NoteCreator {
33 note_type: NoteType,
34}
35
36impl NoteCreator {
37 pub fn new(note_type: NoteType) -> Self {
39 Self { note_type }
40 }
41
42 pub fn behavior(&self) -> &dyn NoteBehavior {
44 self.note_type.behavior()
45 }
46
47 pub fn create(&self, ctx: &mut CreationContext) -> DomainResult<CreationResult> {
59 let behavior = self.note_type.behavior();
60
61 behavior.before_create(ctx)?;
66
67 let output_path = if let Some(ref path) = ctx.output_path {
69 path.clone()
70 } else {
71 let path = behavior.output_path(ctx)?;
72 ctx.output_path = Some(path.clone());
73 path
74 };
75
76 if output_path.exists() {
78 return Err(DomainError::Other(format!(
79 "Refusing to overwrite existing file: {}",
80 output_path.display()
81 )));
82 }
83
84 let content = self.generate_content(ctx)?;
86
87 let order = ctx.typedef.as_ref().and_then(|td| td.frontmatter_order.as_deref());
89 let content =
90 ctx.core_metadata.apply_to_content(&content, order).map_err(|e| {
91 DomainError::Other(format!("Failed to apply core metadata: {}", e))
92 })?;
93
94 if let Some(parent) = output_path.parent() {
99 fs::create_dir_all(parent).map_err(DomainError::Io)?;
100 }
101 fs::write(&output_path, &content).map_err(DomainError::Io)?;
102
103 if let Err(e) = super::services::set_updated_at(&output_path) {
105 tracing::warn!("Failed to set updated_at on new note: {}", e);
106 }
107
108 let generated_id = ctx
112 .core_metadata
113 .task_id
114 .clone()
115 .or_else(|| ctx.core_metadata.project_id.clone());
116
117 Ok(CreationResult {
118 path: output_path,
119 generated_id,
120 type_name: ctx.type_name.clone(),
121 content,
122 })
123 }
124
125 fn generate_content(&self, ctx: &CreationContext) -> DomainResult<String> {
130 if let Some(ref template) = ctx.template {
131 let render_ctx = self.build_render_context(ctx);
133 render_template(template, &render_ctx, ctx.reference_date).map_err(|e| {
134 DomainError::Other(format!("Failed to render template: {}", e))
135 })
136 } else {
137 let title_for_scaffolding =
141 ctx.core_metadata.title.as_ref().unwrap_or(&ctx.title);
142 Ok(generate_scaffolding(
143 &ctx.type_name,
144 ctx.typedef.as_deref(),
145 title_for_scaffolding,
146 &ctx.vars,
147 ))
148 }
149 }
150
151 fn build_render_context(&self, ctx: &CreationContext) -> HashMap<String, String> {
153 let mut render_ctx = HashMap::new();
154
155 let now = Local::now();
157 render_ctx.insert("date".into(), now.format("%Y-%m-%d").to_string());
158 render_ctx.insert("time".into(), now.format("%H:%M").to_string());
159 render_ctx.insert("datetime".into(), now.to_rfc3339());
160 render_ctx.insert("today".into(), now.format("%Y-%m-%d").to_string());
161 render_ctx.insert("now".into(), now.to_rfc3339());
162
163 render_ctx.extend(ctx.vars.clone());
165
166 render_ctx.insert(
168 "vault_root".into(),
169 ctx.config.vault_root.to_string_lossy().to_string(),
170 );
171 render_ctx.insert(
172 "templates_dir".into(),
173 ctx.config.templates_dir.to_string_lossy().to_string(),
174 );
175
176 if let Some(ref template) = ctx.template {
178 render_ctx.insert("template_name".into(), template.logical_name.clone());
179 render_ctx.insert(
180 "template_path".into(),
181 template.path.to_string_lossy().to_string(),
182 );
183 }
184
185 if let Some(ref output_path) = ctx.output_path {
187 render_ctx
188 .insert("output_path".into(), output_path.to_string_lossy().to_string());
189 if let Some(name) = output_path.file_name().and_then(|s| s.to_str()) {
190 render_ctx.insert("output_filename".into(), name.to_string());
191 }
192 if let Some(parent) = output_path.parent() {
193 render_ctx
194 .insert("output_dir".into(), parent.to_string_lossy().to_string());
195 }
196 }
197
198 if let Some(ref id) = ctx.core_metadata.task_id {
200 render_ctx.insert("task-id".into(), id.clone());
201 }
202 if let Some(ref id) = ctx.core_metadata.project_id {
203 render_ctx.insert("project-id".into(), id.clone());
204 }
205 if let Some(ref project) = ctx.core_metadata.project {
206 render_ctx.insert("project".into(), project.clone());
207 }
208
209 render_ctx
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use crate::domain::CoreMetadata;
216
217 #[test]
218 fn test_apply_core_metadata() {
219 let content =
220 "---\ntype: wrong\ntitle: Wrong Title\ncustom: value\n---\n# Body\n";
221
222 let core = CoreMetadata {
223 note_type: Some("task".into()),
224 title: Some("Correct Title".into()),
225 task_id: Some("TST-001".into()),
226 project: Some("TST".into()),
227 ..Default::default()
228 };
229
230 let result = core.apply_to_content(content, None).unwrap();
231
232 assert!(result.contains("type: task"));
233 assert!(result.contains("title: Correct Title"));
234 assert!(result.contains("task-id: TST-001"));
235 assert!(result.contains("project: TST"));
236 assert!(result.contains("custom: value")); }
238}