1use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use chrono::Local;
11
12use crate::config::types::ResolvedConfig;
13
14pub 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
31pub struct DailyLogService;
33
34impl DailyLogService {
35 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 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 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 let template = TemplateRepository::new(&config.templates_dir)
75 .ok()
76 .and_then(|repo| repo.get_by_name("daily").ok());
77
78 let note_type = NoteType::from_name("daily", ®istry)
80 .map_err(|e| format!("Could not resolve daily note type: {e}"))?;
81 let creator = NoteCreator::new(note_type);
82
83 let mut ctx = CreationContext::new("daily", today, config, ®istry)
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 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 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 let daily_path = Self::ensure_daily_note(config, &today)?;
140
141 let mut content = fs::read_to_string(&daily_path)
143 .map_err(|e| format!("Could not read daily note: {e}"))?;
144
145 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 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 if let Some(log_pos) = content.find("## Logs") {
161 let after_log = &content[log_pos + 7..]; 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 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 content.push_str(&format!("\n## Logs\n{}", log_entry));
180 }
181
182 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 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 let daily_path = Self::ensure_daily_note(config, &today)?;
220
221 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
266pub struct ProjectLogService;
268
269impl ProjectLogService {
270 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..]; 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 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 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 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 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}