Skip to main content

mdvault_core/domain/
services.rs

1//! Domain services for note lifecycle operations.
2//!
3//! These services handle cross-cutting concerns like daily logging
4//! that can be used by multiple behaviors.
5
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use chrono::Local;
11
12use crate::config::types::ResolvedConfig;
13
14/// Update the `updated_at` frontmatter field in a note file.
15pub fn set_updated_at(path: &Path) -> Result<(), String> {
16    let content =
17        fs::read_to_string(path).map_err(|e| format!("Could not read file: {e}"))?;
18    let parsed = crate::frontmatter::parse(&content)
19        .map_err(|e| format!("Could not parse frontmatter: {e}"))?;
20    let mut fields: HashMap<String, serde_yaml::Value> =
21        parsed.frontmatter.map(|fm| fm.fields).unwrap_or_default();
22    let now = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
23    fields.insert("updated_at".to_string(), serde_yaml::Value::String(now));
24    let yaml = serde_yaml::to_string(&fields)
25        .map_err(|e| format!("Could not serialize frontmatter: {e}"))?;
26    let new_content = format!("---\n{}---\n{}", yaml, parsed.body);
27    fs::write(path, new_content).map_err(|e| format!("Could not write file: {e}"))?;
28    Ok(())
29}
30
31/// Service for logging note creation events to daily notes.
32pub struct DailyLogService;
33
34impl DailyLogService {
35    /// Ensure today's daily note exists, creating it via `NoteCreator` if needed.
36    ///
37    /// Attempts the full template pipeline first (template + scaffolding + type definitions).
38    /// Falls back to minimal creation if the pipeline fails.
39    ///
40    /// Returns the path to the daily note.
41    fn ensure_daily_note(
42        config: &ResolvedConfig,
43        today: &str,
44    ) -> Result<PathBuf, String> {
45        let year = &today[..4];
46        let daily_path =
47            config.vault_root.join(format!("Journal/{}/Daily/{}.md", year, today));
48
49        if daily_path.exists() {
50            return Ok(daily_path);
51        }
52
53        // Try to create via NoteCreator with the full template pipeline
54        use crate::domain::NoteType;
55        use crate::domain::context::CreationContext;
56        use crate::domain::creator::NoteCreator;
57        use crate::templates::repository::TemplateRepository;
58        use crate::types::{TypeRegistry, TypedefRepository};
59
60        let try_full_creation = || -> Result<PathBuf, String> {
61            // Build TypeRegistry (with fallback if configured)
62            let typedef_repo = match &config.typedefs_fallback_dir {
63                Some(fallback) => {
64                    TypedefRepository::with_fallback(&config.typedefs_dir, fallback)
65                }
66                None => TypedefRepository::new(&config.typedefs_dir),
67            }
68            .map_err(|e| format!("Could not load type definitions: {e}"))?;
69
70            let registry = TypeRegistry::from_repository(&typedef_repo)
71                .map_err(|e| format!("Could not build type registry: {e}"))?;
72
73            // Try to load the daily template
74            let template = TemplateRepository::new(&config.templates_dir)
75                .ok()
76                .and_then(|repo| repo.get_by_name("daily").ok());
77
78            // Build NoteType and NoteCreator
79            let note_type = NoteType::from_name("daily", &registry)
80                .map_err(|e| format!("Could not resolve daily note type: {e}"))?;
81            let creator = NoteCreator::new(note_type);
82
83            // Build CreationContext
84            let mut ctx = CreationContext::new("daily", today, config, &registry)
85                .with_batch_mode(true);
86            if let Some(tmpl) = template {
87                ctx = ctx.with_template(tmpl);
88            }
89            ctx.set_var("date", today);
90
91            let result = creator
92                .create(&mut ctx)
93                .map_err(|e| format!("NoteCreator failed: {e}"))?;
94            Ok(result.path)
95        };
96
97        match try_full_creation() {
98            Ok(path) => Ok(path),
99            Err(e) => {
100                // Fallback to minimal creation if the pipeline fails
101                tracing::warn!("Full template creation failed, using minimal: {e}");
102                if let Some(parent) = daily_path.parent() {
103                    fs::create_dir_all(parent)
104                        .map_err(|e| format!("Could not create daily directory: {e}"))?;
105                }
106                let content = format!(
107                    "---\ntype: daily\ndate: {}\n---\n\n# {}\n\n## Logs\n",
108                    today, today
109                );
110                fs::write(&daily_path, &content)
111                    .map_err(|e| format!("Could not create daily note: {e}"))?;
112                Ok(daily_path)
113            }
114        }
115    }
116
117    /// Log a creation event to today's daily note.
118    ///
119    /// Creates the daily note if it doesn't exist. The log entry includes
120    /// a wikilink to the created note.
121    ///
122    /// # Arguments
123    /// * `config` - Resolved vault configuration
124    /// * `note_type` - Type of note created (e.g., "task", "project")
125    /// * `title` - Title of the created note
126    /// * `note_id` - ID of the note (e.g., "TST-001"), can be empty
127    /// * `output_path` - Path where the note was written
128    pub fn log_creation(
129        config: &ResolvedConfig,
130        note_type: &str,
131        title: &str,
132        note_id: &str,
133        output_path: &Path,
134    ) -> Result<(), String> {
135        let today = Local::now().format("%Y-%m-%d").to_string();
136        let time = Local::now().format("%H:%M").to_string();
137
138        // Ensure the daily note exists (creates via full pipeline if needed)
139        let daily_path = Self::ensure_daily_note(config, &today)?;
140
141        // Read the daily note content
142        let mut content = fs::read_to_string(&daily_path)
143            .map_err(|e| format!("Could not read daily note: {e}"))?;
144
145        // Build the log entry with link to the note
146        let rel_path =
147            output_path.strip_prefix(&config.vault_root).unwrap_or(output_path);
148        let link = rel_path.file_stem().and_then(|s| s.to_str()).unwrap_or("note");
149
150        // Format: "- **HH:MM**: Created task TST-001: [[TST-001|Title]]"
151        let id_display =
152            if note_id.is_empty() { String::new() } else { format!(" {}", note_id) };
153
154        let log_entry = format!(
155            "- **{}**: Created {}{}: [[{}|{}]]\n",
156            time, note_type, id_display, link, title
157        );
158
159        // Find the Logs section and append, or append at end
160        if let Some(log_pos) = content.find("## Logs") {
161            // Find the end of the Logs section (next ## or end of file)
162            let after_log = &content[log_pos + 7..]; // Skip "## Logs"
163            let insert_pos = if let Some(next_section) = after_log.find("\n## ") {
164                log_pos + 7 + next_section
165            } else {
166                content.len()
167            };
168
169            // Insert the log entry, avoiding double newline
170            let prefix = if insert_pos > 0 && content.as_bytes()[insert_pos - 1] == b'\n'
171            {
172                ""
173            } else {
174                "\n"
175            };
176            content.insert_str(insert_pos, &format!("{}{}", prefix, log_entry));
177        } else {
178            // No Logs section, add one
179            content.push_str(&format!("\n## Logs\n{}", log_entry));
180        }
181
182        // Write back
183        fs::write(&daily_path, &content)
184            .map_err(|e| format!("Could not write daily note: {e}"))?;
185
186        if let Err(e) = set_updated_at(&daily_path) {
187            tracing::warn!("Failed to set updated_at on daily note: {}", e);
188        }
189
190        Ok(())
191    }
192}
193
194impl DailyLogService {
195    /// Log a generic event to today's daily note.
196    ///
197    /// Used for task completion, cancellation, and other lifecycle events
198    /// that should appear in the daily journal.
199    ///
200    /// # Arguments
201    /// * `config` - Resolved vault configuration
202    /// * `action` - Action verb (e.g., "Completed", "Cancelled")
203    /// * `note_type` - Type of note (e.g., "task")
204    /// * `title` - Title of the note
205    /// * `note_id` - ID of the note (e.g., "TST-001"), can be empty
206    /// * `output_path` - Path to the note file
207    pub fn log_event(
208        config: &ResolvedConfig,
209        action: &str,
210        note_type: &str,
211        title: &str,
212        note_id: &str,
213        output_path: &Path,
214    ) -> Result<(), String> {
215        let today = Local::now().format("%Y-%m-%d").to_string();
216        let time = Local::now().format("%H:%M").to_string();
217
218        // Ensure the daily note exists (creates via full pipeline if needed)
219        let daily_path = Self::ensure_daily_note(config, &today)?;
220
221        // Read the daily note content
222        let mut content = fs::read_to_string(&daily_path)
223            .map_err(|e| format!("Could not read daily note: {e}"))?;
224
225        let rel_path =
226            output_path.strip_prefix(&config.vault_root).unwrap_or(output_path);
227        let link = rel_path.file_stem().and_then(|s| s.to_str()).unwrap_or("note");
228
229        let id_display =
230            if note_id.is_empty() { String::new() } else { format!(" {}", note_id) };
231
232        let log_entry = format!(
233            "- **{}**: {} {}{}: [[{}|{}]]\n",
234            time, action, note_type, id_display, link, title
235        );
236
237        if let Some(log_pos) = content.find("## Logs") {
238            let after_log = &content[log_pos + 7..];
239            let insert_pos = if let Some(next_section) = after_log.find("\n## ") {
240                log_pos + 7 + next_section
241            } else {
242                content.len()
243            };
244            let prefix = if insert_pos > 0 && content.as_bytes()[insert_pos - 1] == b'\n'
245            {
246                ""
247            } else {
248                "\n"
249            };
250            content.insert_str(insert_pos, &format!("{}{}", prefix, log_entry));
251        } else {
252            content.push_str(&format!("\n## Logs\n{}", log_entry));
253        }
254
255        fs::write(&daily_path, &content)
256            .map_err(|e| format!("Could not write daily note: {e}"))?;
257
258        if let Err(e) = set_updated_at(&daily_path) {
259            tracing::warn!("Failed to set updated_at on daily note: {}", e);
260        }
261
262        Ok(())
263    }
264}
265
266/// Service for logging events to project notes.
267pub struct ProjectLogService;
268
269impl ProjectLogService {
270    /// Append a log entry to a project note's "## Logs" section.
271    pub fn log_entry(project_file: &Path, message: &str) -> Result<(), String> {
272        let today = Local::now().format("%Y-%m-%d").to_string();
273        let time = Local::now().format("%H:%M").to_string();
274
275        let content = fs::read_to_string(project_file)
276            .map_err(|e| format!("Could not read project note: {e}"))?;
277
278        let log_entry = format!("- [[{}]] - {}: {}\n", today, time, message);
279
280        let new_content = if let Some(log_pos) = content.find("## Logs") {
281            let after_log = &content[log_pos + 7..]; // Skip "## Logs"
282            let insert_pos = if let Some(next_section) = after_log.find("\n## ") {
283                log_pos + 7 + next_section
284            } else {
285                content.len()
286            };
287            let mut c = content.clone();
288            let prefix = if insert_pos > 0 && c.as_bytes()[insert_pos - 1] == b'\n' {
289                ""
290            } else {
291                "\n"
292            };
293            c.insert_str(insert_pos, &format!("{}{}", prefix, log_entry));
294            c
295        } else {
296            format!("{}\n## Logs\n{}", content, log_entry)
297        };
298
299        fs::write(project_file, &new_content)
300            .map_err(|e| format!("Could not write project note: {e}"))?;
301
302        if let Err(e) = set_updated_at(project_file) {
303            tracing::warn!("Failed to set updated_at on project note: {}", e);
304        }
305
306        Ok(())
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use std::path::PathBuf;
314    use tempfile::tempdir;
315
316    fn make_test_config(vault_root: PathBuf) -> ResolvedConfig {
317        ResolvedConfig {
318            active_profile: "test".into(),
319            vault_root: vault_root.clone(),
320            templates_dir: vault_root.join(".mdvault/templates"),
321            captures_dir: vault_root.join(".mdvault/captures"),
322            macros_dir: vault_root.join(".mdvault/macros"),
323            typedefs_dir: vault_root.join(".mdvault/typedefs"),
324            typedefs_fallback_dir: None,
325            excluded_folders: vec![],
326            security: Default::default(),
327            logging: Default::default(),
328            activity: Default::default(),
329        }
330    }
331
332    #[test]
333    fn test_log_creation_creates_daily_note() {
334        let tmp = tempdir().unwrap();
335        let config = make_test_config(tmp.path().to_path_buf());
336        let output_path = tmp.path().join("Projects/TST/Tasks/TST-001.md");
337
338        // Create the task file so strip_prefix works
339        fs::create_dir_all(output_path.parent().unwrap()).unwrap();
340        fs::write(&output_path, "test").unwrap();
341
342        let result = DailyLogService::log_creation(
343            &config,
344            "task",
345            "Test Task",
346            "TST-001",
347            &output_path,
348        );
349
350        assert!(result.is_ok());
351
352        // Check daily note was created
353        let today = Local::now().format("%Y-%m-%d").to_string();
354        let year = &today[..4];
355        let daily_path = tmp.path().join(format!("Journal/{}/Daily/{}.md", year, today));
356        assert!(daily_path.exists());
357
358        let content = fs::read_to_string(&daily_path).unwrap();
359        assert!(content.contains("type: daily"));
360        assert!(content.contains("## Logs"));
361        assert!(content.contains("Created task TST-001"));
362        assert!(content.contains("[[TST-001|Test Task]]"));
363    }
364
365    #[test]
366    fn test_log_creation_appends_to_existing() {
367        let tmp = tempdir().unwrap();
368        let config = make_test_config(tmp.path().to_path_buf());
369
370        // Create existing daily note
371        let today = Local::now().format("%Y-%m-%d").to_string();
372        let year = &today[..4];
373        let daily_path = tmp.path().join(format!("Journal/{}/Daily/{}.md", year, today));
374        fs::create_dir_all(daily_path.parent().unwrap()).unwrap();
375        fs::write(
376            &daily_path,
377            "---\ntype: daily\n---\n\n# Today\n\n## Logs\n- Existing entry\n",
378        )
379        .unwrap();
380
381        let output_path = tmp.path().join("Projects/NEW/NEW-001.md");
382        fs::create_dir_all(output_path.parent().unwrap()).unwrap();
383        fs::write(&output_path, "test").unwrap();
384
385        let result = DailyLogService::log_creation(
386            &config,
387            "project",
388            "New Project",
389            "NEW",
390            &output_path,
391        );
392
393        assert!(result.is_ok());
394
395        let content = fs::read_to_string(&daily_path).unwrap();
396        assert!(content.contains("- Existing entry"));
397        assert!(content.contains("Created project NEW"));
398    }
399
400    #[test]
401    fn test_project_log_appends_to_existing_logs_section() {
402        let tmp = tempdir().unwrap();
403        let project_file = tmp.path().join("project.md");
404        fs::write(&project_file, "---\ntitle: Test\n---\n\n## Logs\n- Existing log\n")
405            .unwrap();
406
407        let result = ProjectLogService::log_entry(
408            &project_file,
409            "Created task [[TST-001]]: Fix bug",
410        );
411        assert!(result.is_ok());
412
413        let content = fs::read_to_string(&project_file).unwrap();
414        assert!(content.contains("- Existing log"));
415        assert!(content.contains("Created task [[TST-001]]: Fix bug"));
416        // Should still have the Logs heading
417        assert!(content.contains("## Logs"));
418    }
419
420    #[test]
421    fn test_project_log_creates_logs_section_if_missing() {
422        let tmp = tempdir().unwrap();
423        let project_file = tmp.path().join("project.md");
424        fs::write(&project_file, "---\ntitle: Test\n---\n\nSome content\n").unwrap();
425
426        let result = ProjectLogService::log_entry(
427            &project_file,
428            "Created task [[TST-002]]: New feature",
429        );
430        assert!(result.is_ok());
431
432        let content = fs::read_to_string(&project_file).unwrap();
433        assert!(content.contains("## Logs"));
434        assert!(content.contains("Created task [[TST-002]]: New feature"));
435        assert!(content.contains("Some content"));
436    }
437
438    #[test]
439    fn test_log_event_completed_task() {
440        let tmp = tempdir().unwrap();
441        let config = make_test_config(tmp.path().to_path_buf());
442        let output_path = tmp.path().join("Projects/TST/Tasks/TST-001.md");
443
444        fs::create_dir_all(output_path.parent().unwrap()).unwrap();
445        fs::write(&output_path, "test").unwrap();
446
447        let result = DailyLogService::log_event(
448            &config,
449            "Completed",
450            "task",
451            "Fix the bug",
452            "TST-001",
453            &output_path,
454        );
455
456        assert!(result.is_ok());
457
458        let today = Local::now().format("%Y-%m-%d").to_string();
459        let year = &today[..4];
460        let daily_path = tmp.path().join(format!("Journal/{}/Daily/{}.md", year, today));
461        assert!(daily_path.exists());
462
463        let content = fs::read_to_string(&daily_path).unwrap();
464        assert!(content.contains("Completed task TST-001"));
465        assert!(content.contains("[[TST-001|Fix the bug]]"));
466    }
467
468    #[test]
469    fn test_log_event_cancelled_task() {
470        let tmp = tempdir().unwrap();
471        let config = make_test_config(tmp.path().to_path_buf());
472        let output_path = tmp.path().join("Projects/TST/Tasks/TST-002.md");
473
474        fs::create_dir_all(output_path.parent().unwrap()).unwrap();
475        fs::write(&output_path, "test").unwrap();
476
477        let result = DailyLogService::log_event(
478            &config,
479            "Cancelled",
480            "task",
481            "Old feature",
482            "TST-002",
483            &output_path,
484        );
485
486        assert!(result.is_ok());
487
488        let today = Local::now().format("%Y-%m-%d").to_string();
489        let year = &today[..4];
490        let daily_path = tmp.path().join(format!("Journal/{}/Daily/{}.md", year, today));
491        let content = fs::read_to_string(&daily_path).unwrap();
492        assert!(content.contains("Cancelled task TST-002"));
493        assert!(content.contains("[[TST-002|Old feature]]"));
494    }
495
496    #[test]
497    fn test_project_log_preserves_sections_after_logs() {
498        let tmp = tempdir().unwrap();
499        let project_file = tmp.path().join("project.md");
500        fs::write(
501            &project_file,
502            "---\ntitle: Test\n---\n\n## Logs\n- Old entry\n\n## Notes\nSome notes\n",
503        )
504        .unwrap();
505
506        let result = ProjectLogService::log_entry(
507            &project_file,
508            "Created task [[TST-003]]: Refactor",
509        );
510        assert!(result.is_ok());
511
512        let content = fs::read_to_string(&project_file).unwrap();
513        assert!(content.contains("- Old entry"));
514        assert!(content.contains("Created task [[TST-003]]: Refactor"));
515        assert!(content.contains("## Notes"));
516        assert!(content.contains("Some notes"));
517    }
518}