Skip to content

Commit 37b9cc6

Browse files
devwhodevsclaude
andcommitted
feat: who — person context bundle
Finds person note by basename, returns full content + mention edges with snippets + wikilink connections. FTS snippet extraction with disk-read fallback. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 05d9d03 commit 37b9cc6

2 files changed

Lines changed: 164 additions & 1 deletion

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/context.rs

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,23 @@ pub struct FolderInfo {
5656
pub note_count: usize,
5757
}
5858

59+
#[derive(Debug, Serialize)]
60+
pub struct PersonContext {
61+
pub name: String,
62+
pub note: Option<NoteContent>,
63+
pub mentioned_in: Vec<MentionInfo>,
64+
pub linked_from: Vec<String>,
65+
pub linked_to: Vec<String>,
66+
pub total_chars: usize,
67+
}
68+
69+
#[derive(Debug, Serialize)]
70+
pub struct MentionInfo {
71+
pub path: String,
72+
pub docid: Option<String>,
73+
pub snippet: String,
74+
}
75+
5976
// ---------------------------------------------------------------------------
6077
// Helpers
6178
// ---------------------------------------------------------------------------
@@ -233,6 +250,100 @@ pub fn vault_map(params: &ContextParams) -> Result<VaultMap> {
233250
})
234251
}
235252

253+
/// Build a person context bundle: note content, mentions, wikilink connections.
254+
pub fn context_who(params: &ContextParams, name: &str) -> Result<PersonContext> {
255+
let name_md = format!("{}.md", name);
256+
let name_lower = name_md.to_lowercase();
257+
let all_files = params.store.get_all_files()?;
258+
let person_file = all_files.iter().find(|f| {
259+
let basename = f.path.rsplit('/').next().unwrap_or(&f.path).to_lowercase();
260+
basename == name_lower
261+
});
262+
263+
let (note, person_id) = if let Some(pf) = person_file {
264+
let n = context_read(params, &pf.path)?;
265+
(Some(n), Some(pf.id))
266+
} else {
267+
(None, None)
268+
};
269+
270+
let mut mentioned_in = Vec::new();
271+
let mut linked_from = Vec::new();
272+
let mut linked_to = Vec::new();
273+
274+
if let Some(pid) = person_id {
275+
// Mention edges
276+
let mentions = params.store.get_incoming(pid, Some("mention"))?;
277+
for (fid, _) in &mentions {
278+
if let Some(path) = params.store.get_file_path_by_id(*fid).ok().flatten() {
279+
let docid = params
280+
.store
281+
.get_file_by_id(*fid)
282+
.ok()
283+
.flatten()
284+
.and_then(|f| f.docid);
285+
let snippet = get_mention_snippet(params, *fid, name);
286+
mentioned_in.push(MentionInfo {
287+
path,
288+
docid,
289+
snippet,
290+
});
291+
}
292+
}
293+
// Wikilink edges
294+
let incoming_wl = params.store.get_incoming(pid, Some("wikilink"))?;
295+
for (fid, _) in &incoming_wl {
296+
if let Some(path) = params.store.get_file_path_by_id(*fid).ok().flatten() {
297+
linked_from.push(path);
298+
}
299+
}
300+
let outgoing_wl = params.store.get_outgoing(pid, Some("wikilink"))?;
301+
for (fid, _) in &outgoing_wl {
302+
if let Some(path) = params.store.get_file_path_by_id(*fid).ok().flatten() {
303+
linked_to.push(path);
304+
}
305+
}
306+
}
307+
308+
let total_chars = note.as_ref().map(|n| n.char_count).unwrap_or(0)
309+
+ mentioned_in.iter().map(|m| m.snippet.len()).sum::<usize>();
310+
311+
Ok(PersonContext {
312+
name: name.to_string(),
313+
note,
314+
mentioned_in,
315+
linked_from,
316+
linked_to,
317+
total_chars,
318+
})
319+
}
320+
321+
/// Get a snippet from a file mentioning a name. Try FTS first, fall back to disk read.
322+
fn get_mention_snippet(params: &ContextParams, file_id: i64, name: &str) -> String {
323+
if let Ok(results) = params.store.fts_search(name, 5)
324+
&& let Some(r) = results.iter().find(|r| r.file_id == file_id)
325+
{
326+
return r.snippet.clone();
327+
}
328+
if let Some(path) = params.store.get_file_path_by_id(file_id).ok().flatten() {
329+
let full_path = params.vault_path.join(&path);
330+
if let Ok(content) = std::fs::read_to_string(&full_path) {
331+
let name_lower = name.to_lowercase();
332+
for line in content.lines() {
333+
if line.to_lowercase().contains(&name_lower) {
334+
let truncated: String = line.chars().take(200).collect();
335+
return if line.len() > 200 {
336+
format!("{}...", truncated)
337+
} else {
338+
truncated
339+
};
340+
}
341+
}
342+
}
343+
}
344+
String::new()
345+
}
346+
236347
// ---------------------------------------------------------------------------
237348
// Tests
238349
// ---------------------------------------------------------------------------
@@ -383,4 +494,56 @@ mod tests {
383494
assert!(fm.is_empty());
384495
assert!(body.contains("# Just content"));
385496
}
497+
498+
#[test]
499+
fn test_who_finds_person() {
500+
let tmp = TempDir::new().unwrap();
501+
let root = tmp.path().to_path_buf();
502+
std::fs::create_dir_all(root.join("People")).unwrap();
503+
std::fs::write(
504+
root.join("People/John.md"),
505+
"---\naliases:\n - JN\n---\n# John\nDeveloper.",
506+
)
507+
.unwrap();
508+
std::fs::write(root.join("daily.md"), "# Daily\nTalked to John about Rust.").unwrap();
509+
510+
let store = Store::open_memory().unwrap();
511+
let f1 = store
512+
.insert_file("People/John.md", "h1", 100, &["person".into()], "aaa111")
513+
.unwrap();
514+
let f2 = store
515+
.insert_file("daily.md", "h2", 100, &[], "bbb222")
516+
.unwrap();
517+
store.insert_edge(f2, f1, "mention").unwrap();
518+
store
519+
.insert_chunk(f2, "# Daily", "Talked to John about Rust.", 10, 20)
520+
.unwrap();
521+
store
522+
.insert_fts_chunk(f2, 0, "Talked to John about Rust.")
523+
.unwrap();
524+
525+
let params = ContextParams {
526+
store: &store,
527+
vault_path: &root,
528+
profile: None,
529+
};
530+
let person = context_who(&params, "John").unwrap();
531+
assert!(person.note.is_some());
532+
assert_eq!(person.name, "John");
533+
assert_eq!(person.mentioned_in.len(), 1);
534+
assert!(person.mentioned_in[0].path.contains("daily"));
535+
}
536+
537+
#[test]
538+
fn test_who_not_found() {
539+
let (_tmp, store, root) = setup_vault();
540+
let params = ContextParams {
541+
store: &store,
542+
vault_path: &root,
543+
profile: None,
544+
};
545+
let person = context_who(&params, "NonExistent").unwrap();
546+
assert!(person.note.is_none());
547+
assert!(person.mentioned_in.is_empty());
548+
}
386549
}

0 commit comments

Comments
 (0)