Skip to content

Commit fe0106a

Browse files
devwhodevsclaude
andcommitted
feat: engraph graph CLI — show connections and stats
'engraph graph show <file>' displays outgoing/incoming wikilinks and mentions. Supports file path, basename, or #docid resolution. 'engraph graph stats' shows vault graph overview. Status output now includes edge counts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9406529 commit fe0106a

3 files changed

Lines changed: 188 additions & 8 deletions

File tree

src/main.rs

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,23 @@ enum Command {
8181
#[command(subcommand)]
8282
action: ModelsAction,
8383
},
84+
85+
/// Inspect vault graph connections.
86+
Graph {
87+
#[command(subcommand)]
88+
action: GraphAction,
89+
},
90+
}
91+
92+
#[derive(Subcommand, Debug)]
93+
enum GraphAction {
94+
/// Show connections for a note.
95+
Show {
96+
/// File path or #docid.
97+
file: String,
98+
},
99+
/// Show vault graph statistics.
100+
Stats,
84101
}
85102

86103
#[derive(Subcommand, Debug)]
@@ -316,6 +333,133 @@ fn main() -> Result<()> {
316333
);
317334
}
318335

336+
Command::Graph { action } => {
337+
if !index_exists(&data_dir) {
338+
eprintln!("No index found. Run 'engraph index <path>' first.");
339+
std::process::exit(1);
340+
}
341+
let db_path = data_dir.join("engraph.db");
342+
let store = store::Store::open(&db_path)?;
343+
344+
match action {
345+
GraphAction::Show { file } => {
346+
// Resolve: docid first, then exact path, then basename
347+
let record = if file.starts_with('#') && file.len() == 7 {
348+
store.get_file_by_docid(&file[1..])?
349+
} else if let Some(f) = store.get_file(&file)? {
350+
Some(f)
351+
} else {
352+
// Basename match (case-insensitive)
353+
let target = if file.ends_with(".md") {
354+
file.clone()
355+
} else {
356+
format!("{}.md", file)
357+
};
358+
let all = store.get_all_files()?;
359+
let target_lower = target.to_lowercase();
360+
all.into_iter().find(|f| {
361+
let path_lower = f.path.to_lowercase();
362+
path_lower == target_lower
363+
|| path_lower.ends_with(&format!("/{}", target_lower))
364+
})
365+
};
366+
367+
let record = match record {
368+
Some(r) => r,
369+
None => {
370+
eprintln!("File not found: {file}");
371+
std::process::exit(1);
372+
}
373+
};
374+
375+
let docid_str = record
376+
.docid
377+
.as_deref()
378+
.map(|d| format!(" (#{d})"))
379+
.unwrap_or_default();
380+
println!("{}{}\n", record.path, docid_str);
381+
382+
let outgoing_wl = store.get_outgoing(record.id, Some("wikilink"))?;
383+
println!("Outgoing wikilinks ({}):", outgoing_wl.len());
384+
for (fid, _) in &outgoing_wl {
385+
if let Some(f) = store.get_file_by_id(*fid)? {
386+
let did = f
387+
.docid
388+
.as_deref()
389+
.map(|d| format!(" (#{d})"))
390+
.unwrap_or_default();
391+
println!(" → {}{}", f.path, did);
392+
}
393+
}
394+
395+
println!();
396+
let incoming_wl = store.get_incoming(record.id, Some("wikilink"))?;
397+
println!("Incoming wikilinks ({}):", incoming_wl.len());
398+
for (fid, _) in &incoming_wl {
399+
if let Some(f) = store.get_file_by_id(*fid)? {
400+
let did = f
401+
.docid
402+
.as_deref()
403+
.map(|d| format!(" (#{d})"))
404+
.unwrap_or_default();
405+
println!(" ← {}{}", f.path, did);
406+
}
407+
}
408+
409+
println!();
410+
let mentions_out = store.get_outgoing(record.id, Some("mention"))?;
411+
let mentions_in = store.get_incoming(record.id, Some("mention"))?;
412+
println!("Mentions out ({}):", mentions_out.len());
413+
for (fid, _) in &mentions_out {
414+
if let Some(f) = store.get_file_by_id(*fid)? {
415+
let did = f
416+
.docid
417+
.as_deref()
418+
.map(|d| format!(" (#{d})"))
419+
.unwrap_or_default();
420+
println!(" → {}{}", f.path, did);
421+
}
422+
}
423+
if !mentions_in.is_empty() {
424+
println!("Mentioned by ({}):", mentions_in.len());
425+
for (fid, _) in &mentions_in {
426+
if let Some(f) = store.get_file_by_id(*fid)? {
427+
let did = f
428+
.docid
429+
.as_deref()
430+
.map(|d| format!(" (#{d})"))
431+
.unwrap_or_default();
432+
println!(" ← {}{}", f.path, did);
433+
}
434+
}
435+
}
436+
}
437+
438+
GraphAction::Stats => {
439+
let stats = store.get_edge_stats()?;
440+
println!("Vault Graph:");
441+
println!(
442+
" Wikilink edges: {} ({} bidirectional pairs)",
443+
stats.wikilink_count,
444+
stats.wikilink_count / 2
445+
);
446+
println!(" Mention edges: {}", stats.mention_count);
447+
println!(" Total edges: {}", stats.total_edges);
448+
let total_files = stats.connected_file_count + stats.isolated_file_count;
449+
let pct = if total_files > 0 {
450+
stats.connected_file_count as f64 / total_files as f64 * 100.0
451+
} else {
452+
0.0
453+
};
454+
println!(
455+
" Connected files: {} / {} ({:.1}%)",
456+
stats.connected_file_count, total_files, pct
457+
);
458+
println!(" Isolated files: {}", stats.isolated_file_count);
459+
}
460+
}
461+
}
462+
319463
Command::Models { action } => {
320464
let registry = model::ModelRegistry::default();
321465
match action {

src/search.rs

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ pub fn format_status(stats: &StoreStats, index_size: u64, model_name: &str, json
263263
let last_indexed = stats.last_indexed_at.as_deref().unwrap_or("never");
264264

265265
if json {
266-
let obj = json!({
266+
let mut obj = json!({
267267
"vault": vault,
268268
"files": stats.file_count,
269269
"chunks": stats.chunk_count,
@@ -272,24 +272,40 @@ pub fn format_status(stats: &StoreStats, index_size: u64, model_name: &str, json
272272
"index_size": index_size,
273273
"model": model_name,
274274
});
275+
if let (Some(edges), Some(wl), Some(mn)) =
276+
(stats.edge_count, stats.wikilink_count, stats.mention_count)
277+
{
278+
obj["edges"] = json!(edges);
279+
obj["wikilink_edges"] = json!(wl);
280+
obj["mention_edges"] = json!(mn);
281+
}
275282
format!("{}\n", serde_json::to_string_pretty(&obj).unwrap())
276283
} else {
277-
format!(
284+
let mut out = format!(
278285
"Vault: {}\n\
279286
Files: {}\n\
280-
Chunks: {}\n\
281-
Tombstones: {} (pending cleanup)\n\
287+
Chunks: {}\n",
288+
vault, stats.file_count, stats.chunk_count,
289+
);
290+
if let (Some(edges), Some(wl), Some(mn)) =
291+
(stats.edge_count, stats.wikilink_count, stats.mention_count)
292+
{
293+
out.push_str(&format!(
294+
"Edges: {} ({} wikilinks, {} mentions)\n",
295+
edges, wl, mn
296+
));
297+
}
298+
out.push_str(&format!(
299+
"Tombstones: {} (pending cleanup)\n\
282300
Last index: {}\n\
283301
Index size: {}\n\
284302
Model: {}\n",
285-
vault,
286-
stats.file_count,
287-
stats.chunk_count,
288303
stats.tombstone_count,
289304
last_indexed,
290305
format_bytes(index_size),
291306
model_name,
292-
)
307+
));
308+
out
293309
}
294310
}
295311

@@ -412,6 +428,9 @@ mod tests {
412428
tombstone_count: 3,
413429
last_indexed_at: Some("2026-03-19 14:30:00".to_string()),
414430
vault_path: Some("/path/to/vault".to_string()),
431+
edge_count: None,
432+
wikilink_count: None,
433+
mention_count: None,
415434
};
416435
let output = format_status(&stats, 2_516_582, "all-MiniLM-L6-v2", false);
417436

@@ -432,6 +451,9 @@ mod tests {
432451
tombstone_count: 3,
433452
last_indexed_at: Some("2026-03-19 14:30:00".to_string()),
434453
vault_path: Some("/path/to/vault".to_string()),
454+
edge_count: None,
455+
wikilink_count: None,
456+
mention_count: None,
435457
};
436458
let output = format_status(&stats, 2_516_582, "all-MiniLM-L6-v2", true);
437459
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();

src/store.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ pub struct StoreStats {
5353
pub tombstone_count: usize,
5454
pub last_indexed_at: Option<String>,
5555
pub vault_path: Option<String>,
56+
pub edge_count: Option<usize>,
57+
pub wikilink_count: Option<usize>,
58+
pub mention_count: Option<usize>,
5659
}
5760

5861
const SCHEMA: &str = r#"
@@ -520,12 +523,23 @@ impl Store {
520523
let tombstone_count = self.tombstone_count()?;
521524
let last_indexed_at = self.get_meta("last_indexed_at")?;
522525
let vault_path = self.get_meta("vault_path")?;
526+
let (edge_count, wikilink_count, mention_count) = match self.get_edge_stats() {
527+
Ok(es) => (
528+
Some(es.total_edges),
529+
Some(es.wikilink_count),
530+
Some(es.mention_count),
531+
),
532+
Err(_) => (None, None, None),
533+
};
523534
Ok(StoreStats {
524535
file_count: file_count as usize,
525536
chunk_count: chunk_count as usize,
526537
tombstone_count,
527538
last_indexed_at,
528539
vault_path,
540+
edge_count,
541+
wikilink_count,
542+
mention_count,
529543
})
530544
}
531545

0 commit comments

Comments
 (0)