Skip to main content

lean_ctx/core/
knowledge_bootstrap.rs

1use std::path::Path;
2
3use crate::core::graph_provider::GraphProvider;
4use crate::core::knowledge::ProjectKnowledge;
5use crate::core::memory_policy::MemoryPolicy;
6
7const BOOTSTRAP_SESSION_ID: &str = "auto-bootstrap";
8const BOOTSTRAP_CONFIDENCE: f32 = 0.95;
9
10/// Seed a minimal set of *real*, deterministic facts so the dashboard Knowledge Graph
11/// is never empty on a new project.
12///
13/// This does not use placeholders — it only derives values from the filesystem and/or index.
14pub fn bootstrap_if_empty(
15    knowledge: &mut ProjectKnowledge,
16    project_root: &str,
17    graph: Option<&GraphProvider>,
18    policy: &MemoryPolicy,
19) -> bool {
20    if !knowledge.facts.is_empty() {
21        return false;
22    }
23
24    let mut changed = false;
25
26    // Always safe + real: makes the graph non-empty even for marker-less folders.
27    changed |= remember_fact(knowledge, "workflow", "project_root", project_root, policy);
28
29    if let Some(identity) = crate::core::project_hash::project_identity(project_root) {
30        changed |= remember_fact(
31            knowledge,
32            "architecture",
33            "project_identity",
34            &identity,
35            policy,
36        );
37        if let Some(url) = identity.strip_prefix("git:") {
38            changed |= remember_fact(knowledge, "deployment", "git_remote", url, policy);
39        }
40    }
41
42    let markers = detect_build_markers(project_root);
43    if !markers.is_empty() {
44        changed |= remember_fact(
45            knowledge,
46            "architecture",
47            "build_markers",
48            &markers.join(", "),
49            policy,
50        );
51    }
52
53    if let Some(gp) = graph {
54        let file_count = gp.file_count();
55        let symbol_count = gp.symbol_count();
56        let edge_count = gp.edge_count().unwrap_or(0);
57        changed |= remember_fact(
58            knowledge,
59            "workflow",
60            "index_stats",
61            &format!("files={file_count}, symbols={symbol_count}, edges={edge_count}"),
62            policy,
63        );
64
65        let last_scan = gp.last_scan();
66        if !last_scan.trim().is_empty() {
67            changed |= remember_fact(knowledge, "workflow", "index_last_scan", &last_scan, policy);
68        }
69
70        let (langs, total_tokens) = summarize_languages_and_tokens(gp);
71        if !langs.is_empty() {
72            changed |= remember_fact(knowledge, "architecture", "languages_top", &langs, policy);
73        }
74        if total_tokens > 0 {
75            changed |= remember_fact(
76                knowledge,
77                "performance",
78                "tokens_indexed",
79                &total_tokens.to_string(),
80                policy,
81            );
82        }
83    }
84
85    changed
86}
87
88fn remember_fact(
89    knowledge: &mut ProjectKnowledge,
90    category: &str,
91    key: &str,
92    value: &str,
93    policy: &MemoryPolicy,
94) -> bool {
95    if value.trim().is_empty() {
96        return false;
97    }
98    knowledge.remember(
99        category,
100        key,
101        value,
102        BOOTSTRAP_SESSION_ID,
103        BOOTSTRAP_CONFIDENCE,
104        policy,
105    );
106    true
107}
108
109fn detect_build_markers(project_root: &str) -> Vec<&'static str> {
110    let root = Path::new(project_root);
111    let mut out: Vec<&'static str> = Vec::new();
112
113    // macOS TCC (#356): a launchd-standalone process must not stat or read_dir
114    // under ~/Documents/Desktop/Downloads. Skip detection (no markers) without
115    // touching the filesystem; editor/CLI-attached runs detect normally.
116    if !crate::core::pathutil::may_probe_path(root) {
117        return out;
118    }
119
120    if root.join(".git").exists() {
121        out.push("git");
122    }
123    if root.join("Cargo.toml").exists() {
124        out.push("cargo");
125    }
126    if root.join("package.json").exists() {
127        out.push("npm");
128    }
129    if root.join("pyproject.toml").exists() {
130        out.push("python");
131    }
132    if root.join("go.mod").exists() {
133        out.push("go");
134    }
135    if root.join("pom.xml").exists() {
136        out.push("maven");
137    }
138    if root.join("build.gradle").exists() || root.join("build.gradle.kts").exists() {
139        out.push("gradle");
140    }
141    if root.join("CMakeLists.txt").exists() {
142        out.push("cmake");
143    }
144
145    if let Ok(entries) = std::fs::read_dir(root)
146        && entries
147            .flatten()
148            .any(|e| e.path().extension().is_some_and(|ext| ext == "sln"))
149    {
150        out.push("dotnet");
151    }
152
153    out
154}
155
156fn summarize_languages_and_tokens(gp: &GraphProvider) -> (String, u64) {
157    let mut total_tokens: u64 = 0;
158    let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
159
160    for f in gp.file_entries() {
161        total_tokens = total_tokens.saturating_add(f.token_count as u64);
162        let lang = if f.language.trim().is_empty() {
163            "unknown".to_string()
164        } else {
165            f.language
166        };
167        *counts.entry(lang).or_insert(0) += 1;
168    }
169
170    let mut entries: Vec<(String, usize)> = counts.into_iter().collect();
171    entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
172
173    let langs = entries
174        .into_iter()
175        .take(6)
176        .map(|(lang, count)| format!("{lang}:{count}"))
177        .collect::<Vec<_>>()
178        .join(", ");
179
180    (langs, total_tokens)
181}