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        // Set updated_at on the newly created note
104        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        // Note: after_create is called by the CLI layer (after hooks)
109
110        // Build result
111        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    /// Generate the note content.
126    ///
127    /// If a template is provided in the context, renders it with variable substitution.
128    /// Otherwise, generates scaffolding from the type definition.
129    fn generate_content(&self, ctx: &CreationContext) -> DomainResult<String> {
130        if let Some(ref template) = ctx.template {
131            // Build render context with standard variables
132            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            // Fall back to scaffolding generation
138            // Use evaluated title from core_metadata if available (e.g., for daily/weekly
139            // notes where date expressions like "today + 7d" are evaluated to actual dates)
140            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    /// Build a render context with standard template variables.
152    fn build_render_context(&self, ctx: &CreationContext) -> HashMap<String, String> {
153        let mut render_ctx = HashMap::new();
154
155        // Add date/time defaults FIRST (behaviours can override these)
156        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        // Overlay user/behaviour vars — these WIN over defaults
164        render_ctx.extend(ctx.vars.clone());
165
166        // Add config paths
167        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        // Add template info if available
177        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        // Add output path info if available
186        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        // Add core metadata fields
199        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")); // Non-core fields preserved
237    }
238}