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::fs;
7use std::path::Path;
8
9use chrono::Local;
10
11use crate::config::types::ResolvedConfig;
12
13/// Service for logging note creation events to daily notes.
14pub struct DailyLogService;
15
16impl DailyLogService {
17    /// Log a creation event to today's daily note.
18    ///
19    /// Creates the daily note if it doesn't exist. The log entry includes
20    /// a wikilink to the created note.
21    ///
22    /// # Arguments
23    /// * `config` - Resolved vault configuration
24    /// * `note_type` - Type of note created (e.g., "task", "project")
25    /// * `title` - Title of the created note
26    /// * `note_id` - ID of the note (e.g., "TST-001"), can be empty
27    /// * `output_path` - Path where the note was written
28    pub fn log_creation(
29        config: &ResolvedConfig,
30        note_type: &str,
31        title: &str,
32        note_id: &str,
33        output_path: &Path,
34    ) -> Result<(), String> {
35        let today = Local::now().format("%Y-%m-%d").to_string();
36        let time = Local::now().format("%H:%M").to_string();
37
38        // Build daily note path (default pattern: Journal/Daily/YYYY-MM-DD.md)
39        let daily_path = config.vault_root.join(format!("Journal/Daily/{}.md", today));
40
41        // Ensure parent directory exists
42        if let Some(parent) = daily_path.parent() {
43            fs::create_dir_all(parent)
44                .map_err(|e| format!("Could not create daily directory: {e}"))?;
45        }
46
47        // Read or create daily note
48        let mut content = match fs::read_to_string(&daily_path) {
49            Ok(c) => c,
50            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
51                // Create minimal daily note
52                let content = format!(
53                    "---\ntype: daily\ndate: {}\n---\n\n# {}\n\n## Log\n",
54                    today, today
55                );
56                fs::write(&daily_path, &content)
57                    .map_err(|e| format!("Could not create daily note: {e}"))?;
58                content
59            }
60            Err(e) => return Err(format!("Could not read daily note: {e}")),
61        };
62
63        // Build the log entry with link to the note
64        let rel_path =
65            output_path.strip_prefix(&config.vault_root).unwrap_or(output_path);
66        let link = rel_path.file_stem().and_then(|s| s.to_str()).unwrap_or("note");
67
68        // Format: "- **HH:MM**: Created task TST-001: [[TST-001|Title]]"
69        let id_display =
70            if note_id.is_empty() { String::new() } else { format!(" {}", note_id) };
71
72        let log_entry = format!(
73            "- **{}**: Created {}{}: [[{}|{}]]\n",
74            time, note_type, id_display, link, title
75        );
76
77        // Find the Log section and append, or append at end
78        if let Some(log_pos) = content.find("## Log") {
79            // Find the end of the Log section (next ## or end of file)
80            let after_log = &content[log_pos + 6..]; // Skip "## Log"
81            let insert_pos = if let Some(next_section) = after_log.find("\n## ") {
82                log_pos + 6 + next_section
83            } else {
84                content.len()
85            };
86
87            // Insert the log entry after a newline
88            content.insert_str(insert_pos, &format!("\n{}", log_entry));
89        } else {
90            // No Log section, add one
91            content.push_str(&format!("\n## Log\n{}", log_entry));
92        }
93
94        // Write back
95        fs::write(&daily_path, &content)
96            .map_err(|e| format!("Could not write daily note: {e}"))?;
97
98        Ok(())
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use std::path::PathBuf;
106    use tempfile::tempdir;
107
108    fn make_test_config(vault_root: PathBuf) -> ResolvedConfig {
109        ResolvedConfig {
110            active_profile: "test".into(),
111            vault_root: vault_root.clone(),
112            templates_dir: vault_root.join(".mdvault/templates"),
113            captures_dir: vault_root.join(".mdvault/captures"),
114            macros_dir: vault_root.join(".mdvault/macros"),
115            typedefs_dir: vault_root.join(".mdvault/typedefs"),
116            excluded_folders: vec![],
117            security: Default::default(),
118            logging: Default::default(),
119            activity: Default::default(),
120        }
121    }
122
123    #[test]
124    fn test_log_creation_creates_daily_note() {
125        let tmp = tempdir().unwrap();
126        let config = make_test_config(tmp.path().to_path_buf());
127        let output_path = tmp.path().join("Projects/TST/Tasks/TST-001.md");
128
129        // Create the task file so strip_prefix works
130        fs::create_dir_all(output_path.parent().unwrap()).unwrap();
131        fs::write(&output_path, "test").unwrap();
132
133        let result = DailyLogService::log_creation(
134            &config,
135            "task",
136            "Test Task",
137            "TST-001",
138            &output_path,
139        );
140
141        assert!(result.is_ok());
142
143        // Check daily note was created
144        let today = Local::now().format("%Y-%m-%d").to_string();
145        let daily_path = tmp.path().join(format!("Journal/Daily/{}.md", today));
146        assert!(daily_path.exists());
147
148        let content = fs::read_to_string(&daily_path).unwrap();
149        assert!(content.contains("type: daily"));
150        assert!(content.contains("## Log"));
151        assert!(content.contains("Created task TST-001"));
152        assert!(content.contains("[[TST-001|Test Task]]"));
153    }
154
155    #[test]
156    fn test_log_creation_appends_to_existing() {
157        let tmp = tempdir().unwrap();
158        let config = make_test_config(tmp.path().to_path_buf());
159
160        // Create existing daily note
161        let today = Local::now().format("%Y-%m-%d").to_string();
162        let daily_path = tmp.path().join(format!("Journal/Daily/{}.md", today));
163        fs::create_dir_all(daily_path.parent().unwrap()).unwrap();
164        fs::write(
165            &daily_path,
166            "---\ntype: daily\n---\n\n# Today\n\n## Log\n- Existing entry\n",
167        )
168        .unwrap();
169
170        let output_path = tmp.path().join("Projects/NEW/NEW-001.md");
171        fs::create_dir_all(output_path.parent().unwrap()).unwrap();
172        fs::write(&output_path, "test").unwrap();
173
174        let result = DailyLogService::log_creation(
175            &config,
176            "project",
177            "New Project",
178            "NEW",
179            &output_path,
180        );
181
182        assert!(result.is_ok());
183
184        let content = fs::read_to_string(&daily_path).unwrap();
185        assert!(content.contains("- Existing entry"));
186        assert!(content.contains("Created project NEW"));
187    }
188}