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/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 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        // Extract headings using MarkdownEditor
323        let headings = MarkdownEditor::find_headings(&content);
324        let sections: Vec<String> = headings.iter().map(|h| h.title.clone()).collect();
325
326        // Count log entries (lines starting with "- ")
327        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    /// Aggregate task activity from entries.
339    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        // Get in-progress tasks from index
372        activity.in_progress = self.get_in_progress_tasks();
373
374        activity
375    }
376
377    /// Get currently in-progress tasks from index.
378    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                // Parse frontmatter to get status
396                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    /// Get focus for a specific day.
423    fn get_focus_for_day(&self, _date: NaiveDate) -> Option<String> {
424        // For now, just return current focus
425        // TODO: Could query activity log for focus changes on that day
426        ContextManager::load(&self.vault_root)
427            .ok()
428            .and_then(|mgr| mgr.active_project().map(String::from))
429    }
430
431    /// Aggregate project activity from entries.
432    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            // Try to get project from meta or from path
437            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    /// Extract project name from a path like "Projects/MyProject/Tasks/TST-001.md".
476    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    /// Get context for a specific note.
488    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        // Get the note from the index
498        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        // Parse frontmatter
512        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        // Get note type
519        let note_type = format!("{:?}", note.note_type).to_lowercase();
520
521        // Get sections
522        let sections = self.parse_note_sections(note_path);
523
524        // Get task counts and recent tasks (for projects)
525        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        // Get activity
534        let activity = self.get_note_activity(note_path, activity_days);
535
536        // Get references
537        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    /// Get context for the focused project.
553    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        // Try to find the project note
565        let project_path = self.find_project_path(&project);
566
567        // Get project context if path found
568        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    /// Find the path to a project note by project name/ID.
578    fn find_project_path(&self, project: &str) -> Option<PathBuf> {
579        // Try common patterns
580        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        // Fall back to index query
594        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                    // Check if project-id matches
603                    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                    // Check if title matches
615                    if proj.title.eq_ignore_ascii_case(project) {
616                        return Some(proj.path);
617                    }
618                }
619            }
620        }
621
622        None
623    }
624
625    /// Get task counts for a project.
626    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        // Extract project folder from path
641        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            // Check if task belongs to this project
651            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            // Get status
659            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    /// Get recent tasks for a project.
680    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        // Extract project folder from path
695        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            // Check if task belongs to this project
706            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    /// Get activity entries related to a specific note.
754    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        // Calculate date range
760        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        // Filter entries for this note
769        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    /// Get references (backlinks and outgoing links) for a note.
795    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        // Get backlinks
801        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                // Get source note info
807                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        // Get outgoing links
817        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                    // Unresolved link
830                    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    /// Parse note sections (headings).
849    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        // Create the state directory but no focus file
915        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}