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