1use std::collections::{HashMap, HashSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use chrono::{Datelike, Duration, Local, NaiveDate, TimeZone, Utc};
8
9use crate::activity::{ActivityEntry, ActivityLogService, Operation};
10use crate::config::types::ResolvedConfig;
11use crate::context::ContextManager;
12use crate::frontmatter::parse as parse_frontmatter;
13use crate::index::IndexDb;
14use crate::markdown_ast::MarkdownEditor;
15
16use super::query_types::*;
17
18pub struct ContextQueryService {
20 vault_root: PathBuf,
22
23 activity_service: Option<ActivityLogService>,
25
26 index_db: Option<IndexDb>,
28
29 daily_note_pattern: String,
31}
32
33impl ContextQueryService {
34 pub fn new(config: &ResolvedConfig) -> Self {
36 let activity_service = ActivityLogService::try_from_config(config);
37
38 let index_path = config.vault_root.join(".mdvault/index.db");
39 let index_db = IndexDb::open(&index_path).ok();
40
41 Self {
42 vault_root: config.vault_root.clone(),
43 activity_service,
44 index_db,
45 daily_note_pattern: "Journal/Daily/{date}.md".to_string(),
47 }
48 }
49
50 pub fn day_context(&self, date: NaiveDate) -> Result<DayContext, ContextError> {
52 let date_str = date.format("%Y-%m-%d").to_string();
53 let day_of_week = date.format("%A").to_string();
54
55 let mut context = DayContext::new(&date_str, &day_of_week);
56
57 let activity_entries = self.get_logged_activity(date);
59
60 for entry in &activity_entries {
62 context.activity.push(ActivityItem {
63 ts: entry.ts.to_rfc3339(),
64 source: "logged".to_string(),
65 op: entry.op.to_string(),
66 note_type: entry.note_type.clone(),
67 id: if entry.id.is_empty() { None } else { Some(entry.id.clone()) },
68 path: entry.path.clone(),
69 summary: entry
70 .meta
71 .get("title")
72 .and_then(|v| v.as_str())
73 .map(String::from),
74 });
75 }
76
77 let detected = self.detect_unlogged_changes(date, &activity_entries);
79 for note in detected {
80 context.activity.push(ActivityItem {
81 ts: format!("{}T00:00:00Z", date_str),
82 source: "detected".to_string(),
83 op: "update".to_string(),
84 note_type: note.note_type.clone().unwrap_or_default(),
85 id: None,
86 path: note.path.clone(),
87 summary: note.change_summary.clone(),
88 });
89 context.modified_notes.push(note);
90 }
91
92 let mut logged_paths: HashSet<PathBuf> = HashSet::new();
94 for entry in &activity_entries {
95 if !logged_paths.contains(&entry.path) {
96 logged_paths.insert(entry.path.clone());
97 context.modified_notes.push(ModifiedNote {
98 path: entry.path.clone(),
99 note_type: Some(entry.note_type.clone()),
100 source: "logged".to_string(),
101 change_summary: Some(entry.op.to_string()),
102 });
103 }
104 }
105
106 context.daily_note = self.parse_daily_note(date);
108
109 context.tasks = self.aggregate_tasks(&activity_entries);
111
112 context.summary.focus = self.get_focus_for_day(date);
114
115 context.summary.tasks_completed = context.tasks.completed.len() as u32;
117 context.summary.tasks_created = context.tasks.created.len() as u32;
118 context.summary.notes_modified = context.modified_notes.len() as u32;
119
120 context.projects = self.aggregate_projects(&activity_entries);
122
123 Ok(context)
124 }
125
126 pub fn week_context(&self, date: NaiveDate) -> Result<WeekContext, ContextError> {
128 let days_from_monday = date.weekday().num_days_from_monday();
130 let monday = date - Duration::days(days_from_monday as i64);
131 let sunday = monday + Duration::days(6);
132
133 let week_str = monday.format("%G-W%V").to_string();
134 let start_str = monday.format("%Y-%m-%d").to_string();
135 let end_str = sunday.format("%Y-%m-%d").to_string();
136
137 let mut context = WeekContext {
138 week: week_str,
139 start_date: start_str,
140 end_date: end_str,
141 summary: WeekSummary::default(),
142 days: Vec::new(),
143 tasks: TaskActivity::default(),
144 projects: Vec::new(),
145 };
146
147 let mut all_entries: Vec<ActivityEntry> = Vec::new();
149 let mut project_map: HashMap<String, ProjectActivity> = HashMap::new();
150
151 for i in 0..7 {
152 let day = monday + Duration::days(i);
153 let day_context = self.day_context(day)?;
154
155 context.days.push(DaySummaryWithDate {
157 date: day.format("%Y-%m-%d").to_string(),
158 day_of_week: day.format("%A").to_string(),
159 summary: day_context.summary.clone(),
160 });
161
162 context.summary.tasks_completed += day_context.summary.tasks_completed;
164 context.summary.tasks_created += day_context.summary.tasks_created;
165 context.summary.notes_modified += day_context.summary.notes_modified;
166
167 if day_context.summary.tasks_completed > 0
168 || day_context.summary.tasks_created > 0
169 || day_context.summary.notes_modified > 0
170 {
171 context.summary.active_days += 1;
172 }
173
174 context.tasks.completed.extend(day_context.tasks.completed);
176 context.tasks.created.extend(day_context.tasks.created);
177
178 for proj in day_context.projects {
180 let entry =
181 project_map.entry(proj.name.clone()).or_insert(ProjectActivity {
182 name: proj.name,
183 tasks_done: 0,
184 tasks_active: 0,
185 logs_added: 0,
186 });
187 entry.tasks_done += proj.tasks_done;
188 entry.tasks_active = entry.tasks_active.max(proj.tasks_active);
189 entry.logs_added += proj.logs_added;
190 }
191
192 all_entries.extend(self.get_logged_activity(day));
194 }
195
196 context.tasks.in_progress = self.get_in_progress_tasks();
198
199 context.projects = project_map.into_values().collect();
201 context.projects.sort_by(|a, b| b.tasks_done.cmp(&a.tasks_done));
202
203 Ok(context)
204 }
205
206 fn get_logged_activity(&self, date: NaiveDate) -> Vec<ActivityEntry> {
208 let Some(ref activity) = self.activity_service else {
209 return Vec::new();
210 };
211
212 let start = Local
214 .from_local_datetime(&date.and_hms_opt(0, 0, 0).unwrap())
215 .unwrap()
216 .with_timezone(&Utc);
217 let end = Local
218 .from_local_datetime(&date.succ_opt().unwrap().and_hms_opt(0, 0, 0).unwrap())
219 .unwrap()
220 .with_timezone(&Utc);
221
222 activity.read_entries(Some(start), Some(end)).unwrap_or_default()
223 }
224
225 fn detect_unlogged_changes(
227 &self,
228 date: NaiveDate,
229 logged_entries: &[ActivityEntry],
230 ) -> Vec<ModifiedNote> {
231 let mut result = Vec::new();
232
233 let logged_paths: HashSet<PathBuf> =
235 logged_entries.iter().map(|e| e.path.clone()).collect();
236
237 let walker = walkdir::WalkDir::new(&self.vault_root)
239 .follow_links(false)
240 .into_iter()
241 .filter_entry(|e| {
242 let name = e.file_name().to_string_lossy();
243 !name.starts_with('.') && !name.starts_with('_')
244 });
245
246 for entry in walker.filter_map(|e| e.ok()) {
247 if !entry.file_type().is_file() {
248 continue;
249 }
250
251 let path = entry.path();
252 if path.extension().map(|e| e != "md").unwrap_or(true) {
253 continue;
254 }
255
256 let rel_path = match path.strip_prefix(&self.vault_root) {
258 Ok(p) => p.to_path_buf(),
259 Err(_) => continue,
260 };
261
262 if logged_paths.contains(&rel_path) {
264 continue;
265 }
266
267 let metadata = match fs::metadata(path) {
269 Ok(m) => m,
270 Err(_) => continue,
271 };
272
273 let mtime = match metadata.modified() {
274 Ok(t) => t,
275 Err(_) => continue,
276 };
277
278 let mtime_date: chrono::DateTime<Local> = mtime.into();
279 if mtime_date.date_naive() == date {
280 let note_type = fs::read_to_string(path)
282 .ok()
283 .and_then(|content| parse_frontmatter(&content).ok())
284 .and_then(|doc| doc.frontmatter)
285 .and_then(|fm| fm.fields.get("type").cloned())
286 .and_then(|v| match v {
287 serde_yaml::Value::String(s) => Some(s),
288 _ => None,
289 });
290
291 result.push(ModifiedNote {
292 path: rel_path,
293 note_type,
294 source: "detected".to_string(),
295 change_summary: Some("modified".to_string()),
296 });
297 }
298 }
299
300 result
301 }
302
303 fn parse_daily_note(&self, date: NaiveDate) -> Option<DailyNoteInfo> {
305 let date_str = date.format("%Y-%m-%d").to_string();
306 let rel_path = self.daily_note_pattern.replace("{date}", &date_str);
307 let path = self.vault_root.join(&rel_path);
308
309 let exists = path.exists();
310
311 if !exists {
312 return Some(DailyNoteInfo {
313 path: PathBuf::from(rel_path),
314 exists: false,
315 sections: Vec::new(),
316 log_count: 0,
317 });
318 }
319
320 let content = fs::read_to_string(&path).ok()?;
321
322 let headings = MarkdownEditor::find_headings(&content);
324 let sections: Vec<String> = headings.iter().map(|h| h.title.clone()).collect();
325
326 let log_count =
328 content.lines().filter(|line| line.trim_start().starts_with("- ")).count();
329
330 Some(DailyNoteInfo {
331 path: PathBuf::from(rel_path),
332 exists: true,
333 sections,
334 log_count: log_count as u32,
335 })
336 }
337
338 fn aggregate_tasks(&self, entries: &[ActivityEntry]) -> TaskActivity {
340 let mut activity = TaskActivity::default();
341
342 for entry in entries {
343 if entry.note_type != "task" {
344 continue;
345 }
346
347 let title = entry
348 .meta
349 .get("title")
350 .and_then(|v| v.as_str())
351 .unwrap_or("Untitled")
352 .to_string();
353
354 let project =
355 entry.meta.get("project").and_then(|v| v.as_str()).map(String::from);
356
357 let task_info = TaskInfo {
358 id: entry.id.clone(),
359 title,
360 project,
361 path: entry.path.clone(),
362 };
363
364 match entry.op {
365 Operation::New => activity.created.push(task_info),
366 Operation::Complete => activity.completed.push(task_info),
367 _ => {}
368 }
369 }
370
371 activity.in_progress = self.get_in_progress_tasks();
373
374 activity
375 }
376
377 fn get_in_progress_tasks(&self) -> Vec<TaskInfo> {
379 let Some(ref db) = self.index_db else {
380 return Vec::new();
381 };
382
383 use crate::index::{NoteQuery, NoteType};
384
385 let query = NoteQuery { note_type: Some(NoteType::Task), ..Default::default() };
386
387 let tasks = match db.query_notes(&query) {
388 Ok(t) => t,
389 Err(_) => return Vec::new(),
390 };
391
392 tasks
393 .into_iter()
394 .filter_map(|note| {
395 let fm: serde_json::Value = note
397 .frontmatter_json
398 .as_ref()
399 .and_then(|s| serde_json::from_str(s).ok())?;
400
401 let status = fm.get("status").and_then(|v| v.as_str()).unwrap_or("todo");
402
403 if status == "doing" || status == "in_progress" || status == "in-progress"
404 {
405 let id = fm
406 .get("task-id")
407 .and_then(|v| v.as_str())
408 .unwrap_or("")
409 .to_string();
410
411 let project =
412 fm.get("project").and_then(|v| v.as_str()).map(String::from);
413
414 Some(TaskInfo { id, title: note.title, project, path: note.path })
415 } else {
416 None
417 }
418 })
419 .collect()
420 }
421
422 fn get_focus_for_day(&self, _date: NaiveDate) -> Option<String> {
424 ContextManager::load(&self.vault_root)
427 .ok()
428 .and_then(|mgr| mgr.active_project().map(String::from))
429 }
430
431 fn aggregate_projects(&self, entries: &[ActivityEntry]) -> Vec<ProjectActivity> {
433 let mut project_map: HashMap<String, ProjectActivity> = HashMap::new();
434
435 for entry in entries {
436 let project = entry
438 .meta
439 .get("project")
440 .and_then(|v| v.as_str())
441 .map(String::from)
442 .or_else(|| self.extract_project_from_path(&entry.path));
443
444 let Some(project_name) = project else {
445 continue;
446 };
447
448 let proj =
449 project_map.entry(project_name.clone()).or_insert(ProjectActivity {
450 name: project_name,
451 tasks_done: 0,
452 tasks_active: 0,
453 logs_added: 0,
454 });
455
456 match entry.op {
457 Operation::Complete if entry.note_type == "task" => {
458 proj.tasks_done += 1;
459 }
460 Operation::New if entry.note_type == "task" => {
461 proj.tasks_active += 1;
462 }
463 Operation::Capture => {
464 proj.logs_added += 1;
465 }
466 _ => {}
467 }
468 }
469
470 let mut result: Vec<ProjectActivity> = project_map.into_values().collect();
471 result.sort_by(|a, b| b.tasks_done.cmp(&a.tasks_done));
472 result
473 }
474
475 fn extract_project_from_path(&self, path: &Path) -> Option<String> {
477 let path_str = path.to_string_lossy();
478 let parts: Vec<&str> = path_str.split('/').collect();
479
480 if parts.len() >= 2 && parts[0] == "Projects" {
481 Some(parts[1].to_string())
482 } else {
483 None
484 }
485 }
486
487 pub fn note_context(
489 &self,
490 note_path: &Path,
491 activity_days: u32,
492 ) -> Result<NoteContext, ContextError> {
493 let Some(ref db) = self.index_db else {
494 return Err(ContextError::IndexError("Index database not available".into()));
495 };
496
497 let note = db
499 .get_note_by_path(note_path)
500 .map_err(|e| ContextError::IndexError(e.to_string()))?
501 .ok_or_else(|| {
502 ContextError::IndexError(format!(
503 "Note not found: {}",
504 note_path.display()
505 ))
506 })?;
507
508 let note_id =
509 note.id.ok_or_else(|| ContextError::IndexError("Note has no ID".into()))?;
510
511 let metadata: serde_json::Value = note
513 .frontmatter_json
514 .as_ref()
515 .and_then(|fm| serde_json::from_str(fm).ok())
516 .unwrap_or(serde_json::Value::Null);
517
518 let note_type = format!("{:?}", note.note_type).to_lowercase();
520
521 let sections = self.parse_note_sections(note_path);
523
524 let (tasks, recent_tasks) = if note.note_type == crate::index::NoteType::Project {
526 let counts = self.get_task_counts(note_path);
527 let recent = self.get_recent_tasks(note_path);
528 (Some(counts), Some(recent))
529 } else {
530 (None, None)
531 };
532
533 let activity = self.get_note_activity(note_path, activity_days);
535
536 let references = self.get_note_references(note_id);
538
539 Ok(NoteContext {
540 note_type,
541 path: note_path.to_path_buf(),
542 title: note.title,
543 metadata,
544 sections,
545 tasks,
546 recent_tasks,
547 activity,
548 references,
549 })
550 }
551
552 pub fn focus_context(&self) -> Result<FocusContextOutput, ContextError> {
554 let mgr = ContextManager::load(&self.vault_root)
555 .map_err(|e| ContextError::IoError(std::io::Error::other(e.to_string())))?;
556
557 let focus =
558 mgr.focus().ok_or_else(|| ContextError::IndexError("No focus set".into()))?;
559
560 let project = focus.project.clone();
561 let note = focus.note.clone();
562 let started_at = focus.started_at.map(|dt| dt.to_rfc3339());
563
564 let project_path = self.find_project_path(&project);
566
567 let context = if let Some(ref path) = project_path {
569 self.note_context(path, 7).ok().map(Box::new)
570 } else {
571 None
572 };
573
574 Ok(FocusContextOutput { project, project_path, started_at, note, context })
575 }
576
577 fn find_project_path(&self, project: &str) -> Option<PathBuf> {
579 let patterns = [
581 format!("Projects/{}/{}.md", project, project),
582 format!("Projects/{}.md", project),
583 ];
584
585 for pattern in &patterns {
586 let path = PathBuf::from(pattern);
587 let full_path = self.vault_root.join(&path);
588 if full_path.exists() {
589 return Some(path);
590 }
591 }
592
593 if let Some(ref db) = self.index_db {
595 use crate::index::{NoteQuery, NoteType};
596
597 let query =
598 NoteQuery { note_type: Some(NoteType::Project), ..Default::default() };
599
600 if let Ok(projects) = db.query_notes(&query) {
601 for proj in projects {
602 let fm: Option<serde_json::Value> = proj
604 .frontmatter_json
605 .as_ref()
606 .and_then(|s| serde_json::from_str(s).ok());
607
608 if let Some(fm) = fm
609 && fm.get("project-id").and_then(|v| v.as_str()) == Some(project)
610 {
611 return Some(proj.path);
612 }
613
614 if proj.title.eq_ignore_ascii_case(project) {
616 return Some(proj.path);
617 }
618 }
619 }
620 }
621
622 None
623 }
624
625 fn get_task_counts(&self, project_path: &Path) -> TaskCounts {
627 let Some(ref db) = self.index_db else {
628 return TaskCounts::default();
629 };
630
631 use crate::index::{NoteQuery, NoteType};
632
633 let query = NoteQuery { note_type: Some(NoteType::Task), ..Default::default() };
634
635 let tasks = match db.query_notes(&query) {
636 Ok(t) => t,
637 Err(_) => return TaskCounts::default(),
638 };
639
640 let project_folder = project_path
642 .parent()
643 .and_then(|p| p.file_name())
644 .map(|s| s.to_string_lossy().to_string())
645 .unwrap_or_default();
646
647 let mut counts = TaskCounts::default();
648
649 for task in tasks {
650 let task_path_str = task.path.to_string_lossy();
652 if !task_path_str.contains(&format!("Projects/{}/", project_folder)) {
653 continue;
654 }
655
656 counts.total += 1;
657
658 let status = task
660 .frontmatter_json
661 .as_ref()
662 .and_then(|fm| serde_json::from_str::<serde_json::Value>(fm).ok())
663 .and_then(|fm| {
664 fm.get("status").and_then(|v| v.as_str()).map(String::from)
665 })
666 .unwrap_or_else(|| "todo".to_string());
667
668 match status.as_str() {
669 "done" | "completed" => counts.done += 1,
670 "doing" | "in-progress" | "in_progress" => counts.doing += 1,
671 "blocked" | "waiting" => counts.blocked += 1,
672 _ => counts.todo += 1,
673 }
674 }
675
676 counts
677 }
678
679 fn get_recent_tasks(&self, project_path: &Path) -> RecentTasks {
681 let Some(ref db) = self.index_db else {
682 return RecentTasks::default();
683 };
684
685 use crate::index::{NoteQuery, NoteType};
686
687 let query = NoteQuery { note_type: Some(NoteType::Task), ..Default::default() };
688
689 let tasks = match db.query_notes(&query) {
690 Ok(t) => t,
691 Err(_) => return RecentTasks::default(),
692 };
693
694 let project_folder = project_path
696 .parent()
697 .and_then(|p| p.file_name())
698 .map(|s| s.to_string_lossy().to_string())
699 .unwrap_or_default();
700
701 let mut completed = Vec::new();
702 let mut active = Vec::new();
703
704 for task in tasks {
705 let task_path_str = task.path.to_string_lossy();
707 if !task_path_str.contains(&format!("Projects/{}/", project_folder)) {
708 continue;
709 }
710
711 let fm: Option<serde_json::Value> =
712 task.frontmatter_json.as_ref().and_then(|s| serde_json::from_str(s).ok());
713
714 let status = fm
715 .as_ref()
716 .and_then(|f| f.get("status").and_then(|v| v.as_str()))
717 .unwrap_or("todo");
718
719 let task_id = fm
720 .as_ref()
721 .and_then(|f| f.get("task-id").and_then(|v| v.as_str()))
722 .unwrap_or("")
723 .to_string();
724
725 let project_name = fm
726 .as_ref()
727 .and_then(|f| f.get("project").and_then(|v| v.as_str()))
728 .map(String::from);
729
730 let task_info = TaskInfo {
731 id: task_id,
732 title: task.title.clone(),
733 project: project_name,
734 path: task.path.clone(),
735 };
736
737 match status {
738 "done" | "completed" => {
739 if completed.len() < 5 {
740 completed.push(task_info);
741 }
742 }
743 "doing" | "in-progress" | "in_progress" => {
744 active.push(task_info);
745 }
746 _ => {}
747 }
748 }
749
750 RecentTasks { completed, active }
751 }
752
753 fn get_note_activity(&self, note_path: &Path, days: u32) -> NoteActivity {
755 let Some(ref activity) = self.activity_service else {
756 return NoteActivity { period_days: days, entries: Vec::new() };
757 };
758
759 let end = Utc::now();
761 let start = end - Duration::days(days as i64);
762
763 let entries = match activity.read_entries(Some(start), Some(end)) {
764 Ok(e) => e,
765 Err(_) => return NoteActivity { period_days: days, entries: Vec::new() },
766 };
767
768 let note_path_str = note_path.to_string_lossy();
770 let filtered: Vec<ActivityItem> = entries
771 .into_iter()
772 .filter(|e| {
773 let entry_path_str = e.path.to_string_lossy();
774 entry_path_str == note_path_str
775 || entry_path_str.starts_with(&format!(
776 "{}/",
777 note_path_str.trim_end_matches(".md")
778 ))
779 })
780 .map(|e| ActivityItem {
781 ts: e.ts.to_rfc3339(),
782 source: "logged".to_string(),
783 op: e.op.to_string(),
784 note_type: e.note_type.clone(),
785 id: if e.id.is_empty() { None } else { Some(e.id.clone()) },
786 path: e.path.clone(),
787 summary: e.meta.get("title").and_then(|v| v.as_str()).map(String::from),
788 })
789 .collect();
790
791 NoteActivity { period_days: days, entries: filtered }
792 }
793
794 fn get_note_references(&self, note_id: i64) -> NoteReferences {
796 let Some(ref db) = self.index_db else {
797 return NoteReferences::default();
798 };
799
800 let backlinks = db.get_backlinks(note_id).unwrap_or_default();
802
803 let backlink_infos: Vec<LinkInfo> = backlinks
804 .iter()
805 .filter_map(|link| {
806 db.get_note_by_id(link.source_id).ok().flatten().map(|note| LinkInfo {
808 path: note.path,
809 title: Some(note.title),
810 link_text: link.link_text.clone(),
811 })
812 })
813 .take(10)
814 .collect();
815
816 let outgoing = db.get_outgoing_links(note_id).unwrap_or_default();
818
819 let outgoing_infos: Vec<LinkInfo> = outgoing
820 .iter()
821 .filter_map(|link| {
822 if let Some(target_id) = link.target_id {
823 db.get_note_by_id(target_id).ok().flatten().map(|note| LinkInfo {
824 path: note.path,
825 title: Some(note.title),
826 link_text: link.link_text.clone(),
827 })
828 } else {
829 Some(LinkInfo {
831 path: PathBuf::from(&link.target_path),
832 title: None,
833 link_text: link.link_text.clone(),
834 })
835 }
836 })
837 .take(10)
838 .collect();
839
840 NoteReferences {
841 backlink_count: backlinks.len() as u32,
842 backlinks: backlink_infos,
843 outgoing_count: outgoing.len() as u32,
844 outgoing: outgoing_infos,
845 }
846 }
847
848 fn parse_note_sections(&self, note_path: &Path) -> Vec<String> {
850 let full_path = self.vault_root.join(note_path);
851 let content = match fs::read_to_string(&full_path) {
852 Ok(c) => c,
853 Err(_) => return Vec::new(),
854 };
855
856 let headings = MarkdownEditor::find_headings(&content);
857 headings.iter().map(|h| h.title.clone()).collect()
858 }
859}
860
861#[cfg(test)]
862mod tests {
863 use super::*;
864 use tempfile::tempdir;
865
866 #[test]
867 fn test_day_context_empty() {
868 let tmp = tempdir().unwrap();
869 let config = ResolvedConfig {
870 vault_root: tmp.path().to_path_buf(),
871 activity: Default::default(),
872 ..make_test_config(tmp.path().to_path_buf())
873 };
874
875 let service = ContextQueryService::new(&config);
876 let today = Local::now().date_naive();
877 let context = service.day_context(today).unwrap();
878
879 assert_eq!(context.summary.tasks_completed, 0);
880 assert_eq!(context.summary.tasks_created, 0);
881 }
882
883 #[test]
884 fn test_week_context_empty() {
885 let tmp = tempdir().unwrap();
886 let config = make_test_config(tmp.path().to_path_buf());
887
888 let service = ContextQueryService::new(&config);
889 let today = Local::now().date_naive();
890 let context = service.week_context(today).unwrap();
891
892 assert_eq!(context.summary.tasks_completed, 0);
893 assert_eq!(context.summary.tasks_created, 0);
894 assert_eq!(context.days.len(), 7);
895 }
896
897 #[test]
898 fn test_note_context_no_index() {
899 let tmp = tempdir().unwrap();
900 let config = make_test_config(tmp.path().to_path_buf());
901
902 let service = ContextQueryService::new(&config);
903 let result = service.note_context(Path::new("test.md"), 7);
904
905 assert!(result.is_err());
906 assert!(result.unwrap_err().to_string().contains("Index database not available"));
907 }
908
909 #[test]
910 fn test_focus_context_no_focus() {
911 let tmp = tempdir().unwrap();
912 let config = make_test_config(tmp.path().to_path_buf());
913
914 fs::create_dir_all(tmp.path().join(".mdvault/state")).unwrap();
916
917 let service = ContextQueryService::new(&config);
918 let result = service.focus_context();
919
920 assert!(result.is_err());
921 assert!(result.unwrap_err().to_string().contains("No focus set"));
922 }
923
924 #[test]
925 fn test_day_context_to_summary() {
926 let context = DayContext::new("2026-01-24", "Friday");
927 let summary = context.to_summary();
928
929 assert!(summary.contains("2026-01-24"));
930 assert!(summary.contains("0 done"));
931 }
932
933 #[test]
934 fn test_day_context_to_markdown() {
935 let mut context = DayContext::new("2026-01-24", "Friday");
936 context.summary.tasks_completed = 3;
937 context.summary.tasks_created = 2;
938
939 let md = context.to_markdown();
940
941 assert!(md.contains("# Context: 2026-01-24 (Friday)"));
942 assert!(md.contains("3 tasks completed"));
943 assert!(md.contains("2 tasks created"));
944 }
945
946 #[test]
947 fn test_week_context_to_summary() {
948 let context = WeekContext {
949 week: "2026-W04".to_string(),
950 start_date: "2026-01-20".to_string(),
951 end_date: "2026-01-26".to_string(),
952 summary: WeekSummary {
953 tasks_completed: 5,
954 tasks_created: 3,
955 notes_modified: 10,
956 active_days: 4,
957 },
958 days: vec![],
959 tasks: TaskActivity::default(),
960 projects: vec![],
961 };
962
963 let summary = context.to_summary();
964
965 assert!(summary.contains("2026-W04"));
966 assert!(summary.contains("5 done"));
967 assert!(summary.contains("4 days"));
968 }
969
970 #[test]
971 fn test_note_context_to_summary() {
972 let context = NoteContext {
973 note_type: "project".to_string(),
974 path: PathBuf::from("Projects/test/test.md"),
975 title: "Test Project".to_string(),
976 metadata: serde_json::json!({"status": "active"}),
977 sections: vec!["Overview".to_string()],
978 tasks: Some(TaskCounts { total: 10, todo: 5, doing: 2, done: 3, blocked: 0 }),
979 recent_tasks: None,
980 activity: NoteActivity::default(),
981 references: NoteReferences::default(),
982 };
983
984 let summary = context.to_summary();
985
986 assert!(summary.contains("Projects/test/test.md"));
987 assert!(summary.contains("project"));
988 assert!(summary.contains("3 done"));
989 assert!(summary.contains("2 doing"));
990 }
991
992 #[test]
993 fn test_focus_context_output_to_summary() {
994 let output = FocusContextOutput {
995 project: "test-project".to_string(),
996 project_path: Some(PathBuf::from("Projects/test/test.md")),
997 started_at: Some("2026-01-24T10:00:00Z".to_string()),
998 note: None,
999 context: None,
1000 };
1001
1002 let summary = output.to_summary();
1003
1004 assert!(summary.contains("test-project"));
1005 }
1006
1007 #[test]
1008 fn test_task_counts_default() {
1009 let counts = TaskCounts::default();
1010
1011 assert_eq!(counts.total, 0);
1012 assert_eq!(counts.todo, 0);
1013 assert_eq!(counts.doing, 0);
1014 assert_eq!(counts.done, 0);
1015 assert_eq!(counts.blocked, 0);
1016 }
1017
1018 fn make_test_config(vault_root: PathBuf) -> ResolvedConfig {
1019 ResolvedConfig {
1020 active_profile: "test".into(),
1021 vault_root: vault_root.clone(),
1022 templates_dir: vault_root.join(".mdvault/templates"),
1023 captures_dir: vault_root.join(".mdvault/captures"),
1024 macros_dir: vault_root.join(".mdvault/macros"),
1025 typedefs_dir: vault_root.join(".mdvault/typedefs"),
1026 excluded_folders: vec![],
1027 security: Default::default(),
1028 logging: Default::default(),
1029 activity: Default::default(),
1030 }
1031 }
1032}