Skip to main content

mdvault_core/context/
query.rs

1//! Context query service for day/week aggregation.
2
3use 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
18/// Service for querying day and week context.
19pub struct ContextQueryService {
20    /// Vault root path.
21    vault_root: PathBuf,
22
23    /// Activity log service.
24    activity_service: Option<ActivityLogService>,
25
26    /// Index database (optional).
27    index_db: Option<IndexDb>,
28
29    /// Daily note path pattern (relative to vault root).
30    daily_note_pattern: String,
31}
32
33impl ContextQueryService {
34    /// Create a new ContextQueryService.
35    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            // TODO: Make configurable
46            daily_note_pattern: "Journal/{year}/Daily/{date}.md".to_string(),
47        }
48    }
49
50    /// Get context for a specific day.
51    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        // Get logged activity for the day
58        let activity_entries = self.get_logged_activity(date);
59
60        // Convert to ActivityItems
61        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        // Detect unlogged changes
78        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        // Add logged notes to modified_notes
93        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        // Parse daily note
107        context.daily_note = self.parse_daily_note(date);
108
109        // Aggregate tasks
110        context.tasks = self.aggregate_tasks(&activity_entries);
111
112        // Get focus context
113        context.summary.focus = self.get_focus_for_day(date);
114
115        // Calculate summary
116        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        // Aggregate project activity
121        context.projects = self.aggregate_projects(&activity_entries);
122
123        Ok(context)
124    }
125
126    /// Get context for a specific week.
127    pub fn week_context(&self, date: NaiveDate) -> Result<WeekContext, ContextError> {
128        // Get Monday of the week containing the date
129        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        // Collect data for each day
148        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            // Add to days list
156            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            // Accumulate summary
163            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            // Accumulate tasks
175            context.tasks.completed.extend(day_context.tasks.completed);
176            context.tasks.created.extend(day_context.tasks.created);
177
178            // Accumulate project activity
179            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            // Get logged entries for in-progress calculation
193            all_entries.extend(self.get_logged_activity(day));
194        }
195
196        // Set in-progress tasks (query current state, not historical)
197        context.tasks.in_progress = self.get_in_progress_tasks();
198
199        // Convert project map to vec
200        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    /// Get logged activity entries for a specific day.
207    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        // Create start and end times for the day (in UTC)
213        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    /// Detect files modified on the given date that weren't logged.
226    fn detect_unlogged_changes(
227        &self,
228        date: NaiveDate,
229        logged_entries: &[ActivityEntry],
230    ) -> Vec<ModifiedNote> {
231        let mut result = Vec::new();
232
233        // Collect paths already in activity log
234        let logged_paths: HashSet<PathBuf> =
235            logged_entries.iter().map(|e| e.path.clone()).collect();
236
237        // Walk vault and check mtimes
238        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            // Get relative path
257            let rel_path = match path.strip_prefix(&self.vault_root) {
258                Ok(p) => p.to_path_buf(),
259                Err(_) => continue,
260            };
261
262            // Skip if already logged
263            if logged_paths.contains(&rel_path) {
264                continue;
265            }
266
267            // Check modification time
268            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                // Try to get note type from frontmatter
281                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    /// Parse daily note for sections and log count.
304    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        // Extract headings using MarkdownEditor
327        let headings = MarkdownEditor::find_headings(&content);
328        let sections: Vec<String> = headings.iter().map(|h| h.title.clone()).collect();
329
330        // Count log entries (lines starting with "- ")
331        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    /// Aggregate task activity from entries.
343    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        // Get in-progress tasks from index
376        activity.in_progress = self.get_in_progress_tasks();
377
378        activity
379    }
380
381    /// Get currently in-progress tasks from index.
382    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                // Parse frontmatter to get status
400                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    /// Get focus for a specific day.
427    fn get_focus_for_day(&self, _date: NaiveDate) -> Option<String> {
428        // For now, just return current focus
429        // TODO: Could query activity log for focus changes on that day
430        ContextManager::load(&self.vault_root)
431            .ok()
432            .and_then(|mgr| mgr.active_project().map(String::from))
433    }
434
435    /// Aggregate project activity from entries.
436    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            // Try to get project from meta or from path
441            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    /// Extract project name from a path like "Projects/MyProject/Tasks/TST-001.md"
480    /// or "Projects/_archive/MyProject/Tasks/TST-001.md".
481    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    /// Get context for a specific note.
496    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        // Get the note from the index
506        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        // Parse frontmatter
520        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        // Get note type
527        let note_type = format!("{:?}", note.note_type).to_lowercase();
528
529        // Get sections
530        let sections = self.parse_note_sections(note_path);
531
532        // Get task counts and recent tasks (for projects)
533        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        // Get activity
542        let activity = self.get_note_activity(note_path, activity_days);
543
544        // Get references
545        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    /// Get context for the focused project.
561    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        // Try to find the project note
573        let project_path = self.find_project_path(&project);
574
575        // Get project context if path found
576        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    /// Find the path to a project note by project name/ID.
586    fn find_project_path(&self, project: &str) -> Option<PathBuf> {
587        // Try common patterns
588        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        // Fall back to index query
602        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                    // Check if project-id matches
611                    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                    // Check if title matches
623                    if proj.title.eq_ignore_ascii_case(project) {
624                        return Some(proj.path);
625                    }
626                }
627            }
628        }
629
630        None
631    }
632
633    /// Get task counts for a project.
634    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        // Extract project folder from path
649        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            // Check if task belongs to this project
659            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            // Get status
667            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    /// Get recent tasks for a project.
688    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        // Extract project folder from path
703        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            // Check if task belongs to this project
714            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    /// Get activity entries related to a specific note.
762    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        // Calculate date range
768        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        // Filter entries for this note
777        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    /// Get references (backlinks and outgoing links) for a note.
803    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        // Get backlinks
809        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                // Get source note info
815                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        // Get outgoing links
825        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                    // Unresolved link
838                    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    /// Parse note sections (headings).
857    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        // Create the state directory but no focus file
923        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}