1use std::fs;
7use std::path::Path;
8
9use chrono::Local;
10
11use crate::config::types::ResolvedConfig;
12
13pub struct DailyLogService;
15
16impl DailyLogService {
17 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 let daily_path = config.vault_root.join(format!("Journal/Daily/{}.md", today));
40
41 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 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 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 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 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 if let Some(log_pos) = content.find("## Log") {
79 let after_log = &content[log_pos + 6..]; 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 content.insert_str(insert_pos, &format!("\n{}", log_entry));
89 } else {
90 content.push_str(&format!("\n## Log\n{}", log_entry));
92 }
93
94 fs::write(&daily_path, &content)
96 .map_err(|e| format!("Could not write daily note: {e}"))?;
97
98 Ok(())
99 }
100}
101
102impl DailyLogService {
103 pub fn log_event(
116 config: &ResolvedConfig,
117 action: &str,
118 note_type: &str,
119 title: &str,
120 note_id: &str,
121 output_path: &Path,
122 ) -> Result<(), String> {
123 let today = Local::now().format("%Y-%m-%d").to_string();
124 let time = Local::now().format("%H:%M").to_string();
125
126 let daily_path = config.vault_root.join(format!("Journal/Daily/{}.md", today));
127
128 if let Some(parent) = daily_path.parent() {
129 fs::create_dir_all(parent)
130 .map_err(|e| format!("Could not create daily directory: {e}"))?;
131 }
132
133 let mut content = match fs::read_to_string(&daily_path) {
134 Ok(c) => c,
135 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
136 let content = format!(
137 "---\ntype: daily\ndate: {}\n---\n\n# {}\n\n## Log\n",
138 today, today
139 );
140 fs::write(&daily_path, &content)
141 .map_err(|e| format!("Could not create daily note: {e}"))?;
142 content
143 }
144 Err(e) => return Err(format!("Could not read daily note: {e}")),
145 };
146
147 let rel_path =
148 output_path.strip_prefix(&config.vault_root).unwrap_or(output_path);
149 let link = rel_path.file_stem().and_then(|s| s.to_str()).unwrap_or("note");
150
151 let id_display =
152 if note_id.is_empty() { String::new() } else { format!(" {}", note_id) };
153
154 let log_entry = format!(
155 "- **{}**: {} {}{}: [[{}|{}]]\n",
156 time, action, note_type, id_display, link, title
157 );
158
159 if let Some(log_pos) = content.find("## Log") {
160 let after_log = &content[log_pos + 6..];
161 let insert_pos = if let Some(next_section) = after_log.find("\n## ") {
162 log_pos + 6 + next_section
163 } else {
164 content.len()
165 };
166 content.insert_str(insert_pos, &format!("\n{}", log_entry));
167 } else {
168 content.push_str(&format!("\n## Log\n{}", log_entry));
169 }
170
171 fs::write(&daily_path, &content)
172 .map_err(|e| format!("Could not write daily note: {e}"))?;
173
174 Ok(())
175 }
176}
177
178pub struct ProjectLogService;
180
181impl ProjectLogService {
182 pub fn log_entry(project_file: &Path, message: &str) -> Result<(), String> {
184 let today = Local::now().format("%Y-%m-%d").to_string();
185 let time = Local::now().format("%H:%M").to_string();
186
187 let content = fs::read_to_string(project_file)
188 .map_err(|e| format!("Could not read project note: {e}"))?;
189
190 let log_entry = format!("- [[{}]] - {}: {}\n", today, time, message);
191
192 let new_content = if let Some(log_pos) = content.find("## Logs") {
193 let after_log = &content[log_pos + 7..]; let insert_pos = if let Some(next_section) = after_log.find("\n## ") {
195 log_pos + 7 + next_section
196 } else {
197 content.len()
198 };
199 let mut c = content.clone();
200 c.insert_str(insert_pos, &format!("\n{}", log_entry));
201 c
202 } else {
203 format!("{}\n## Logs\n{}", content, log_entry)
204 };
205
206 fs::write(project_file, &new_content)
207 .map_err(|e| format!("Could not write project note: {e}"))?;
208
209 Ok(())
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use std::path::PathBuf;
217 use tempfile::tempdir;
218
219 fn make_test_config(vault_root: PathBuf) -> ResolvedConfig {
220 ResolvedConfig {
221 active_profile: "test".into(),
222 vault_root: vault_root.clone(),
223 templates_dir: vault_root.join(".mdvault/templates"),
224 captures_dir: vault_root.join(".mdvault/captures"),
225 macros_dir: vault_root.join(".mdvault/macros"),
226 typedefs_dir: vault_root.join(".mdvault/typedefs"),
227 excluded_folders: vec![],
228 security: Default::default(),
229 logging: Default::default(),
230 activity: Default::default(),
231 }
232 }
233
234 #[test]
235 fn test_log_creation_creates_daily_note() {
236 let tmp = tempdir().unwrap();
237 let config = make_test_config(tmp.path().to_path_buf());
238 let output_path = tmp.path().join("Projects/TST/Tasks/TST-001.md");
239
240 fs::create_dir_all(output_path.parent().unwrap()).unwrap();
242 fs::write(&output_path, "test").unwrap();
243
244 let result = DailyLogService::log_creation(
245 &config,
246 "task",
247 "Test Task",
248 "TST-001",
249 &output_path,
250 );
251
252 assert!(result.is_ok());
253
254 let today = Local::now().format("%Y-%m-%d").to_string();
256 let daily_path = tmp.path().join(format!("Journal/Daily/{}.md", today));
257 assert!(daily_path.exists());
258
259 let content = fs::read_to_string(&daily_path).unwrap();
260 assert!(content.contains("type: daily"));
261 assert!(content.contains("## Log"));
262 assert!(content.contains("Created task TST-001"));
263 assert!(content.contains("[[TST-001|Test Task]]"));
264 }
265
266 #[test]
267 fn test_log_creation_appends_to_existing() {
268 let tmp = tempdir().unwrap();
269 let config = make_test_config(tmp.path().to_path_buf());
270
271 let today = Local::now().format("%Y-%m-%d").to_string();
273 let daily_path = tmp.path().join(format!("Journal/Daily/{}.md", today));
274 fs::create_dir_all(daily_path.parent().unwrap()).unwrap();
275 fs::write(
276 &daily_path,
277 "---\ntype: daily\n---\n\n# Today\n\n## Log\n- Existing entry\n",
278 )
279 .unwrap();
280
281 let output_path = tmp.path().join("Projects/NEW/NEW-001.md");
282 fs::create_dir_all(output_path.parent().unwrap()).unwrap();
283 fs::write(&output_path, "test").unwrap();
284
285 let result = DailyLogService::log_creation(
286 &config,
287 "project",
288 "New Project",
289 "NEW",
290 &output_path,
291 );
292
293 assert!(result.is_ok());
294
295 let content = fs::read_to_string(&daily_path).unwrap();
296 assert!(content.contains("- Existing entry"));
297 assert!(content.contains("Created project NEW"));
298 }
299
300 #[test]
301 fn test_project_log_appends_to_existing_logs_section() {
302 let tmp = tempdir().unwrap();
303 let project_file = tmp.path().join("project.md");
304 fs::write(&project_file, "---\ntitle: Test\n---\n\n## Logs\n- Existing log\n")
305 .unwrap();
306
307 let result = ProjectLogService::log_entry(
308 &project_file,
309 "Created task [[TST-001]]: Fix bug",
310 );
311 assert!(result.is_ok());
312
313 let content = fs::read_to_string(&project_file).unwrap();
314 assert!(content.contains("- Existing log"));
315 assert!(content.contains("Created task [[TST-001]]: Fix bug"));
316 assert!(content.contains("## Logs"));
318 }
319
320 #[test]
321 fn test_project_log_creates_logs_section_if_missing() {
322 let tmp = tempdir().unwrap();
323 let project_file = tmp.path().join("project.md");
324 fs::write(&project_file, "---\ntitle: Test\n---\n\nSome content\n").unwrap();
325
326 let result = ProjectLogService::log_entry(
327 &project_file,
328 "Created task [[TST-002]]: New feature",
329 );
330 assert!(result.is_ok());
331
332 let content = fs::read_to_string(&project_file).unwrap();
333 assert!(content.contains("## Logs"));
334 assert!(content.contains("Created task [[TST-002]]: New feature"));
335 assert!(content.contains("Some content"));
336 }
337
338 #[test]
339 fn test_log_event_completed_task() {
340 let tmp = tempdir().unwrap();
341 let config = make_test_config(tmp.path().to_path_buf());
342 let output_path = tmp.path().join("Projects/TST/Tasks/TST-001.md");
343
344 fs::create_dir_all(output_path.parent().unwrap()).unwrap();
345 fs::write(&output_path, "test").unwrap();
346
347 let result = DailyLogService::log_event(
348 &config,
349 "Completed",
350 "task",
351 "Fix the bug",
352 "TST-001",
353 &output_path,
354 );
355
356 assert!(result.is_ok());
357
358 let today = Local::now().format("%Y-%m-%d").to_string();
359 let daily_path = tmp.path().join(format!("Journal/Daily/{}.md", today));
360 assert!(daily_path.exists());
361
362 let content = fs::read_to_string(&daily_path).unwrap();
363 assert!(content.contains("Completed task TST-001"));
364 assert!(content.contains("[[TST-001|Fix the bug]]"));
365 }
366
367 #[test]
368 fn test_log_event_cancelled_task() {
369 let tmp = tempdir().unwrap();
370 let config = make_test_config(tmp.path().to_path_buf());
371 let output_path = tmp.path().join("Projects/TST/Tasks/TST-002.md");
372
373 fs::create_dir_all(output_path.parent().unwrap()).unwrap();
374 fs::write(&output_path, "test").unwrap();
375
376 let result = DailyLogService::log_event(
377 &config,
378 "Cancelled",
379 "task",
380 "Old feature",
381 "TST-002",
382 &output_path,
383 );
384
385 assert!(result.is_ok());
386
387 let today = Local::now().format("%Y-%m-%d").to_string();
388 let daily_path = tmp.path().join(format!("Journal/Daily/{}.md", today));
389 let content = fs::read_to_string(&daily_path).unwrap();
390 assert!(content.contains("Cancelled task TST-002"));
391 assert!(content.contains("[[TST-002|Old feature]]"));
392 }
393
394 #[test]
395 fn test_project_log_preserves_sections_after_logs() {
396 let tmp = tempdir().unwrap();
397 let project_file = tmp.path().join("project.md");
398 fs::write(
399 &project_file,
400 "---\ntitle: Test\n---\n\n## Logs\n- Old entry\n\n## Notes\nSome notes\n",
401 )
402 .unwrap();
403
404 let result = ProjectLogService::log_entry(
405 &project_file,
406 "Created task [[TST-003]]: Refactor",
407 );
408 assert!(result.is_ok());
409
410 let content = fs::read_to_string(&project_file).unwrap();
411 assert!(content.contains("- Old entry"));
412 assert!(content.contains("Created task [[TST-003]]: Refactor"));
413 assert!(content.contains("## Notes"));
414 assert!(content.contains("Some notes"));
415 }
416}