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