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::templates::engine::render_with_ref_date as render_template;
16use crate::types::scaffolding::generate_scaffolding;
17
18/// Result of a successful note creation.
19#[derive(Debug)]
20pub struct CreationResult {
21    /// Path where the note was written.
22    pub path: PathBuf,
23    /// Generated ID (if applicable, e.g., task-id, project-id).
24    pub generated_id: Option<String>,
25    /// The type of note created.
26    pub type_name: String,
27    /// The final rendered content that was written to disk.
28    pub content: String,
29}
30
31/// Orchestrates the note creation flow.
32pub struct NoteCreator {
33    note_type: NoteType,
34}
35
36impl NoteCreator {
37    /// Create a new NoteCreator for the given note type.
38    pub fn new(note_type: NoteType) -> Self {
39        Self { note_type }
40    }
41
42    /// Get the underlying behavior.
43    pub fn behavior(&self) -> &dyn NoteBehavior {
44        self.note_type.behavior()
45    }
46
47    /// Execute the full note creation flow.
48    ///
49    /// Flow:
50    /// 1. Collect type-specific prompts
51    /// 2. Run before_create (sets IDs, counters, etc.)
52    /// 3. Resolve output path
53    /// 4. Generate content (template or scaffolding)
54    /// 5. Ensure core metadata is present
55    /// 6. Validate content
56    /// 7. Write to disk
57    /// 8. Run after_create (logging, hooks, reindex)
58    pub fn create(&self, ctx: &mut CreationContext) -> DomainResult<CreationResult> {
59        let behavior = self.note_type.behavior();
60
61        // Step 1: Type-specific prompts would be collected here
62        // (In practice, prompts are handled by the CLI layer)
63
64        // Step 2: Before create - sets IDs, updates counters, etc.
65        behavior.before_create(ctx)?;
66
67        // Step 3: Resolve output path (use pre-set path if provided, otherwise resolve)
68        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        // Check if file already exists
77        if output_path.exists() {
78            return Err(DomainError::Other(format!(
79                "Refusing to overwrite existing file: {}",
80                output_path.display()
81            )));
82        }
83
84        // Step 4: Generate content
85        let content = self.generate_content(ctx)?;
86
87        // Step 5: Ensure core metadata is preserved
88        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        // Step 6: Validation would happen here
95        // (Deferred to integration phase)
96
97        // Step 7: Write to disk
98        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        // Note: after_create is called by the CLI layer (after hooks)
104
105        // Build result
106        let generated_id = ctx
107            .core_metadata
108            .task_id
109            .clone()
110            .or_else(|| ctx.core_metadata.project_id.clone());
111
112        Ok(CreationResult {
113            path: output_path,
114            generated_id,
115            type_name: ctx.type_name.clone(),
116            content,
117        })
118    }
119
120    /// Generate the note content.
121    ///
122    /// If a template is provided in the context, renders it with variable substitution.
123    /// Otherwise, generates scaffolding from the type definition.
124    fn generate_content(&self, ctx: &CreationContext) -> DomainResult<String> {
125        if let Some(ref template) = ctx.template {
126            // Build render context with standard variables
127            let render_ctx = self.build_render_context(ctx);
128            render_template(template, &render_ctx, ctx.reference_date).map_err(|e| {
129                DomainError::Other(format!("Failed to render template: {}", e))
130            })
131        } else {
132            // Fall back to scaffolding generation
133            // Use evaluated title from core_metadata if available (e.g., for daily/weekly
134            // notes where date expressions like "today + 7d" are evaluated to actual dates)
135            let title_for_scaffolding =
136                ctx.core_metadata.title.as_ref().unwrap_or(&ctx.title);
137            Ok(generate_scaffolding(
138                &ctx.type_name,
139                ctx.typedef.as_deref(),
140                title_for_scaffolding,
141                &ctx.vars,
142            ))
143        }
144    }
145
146    /// Build a render context with standard template variables.
147    fn build_render_context(&self, ctx: &CreationContext) -> HashMap<String, String> {
148        let mut render_ctx = HashMap::new();
149
150        // Add date/time defaults FIRST (behaviours can override these)
151        let now = Local::now();
152        render_ctx.insert("date".into(), now.format("%Y-%m-%d").to_string());
153        render_ctx.insert("time".into(), now.format("%H:%M").to_string());
154        render_ctx.insert("datetime".into(), now.to_rfc3339());
155        render_ctx.insert("today".into(), now.format("%Y-%m-%d").to_string());
156        render_ctx.insert("now".into(), now.to_rfc3339());
157
158        // Overlay user/behaviour vars — these WIN over defaults
159        render_ctx.extend(ctx.vars.clone());
160
161        // Add config paths
162        render_ctx.insert(
163            "vault_root".into(),
164            ctx.config.vault_root.to_string_lossy().to_string(),
165        );
166        render_ctx.insert(
167            "templates_dir".into(),
168            ctx.config.templates_dir.to_string_lossy().to_string(),
169        );
170
171        // Add template info if available
172        if let Some(ref template) = ctx.template {
173            render_ctx.insert("template_name".into(), template.logical_name.clone());
174            render_ctx.insert(
175                "template_path".into(),
176                template.path.to_string_lossy().to_string(),
177            );
178        }
179
180        // Add output path info if available
181        if let Some(ref output_path) = ctx.output_path {
182            render_ctx
183                .insert("output_path".into(), output_path.to_string_lossy().to_string());
184            if let Some(name) = output_path.file_name().and_then(|s| s.to_str()) {
185                render_ctx.insert("output_filename".into(), name.to_string());
186            }
187            if let Some(parent) = output_path.parent() {
188                render_ctx
189                    .insert("output_dir".into(), parent.to_string_lossy().to_string());
190            }
191        }
192
193        // Add core metadata fields
194        if let Some(ref id) = ctx.core_metadata.task_id {
195            render_ctx.insert("task-id".into(), id.clone());
196        }
197        if let Some(ref id) = ctx.core_metadata.project_id {
198            render_ctx.insert("project-id".into(), id.clone());
199        }
200        if let Some(ref project) = ctx.core_metadata.project {
201            render_ctx.insert("project".into(), project.clone());
202        }
203
204        render_ctx
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use crate::domain::CoreMetadata;
211
212    #[test]
213    fn test_apply_core_metadata() {
214        let content =
215            "---\ntype: wrong\ntitle: Wrong Title\ncustom: value\n---\n# Body\n";
216
217        let core = CoreMetadata {
218            note_type: Some("task".into()),
219            title: Some("Correct Title".into()),
220            task_id: Some("TST-001".into()),
221            project: Some("TST".into()),
222            ..Default::default()
223        };
224
225        let result = core.apply_to_content(content, None).unwrap();
226
227        assert!(result.contains("type: task"));
228        assert!(result.contains("title: Correct Title"));
229        assert!(result.contains("task-id: TST-001"));
230        assert!(result.contains("project: TST"));
231        assert!(result.contains("custom: value")); // Non-core fields preserved
232    }
233}