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/{year}/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 year_str = date.format("%Y").to_string();
307 let rel_path = self
308 .daily_note_pattern
309 .replace("{year}", &year_str)
310 .replace("{date}", &date_str);
311 let path = self.vault_root.join(&rel_path);
312
313 let exists = path.exists();
314
315 if !exists {
316 return Some(DailyNoteInfo {
317 path: PathBuf::from(rel_path),
318 exists: false,
319 sections: Vec::new(),
320 log_count: 0,
321 });
322 }
323
324 let content = fs::read_to_string(&path).ok()?;
325
326 let headings = MarkdownEditor::find_headings(&content);
328 let sections: Vec<String> = headings.iter().map(|h| h.title.clone()).collect();
329
330 let log_count =
332 content.lines().filter(|line| line.trim_start().starts_with("- ")).count();
333
334 Some(DailyNoteInfo {
335 path: PathBuf::from(rel_path),
336 exists: true,
337 sections,
338 log_count: log_count as u32,
339 })
340 }
341
342 fn aggregate_tasks(&self, entries: &[ActivityEntry]) -> TaskActivity {
344 let mut activity = TaskActivity::default();
345
346 for entry in entries {
347 if entry.note_type != "task" {
348 continue;
349 }
350
351 let title = entry
352 .meta
353 .get("title")
354 .and_then(|v| v.as_str())
355 .unwrap_or("Untitled")
356 .to_string();
357
358 let project =
359 entry.meta.get("project").and_then(|v| v.as_str()).map(String::from);
360
361 let task_info = TaskInfo {
362 id: entry.id.clone(),
363 title,
364 project,
365 path: entry.path.clone(),
366 };
367
368 match entry.op {
369 Operation::New => activity.created.push(task_info),
370 Operation::Complete => activity.completed.push(task_info),
371 _ => {}
372 }
373 }
374
375 activity.in_progress = self.get_in_progress_tasks();
377
378 activity
379 }
380
381 fn get_in_progress_tasks(&self) -> Vec<TaskInfo> {
383 let Some(ref db) = self.index_db else {
384 return Vec::new();
385 };
386
387 use crate::index::{NoteQuery, NoteType};
388
389 let query = NoteQuery { note_type: Some(NoteType::Task), ..Default::default() };
390
391 let tasks = match db.query_notes(&query) {
392 Ok(t) => t,
393 Err(_) => return Vec::new(),
394 };
395
396 tasks
397 .into_iter()
398 .filter_map(|note| {
399 let fm: serde_json::Value = note
401 .frontmatter_json
402 .as_ref()
403 .and_then(|s| serde_json::from_str(s).ok())?;
404
405 let status = fm.get("status").and_then(|v| v.as_str()).unwrap_or("todo");
406
407 if status == "doing" || status == "in_progress" || status == "in-progress"
408 {
409 let id = fm
410 .get("task-id")
411 .and_then(|v| v.as_str())
412 .unwrap_or("")
413 .to_string();
414
415 let project =
416 fm.get("project").and_then(|v| v.as_str()).map(String::from);
417
418 Some(TaskInfo { id, title: note.title, project, path: note.path })
419 } else {
420 None
421 }
422 })
423 .collect()
424 }
425
426 fn get_focus_for_day(&self, _date: NaiveDate) -> Option<String> {
428 ContextManager::load(&self.vault_root)
431 .ok()
432 .and_then(|mgr| mgr.active_project().map(String::from))
433 }
434
435 fn aggregate_projects(&self, entries: &[ActivityEntry]) -> Vec<ProjectActivity> {
437 let mut project_map: HashMap<String, ProjectActivity> = HashMap::new();
438
439 for entry in entries {
440 let project = entry
442 .meta
443 .get("project")
444 .and_then(|v| v.as_str())
445 .map(String::from)
446 .or_else(|| self.extract_project_from_path(&entry.path));
447
448 let Some(project_name) = project else {
449 continue;
450 };
451
452 let proj =
453 project_map.entry(project_name.clone()).or_insert(ProjectActivity {
454 name: project_name,
455 tasks_done: 0,
456 tasks_active: 0,
457 logs_added: 0,
458 });
459
460 match entry.op {
461 Operation::Complete if entry.note_type == "task" => {
462 proj.tasks_done += 1;
463 }
464 Operation::New if entry.note_type == "task" => {
465 proj.tasks_active += 1;
466 }
467 Operation::Capture => {
468 proj.logs_added += 1;
469 }
470 _ => {}
471 }
472 }
473
474 let mut result: Vec<ProjectActivity> = project_map.into_values().collect();
475 result.sort_by(|a, b| b.tasks_done.cmp(&a.tasks_done));
476 result
477 }
478
479 fn extract_project_from_path(&self, path: &Path) -> Option<String> {
482 let path_str = path.to_string_lossy();
483 let parts: Vec<&str> = path_str.split('/').collect();
484
485 if parts.len() >= 4 && parts[0] == "Projects" && parts[1] == "_archive" {
486 return Some(parts[2].to_string());
487 }
488 if parts.len() >= 2 && parts[0] == "Projects" {
489 Some(parts[1].to_string())
490 } else {
491 None
492 }
493 }
494
495 pub fn note_context(
497 &self,
498 note_path: &Path,
499 activity_days: u32,
500 ) -> Result<NoteContext, ContextError> {
501 let Some(ref db) = self.index_db else {
502 return Err(ContextError::IndexError("Index database not available".into()));
503 };
504
505 let note = db
507 .get_note_by_path(note_path)
508 .map_err(|e| ContextError::IndexError(e.to_string()))?
509 .ok_or_else(|| {
510 ContextError::IndexError(format!(
511 "Note not found: {}",
512 note_path.display()
513 ))
514 })?;
515
516 let note_id =
517 note.id.ok_or_else(|| ContextError::IndexError("Note has no ID".into()))?;
518
519 let metadata: serde_json::Value = note
521 .frontmatter_json
522 .as_ref()
523 .and_then(|fm| serde_json::from_str(fm).ok())
524 .unwrap_or(serde_json::Value::Null);
525
526 let note_type = format!("{:?}", note.note_type).to_lowercase();
528
529 let sections = self.parse_note_sections(note_path);
531
532 let (tasks, recent_tasks) = if note.note_type == crate::index::NoteType::Project {
534 let counts = self.get_task_counts(note_path);
535 let recent = self.get_recent_tasks(note_path);
536 (Some(counts), Some(recent))
537 } else {
538 (None, None)
539 };
540
541 let activity = self.get_note_activity(note_path, activity_days);
543
544 let references = self.get_note_references(note_id);
546
547 Ok(NoteContext {
548 note_type,
549 path: note_path.to_path_buf(),
550 title: note.title,
551 metadata,
552 sections,
553 tasks,
554 recent_tasks,
555 activity,
556 references,
557 })
558 }
559
560 pub fn focus_context(&self) -> Result<FocusContextOutput, ContextError> {
562 let mgr = ContextManager::load(&self.vault_root)
563 .map_err(|e| ContextError::IoError(std::io::Error::other(e.to_string())))?;
564
565 let focus =
566 mgr.focus().ok_or_else(|| ContextError::IndexError("No focus set".into()))?;
567
568 let project = focus.project.clone();
569 let note = focus.note.clone();
570 let started_at = focus.started_at.map(|dt| dt.to_rfc3339());
571
572 let project_path = self.find_project_path(&project);
574
575 let context = if let Some(ref path) = project_path {
577 self.note_context(path, 7).ok().map(Box::new)
578 } else {
579 None
580 };
581
582 Ok(FocusContextOutput { project, project_path, started_at, note, context })
583 }
584
585 fn find_project_path(&self, project: &str) -> Option<PathBuf> {
587 let patterns = [
589 format!("Projects/{}/{}.md", project, project),
590 format!("Projects/{}.md", project),
591 ];
592
593 for pattern in &patterns {
594 let path = PathBuf::from(pattern);
595 let full_path = self.vault_root.join(&path);
596 if full_path.exists() {
597 return Some(path);
598 }
599 }
600
601 if let Some(ref db) = self.index_db {
603 use crate::index::{NoteQuery, NoteType};
604
605 let query =
606 NoteQuery { note_type: Some(NoteType::Project), ..Default::default() };
607
608 if let Ok(projects) = db.query_notes(&query) {
609 for proj in projects {
610 let fm: Option<serde_json::Value> = proj
612 .frontmatter_json
613 .as_ref()
614 .and_then(|s| serde_json::from_str(s).ok());
615
616 if let Some(fm) = fm
617 && fm.get("project-id").and_then(|v| v.as_str()) == Some(project)
618 {
619 return Some(proj.path);
620 }
621
622 if proj.title.eq_ignore_ascii_case(project) {
624 return Some(proj.path);
625 }
626 }
627 }
628 }
629
630 None
631 }
632
633 fn get_task_counts(&self, project_path: &Path) -> TaskCounts {
635 let Some(ref db) = self.index_db else {
636 return TaskCounts::default();
637 };
638
639 use crate::index::{NoteQuery, NoteType};
640
641 let query = NoteQuery { note_type: Some(NoteType::Task), ..Default::default() };
642
643 let tasks = match db.query_notes(&query) {
644 Ok(t) => t,
645 Err(_) => return TaskCounts::default(),
646 };
647
648 let project_folder = project_path
650 .parent()
651 .and_then(|p| p.file_name())
652 .map(|s| s.to_string_lossy().to_string())
653 .unwrap_or_default();
654
655 let mut counts = TaskCounts::default();
656
657 for task in tasks {
658 let task_path_str = task.path.to_string_lossy();
660 if !crate::domain::task_belongs_to_project(&task_path_str, &project_folder) {
661 continue;
662 }
663
664 counts.total += 1;
665
666 let status = task
668 .frontmatter_json
669 .as_ref()
670 .and_then(|fm| serde_json::from_str::<serde_json::Value>(fm).ok())
671 .and_then(|fm| {
672 fm.get("status").and_then(|v| v.as_str()).map(String::from)
673 })
674 .unwrap_or_else(|| "todo".to_string());
675
676 match status.as_str() {
677 "done" | "completed" => counts.done += 1,
678 "doing" | "in-progress" | "in_progress" => counts.doing += 1,
679 "blocked" | "waiting" => counts.blocked += 1,
680 _ => counts.todo += 1,
681 }
682 }
683
684 counts
685 }
686
687 fn get_recent_tasks(&self, project_path: &Path) -> RecentTasks {
689 let Some(ref db) = self.index_db else {
690 return RecentTasks::default();
691 };
692
693 use crate::index::{NoteQuery, NoteType};
694
695 let query = NoteQuery { note_type: Some(NoteType::Task), ..Default::default() };
696
697 let tasks = match db.query_notes(&query) {
698 Ok(t) => t,
699 Err(_) => return RecentTasks::default(),
700 };
701
702 let project_folder = project_path
704 .parent()
705 .and_then(|p| p.file_name())
706 .map(|s| s.to_string_lossy().to_string())
707 .unwrap_or_default();
708
709 let mut completed = Vec::new();
710 let mut active = Vec::new();
711
712 for task in tasks {
713 let task_path_str = task.path.to_string_lossy();
715 if !crate::domain::task_belongs_to_project(&task_path_str, &project_folder) {
716 continue;
717 }
718
719 let fm: Option<serde_json::Value> =
720 task.frontmatter_json.as_ref().and_then(|s| serde_json::from_str(s).ok());
721
722 let status = fm
723 .as_ref()
724 .and_then(|f| f.get("status").and_then(|v| v.as_str()))
725 .unwrap_or("todo");
726
727 let task_id = fm
728 .as_ref()
729 .and_then(|f| f.get("task-id").and_then(|v| v.as_str()))
730 .unwrap_or("")
731 .to_string();
732
733 let project_name = fm
734 .as_ref()
735 .and_then(|f| f.get("project").and_then(|v| v.as_str()))
736 .map(String::from);
737
738 let task_info = TaskInfo {
739 id: task_id,
740 title: task.title.clone(),
741 project: project_name,
742 path: task.path.clone(),
743 };
744
745 match status {
746 "done" | "completed" => {
747 if completed.len() < 5 {
748 completed.push(task_info);
749 }
750 }
751 "doing" | "in-progress" | "in_progress" => {
752 active.push(task_info);
753 }
754 _ => {}
755 }
756 }
757
758 RecentTasks { completed, active }
759 }
760
761 fn get_note_activity(&self, note_path: &Path, days: u32) -> NoteActivity {
763 let Some(ref activity) = self.activity_service else {
764 return NoteActivity { period_days: days, entries: Vec::new() };
765 };
766
767 let end = Utc::now();
769 let start = end - Duration::days(days as i64);
770
771 let entries = match activity.read_entries(Some(start), Some(end)) {
772 Ok(e) => e,
773 Err(_) => return NoteActivity { period_days: days, entries: Vec::new() },
774 };
775
776 let note_path_str = note_path.to_string_lossy();
778 let filtered: Vec<ActivityItem> = entries
779 .into_iter()
780 .filter(|e| {
781 let entry_path_str = e.path.to_string_lossy();
782 entry_path_str == note_path_str
783 || entry_path_str.starts_with(&format!(
784 "{}/",
785 note_path_str.trim_end_matches(".md")
786 ))
787 })
788 .map(|e| ActivityItem {
789 ts: e.ts.to_rfc3339(),
790 source: "logged".to_string(),
791 op: e.op.to_string(),
792 note_type: e.note_type.clone(),
793 id: if e.id.is_empty() { None } else { Some(e.id.clone()) },
794 path: e.path.clone(),
795 summary: e.meta.get("title").and_then(|v| v.as_str()).map(String::from),
796 })
797 .collect();
798
799 NoteActivity { period_days: days, entries: filtered }
800 }
801
802 fn get_note_references(&self, note_id: i64) -> NoteReferences {
804 let Some(ref db) = self.index_db else {
805 return NoteReferences::default();
806 };
807
808 let backlinks = db.get_backlinks(note_id).unwrap_or_default();
810
811 let backlink_infos: Vec<LinkInfo> = backlinks
812 .iter()
813 .filter_map(|link| {
814 db.get_note_by_id(link.source_id).ok().flatten().map(|note| LinkInfo {
816 path: note.path,
817 title: Some(note.title),
818 link_text: link.link_text.clone(),
819 })
820 })
821 .take(10)
822 .collect();
823
824 let outgoing = db.get_outgoing_links(note_id).unwrap_or_default();
826
827 let outgoing_infos: Vec<LinkInfo> = outgoing
828 .iter()
829 .filter_map(|link| {
830 if let Some(target_id) = link.target_id {
831 db.get_note_by_id(target_id).ok().flatten().map(|note| LinkInfo {
832 path: note.path,
833 title: Some(note.title),
834 link_text: link.link_text.clone(),
835 })
836 } else {
837 Some(LinkInfo {
839 path: PathBuf::from(&link.target_path),
840 title: None,
841 link_text: link.link_text.clone(),
842 })
843 }
844 })
845 .take(10)
846 .collect();
847
848 NoteReferences {
849 backlink_count: backlinks.len() as u32,
850 backlinks: backlink_infos,
851 outgoing_count: outgoing.len() as u32,
852 outgoing: outgoing_infos,
853 }
854 }
855
856 fn parse_note_sections(&self, note_path: &Path) -> Vec<String> {
858 let full_path = self.vault_root.join(note_path);
859 let content = match fs::read_to_string(&full_path) {
860 Ok(c) => c,
861 Err(_) => return Vec::new(),
862 };
863
864 let headings = MarkdownEditor::find_headings(&content);
865 headings.iter().map(|h| h.title.clone()).collect()
866 }
867}
868
869#[cfg(test)]
870mod tests {
871 use super::*;
872 use tempfile::tempdir;
873
874 #[test]
875 fn test_day_context_empty() {
876 let tmp = tempdir().unwrap();
877 let config = ResolvedConfig {
878 vault_root: tmp.path().to_path_buf(),
879 activity: Default::default(),
880 ..make_test_config(tmp.path().to_path_buf())
881 };
882
883 let service = ContextQueryService::new(&config);
884 let today = Local::now().date_naive();
885 let context = service.day_context(today).unwrap();
886
887 assert_eq!(context.summary.tasks_completed, 0);
888 assert_eq!(context.summary.tasks_created, 0);
889 }
890
891 #[test]
892 fn test_week_context_empty() {
893 let tmp = tempdir().unwrap();
894 let config = make_test_config(tmp.path().to_path_buf());
895
896 let service = ContextQueryService::new(&config);
897 let today = Local::now().date_naive();
898 let context = service.week_context(today).unwrap();
899
900 assert_eq!(context.summary.tasks_completed, 0);
901 assert_eq!(context.summary.tasks_created, 0);
902 assert_eq!(context.days.len(), 7);
903 }
904
905 #[test]
906 fn test_note_context_no_index() {
907 let tmp = tempdir().unwrap();
908 let config = make_test_config(tmp.path().to_path_buf());
909
910 let service = ContextQueryService::new(&config);
911 let result = service.note_context(Path::new("test.md"), 7);
912
913 assert!(result.is_err());
914 assert!(result.unwrap_err().to_string().contains("Index database not available"));
915 }
916
917 #[test]
918 fn test_focus_context_no_focus() {
919 let tmp = tempdir().unwrap();
920 let config = make_test_config(tmp.path().to_path_buf());
921
922 fs::create_dir_all(tmp.path().join(".mdvault/state")).unwrap();
924
925 let service = ContextQueryService::new(&config);
926 let result = service.focus_context();
927
928 assert!(result.is_err());
929 assert!(result.unwrap_err().to_string().contains("No focus set"));
930 }
931
932 #[test]
933 fn test_day_context_to_summary() {
934 let context = DayContext::new("2026-01-24", "Friday");
935 let summary = context.to_summary();
936
937 assert!(summary.contains("2026-01-24"));
938 assert!(summary.contains("0 done"));
939 }
940
941 #[test]
942 fn test_day_context_to_markdown() {
943 let mut context = DayContext::new("2026-01-24", "Friday");
944 context.summary.tasks_completed = 3;
945 context.summary.tasks_created = 2;
946
947 let md = context.to_markdown();
948
949 assert!(md.contains("# Context: 2026-01-24 (Friday)"));
950 assert!(md.contains("3 tasks completed"));
951 assert!(md.contains("2 tasks created"));
952 }
953
954 #[test]
955 fn test_week_context_to_summary() {
956 let context = WeekContext {
957 week: "2026-W04".to_string(),
958 start_date: "2026-01-20".to_string(),
959 end_date: "2026-01-26".to_string(),
960 summary: WeekSummary {
961 tasks_completed: 5,
962 tasks_created: 3,
963 notes_modified: 10,
964 active_days: 4,
965 },
966 days: vec![],
967 tasks: TaskActivity::default(),
968 projects: vec![],
969 };
970
971 let summary = context.to_summary();
972
973 assert!(summary.contains("2026-W04"));
974 assert!(summary.contains("5 done"));
975 assert!(summary.contains("4 days"));
976 }
977
978 #[test]
979 fn test_note_context_to_summary() {
980 let context = NoteContext {
981 note_type: "project".to_string(),
982 path: PathBuf::from("Projects/test/test.md"),
983 title: "Test Project".to_string(),
984 metadata: serde_json::json!({"status": "active"}),
985 sections: vec!["Overview".to_string()],
986 tasks: Some(TaskCounts { total: 10, todo: 5, doing: 2, done: 3, blocked: 0 }),
987 recent_tasks: None,
988 activity: NoteActivity::default(),
989 references: NoteReferences::default(),
990 };
991
992 let summary = context.to_summary();
993
994 assert!(summary.contains("Projects/test/test.md"));
995 assert!(summary.contains("project"));
996 assert!(summary.contains("3 done"));
997 assert!(summary.contains("2 doing"));
998 }
999
1000 #[test]
1001 fn test_focus_context_output_to_summary() {
1002 let output = FocusContextOutput {
1003 project: "test-project".to_string(),
1004 project_path: Some(PathBuf::from("Projects/test/test.md")),
1005 started_at: Some("2026-01-24T10:00:00Z".to_string()),
1006 note: None,
1007 context: None,
1008 };
1009
1010 let summary = output.to_summary();
1011
1012 assert!(summary.contains("test-project"));
1013 }
1014
1015 #[test]
1016 fn test_task_counts_default() {
1017 let counts = TaskCounts::default();
1018
1019 assert_eq!(counts.total, 0);
1020 assert_eq!(counts.todo, 0);
1021 assert_eq!(counts.doing, 0);
1022 assert_eq!(counts.done, 0);
1023 assert_eq!(counts.blocked, 0);
1024 }
1025
1026 fn make_test_config(vault_root: PathBuf) -> ResolvedConfig {
1027 ResolvedConfig {
1028 active_profile: "test".into(),
1029 vault_root: vault_root.clone(),
1030 templates_dir: vault_root.join(".mdvault/templates"),
1031 captures_dir: vault_root.join(".mdvault/captures"),
1032 macros_dir: vault_root.join(".mdvault/macros"),
1033 typedefs_dir: vault_root.join(".mdvault/typedefs"),
1034 typedefs_fallback_dir: None,
1035 excluded_folders: vec![],
1036 security: Default::default(),
1037 logging: Default::default(),
1038 activity: Default::default(),
1039 }
1040 }
1041}