Skip to main content

mdvault_core/domain/
creator.rs

1//! Note creation orchestrator.
2//!
3//! The `NoteCreator` provides a unified flow for creating notes of any type,
4//! using polymorphic dispatch to handle type-specific behaviors.
5
6use 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/// Result of a successful note creation.
20#[derive(Debug)]
21pub struct CreationResult {
22    /// Path where the note was written.
23    pub path: PathBuf,
24    /// Generated ID (if applicable, e.g., task-id, project-id).
25    pub generated_id: Option<String>,
26    /// The type of note created.
27    pub type_name: String,
28}
29
30/// Orchestrates the note creation flow.
31pub struct NoteCreator {
32    note_type: NoteType,
33}
34
35impl NoteCreator {
36    /// Create a new NoteCreator for the given note type.
37    pub fn new(note_type: NoteType) -> Self {
38        Self { note_type }
39    }
40
41    /// Get the underlying behavior.
42    pub fn behavior(&self) -> &dyn NoteBehavior {
43        self.note_type.behavior()
44    }
45
46    /// Execute the full note creation flow.
47    ///
48    /// Flow:
49    /// 1. Collect type-specific prompts
50    /// 2. Run before_create (sets IDs, counters, etc.)
51    /// 3. Resolve output path
52    /// 4. Generate content (template or scaffolding)
53    /// 5. Ensure core metadata is present
54    /// 6. Validate content
55    /// 7. Write to disk
56    /// 8. Run after_create (logging, hooks, reindex)
57    pub fn create(&self, ctx: &mut CreationContext) -> DomainResult<CreationResult> {
58        let behavior = self.note_type.behavior();
59
60        // Step 1: Type-specific prompts would be collected here
61        // (In practice, prompts are handled by the CLI layer)
62
63        // Step 2: Before create - sets IDs, updates counters, etc.
64        behavior.before_create(ctx)?;
65
66        // Step 3: Resolve output path (use pre-set path if provided, otherwise resolve)
67        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        // Check if file already exists
76        if output_path.exists() {
77            return Err(DomainError::Other(format!(
78                "Refusing to overwrite existing file: {}",
79                output_path.display()
80            )));
81        }
82
83        // Step 4: Generate content
84        let content = self.generate_content(ctx)?;
85
86        // Step 5: Ensure core metadata is preserved
87        let content = ensure_core_metadata(&content, ctx)?;
88
89        // Step 6: Validation would happen here
90        // (Deferred to integration phase)
91
92        // Step 7: Write to disk
93        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        // Step 8: After create - logging, hooks, reindex
99        behavior.after_create(ctx, &content)?;
100
101        // Build result
102        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    /// Generate the note content.
116    ///
117    /// If a template is provided in the context, renders it with variable substitution.
118    /// Otherwise, generates scaffolding from the type definition.
119    fn generate_content(&self, ctx: &CreationContext) -> DomainResult<String> {
120        if let Some(ref template) = ctx.template {
121            // Build render context with standard variables
122            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            // Fall back to scaffolding generation
128            // Use evaluated title from core_metadata if available (e.g., for daily/weekly
129            // notes where date expressions like "today + 7d" are evaluated to actual dates)
130            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    /// Build a render context with standard template variables.
142    fn build_render_context(&self, ctx: &CreationContext) -> HashMap<String, String> {
143        let mut render_ctx = HashMap::new();
144
145        // Add date/time defaults FIRST (behaviours can override these)
146        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        // Overlay user/behaviour vars — these WIN over defaults
154        render_ctx.extend(ctx.vars.clone());
155
156        // Add config paths
157        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        // Add template info if available
167        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        // Add output path info if available
176        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        // Add core metadata fields
189        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
203/// Ensure core metadata fields are present in the content.
204///
205/// This function parses the frontmatter and ensures that core fields
206/// (type, title, task-id, project-id, etc.) are set correctly,
207/// overwriting any values that may have been modified by templates or hooks.
208fn 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    // Apply core metadata (these are authoritative)
217    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    // Rebuild content using serializer with order
245    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, &registry);
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")); // Non-core fields preserved
295    }
296}