trusty_memory/bootstrap/mod.rs
1//! Knowledge-graph bootstrap helpers.
2//!
3//! Why: Issue #60 — after `palace_create`, the knowledge graph (KG) sits at
4//! zero triples and there is no auto-discovery path. Users have no idea
5//! they're supposed to call `kg_assert` manually before `kg_query` returns
6//! anything useful. `kg_bootstrap` closes this gap by scanning well-known
7//! project files (`Cargo.toml`, `package.json`, `pyproject.toml`, `CLAUDE.md`,
8//! `.git/config`, `go.mod`) and seeding structured triples that describe the
9//! project (language, version, source repo, etc.). It also seeds temporal
10//! metadata (`created_at`, `bootstrapped_at`) so even an empty project at
11//! least has *something* in the KG and a timestamp anchor for future queries.
12//! What: `scan_project` (in `scan`) returns a flat list of triples; the public
13//! async entry point `bootstrap_palace` resolves a palace handle, runs the
14//! scanner, and asserts each tuple through the existing `KnowledgeGraph::assert`
15//! path. Types and helpers live in `types`.
16//! Test: Unit tests in `scan` pin each scanner against fixture directories;
17//! `kg_bootstrap` is exercised end-to-end from the MCP tool surface in
18//! `tools.rs`.
19
20mod scan;
21mod types;
22
23pub use scan::scan_project;
24pub use types::{
25 is_kg_empty_for_subject, result_to_json, BootstrapResult, BootstrapTriple, ScannedFile,
26 KG_EMPTY_HINT,
27};
28
29use anyhow::{Context, Result};
30use std::path::{Path, PathBuf};
31use trusty_common::memory_core::store::kg::Triple;
32
33use crate::AppState;
34
35/// Run the bootstrap scan against a palace.
36///
37/// Why: Single async entry point that the MCP dispatcher (and the
38/// auto-bootstrap hook in `palace_create`) calls. Encapsulates path
39/// resolution, scanning, triple construction, and KG assertion.
40/// What: Resolves `project_path` (caller-supplied), runs the blocking
41/// scanner, seeds temporal metadata triples, and asserts every discovery
42/// through `handle.kg.assert(...)`. Returns a summary of what was written.
43/// Test: `bootstrap_palace_seeds_temporal_metadata_when_no_files`,
44/// `bootstrap_palace_scans_cargo_toml`.
45pub async fn bootstrap_palace(
46 state: &AppState,
47 palace_id: &str,
48 project_path: Option<&Path>,
49) -> Result<BootstrapResult> {
50 let handle = state
51 .registry
52 .open_palace(
53 &state.data_root,
54 &trusty_common::memory_core::palace::PalaceId::new(palace_id),
55 )
56 .with_context(|| format!("open palace {palace_id}"))?;
57
58 // Choose the scan root. When the caller did not supply a project path,
59 // we still scan the palace's own data dir so `CLAUDE.md` or other
60 // operator-placed files inside the palace are picked up.
61 let scan_root: PathBuf = match project_path {
62 Some(p) => p.to_path_buf(),
63 None => handle
64 .data_dir
65 .clone()
66 .unwrap_or_else(|| state.data_root.join(palace_id)),
67 };
68 let palace_id_owned = palace_id.to_string();
69
70 let (triples, scanned_files, project_subject) =
71 tokio::task::spawn_blocking(move || scan_project(&scan_root, &palace_id_owned))
72 .await
73 .context("join scan_project")??;
74
75 // Seed temporal metadata (always present, even for empty projects).
76 let now = chrono::Utc::now();
77 let mut all = triples;
78 all.push(BootstrapTriple {
79 subject: project_subject.clone(),
80 predicate: "bootstrapped_at".to_string(),
81 object: now.to_rfc3339(),
82 provenance: "bootstrap:temporal".to_string(),
83 });
84 // `created_at` is only inserted when the palace doesn't yet have one;
85 // re-running bootstrap must not lie about when the palace first came
86 // into being. The KG's temporal layer would close the prior interval
87 // and the new interval would carry a misleading `valid_from`. Check
88 // `query_active` before writing.
89 let existing = handle
90 .kg
91 .query_active(&project_subject)
92 .await
93 .context("kg.query_active for created_at check")?;
94 if !existing.iter().any(|t| t.predicate == "created_at") {
95 all.push(BootstrapTriple {
96 subject: project_subject.clone(),
97 predicate: "created_at".to_string(),
98 object: now.to_rfc3339(),
99 provenance: "bootstrap:temporal".to_string(),
100 });
101 }
102
103 let mut asserted = 0usize;
104 for bt in &all {
105 let triple = Triple {
106 subject: bt.subject.clone(),
107 predicate: bt.predicate.clone(),
108 object: bt.object.clone(),
109 valid_from: now,
110 valid_to: None,
111 confidence: 1.0,
112 provenance: Some(bt.provenance.clone()),
113 };
114 handle
115 .kg
116 .assert(triple)
117 .await
118 .with_context(|| format!("kg.assert {} {}", bt.subject, bt.predicate))?;
119 asserted += 1;
120 }
121
122 Ok(BootstrapResult {
123 palace: palace_id.to_string(),
124 project_subject,
125 triples_asserted: asserted,
126 scanned_files,
127 })
128}