1use axum::{
2 extract::State,
3 routing::{get, patch, post},
4 Json, Router,
5};
6use serde::{Deserialize, Serialize};
7
8use crate::api::repo_context::{
9 normalize_local_repo_path, validate_local_git_repo_path, validate_repo_path,
10};
11use crate::error::ServerError;
12use crate::models::codebase::{Codebase, CodebaseSourceType};
13use crate::state::AppState;
14
15fn repo_label_from_path(repo_path: &str) -> String {
16 std::path::Path::new(repo_path)
17 .file_name()
18 .and_then(|name| name.to_str())
19 .map(str::to_string)
20 .unwrap_or_else(|| repo_path.to_string())
21}
22
23pub fn router() -> Router<AppState> {
24 Router::new()
25 .route(
26 "/workspaces/{workspace_id}/codebases",
27 get(list_codebases).post(add_codebase),
28 )
29 .route(
30 "/workspaces/{workspace_id}/codebases/changes",
31 get(list_codebase_changes),
32 )
33 .route(
34 "/workspaces/{workspace_id}/codebases/{codebase_id}/reposlide",
35 get(get_reposlide),
36 )
37 .route(
38 "/workspaces/{workspace_id}/codebases/{codebase_id}/wiki",
39 get(get_wiki),
40 )
41 .route(
42 "/codebases/{id}",
43 patch(update_codebase).delete(delete_codebase),
44 )
45 .route("/codebases/{id}/default", post(set_default_codebase))
46}
47
48async fn list_codebases(
49 State(state): State<AppState>,
50 axum::extract::Path(workspace_id): axum::extract::Path<String>,
51) -> Result<Json<serde_json::Value>, ServerError> {
52 let codebases = state
53 .codebase_store
54 .list_by_workspace(&workspace_id)
55 .await?;
56 Ok(Json(serde_json::json!({ "codebases": codebases })))
57}
58
59async fn list_codebase_changes(
60 State(state): State<AppState>,
61 axum::extract::Path(workspace_id): axum::extract::Path<String>,
62) -> Result<Json<serde_json::Value>, ServerError> {
63 let codebases = state
64 .codebase_store
65 .list_by_workspace(&workspace_id)
66 .await?;
67
68 let repos = codebases
69 .into_iter()
70 .map(|codebase| {
71 let label = codebase
72 .label
73 .clone()
74 .unwrap_or_else(|| repo_label_from_path(&codebase.repo_path));
75
76 if codebase.repo_path.is_empty() {
77 return serde_json::json!({
78 "codebaseId": codebase.id,
79 "repoPath": codebase.repo_path,
80 "label": label,
81 "branch": codebase.branch.unwrap_or_else(|| "unknown".to_string()),
82 "status": { "clean": true, "ahead": 0, "behind": 0, "modified": 0, "untracked": 0 },
83 "files": [],
84 "error": "Missing repository path",
85 });
86 }
87
88 if !crate::git::is_git_repository(&codebase.repo_path) {
89 return serde_json::json!({
90 "codebaseId": codebase.id,
91 "repoPath": codebase.repo_path,
92 "label": label,
93 "branch": codebase.branch.unwrap_or_else(|| "unknown".to_string()),
94 "status": { "clean": true, "ahead": 0, "behind": 0, "modified": 0, "untracked": 0 },
95 "files": [],
96 "error": "Repository is missing or not a git repository",
97 });
98 }
99
100 let changes = crate::git::get_repo_changes(&codebase.repo_path);
101 serde_json::json!({
102 "codebaseId": codebase.id,
103 "repoPath": codebase.repo_path,
104 "label": label,
105 "branch": changes.branch,
106 "status": changes.status,
107 "files": changes.files,
108 })
109 })
110 .collect::<Vec<_>>();
111
112 Ok(Json(serde_json::json!({
113 "workspaceId": workspace_id,
114 "repos": repos,
115 })))
116}
117
118#[derive(Debug, Deserialize)]
119#[serde(rename_all = "camelCase")]
120struct AddCodebaseRequest {
121 repo_path: String,
122 branch: Option<String>,
123 label: Option<String>,
124 source_type: Option<CodebaseSourceType>,
125 source_url: Option<String>,
126 #[serde(default)]
127 is_default: bool,
128}
129
130async fn add_codebase(
131 State(state): State<AppState>,
132 axum::extract::Path(workspace_id): axum::extract::Path<String>,
133 Json(body): Json<AddCodebaseRequest>,
134) -> Result<Json<serde_json::Value>, ServerError> {
135 let source_type = body.source_type.unwrap_or(CodebaseSourceType::Local);
136 let repo_path = normalize_local_repo_path(&body.repo_path);
137 match source_type {
138 CodebaseSourceType::Local => validate_local_git_repo_path(&repo_path)?,
139 CodebaseSourceType::Github => validate_repo_path(&repo_path, "Path ")?,
140 }
141 let repo_path = repo_path.to_string_lossy().to_string();
142
143 if let Some(_existing) = state
145 .codebase_store
146 .find_by_repo_path(&workspace_id, &repo_path)
147 .await?
148 {
149 return Err(ServerError::Conflict(format!(
150 "Codebase with repo_path '{}' already exists in workspace {}",
151 repo_path, workspace_id
152 )));
153 }
154
155 let codebase = Codebase::new(
156 uuid::Uuid::new_v4().to_string(),
157 workspace_id,
158 repo_path,
159 body.branch,
160 body.label,
161 body.is_default,
162 Some(source_type),
163 body.source_url,
164 );
165
166 state.codebase_store.save(&codebase).await?;
167 Ok(Json(serde_json::json!({ "codebase": codebase })))
168}
169
170#[derive(Debug, Deserialize)]
171#[serde(rename_all = "camelCase")]
172struct UpdateCodebaseRequest {
173 branch: Option<String>,
174 label: Option<String>,
175 repo_path: Option<String>,
176 source_type: Option<CodebaseSourceType>,
177 source_url: Option<String>,
178}
179
180async fn update_codebase(
181 State(state): State<AppState>,
182 axum::extract::Path(id): axum::extract::Path<String>,
183 Json(body): Json<UpdateCodebaseRequest>,
184) -> Result<Json<serde_json::Value>, ServerError> {
185 let existing = state
186 .codebase_store
187 .get(&id)
188 .await?
189 .ok_or_else(|| ServerError::NotFound(format!("Codebase {} not found", id)))?;
190 let requested_source_type = body
191 .source_type
192 .clone()
193 .or_else(|| existing.source_type.clone())
194 .unwrap_or(CodebaseSourceType::Local);
195
196 let repo_path = if let Some(repo_path) = body.repo_path.as_deref() {
197 let normalized = normalize_local_repo_path(repo_path);
198 match requested_source_type {
199 CodebaseSourceType::Local => validate_local_git_repo_path(&normalized)?,
200 CodebaseSourceType::Github => validate_repo_path(&normalized, "Path ")?,
201 }
202 let normalized = normalized.to_string_lossy().to_string();
203
204 if let Some(duplicate) = state
205 .codebase_store
206 .find_by_repo_path(&existing.workspace_id, &normalized)
207 .await?
208 {
209 if duplicate.id != id {
210 return Err(ServerError::Conflict(format!(
211 "Codebase with repo_path '{}' already exists in workspace {}",
212 normalized, existing.workspace_id
213 )));
214 }
215 }
216
217 Some(normalized)
218 } else {
219 None
220 };
221
222 state
223 .codebase_store
224 .update(
225 &id,
226 body.branch.as_deref(),
227 body.label.as_deref(),
228 repo_path.as_deref(),
229 body.source_type.as_ref().map(CodebaseSourceType::as_str),
230 body.source_url.as_deref(),
231 )
232 .await?;
233
234 let codebase = state
235 .codebase_store
236 .get(&id)
237 .await?
238 .ok_or_else(|| ServerError::NotFound(format!("Codebase {} not found", id)))?;
239
240 Ok(Json(serde_json::json!({ "codebase": codebase })))
241}
242
243async fn delete_codebase(
244 State(state): State<AppState>,
245 axum::extract::Path(id): axum::extract::Path<String>,
246) -> Result<Json<serde_json::Value>, ServerError> {
247 if let Ok(Some(codebase)) = state.codebase_store.get(&id).await {
249 let repo_path = &codebase.repo_path;
250
251 let lock = {
253 let mut locks = crate::api::worktrees::get_repo_locks().lock().await;
254 locks
255 .entry(repo_path.to_string())
256 .or_insert_with(|| std::sync::Arc::new(tokio::sync::Mutex::new(())))
257 .clone()
258 };
259 let _guard = lock.lock().await;
260
261 let worktrees = state
262 .worktree_store
263 .list_by_codebase(&id)
264 .await
265 .map_err(|e| ServerError::Internal(format!("Failed to list worktrees: {}", e)))?;
266 for wt in &worktrees {
267 if let Err(e) = crate::git::worktree_remove(repo_path, &wt.worktree_path, true) {
268 tracing::warn!(
269 "[Codebase DELETE] Failed to remove worktree {}: {}",
270 wt.id,
271 e
272 );
273 }
274 }
275 if !worktrees.is_empty() {
276 let _ = crate::git::worktree_prune(repo_path);
277 }
278 }
279
280 state.codebase_store.delete(&id).await?;
281 Ok(Json(serde_json::json!({ "deleted": true })))
282}
283
284async fn set_default_codebase(
285 State(state): State<AppState>,
286 axum::extract::Path(id): axum::extract::Path<String>,
287) -> Result<Json<serde_json::Value>, ServerError> {
288 let codebase = state
289 .codebase_store
290 .get(&id)
291 .await?
292 .ok_or_else(|| ServerError::NotFound(format!("Codebase {} not found", id)))?;
293
294 state
295 .codebase_store
296 .set_default(&codebase.workspace_id, &id)
297 .await?;
298
299 let updated = state
300 .codebase_store
301 .get(&id)
302 .await?
303 .ok_or_else(|| ServerError::NotFound(format!("Codebase {} not found", id)))?;
304
305 Ok(Json(serde_json::json!({ "codebase": updated })))
306}
307
308const IGNORE_DIRS: &[&str] = &[
311 "node_modules",
312 ".git",
313 ".next",
314 "dist",
315 "build",
316 "target",
317 ".routa",
318 ".worktrees",
319 "__pycache__",
320 ".tox",
321 ".venv",
322 "venv",
323 ".cache",
324];
325
326const MAX_DEPTH: usize = 4;
327const MAX_CHILDREN: usize = 50;
328const MAX_DIR_FOCUS_SLIDES: usize = 6;
329const MAX_REPOWIKI_MODULES: usize = 8;
330
331const ENTRY_POINT_FILES: &[&str] = &[
332 "README.md",
333 "AGENTS.md",
334 "package.json",
335 "Cargo.toml",
336 "go.mod",
337 "pyproject.toml",
338 "setup.py",
339 "pom.xml",
340 "build.gradle",
341 "Makefile",
342 "Dockerfile",
343 "docker-compose.yml",
344 "tsconfig.json",
345];
346
347const ANCHOR_DIRS: &[&str] = &[
348 "src/app",
349 "src/core",
350 "src/client",
351 "crates",
352 "apps",
353 "lib",
354 "pkg",
355 "cmd",
356 "internal",
357 "api",
358];
359
360const KEY_FILE_NAMES: &[&str] = &[
361 "README.md",
362 "AGENTS.md",
363 "ARCHITECTURE.md",
364 "CONTRIBUTING.md",
365 "LICENSE",
366 "CHANGELOG.md",
367];
368
369const REPOWIKI_ROOT_FILE_ANCHORS: &[&str] = &[
370 "README.md",
371 "README",
372 "AGENTS.md",
373 "package.json",
374 "Cargo.toml",
375 "pyproject.toml",
376 "go.mod",
377];
378
379const REPOWIKI_NESTED_FILE_ANCHORS: &[&str] = &["docs/ARCHITECTURE.md", "docs/adr/README.md"];
380
381const REPOWIKI_DIRECTORY_ANCHORS: &[&str] = &[
382 "src/app",
383 "src/core",
384 "src/client",
385 "crates",
386 "docs",
387 "apps",
388 "api",
389];
390
391const REPOWIKI_STORYLINE_KEY_FILES: &[&str] = &[
392 "README.md",
393 "AGENTS.md",
394 "ARCHITECTURE.md",
395 "CONTRIBUTING.md",
396 "Cargo.toml",
397 "package.json",
398];
399
400#[derive(Debug, Serialize)]
401#[serde(rename_all = "camelCase")]
402struct RepoTreeNode {
403 name: String,
404 path: String,
405 #[serde(rename = "type")]
406 node_type: String,
407 #[serde(skip_serializing_if = "Option::is_none")]
408 children: Option<Vec<RepoTreeNode>>,
409 #[serde(skip_serializing_if = "Option::is_none")]
410 file_count: Option<u64>,
411}
412
413#[derive(Debug, Serialize)]
414#[serde(rename_all = "camelCase")]
415struct RepoSummary {
416 total_files: u64,
417 total_directories: u64,
418 top_level_folders: Vec<String>,
419 source_type: String,
420 #[serde(skip_serializing_if = "Option::is_none")]
421 branch: Option<String>,
422}
423
424fn scan_repo_tree(repo_path: &str) -> RepoTreeNode {
425 let root_name = std::path::Path::new(repo_path)
426 .file_name()
427 .and_then(|n| n.to_str())
428 .unwrap_or(repo_path)
429 .to_string();
430 scan_dir(repo_path, &root_name, ".", 0)
431}
432
433fn scan_dir(abs_path: &str, name: &str, rel_path: &str, depth: usize) -> RepoTreeNode {
434 let mut node = RepoTreeNode {
435 name: name.to_string(),
436 path: rel_path.to_string(),
437 node_type: "directory".to_string(),
438 children: Some(Vec::new()),
439 file_count: Some(0),
440 };
441
442 if depth >= MAX_DEPTH {
443 return node;
444 }
445
446 let mut entries: Vec<std::fs::DirEntry> = match std::fs::read_dir(abs_path) {
447 Ok(rd) => rd.filter_map(|e| e.ok()).collect(),
448 Err(_) => return node,
449 };
450
451 entries.sort_by(|a, b| {
452 let a_dir = a.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
453 let b_dir = b.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
454 match (a_dir, b_dir) {
455 (true, false) => std::cmp::Ordering::Less,
456 (false, true) => std::cmp::Ordering::Greater,
457 _ => a.file_name().cmp(&b.file_name()),
458 }
459 });
460
461 let children = node.children.as_mut().unwrap();
462 let mut file_count: u64 = 0;
463 let mut child_count = 0;
464
465 for entry in entries {
466 if child_count >= MAX_CHILDREN {
467 break;
468 }
469 let entry_name = entry.file_name().to_string_lossy().to_string();
470 if IGNORE_DIRS.contains(&entry_name.as_str()) {
471 continue;
472 }
473 let ft = match entry.file_type() {
474 Ok(ft) => ft,
475 Err(_) => continue,
476 };
477 let child_rel = if rel_path == "." {
478 entry_name.clone()
479 } else {
480 format!("{}/{}", rel_path, entry_name)
481 };
482 let child_abs = format!("{}/{}", abs_path, entry_name);
483
484 if ft.is_dir() {
485 let child = scan_dir(&child_abs, &entry_name, &child_rel, depth + 1);
486 file_count += child.file_count.unwrap_or(0);
487 children.push(child);
488 } else if ft.is_file() {
489 children.push(RepoTreeNode {
490 name: entry_name,
491 path: child_rel,
492 node_type: "file".to_string(),
493 children: None,
494 file_count: None,
495 });
496 file_count += 1;
497 }
498
499 child_count += 1;
500 }
501
502 node.file_count = Some(file_count);
503 node
504}
505
506fn compute_summary(tree: &RepoTreeNode, source_type: &str, branch: Option<&str>) -> RepoSummary {
507 let (files, dirs) = count_tree(tree);
508 let top_level_folders = tree
509 .children
510 .as_ref()
511 .map(|c| {
512 c.iter()
513 .filter(|n| n.node_type == "directory")
514 .map(|n| n.name.clone())
515 .collect()
516 })
517 .unwrap_or_default();
518
519 RepoSummary {
520 total_files: files,
521 total_directories: dirs,
522 top_level_folders,
523 source_type: source_type.to_string(),
524 branch: branch.map(str::to_string),
525 }
526}
527
528fn count_tree(node: &RepoTreeNode) -> (u64, u64) {
529 if node.node_type == "file" {
530 return (1, 0);
531 }
532 let mut files = 0u64;
533 let mut dirs = 1u64;
534 for child in node.children.as_deref().unwrap_or(&[]) {
535 let (f, d) = count_tree(child);
536 files += f;
537 dirs += d;
538 }
539 (files, dirs)
540}
541
542fn detect_entry_points(tree: &RepoTreeNode) -> Vec<serde_json::Value> {
543 let mut found = Vec::new();
544
545 for child in tree.children.as_deref().unwrap_or(&[]) {
546 if child.node_type == "file" && ENTRY_POINT_FILES.contains(&child.name.as_str()) {
547 found.push(serde_json::json!({
548 "name": child.name,
549 "path": child.path,
550 "reason": format!("Project entry point ({})", child.name),
551 }));
552 }
553 }
554
555 for anchor in ANCHOR_DIRS {
556 if let Some(node) = find_node_by_path(tree, anchor) {
557 found.push(serde_json::json!({
558 "name": *anchor,
559 "path": node.path,
560 "reason": "Architecture anchor directory",
561 }));
562 }
563 }
564
565 found
566}
567
568fn detect_key_files(tree: &RepoTreeNode) -> Vec<serde_json::Value> {
569 tree.children
570 .as_deref()
571 .unwrap_or(&[])
572 .iter()
573 .filter(|c| c.node_type == "file" && KEY_FILE_NAMES.contains(&c.name.as_str()))
574 .map(|c| {
575 serde_json::json!({
576 "name": c.name,
577 "path": c.path,
578 })
579 })
580 .collect()
581}
582
583fn extract_architecture_anchors(tree: &RepoTreeNode) -> Vec<serde_json::Value> {
584 let mut anchors = Vec::new();
585
586 for child in tree.children.as_deref().unwrap_or(&[]) {
587 if child.node_type != "file" {
588 continue;
589 }
590
591 if REPOWIKI_ROOT_FILE_ANCHORS
592 .iter()
593 .any(|anchor| matches_root_file_anchor(&child.name, anchor))
594 {
595 anchors.push(serde_json::json!({
596 "kind": "file",
597 "path": child.path,
598 "reason": format!("Architecture/documentation anchor ({})", child.name),
599 }));
600 }
601 }
602
603 for anchor in REPOWIKI_DIRECTORY_ANCHORS {
604 if let Some(node) = find_node_by_path(tree, anchor) {
605 anchors.push(serde_json::json!({
606 "kind": "directory",
607 "path": node.path,
608 "reason": "Architecture anchor directory",
609 }));
610 }
611 }
612
613 for anchor in REPOWIKI_NESTED_FILE_ANCHORS {
614 if let Some(node) = find_node_by_path(tree, anchor) {
615 if node.node_type == "file" {
616 anchors.push(serde_json::json!({
617 "kind": "file",
618 "path": node.path,
619 "reason": format!("Architecture/documentation anchor ({})", node.name),
620 }));
621 }
622 }
623 }
624
625 anchors
626}
627
628fn matches_root_file_anchor(file_name: &str, anchor: &str) -> bool {
629 let base_name = anchor.split('.').next().unwrap_or(anchor);
630 file_name == anchor || file_name == base_name || file_name.starts_with(&format!("{base_name}."))
631}
632
633fn build_repowiki_modules(tree: &RepoTreeNode) -> Vec<serde_json::Value> {
634 let mut modules: Vec<&RepoTreeNode> = tree
635 .children
636 .as_deref()
637 .unwrap_or(&[])
638 .iter()
639 .filter(|child| child.node_type == "directory")
640 .collect();
641 modules.sort_by(|left, right| {
642 right
643 .file_count
644 .unwrap_or(0)
645 .cmp(&left.file_count.unwrap_or(0))
646 });
647
648 modules
649 .into_iter()
650 .take(MAX_REPOWIKI_MODULES)
651 .map(|child| {
652 serde_json::json!({
653 "name": child.name,
654 "path": child.path,
655 "fileCount": child.file_count.unwrap_or(0),
656 "role": infer_module_role(&child.name),
657 })
658 })
659 .collect()
660}
661
662fn infer_module_role(name: &str) -> &'static str {
663 match name {
664 "src" => "Primary application source code.",
665 "docs" => "Documentation, architecture notes, and operational guides.",
666 "crates" => "Rust service/runtime modules.",
667 "apps" => "Application entrypoints and package surfaces.",
668 "app" => "User-facing application layer.",
669 _ => "Core repository module area.",
670 }
671}
672
673fn build_repository_role_summary(top_level_folders: &[String]) -> String {
674 if top_level_folders.is_empty() {
675 return "Repository is compact and mostly root-file driven.".to_string();
676 }
677
678 format!(
679 "Repository is organized around {}.",
680 top_level_folders
681 .iter()
682 .take(4)
683 .cloned()
684 .collect::<Vec<_>>()
685 .join(", ")
686 )
687}
688
689fn build_runtime_boundaries(top_level_folders: &[String]) -> Vec<String> {
690 let mut boundaries = Vec::new();
691
692 if top_level_folders.iter().any(|folder| folder == "src") {
693 boundaries.push("Source runtime boundary under src/".to_string());
694 }
695 if top_level_folders.iter().any(|folder| folder == "crates") {
696 boundaries.push("Rust/Axum backend boundary under crates/".to_string());
697 }
698 if top_level_folders.iter().any(|folder| folder == "apps") {
699 boundaries.push("Multi-app boundary under apps/".to_string());
700 }
701 if top_level_folders.iter().any(|folder| folder == "docs") {
702 boundaries.push("Documentation and architecture boundary under docs/".to_string());
703 }
704
705 boundaries
706}
707
708fn build_cross_layer_relationships(top_level_folders: &[String]) -> Vec<String> {
709 if top_level_folders.iter().any(|folder| folder == "src")
710 && top_level_folders.iter().any(|folder| folder == "crates")
711 {
712 return vec![
713 "Next.js app layer in src/ coordinates with Rust services in crates/.".to_string(),
714 ];
715 }
716
717 if top_level_folders.iter().any(|folder| folder == "src")
718 && top_level_folders.iter().any(|folder| folder == "docs")
719 {
720 return vec![
721 "Implementation in src/ is guided by architecture and ADR documents in docs/."
722 .to_string(),
723 ];
724 }
725
726 vec!["Cross-layer relationships require deeper file-level inspection.".to_string()]
727}
728
729fn build_repowiki_workflows(top_level_folders: &[String]) -> Vec<serde_json::Value> {
730 let top_level_paths = top_level_folders
731 .iter()
732 .map(|folder| format!("{folder}/"))
733 .collect::<Vec<_>>();
734 let repo_orientation_paths = [
735 vec!["README.md".to_string(), "AGENTS.md".to_string()],
736 top_level_paths.clone(),
737 ]
738 .concat();
739
740 vec![
741 serde_json::json!({
742 "name": "Repo orientation",
743 "description": "Start from README/AGENTS and map top-level modules before detailed tracing.",
744 "relatedPaths": repo_orientation_paths,
745 }),
746 serde_json::json!({
747 "name": "Architecture walkthrough",
748 "description": "Trace runtime boundaries and handoffs between major layers.",
749 "relatedPaths": top_level_paths,
750 }),
751 ]
752}
753
754fn build_repowiki_glossary(top_level_folders: &[String]) -> Vec<serde_json::Value> {
755 let mut glossary = vec![
756 serde_json::json!({
757 "term": "RepoWiki",
758 "meaning": "Intermediate architecture-aware repository knowledge artifact."
759 }),
760 serde_json::json!({
761 "term": "Storyline context",
762 "meaning": "Slide-ready narrative hints generated from repository evidence."
763 }),
764 ];
765
766 if top_level_folders.iter().any(|folder| folder == "crates") {
767 glossary.push(serde_json::json!({
768 "term": "crates",
769 "meaning": "Rust package/workspace area.",
770 "sourcePath": "crates/",
771 }));
772 }
773
774 if top_level_folders.iter().any(|folder| folder == "src") {
775 glossary.push(serde_json::json!({
776 "term": "src",
777 "meaning": "Application source root.",
778 "sourcePath": "src/",
779 }));
780 }
781
782 glossary
783}
784
785fn build_repowiki_storyline_context(
786 tree: &RepoTreeNode,
787 anchors: &[serde_json::Value],
788) -> serde_json::Value {
789 let mut focus_areas: Vec<&RepoTreeNode> = tree
790 .children
791 .as_deref()
792 .unwrap_or(&[])
793 .iter()
794 .filter(|child| child.node_type == "directory")
795 .collect();
796 focus_areas.sort_by(|left, right| {
797 right
798 .file_count
799 .unwrap_or(0)
800 .cmp(&left.file_count.unwrap_or(0))
801 });
802
803 let focus_areas = focus_areas
804 .into_iter()
805 .take(MAX_DIR_FOCUS_SLIDES)
806 .map(|directory| {
807 serde_json::json!({
808 "path": directory.path,
809 "fileCount": directory.file_count.unwrap_or(0),
810 })
811 })
812 .collect::<Vec<_>>();
813
814 let entry_points = anchors
815 .iter()
816 .filter(|anchor| {
817 anchor
818 .get("kind")
819 .and_then(|value| value.as_str())
820 .unwrap_or("file")
821 == "file"
822 })
823 .filter_map(|anchor| anchor.get("path").and_then(|value| value.as_str()))
824 .map(str::to_string)
825 .collect::<Vec<_>>();
826
827 let key_files = tree
828 .children
829 .as_deref()
830 .unwrap_or(&[])
831 .iter()
832 .filter(|child| {
833 child.node_type == "file" && REPOWIKI_STORYLINE_KEY_FILES.contains(&child.name.as_str())
834 })
835 .map(|child| child.path.clone())
836 .collect::<Vec<_>>();
837
838 let primary_module = focus_areas
839 .first()
840 .and_then(|area| area.get("path"))
841 .and_then(|value| value.as_str())
842 .unwrap_or("the primary module");
843
844 serde_json::json!({
845 "suggestedSections": [
846 "Repository overview",
847 "Top-level architecture",
848 "Runtime boundaries",
849 "Important modules and responsibilities",
850 "Key files and why they matter",
851 "Main workflows / narratives",
852 "Slide-ready storyline hints",
853 ],
854 "entryPoints": entry_points,
855 "keyFiles": key_files,
856 "focusAreas": focus_areas,
857 "narrativeHints": [
858 format!("Start from docs/README and then explain {}.", primary_module),
859 "Call out cross-layer boundaries between app/core/client or equivalent runtime layers.",
860 "Label inferred conclusions explicitly when source files do not state intent directly.",
861 ],
862 })
863}
864
865fn build_focus_directories(tree: &RepoTreeNode) -> Vec<serde_json::Value> {
866 let mut focus_dirs: Vec<&RepoTreeNode> = tree
867 .children
868 .as_deref()
869 .unwrap_or(&[])
870 .iter()
871 .filter(|c| c.node_type == "directory")
872 .collect();
873 focus_dirs.sort_by(|a, b| b.file_count.unwrap_or(0).cmp(&a.file_count.unwrap_or(0)));
874
875 focus_dirs
876 .into_iter()
877 .take(MAX_DIR_FOCUS_SLIDES)
878 .map(|dir| {
879 let children: Vec<serde_json::Value> = dir
880 .children
881 .as_deref()
882 .unwrap_or(&[])
883 .iter()
884 .map(|child| {
885 let mut value = serde_json::json!({
886 "name": child.name,
887 "type": child.node_type,
888 });
889 if child.node_type == "directory" {
890 value["fileCount"] = serde_json::json!(child.file_count.unwrap_or(0));
891 }
892 value
893 })
894 .collect();
895
896 serde_json::json!({
897 "name": dir.name,
898 "path": dir.path,
899 "fileCount": dir.file_count.unwrap_or(0),
900 "children": children,
901 })
902 })
903 .collect()
904}
905
906fn build_reposlide_prompt(
907 codebase: &Codebase,
908 summary: &RepoSummary,
909 root_files: &[String],
910 entry_points: &[serde_json::Value],
911 key_files: &[serde_json::Value],
912 focus_directories: &[serde_json::Value],
913) -> String {
914 let repo_label = codebase
915 .label
916 .clone()
917 .unwrap_or_else(|| repo_label_from_path(&codebase.repo_path));
918 let mut lines = vec![
919 format!(
920 "Create a presentation slide deck for the repository \"{}\".",
921 repo_label
922 ),
923 String::new(),
924 "Goal:".to_string(),
925 "- Explain what this repository is, how it is structured, and how an engineer should orient themselves quickly.".to_string(),
926 "- Keep the deck concise: target 6-8 slides.".to_string(),
927 "- Use evidence from the local repository only. If a conclusion is inferred, label it as an inference.".to_string(),
928 String::new(),
929 "Required coverage:".to_string(),
930 "- Repository purpose and audience.".to_string(),
931 "- Runtime or architecture overview.".to_string(),
932 "- Top-level structure and major subsystems.".to_string(),
933 "- Important entry points, docs, and operational files.".to_string(),
934 "- Notable risks, TODOs, or ambiguities if they materially affect understanding.".to_string(),
935 String::new(),
936 "Before drafting slides, inspect these first if they exist:".to_string(),
937 "- AGENTS.md".to_string(),
938 "- README.md".to_string(),
939 "- docs/ARCHITECTURE.md".to_string(),
940 "- docs/adr/README.md".to_string(),
941 "- package.json / Cargo.toml / pyproject.toml / go.mod".to_string(),
942 String::new(),
943 "Output:".to_string(),
944 "- Build the deck with slide-skill and save the final artifact as a PPTX.".to_string(),
945 "- In the final response, report the PPTX path and summarize the slide outline.".to_string(),
946 String::new(),
947 "Repository context:".to_string(),
948 format!("- Repo path: {}", codebase.repo_path),
949 format!(
950 "- Branch: {}",
951 codebase.branch.as_deref().unwrap_or("unknown")
952 ),
953 format!("- Source type: {}", summary.source_type),
954 format!("- Total files scanned: {}", summary.total_files),
955 format!("- Total directories scanned: {}", summary.total_directories),
956 format!(
957 "- Top-level folders: {}",
958 if summary.top_level_folders.is_empty() {
959 "(none detected)".to_string()
960 } else {
961 summary.top_level_folders.join(", ")
962 }
963 ),
964 format!(
965 "- Root files: {}",
966 if root_files.is_empty() {
967 "(none detected)".to_string()
968 } else {
969 root_files.join(", ")
970 }
971 ),
972 ];
973
974 if !entry_points.is_empty() {
975 lines.push(String::new());
976 lines.push("Entry points and architecture anchors:".to_string());
977 for item in entry_points {
978 let path = item
979 .get("path")
980 .and_then(|value| value.as_str())
981 .unwrap_or("(unknown)");
982 let reason = item
983 .get("reason")
984 .and_then(|value| value.as_str())
985 .unwrap_or("(no reason)");
986 lines.push(format!("- {}: {}", path, reason));
987 }
988 }
989
990 if !key_files.is_empty() {
991 lines.push(String::new());
992 lines.push("Key files worth reading:".to_string());
993 for item in key_files {
994 let path = item
995 .get("path")
996 .and_then(|value| value.as_str())
997 .unwrap_or("(unknown)");
998 lines.push(format!("- {}", path));
999 }
1000 }
1001
1002 if !focus_directories.is_empty() {
1003 lines.push(String::new());
1004 lines.push("Largest top-level areas:".to_string());
1005 for item in focus_directories {
1006 let dir_path = item
1007 .get("path")
1008 .and_then(|value| value.as_str())
1009 .unwrap_or("(unknown)");
1010 let file_count = item
1011 .get("fileCount")
1012 .and_then(|value| value.as_u64())
1013 .unwrap_or(0);
1014 let preview = item
1015 .get("children")
1016 .and_then(|value| value.as_array())
1017 .map(|children| {
1018 children
1019 .iter()
1020 .take(8)
1021 .map(|child| {
1022 let name = child
1023 .get("name")
1024 .and_then(|value| value.as_str())
1025 .unwrap_or("(unknown)");
1026 let node_type = child
1027 .get("type")
1028 .and_then(|value| value.as_str())
1029 .unwrap_or("file");
1030 if node_type == "directory" {
1031 let nested_count = child
1032 .get("fileCount")
1033 .and_then(|value| value.as_u64())
1034 .unwrap_or(0);
1035 format!("{}/ ({} files)", name, nested_count)
1036 } else {
1037 name.to_string()
1038 }
1039 })
1040 .collect::<Vec<_>>()
1041 .join(", ")
1042 })
1043 .unwrap_or_default();
1044 lines.push(format!(
1045 "- {} ({} files): {}",
1046 dir_path,
1047 file_count,
1048 if preview.is_empty() {
1049 "no immediate children scanned".to_string()
1050 } else {
1051 preview
1052 }
1053 ));
1054 }
1055 }
1056
1057 lines.push(String::new());
1058 lines.push(
1059 "Work in the repository itself as the primary context. Do not generate application code for Routa; generate the slide deck artifact about this repo.".to_string(),
1060 );
1061
1062 lines.join("\n")
1063}
1064
1065fn resolve_reposlide_skill_repo_path() -> Option<String> {
1066 let cwd = std::env::current_dir().ok()?;
1067 let candidate = cwd.join("tools").join("office-skills");
1068 let skill_file = candidate
1069 .join(".agents")
1070 .join("skills")
1071 .join("slide")
1072 .join("SKILL.md");
1073
1074 if skill_file.is_file() {
1075 Some(candidate.to_string_lossy().to_string())
1076 } else {
1077 None
1078 }
1079}
1080
1081fn find_node_by_path<'a>(tree: &'a RepoTreeNode, target: &str) -> Option<&'a RepoTreeNode> {
1082 let segments: Vec<&str> = target.split('/').collect();
1083 let mut current = tree;
1084 for seg in &segments {
1085 current = current.children.as_ref()?.iter().find(|c| c.name == *seg)?;
1086 }
1087 Some(current)
1088}
1089
1090async fn get_reposlide(
1091 State(state): State<AppState>,
1092 axum::extract::Path((workspace_id, codebase_id)): axum::extract::Path<(String, String)>,
1093) -> Result<Json<serde_json::Value>, ServerError> {
1094 let codebase = state
1095 .codebase_store
1096 .get(&codebase_id)
1097 .await?
1098 .ok_or_else(|| ServerError::NotFound(format!("Codebase {} not found", codebase_id)))?;
1099
1100 if codebase.workspace_id != workspace_id {
1101 return Err(ServerError::NotFound(format!(
1102 "Codebase {} not found in workspace {}",
1103 codebase_id, workspace_id
1104 )));
1105 }
1106
1107 if codebase.repo_path.is_empty() {
1108 return Err(ServerError::BadRequest(
1109 "Codebase has no repository path".to_string(),
1110 ));
1111 }
1112
1113 let tree = scan_repo_tree(&codebase.repo_path);
1114 let source_type = codebase
1115 .source_type
1116 .as_ref()
1117 .map(CodebaseSourceType::as_str)
1118 .unwrap_or("local");
1119 let summary = compute_summary(&tree, source_type, codebase.branch.as_deref());
1120 let root_files: Vec<String> = tree
1121 .children
1122 .as_deref()
1123 .unwrap_or(&[])
1124 .iter()
1125 .filter(|c| c.node_type == "file")
1126 .map(|c| c.name.clone())
1127 .collect();
1128 let entry_points = detect_entry_points(&tree);
1129 let key_files = detect_key_files(&tree);
1130 let focus_directories = build_focus_directories(&tree);
1131 let skill_repo_path = resolve_reposlide_skill_repo_path();
1132 let skill_available = skill_repo_path.is_some();
1133 let prompt = build_reposlide_prompt(
1134 &codebase,
1135 &summary,
1136 &root_files,
1137 &entry_points,
1138 &key_files,
1139 &focus_directories,
1140 );
1141
1142 Ok(Json(serde_json::json!({
1143 "codebase": {
1144 "id": codebase.id,
1145 "label": codebase.label,
1146 "repoPath": codebase.repo_path,
1147 "sourceType": source_type,
1148 "sourceUrl": codebase.source_url,
1149 "branch": codebase.branch,
1150 },
1151 "summary": summary,
1152 "context": {
1153 "rootFiles": root_files,
1154 "entryPoints": entry_points,
1155 "keyFiles": key_files,
1156 "focusDirectories": focus_directories,
1157 },
1158 "launch": {
1159 "skillName": "slide-skill",
1160 "skillRepoPath": skill_repo_path,
1161 "skillAvailable": skill_available,
1162 "unavailableReason": if skill_available {
1163 serde_json::Value::Null
1164 } else {
1165 serde_json::Value::String("slide-skill could not be found relative to the current Routa installation.".to_string())
1166 },
1167 "prompt": prompt,
1168 },
1169 })))
1170}
1171
1172async fn get_wiki(
1173 State(state): State<AppState>,
1174 axum::extract::Path((workspace_id, codebase_id)): axum::extract::Path<(String, String)>,
1175) -> Result<Json<serde_json::Value>, ServerError> {
1176 let codebase = state
1177 .codebase_store
1178 .get(&codebase_id)
1179 .await?
1180 .ok_or_else(|| ServerError::NotFound(format!("Codebase {} not found", codebase_id)))?;
1181
1182 if codebase.workspace_id != workspace_id {
1183 return Err(ServerError::NotFound(format!(
1184 "Codebase {} not found in workspace {}",
1185 codebase_id, workspace_id
1186 )));
1187 }
1188
1189 if codebase.repo_path.is_empty() {
1190 return Err(ServerError::BadRequest(
1191 "Codebase has no repository path".to_string(),
1192 ));
1193 }
1194
1195 let tree = scan_repo_tree(&codebase.repo_path);
1196 let source_type = codebase
1197 .source_type
1198 .as_ref()
1199 .map(CodebaseSourceType::as_str)
1200 .unwrap_or("local");
1201 let summary = compute_summary(&tree, source_type, codebase.branch.as_deref());
1202 let anchors = extract_architecture_anchors(&tree);
1203 let modules = build_repowiki_modules(&tree);
1204
1205 let source_links = anchors
1206 .iter()
1207 .filter_map(|anchor| {
1208 anchor.get("path").map(|path| {
1209 serde_json::json!({
1210 "label": path,
1211 "path": path,
1212 })
1213 })
1214 })
1215 .chain(modules.iter().map(|module| {
1216 serde_json::json!({
1217 "label": module
1218 .get("name")
1219 .and_then(|value| value.as_str())
1220 .unwrap_or("."),
1221 "path": module
1222 .get("path")
1223 .and_then(|value| value.as_str())
1224 .unwrap_or("."),
1225 })
1226 }))
1227 .collect::<Vec<_>>();
1228
1229 let top_level_folders = summary.top_level_folders.clone();
1230 let storyline_context = build_repowiki_storyline_context(&tree, &anchors);
1231
1232 Ok(Json(serde_json::json!({
1233 "codebase": {
1234 "id": codebase.id,
1235 "workspaceId": codebase.workspace_id,
1236 "label": codebase.label,
1237 "repoPath": codebase.repo_path,
1238 "sourceType": source_type,
1239 "sourceUrl": codebase.source_url,
1240 "branch": codebase.branch,
1241 },
1242 "summary": {
1243 "totalFiles": summary.total_files,
1244 "totalDirectories": summary.total_directories,
1245 "topLevelFolders": summary.top_level_folders,
1246 "sourceType": summary.source_type,
1247 "branch": summary.branch,
1248 "repositoryRoleSummary": build_repository_role_summary(&top_level_folders),
1249 },
1250 "anchors": anchors,
1251 "modules": modules,
1252 "architecture": {
1253 "runtimeBoundaries": build_runtime_boundaries(&top_level_folders),
1254 "crossLayerRelationships": build_cross_layer_relationships(&top_level_folders),
1255 },
1256 "workflows": build_repowiki_workflows(&top_level_folders),
1257 "glossary": build_repowiki_glossary(&top_level_folders),
1258 "sourceLinks": source_links,
1259 "storylineContext": storyline_context,
1260 })))
1261}
1262
1263#[cfg(test)]
1264mod tests {
1265 use super::*;
1266
1267 fn file(name: &str, path: &str) -> RepoTreeNode {
1268 RepoTreeNode {
1269 name: name.to_string(),
1270 path: path.to_string(),
1271 node_type: "file".to_string(),
1272 children: None,
1273 file_count: None,
1274 }
1275 }
1276
1277 fn directory(
1278 name: &str,
1279 path: &str,
1280 file_count: u64,
1281 children: Vec<RepoTreeNode>,
1282 ) -> RepoTreeNode {
1283 RepoTreeNode {
1284 name: name.to_string(),
1285 path: path.to_string(),
1286 node_type: "directory".to_string(),
1287 children: Some(children),
1288 file_count: Some(file_count),
1289 }
1290 }
1291
1292 fn sample_tree() -> RepoTreeNode {
1293 directory(
1294 "repo",
1295 ".",
1296 6,
1297 vec![
1298 file("README.md", "README.md"),
1299 file("packagefoo.js", "packagefoo.js"),
1300 directory("app", "app", 1, vec![file("page.tsx", "app/page.tsx")]),
1301 directory(
1302 "docs",
1303 "docs",
1304 2,
1305 vec![
1306 file("ARCHITECTURE.md", "docs/ARCHITECTURE.md"),
1307 directory(
1308 "adr",
1309 "docs/adr",
1310 1,
1311 vec![file("README.md", "docs/adr/README.md")],
1312 ),
1313 ],
1314 ),
1315 directory(
1316 "src",
1317 "src",
1318 2,
1319 vec![directory(
1320 "app",
1321 "src/app",
1322 1,
1323 vec![file("page.tsx", "src/app/page.tsx")],
1324 )],
1325 ),
1326 ],
1327 )
1328 }
1329
1330 #[test]
1331 fn repowiki_extract_architecture_anchors_includes_nested_docs_and_skips_false_positives() {
1332 let anchors = extract_architecture_anchors(&sample_tree());
1333
1334 let paths = anchors
1335 .iter()
1336 .filter_map(|anchor| anchor.get("path").and_then(|value| value.as_str()))
1337 .collect::<Vec<_>>();
1338
1339 assert!(paths.contains(&"README.md"));
1340 assert!(paths.contains(&"docs/ARCHITECTURE.md"));
1341 assert!(paths.contains(&"docs/adr/README.md"));
1342 assert!(!paths.contains(&"packagefoo.js"));
1343 }
1344
1345 #[test]
1346 fn repowiki_storyline_and_modules_match_expected_semantics() {
1347 let tree = sample_tree();
1348 let anchors = extract_architecture_anchors(&tree);
1349 let modules = build_repowiki_modules(&tree);
1350 let storyline = build_repowiki_storyline_context(&tree, &anchors);
1351
1352 let app_module = modules
1353 .iter()
1354 .find(|module| module.get("path").and_then(|value| value.as_str()) == Some("app"))
1355 .expect("expected app module");
1356 assert_eq!(
1357 app_module
1358 .get("role")
1359 .and_then(|value| value.as_str())
1360 .expect("expected role"),
1361 "User-facing application layer."
1362 );
1363
1364 let entry_points = storyline
1365 .get("entryPoints")
1366 .and_then(|value| value.as_array())
1367 .expect("expected entry points");
1368 let entry_paths = entry_points
1369 .iter()
1370 .filter_map(|value| value.as_str())
1371 .collect::<Vec<_>>();
1372 assert!(entry_paths.contains(&"README.md"));
1373 assert!(entry_paths.contains(&"docs/ARCHITECTURE.md"));
1374 assert!(!entry_paths.contains(&"docs"));
1375 }
1376}