Skip to content

Commit bb0874f

Browse files
devwhodevsclaude
andcommitted
feat: project — project context bundle
Finds project note, child notes (same folder + linkers), active tasks (unchecked checkboxes), team (people linked from project), recent mentions in daily notes via FTS. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 37b9cc6 commit bb0874f

1 file changed

Lines changed: 212 additions & 0 deletions

File tree

src/context.rs

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::collections::HashSet;
12
use std::path::Path;
23

34
use anyhow::Result;
@@ -73,6 +74,23 @@ pub struct MentionInfo {
7374
pub snippet: String,
7475
}
7576

77+
#[derive(Debug, Serialize)]
78+
pub struct ProjectContext {
79+
pub name: String,
80+
pub note: Option<NoteContent>,
81+
pub child_notes: Vec<NoteListItem>,
82+
pub active_tasks: Vec<TaskItem>,
83+
pub team: Vec<String>,
84+
pub recent_mentions: Vec<MentionInfo>,
85+
pub total_chars: usize,
86+
}
87+
88+
#[derive(Debug, Serialize)]
89+
pub struct TaskItem {
90+
pub text: String,
91+
pub source_file: String,
92+
}
93+
7694
// ---------------------------------------------------------------------------
7795
// Helpers
7896
// ---------------------------------------------------------------------------
@@ -344,6 +362,140 @@ fn get_mention_snippet(params: &ContextParams, file_id: i64, name: &str) -> Stri
344362
String::new()
345363
}
346364

365+
/// Build a project context bundle: note, child notes, tasks, team, recent mentions.
366+
pub fn context_project(params: &ContextParams, name: &str) -> Result<ProjectContext> {
367+
let name_md = format!("{}.md", name);
368+
let name_lower = name_md.to_lowercase();
369+
let all_files = params.store.get_all_files()?;
370+
let project_file = all_files.iter().find(|f| {
371+
let basename = f.path.rsplit('/').next().unwrap_or(&f.path).to_lowercase();
372+
basename == name_lower
373+
});
374+
375+
let (note, project_id, project_folder) = if let Some(pf) = project_file {
376+
let n = context_read(params, &pf.path)?;
377+
let folder = pf.path.rsplit_once('/').map(|(f, _)| f.to_string());
378+
(Some(n), Some(pf.id), folder)
379+
} else {
380+
(None, None, None)
381+
};
382+
383+
let mut child_ids = HashSet::new();
384+
let mut child_notes = Vec::new();
385+
386+
// Files in same folder
387+
if let Some(folder) = &project_folder {
388+
let folder_files = params.store.list_files(Some(folder), &[], 50)?;
389+
for f in folder_files {
390+
if Some(f.id) != project_id && child_ids.insert(f.id) {
391+
let ec = params.store.edge_count_for_file(f.id).unwrap_or(0);
392+
child_notes.push(NoteListItem {
393+
path: f.path,
394+
docid: f.docid,
395+
tags: f.tags,
396+
indexed_at: f.indexed_at,
397+
edge_count: ec,
398+
});
399+
}
400+
}
401+
}
402+
403+
// Files linking to project
404+
if let Some(pid) = project_id {
405+
let incoming = params.store.get_incoming(pid, Some("wikilink"))?;
406+
for (fid, _) in &incoming {
407+
if child_ids.insert(*fid)
408+
&& let Some(f) = params.store.get_file_by_id(*fid).ok().flatten()
409+
{
410+
let ec = params.store.edge_count_for_file(*fid).unwrap_or(0);
411+
child_notes.push(NoteListItem {
412+
path: f.path,
413+
docid: f.docid,
414+
tags: f.tags,
415+
indexed_at: f.indexed_at,
416+
edge_count: ec,
417+
});
418+
}
419+
}
420+
}
421+
422+
// Active tasks
423+
let mut active_tasks = Vec::new();
424+
let scan_tasks = |path: &str, tasks: &mut Vec<TaskItem>| {
425+
let full = params.vault_path.join(path);
426+
if let Ok(content) = std::fs::read_to_string(&full) {
427+
for line in content.lines() {
428+
let trimmed = line.trim();
429+
if trimmed.starts_with("- [ ] ") {
430+
tasks.push(TaskItem {
431+
text: trimmed
432+
.strip_prefix("- [ ] ")
433+
.unwrap_or(trimmed)
434+
.to_string(),
435+
source_file: path.to_string(),
436+
});
437+
}
438+
}
439+
}
440+
};
441+
if let Some(n) = &note {
442+
scan_tasks(&n.path, &mut active_tasks);
443+
}
444+
for child in &child_notes {
445+
scan_tasks(&child.path, &mut active_tasks);
446+
}
447+
448+
// Team: people linked from project
449+
let mut team = Vec::new();
450+
if let Some(pid) = project_id {
451+
let outgoing = params.store.get_outgoing(pid, Some("wikilink"))?;
452+
for (fid, _) in &outgoing {
453+
if let Some(path) = params.store.get_file_path_by_id(*fid).ok().flatten()
454+
&& path.to_lowercase().contains("people")
455+
{
456+
team.push(path);
457+
}
458+
}
459+
}
460+
461+
// Recent mentions in daily notes
462+
let mut recent_mentions = Vec::new();
463+
if let Ok(fts_results) = params.store.fts_search(name, 10) {
464+
for r in fts_results {
465+
if let Some(path) = params.store.get_file_path_by_id(r.file_id).ok().flatten()
466+
&& (path.contains("Daily") || path.contains("daily"))
467+
{
468+
let docid = params
469+
.store
470+
.get_file_by_id(r.file_id)
471+
.ok()
472+
.flatten()
473+
.and_then(|f| f.docid);
474+
recent_mentions.push(MentionInfo {
475+
path,
476+
docid,
477+
snippet: r.snippet,
478+
});
479+
if recent_mentions.len() >= 5 {
480+
break;
481+
}
482+
}
483+
}
484+
}
485+
486+
let total_chars = note.as_ref().map(|n| n.char_count).unwrap_or(0);
487+
488+
Ok(ProjectContext {
489+
name: name.to_string(),
490+
note,
491+
child_notes,
492+
active_tasks,
493+
team,
494+
recent_mentions,
495+
total_chars,
496+
})
497+
}
498+
347499
// ---------------------------------------------------------------------------
348500
// Tests
349501
// ---------------------------------------------------------------------------
@@ -546,4 +698,64 @@ mod tests {
546698
assert!(person.note.is_none());
547699
assert!(person.mentioned_in.is_empty());
548700
}
701+
702+
#[test]
703+
fn test_project_context() {
704+
let tmp = TempDir::new().unwrap();
705+
let root = tmp.path().to_path_buf();
706+
std::fs::create_dir_all(root.join("01-Projects")).unwrap();
707+
std::fs::write(
708+
root.join("01-Projects/MyProject.md"),
709+
"# MyProject\n\n- [ ] Task one\n- [x] Done task\n- [ ] Task two",
710+
)
711+
.unwrap();
712+
std::fs::write(
713+
root.join("01-Projects/child.md"),
714+
"# Child\nRelated to [[MyProject]].\n- [ ] Sub task",
715+
)
716+
.unwrap();
717+
718+
let store = Store::open_memory().unwrap();
719+
let f1 = store
720+
.insert_file(
721+
"01-Projects/MyProject.md",
722+
"h1",
723+
100,
724+
&["project".into()],
725+
"aaa111",
726+
)
727+
.unwrap();
728+
let f2 = store
729+
.insert_file("01-Projects/child.md", "h2", 100, &[], "bbb222")
730+
.unwrap();
731+
store.insert_edge(f2, f1, "wikilink").unwrap();
732+
store.insert_edge(f1, f2, "wikilink").unwrap();
733+
734+
let params = ContextParams {
735+
store: &store,
736+
vault_path: &root,
737+
profile: None,
738+
};
739+
let proj = context_project(&params, "MyProject").unwrap();
740+
assert!(proj.note.is_some());
741+
assert!(!proj.child_notes.is_empty());
742+
// Should find "Task one" and "Task two" (not "Done task")
743+
assert!(proj.active_tasks.len() >= 2);
744+
assert!(proj.active_tasks.iter().any(|t| t.text == "Task one"));
745+
assert!(proj.active_tasks.iter().any(|t| t.text == "Task two"));
746+
assert!(!proj.active_tasks.iter().any(|t| t.text.contains("Done")));
747+
}
748+
749+
#[test]
750+
fn test_project_not_found() {
751+
let (_tmp, store, root) = setup_vault();
752+
let params = ContextParams {
753+
store: &store,
754+
vault_path: &root,
755+
profile: None,
756+
};
757+
let proj = context_project(&params, "NonExistent").unwrap();
758+
assert!(proj.note.is_none());
759+
assert!(proj.child_notes.is_empty());
760+
}
549761
}

0 commit comments

Comments
 (0)