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}