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