Skip to main content

hematite/tools/
project_map.rs

1use serde_json::Value;
2use std::fs;
3use std::path::{Path, PathBuf};
4use walkdir::WalkDir;
5
6#[derive(Debug)]
7struct ArchitectureSketch {
8    relative_path: String,
9    role: &'static str,
10    score: i32,
11    symbols: Vec<String>,
12}
13
14pub async fn map_project(args: &Value) -> Result<String, String> {
15    let root = crate::tools::file_ops::workspace_root();
16    let focus = args.get("focus").and_then(|v| v.as_str()).unwrap_or(".");
17    let include_symbols = args
18        .get("include_symbols")
19        .and_then(|v| v.as_bool())
20        .unwrap_or(true);
21    let max_depth = args
22        .get("max_depth")
23        .and_then(value_as_usize)
24        .unwrap_or(4)
25        .min(6);
26    let focus_root = resolve_focus_root(&root, focus)?;
27
28    let mut report = String::new();
29    report.push_str(&format!("Project Root: {}\n", root.display()));
30    if focus != "." {
31        report.push_str(&format!("Focus Path: {}\n", focus_root.display()));
32    }
33
34    report.push_str("\n-- Configuration DNA --\n");
35    append_configuration_dna(&root, &mut report);
36
37    let sketches = collect_architecture_sketches(&root, &focus_root, include_symbols)?;
38    if !sketches.is_empty() {
39        append_architecture_map(&sketches, &mut report);
40    }
41
42    report.push_str("\n-- Directory Structure --\n");
43    let mut lines = Vec::new();
44    build_tree(&root, &focus_root, 0, max_depth, &mut lines)?;
45    report.push_str(&lines.join("\n"));
46
47    Ok(report)
48}
49
50fn resolve_focus_root(root: &Path, focus: &str) -> Result<PathBuf, String> {
51    if focus == "." {
52        return Ok(root.to_path_buf());
53    }
54
55    let candidate = if Path::new(focus).is_absolute() {
56        PathBuf::from(focus)
57    } else {
58        root.join(focus)
59    };
60
61    let canonical = candidate
62        .canonicalize()
63        .map_err(|e| format!("map_project: could not resolve focus '{}': {}", focus, e))?;
64    crate::tools::guard::path_is_safe(root, &canonical)
65        .map_err(|e| format!("map_project: invalid focus '{}': {}", focus, e))?;
66    if canonical.is_file() {
67        return canonical.parent().map(Path::to_path_buf).ok_or_else(|| {
68            format!(
69                "map_project: focus '{}' has no readable parent directory",
70                focus
71            )
72        });
73    }
74    Ok(canonical)
75}
76
77fn append_configuration_dna(root: &Path, report: &mut String) {
78    let markers = [
79        "Cargo.toml",
80        "package.json",
81        "go.mod",
82        "requirements.txt",
83        "pyproject.toml",
84        "README.md",
85        "CLAUDE.md",
86        "CAPABILITIES.md",
87        "Taskfile.yml",
88        ".env.example",
89    ];
90
91    for marker in markers {
92        let path = root.join(marker);
93        if !path.exists() {
94            continue;
95        }
96        if let Ok(content) = fs::read_to_string(&path) {
97            let snippet = content.chars().take(600).collect::<String>();
98            report.push_str(&format!("### File: {}\n```\n{}\n```\n", marker, snippet));
99        }
100    }
101}
102
103fn append_architecture_map(sketches: &[ArchitectureSketch], report: &mut String) {
104    report.push_str("\n-- Architecture Map --\n");
105
106    let entrypoints: Vec<_> = sketches
107        .iter()
108        .filter(|s| is_entrypoint_path(&s.relative_path))
109        .collect();
110    if !entrypoints.is_empty() {
111        report.push_str("Likely entrypoints\n");
112        for sketch in entrypoints.iter().take(4) {
113            report.push_str(&format!("- {} [{}]\n", sketch.relative_path, sketch.role));
114            if !sketch.symbols.is_empty() {
115                report.push_str(&format!("  symbols: {}\n", sketch.symbols.join(", ")));
116            }
117        }
118    }
119
120    report.push_str("Core owner files\n");
121    for sketch in sketches.iter().take(12) {
122        report.push_str(&format!("- {} [{}]\n", sketch.relative_path, sketch.role));
123        if !sketch.symbols.is_empty() {
124            report.push_str(&format!("  symbols: {}\n", sketch.symbols.join(", ")));
125        }
126    }
127}
128
129fn collect_architecture_sketches(
130    root: &Path,
131    focus_root: &Path,
132    include_symbols: bool,
133) -> Result<Vec<ArchitectureSketch>, String> {
134    let mut sketches = Vec::new();
135
136    for entry in WalkDir::new(focus_root).follow_links(false).max_depth(4) {
137        let entry = entry.map_err(|e| format!("map_project: {}", e))?;
138        if !entry.file_type().is_file() {
139            continue;
140        }
141
142        let path = entry.path();
143        if path_has_hidden_segment(path) || !is_architecture_candidate(path) {
144            continue;
145        }
146
147        let relative_path = to_relative_display(root, path);
148        let role = classify_file_role(&relative_path);
149        let score = score_architecture_file(&relative_path);
150        let symbols = if include_symbols {
151            extract_top_symbols(path).unwrap_or_default()
152        } else {
153            Vec::new()
154        };
155
156        sketches.push(ArchitectureSketch {
157            relative_path,
158            role,
159            score,
160            symbols,
161        });
162    }
163
164    sketches.sort_by(|a, b| {
165        b.score
166            .cmp(&a.score)
167            .then_with(|| a.relative_path.cmp(&b.relative_path))
168    });
169    sketches.truncate(18);
170    Ok(sketches)
171}
172
173fn build_tree(
174    root: &Path,
175    dir: &Path,
176    depth: usize,
177    max_depth: usize,
178    lines: &mut Vec<String>,
179) -> Result<(), String> {
180    if depth > max_depth {
181        return Ok(());
182    }
183
184    let mut entries: Vec<_> = fs::read_dir(dir)
185        .map_err(|e| format!("Failed to read dir {dir:?}: {}", e))?
186        .filter_map(Result::ok)
187        .collect();
188
189    entries.sort_by_key(|e| {
190        (
191            e.file_type().map(|ft| ft.is_file()).unwrap_or(false),
192            e.file_name(),
193        )
194    });
195
196    for entry in entries {
197        let file_type = entry
198            .file_type()
199            .map_err(|e| format!("Failed to inspect entry {:?}: {}", entry.path(), e))?;
200        let name = entry.file_name().to_string_lossy().into_owned();
201        if name.starts_with('.') || name == "target" || name == "node_modules" || name == "vendor" {
202            continue;
203        }
204
205        let indent = "  ".repeat(depth);
206        let path = entry.path();
207        let rel = to_relative_display(root, &path);
208        let prefix = if file_type.is_dir() { "[D]" } else { "[F]" };
209        lines.push(format!("{indent}{prefix} {rel}"));
210
211        if file_type.is_dir() {
212            build_tree(root, &path, depth + 1, max_depth, lines)?;
213        }
214    }
215    Ok(())
216}
217
218fn path_has_hidden_segment(path: &Path) -> bool {
219    path.components().any(|component| {
220        let segment = component.as_os_str().to_string_lossy();
221        (segment.starts_with('.') && segment != "." && segment != "..")
222            || segment == "target"
223            || segment == "node_modules"
224            || segment == "__pycache__"
225    })
226}
227
228fn is_architecture_candidate(path: &Path) -> bool {
229    let Some(ext) = path.extension().and_then(|s| s.to_str()) else {
230        return false;
231    };
232    matches!(
233        ext,
234        "rs" | "py" | "ts" | "tsx" | "js" | "jsx" | "go" | "cs" | "java" | "kt"
235    )
236}
237
238fn to_relative_display(root: &Path, path: &Path) -> String {
239    let root_display = normalize_display_path(root);
240    let path_display = normalize_display_path(path);
241
242    if let Some(stripped) = path_display.strip_prefix(&root_display) {
243        return stripped.trim_start_matches('/').to_string();
244    }
245
246    path.strip_prefix(root)
247        .unwrap_or(path)
248        .to_string_lossy()
249        .replace('\\', "/")
250        .trim_start_matches("//?/")
251        .trim_start_matches("\\\\?\\")
252        .to_string()
253}
254
255fn normalize_display_path(path: &Path) -> String {
256    path.to_string_lossy()
257        .replace('\\', "/")
258        .trim_start_matches("//?/")
259        .trim_start_matches("\\\\?\\")
260        .trim_end_matches('/')
261        .to_string()
262}
263
264fn value_as_usize(value: &Value) -> Option<usize> {
265    if let Some(v) = value.as_u64() {
266        return usize::try_from(v).ok();
267    }
268
269    if let Some(v) = value.as_f64() {
270        if v.is_finite() && v >= 0.0 && v.fract() == 0.0 && v <= (usize::MAX as f64) {
271            return Some(v as usize);
272        }
273    }
274
275    value.as_str().and_then(|s| s.trim().parse::<usize>().ok())
276}
277
278fn is_core_library_path(relative_path: &str) -> bool {
279    relative_path.to_lowercase().ends_with("lib.rs")
280}
281
282fn is_entrypoint_path(relative_path: &str) -> bool {
283    let lower = relative_path.to_lowercase();
284    lower.ends_with("main.rs")
285        || (lower.contains("/bin/") && lower.ends_with(".rs"))
286        || lower.ends_with("app.rs")
287        || lower.ends_with("server.rs")
288        || lower.ends_with("cli.rs")
289        || lower.ends_with("__main__.py")
290        || lower.ends_with("main.py")
291        || lower.ends_with("index.ts")
292        || lower.ends_with("index.js")
293}
294
295fn classify_file_role(relative_path: &str) -> &'static str {
296    let lower = relative_path.to_lowercase();
297    if is_entrypoint_path(relative_path) {
298        "entrypoint"
299    } else if is_core_library_path(relative_path) {
300        "core library"
301    } else if lower.ends_with("src/runtime.rs") || lower.contains("/runtime/") {
302        "runtime assembly"
303    } else if lower.contains("/ui/") || lower.contains("tui") || lower.contains("voice") {
304        "ui / operator surface"
305    } else if lower.contains("/agent/")
306        || lower.contains("conversation")
307        || lower.contains("inference")
308    {
309        "agent orchestration"
310    } else if lower.contains("/tools/") || lower.contains("shell") || lower.contains("git") {
311        "tooling layer"
312    } else if lower.contains("/memory/") || lower.contains("vein") || lower.contains("compaction") {
313        "memory / retrieval"
314    } else if lower.contains("/lsp/") {
315        "language-server integration"
316    } else {
317        "workspace code"
318    }
319}
320
321fn score_architecture_file(relative_path: &str) -> i32 {
322    let lower = relative_path.to_lowercase();
323    let mut score = 0;
324
325    if lower.starts_with("src/") {
326        score += 5;
327    }
328    if is_entrypoint_path(relative_path) {
329        score += 12;
330    } else if is_core_library_path(relative_path) {
331        score += 7;
332    }
333    if lower.ends_with("src/runtime.rs") {
334        score += 14;
335    }
336    for needle in [
337        "/agent/",
338        "/ui/",
339        "/runtime/",
340        "/tools/",
341        "/memory/",
342        "/lsp/",
343        "conversation",
344        "inference",
345        "prompt",
346        "runtime",
347        "voice",
348        "tui",
349        "main",
350    ] {
351        if lower.contains(needle) {
352            score += 4;
353        }
354    }
355
356    score
357}
358
359fn extract_top_symbols(path: &Path) -> Result<Vec<String>, String> {
360    let content = fs::read_to_string(path)
361        .map_err(|e| format!("symbol scan failed for {:?}: {}", path, e))?;
362    let ext = path
363        .extension()
364        .and_then(|s| s.to_str())
365        .unwrap_or_default();
366    let mut symbols = Vec::new();
367
368    let patterns: &[&str] = match ext {
369        "rs" => &[
370            r"(?m)^\s*pub\s+struct\s+([A-Za-z_][A-Za-z0-9_]*)",
371            r"(?m)^\s*struct\s+([A-Za-z_][A-Za-z0-9_]*)",
372            r"(?m)^\s*pub\s+enum\s+([A-Za-z_][A-Za-z0-9_]*)",
373            r"(?m)^\s*enum\s+([A-Za-z_][A-Za-z0-9_]*)",
374            r"(?m)^\s*pub\s+trait\s+([A-Za-z_][A-Za-z0-9_]*)",
375            r"(?m)^\s*trait\s+([A-Za-z_][A-Za-z0-9_]*)",
376            r"(?m)^\s*pub\s+async\s+fn\s+([A-Za-z_][A-Za-z0-9_]*)",
377            r"(?m)^\s*pub\s+fn\s+([A-Za-z_][A-Za-z0-9_]*)",
378            r"(?m)^\s*async\s+fn\s+([A-Za-z_][A-Za-z0-9_]*)",
379            r"(?m)^\s*fn\s+([A-Za-z_][A-Za-z0-9_]*)",
380        ],
381        "py" => &[
382            r"(?m)^\s*class\s+([A-Za-z_][A-Za-z0-9_]*)",
383            r"(?m)^\s*def\s+([A-Za-z_][A-Za-z0-9_]*)",
384        ],
385        "ts" | "tsx" | "js" | "jsx" => &[
386            r"(?m)^\s*export\s+class\s+([A-Za-z_][A-Za-z0-9_]*)",
387            r"(?m)^\s*class\s+([A-Za-z_][A-Za-z0-9_]*)",
388            r"(?m)^\s*export\s+function\s+([A-Za-z_][A-Za-z0-9_]*)",
389            r"(?m)^\s*function\s+([A-Za-z_][A-Za-z0-9_]*)",
390            r"(?m)^\s*export\s+const\s+([A-Za-z_][A-Za-z0-9_]*)",
391        ],
392        "go" => &[
393            r"(?m)^\s*type\s+([A-Za-z_][A-Za-z0-9_]*)\s+struct",
394            r"(?m)^\s*func\s+([A-Za-z_][A-Za-z0-9_]*)",
395        ],
396        _ => &[],
397    };
398
399    for pattern in patterns {
400        let regex =
401            regex::Regex::new(pattern).map_err(|e| format!("invalid symbol regex: {}", e))?;
402        for capture in regex.captures_iter(&content) {
403            let Some(name) = capture.get(1).map(|m| m.as_str().to_string()) else {
404                continue;
405            };
406            if !symbols.contains(&name) {
407                symbols.push(name);
408            }
409            if symbols.len() >= 4 {
410                return Ok(symbols);
411            }
412        }
413    }
414
415    Ok(symbols)
416}