Skip to main content

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}