Skip to main content

routa_server/api/
codebases.rs

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    // Check for duplicate repo_path within the workspace
148    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    // Clean up worktrees on disk before deleting the codebase
251    if let Ok(Some(codebase)) = state.codebase_store.get(&id).await {
252        let repo_path = &codebase.repo_path;
253
254        // Acquire repo lock to prevent races with concurrent worktree operations
255        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
311// ─── RepoSlide ──────────────────────────────────────────────────
312
313const 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}