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::fs;
7use std::path::PathBuf;
8
9use super::NoteType;
10use super::context::CreationContext;
11use super::traits::{DomainError, DomainResult, NoteBehavior};
12use crate::types::scaffolding::generate_scaffolding;
13
14/// Result of a successful note creation.
15#[derive(Debug)]
16pub struct CreationResult {
17    /// Path where the note was written.
18    pub path: PathBuf,
19    /// Generated ID (if applicable, e.g., task-id, project-id).
20    pub generated_id: Option<String>,
21    /// The type of note created.
22    pub type_name: String,
23}
24
25/// Orchestrates the note creation flow.
26pub struct NoteCreator {
27    note_type: NoteType,
28}
29
30impl NoteCreator {
31    /// Create a new NoteCreator for the given note type.
32    pub fn new(note_type: NoteType) -> Self {
33        Self { note_type }
34    }
35
36    /// Get the underlying behavior.
37    pub fn behavior(&self) -> &dyn NoteBehavior {
38        self.note_type.behavior()
39    }
40
41    /// Execute the full note creation flow.
42    ///
43    /// Flow:
44    /// 1. Collect type-specific prompts
45    /// 2. Run before_create (sets IDs, counters, etc.)
46    /// 3. Resolve output path
47    /// 4. Generate content (template or scaffolding)
48    /// 5. Ensure core metadata is present
49    /// 6. Validate content
50    /// 7. Write to disk
51    /// 8. Run after_create (logging, hooks, reindex)
52    pub fn create(&self, ctx: &mut CreationContext) -> DomainResult<CreationResult> {
53        let behavior = self.note_type.behavior();
54
55        // Step 1: Type-specific prompts would be collected here
56        // (In practice, prompts are handled by the CLI layer)
57
58        // Step 2: Before create - sets IDs, updates counters, etc.
59        behavior.before_create(ctx)?;
60
61        // Step 3: Resolve output path (use pre-set path if provided, otherwise resolve)
62        let output_path = if let Some(ref path) = ctx.output_path {
63            path.clone()
64        } else {
65            let path = behavior.output_path(ctx)?;
66            ctx.output_path = Some(path.clone());
67            path
68        };
69
70        // Check if file already exists
71        if output_path.exists() {
72            return Err(DomainError::Other(format!(
73                "Refusing to overwrite existing file: {}",
74                output_path.display()
75            )));
76        }
77
78        // Step 4: Generate content
79        let content = self.generate_content(ctx)?;
80
81        // Step 5: Ensure core metadata is preserved
82        let content = ensure_core_metadata(&content, ctx)?;
83
84        // Step 6: Validation would happen here
85        // (Deferred to integration phase)
86
87        // Step 7: Write to disk
88        if let Some(parent) = output_path.parent() {
89            fs::create_dir_all(parent).map_err(DomainError::Io)?;
90        }
91        fs::write(&output_path, &content).map_err(DomainError::Io)?;
92
93        // Step 8: After create - logging, hooks, reindex
94        behavior.after_create(ctx, &content)?;
95
96        // Build result
97        let generated_id = ctx
98            .core_metadata
99            .task_id
100            .clone()
101            .or_else(|| ctx.core_metadata.project_id.clone());
102
103        Ok(CreationResult {
104            path: output_path,
105            generated_id,
106            type_name: ctx.type_name.clone(),
107        })
108    }
109
110    /// Generate the note content.
111    fn generate_content(&self, ctx: &CreationContext) -> DomainResult<String> {
112        // For now, use scaffolding generation
113        // Template rendering will be added in the integration phase
114        Ok(generate_scaffolding(
115            &ctx.type_name,
116            ctx.typedef.as_deref(),
117            &ctx.title,
118            &ctx.vars,
119        ))
120    }
121}
122
123/// Ensure core metadata fields are present in the content.
124///
125/// This function parses the frontmatter and ensures that core fields
126/// (type, title, task-id, project-id, etc.) are set correctly,
127/// overwriting any values that may have been modified by templates or hooks.
128fn ensure_core_metadata(content: &str, ctx: &CreationContext) -> DomainResult<String> {
129    use crate::frontmatter::parse;
130
131    let parsed = parse(content)
132        .map_err(|e| DomainError::Other(format!("Failed to parse frontmatter: {}", e)))?;
133
134    let mut fields = parsed.frontmatter.map(|fm| fm.fields).unwrap_or_default();
135
136    // Apply core metadata (these are authoritative)
137    let core = &ctx.core_metadata;
138
139    if let Some(ref t) = core.note_type {
140        fields.insert("type".into(), serde_yaml::Value::String(t.clone()));
141    }
142    if let Some(ref t) = core.title {
143        fields.insert("title".into(), serde_yaml::Value::String(t.clone()));
144    }
145    if let Some(ref id) = core.project_id {
146        fields.insert("project-id".into(), serde_yaml::Value::String(id.clone()));
147    }
148    if let Some(ref id) = core.task_id {
149        fields.insert("task-id".into(), serde_yaml::Value::String(id.clone()));
150    }
151    if let Some(counter) = core.task_counter {
152        fields.insert("task_counter".into(), serde_yaml::Value::Number(counter.into()));
153    }
154    if let Some(ref p) = core.project {
155        fields.insert("project".into(), serde_yaml::Value::String(p.clone()));
156    }
157    if let Some(ref d) = core.date {
158        fields.insert("date".into(), serde_yaml::Value::String(d.clone()));
159    }
160    if let Some(ref w) = core.week {
161        fields.insert("week".into(), serde_yaml::Value::String(w.clone()));
162    }
163
164    // Rebuild content
165    let yaml = serde_yaml::to_string(&fields).map_err(|e| {
166        DomainError::Other(format!("Failed to serialize frontmatter: {}", e))
167    })?;
168
169    Ok(format!("---\n{}---\n{}", yaml, parsed.body))
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::config::types::ResolvedConfig;
176    use crate::types::TypeRegistry;
177    use std::path::PathBuf;
178    use tempfile::tempdir;
179
180    fn make_test_config(vault_root: PathBuf) -> ResolvedConfig {
181        ResolvedConfig {
182            active_profile: "test".into(),
183            vault_root: vault_root.clone(),
184            templates_dir: vault_root.join(".mdvault/templates"),
185            captures_dir: vault_root.join(".mdvault/captures"),
186            macros_dir: vault_root.join(".mdvault/macros"),
187            typedefs_dir: vault_root.join(".mdvault/typedefs"),
188            security: Default::default(),
189            logging: Default::default(),
190        }
191    }
192
193    #[test]
194    fn test_ensure_core_metadata() {
195        let content =
196            "---\ntype: wrong\ntitle: Wrong Title\ncustom: value\n---\n# Body\n";
197
198        let tmp = tempdir().unwrap();
199        let config = make_test_config(tmp.path().to_path_buf());
200        let registry = TypeRegistry::new();
201
202        let mut ctx = CreationContext::new("task", "Correct Title", &config, &registry);
203        ctx.core_metadata.task_id = Some("TST-001".into());
204        ctx.core_metadata.project = Some("TST".into());
205
206        let result = ensure_core_metadata(content, &ctx).unwrap();
207
208        assert!(result.contains("type: task"));
209        assert!(result.contains("title: Correct Title"));
210        assert!(result.contains("task-id: TST-001"));
211        assert!(result.contains("project: TST"));
212        assert!(result.contains("custom: value")); // Non-core fields preserved
213    }
214}