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            "/codebases/{id}",
39            patch(update_codebase).delete(delete_codebase),
40        )
41        .route("/codebases/{id}/default", post(set_default_codebase))
42}
43
44async fn list_codebases(
45    State(state): State<AppState>,
46    axum::extract::Path(workspace_id): axum::extract::Path<String>,
47) -> Result<Json<serde_json::Value>, ServerError> {
48    let codebases = state
49        .codebase_store
50        .list_by_workspace(&workspace_id)
51        .await?;
52    Ok(Json(serde_json::json!({ "codebases": codebases })))
53}
54
55async fn list_codebase_changes(
56    State(state): State<AppState>,
57    axum::extract::Path(workspace_id): axum::extract::Path<String>,
58) -> Result<Json<serde_json::Value>, ServerError> {
59    let codebases = state
60        .codebase_store
61        .list_by_workspace(&workspace_id)
62        .await?;
63
64    let repos = codebases
65        .into_iter()
66        .map(|codebase| {
67            let label = codebase
68                .label
69                .clone()
70                .unwrap_or_else(|| repo_label_from_path(&codebase.repo_path));
71
72            if codebase.repo_path.is_empty() {
73                return serde_json::json!({
74                    "codebaseId": codebase.id,
75                    "repoPath": codebase.repo_path,
76                    "label": label,
77                    "branch": codebase.branch.unwrap_or_else(|| "unknown".to_string()),
78                    "status": { "clean": true, "ahead": 0, "behind": 0, "modified": 0, "untracked": 0 },
79                    "files": [],
80                    "error": "Missing repository path",
81                });
82            }
83
84            if !crate::git::is_git_repository(&codebase.repo_path) {
85                return serde_json::json!({
86                    "codebaseId": codebase.id,
87                    "repoPath": codebase.repo_path,
88                    "label": label,
89                    "branch": codebase.branch.unwrap_or_else(|| "unknown".to_string()),
90                    "status": { "clean": true, "ahead": 0, "behind": 0, "modified": 0, "untracked": 0 },
91                    "files": [],
92                    "error": "Repository is missing or not a git repository",
93                });
94            }
95
96            let changes = crate::git::get_repo_changes(&codebase.repo_path);
97            serde_json::json!({
98                "codebaseId": codebase.id,
99                "repoPath": codebase.repo_path,
100                "label": label,
101                "branch": changes.branch,
102                "status": changes.status,
103                "files": changes.files,
104            })
105        })
106        .collect::<Vec<_>>();
107
108    Ok(Json(serde_json::json!({
109        "workspaceId": workspace_id,
110        "repos": repos,
111    })))
112}
113
114#[derive(Debug, Deserialize)]
115#[serde(rename_all = "camelCase")]
116struct AddCodebaseRequest {
117    repo_path: String,
118    branch: Option<String>,
119    label: Option<String>,
120    source_type: Option<CodebaseSourceType>,
121    source_url: Option<String>,
122    #[serde(default)]
123    is_default: bool,
124}
125
126async fn add_codebase(
127    State(state): State<AppState>,
128    axum::extract::Path(workspace_id): axum::extract::Path<String>,
129    Json(body): Json<AddCodebaseRequest>,
130) -> Result<Json<serde_json::Value>, ServerError> {
131    let source_type = body.source_type.unwrap_or(CodebaseSourceType::Local);
132    let repo_path = normalize_local_repo_path(&body.repo_path);
133    match source_type {
134        CodebaseSourceType::Local => validate_local_git_repo_path(&repo_path)?,
135        CodebaseSourceType::Github => validate_repo_path(&repo_path, "Path ")?,
136    }
137    let repo_path = repo_path.to_string_lossy().to_string();
138
139    // Check for duplicate repo_path within the workspace
140    if let Some(_existing) = state
141        .codebase_store
142        .find_by_repo_path(&workspace_id, &repo_path)
143        .await?
144    {
145        return Err(ServerError::Conflict(format!(
146            "Codebase with repo_path '{}' already exists in workspace {}",
147            repo_path, workspace_id
148        )));
149    }
150
151    let codebase = Codebase::new(
152        uuid::Uuid::new_v4().to_string(),
153        workspace_id,
154        repo_path,
155        body.branch,
156        body.label,
157        body.is_default,
158        Some(source_type),
159        body.source_url,
160    );
161
162    state.codebase_store.save(&codebase).await?;
163    Ok(Json(serde_json::json!({ "codebase": codebase })))
164}
165
166#[derive(Debug, Deserialize)]
167#[serde(rename_all = "camelCase")]
168struct UpdateCodebaseRequest {
169    branch: Option<String>,
170    label: Option<String>,
171    repo_path: Option<String>,
172    source_type: Option<CodebaseSourceType>,
173    source_url: Option<String>,
174}
175
176async fn update_codebase(
177    State(state): State<AppState>,
178    axum::extract::Path(id): axum::extract::Path<String>,
179    Json(body): Json<UpdateCodebaseRequest>,
180) -> Result<Json<serde_json::Value>, ServerError> {
181    let existing = state
182        .codebase_store
183        .get(&id)
184        .await?
185        .ok_or_else(|| ServerError::NotFound(format!("Codebase {} not found", id)))?;
186    let requested_source_type = body
187        .source_type
188        .clone()
189        .or_else(|| existing.source_type.clone())
190        .unwrap_or(CodebaseSourceType::Local);
191
192    let repo_path = if let Some(repo_path) = body.repo_path.as_deref() {
193        let normalized = normalize_local_repo_path(repo_path);
194        match requested_source_type {
195            CodebaseSourceType::Local => validate_local_git_repo_path(&normalized)?,
196            CodebaseSourceType::Github => validate_repo_path(&normalized, "Path ")?,
197        }
198        let normalized = normalized.to_string_lossy().to_string();
199
200        if let Some(duplicate) = state
201            .codebase_store
202            .find_by_repo_path(&existing.workspace_id, &normalized)
203            .await?
204        {
205            if duplicate.id != id {
206                return Err(ServerError::Conflict(format!(
207                    "Codebase with repo_path '{}' already exists in workspace {}",
208                    normalized, existing.workspace_id
209                )));
210            }
211        }
212
213        Some(normalized)
214    } else {
215        None
216    };
217
218    state
219        .codebase_store
220        .update(
221            &id,
222            body.branch.as_deref(),
223            body.label.as_deref(),
224            repo_path.as_deref(),
225            body.source_type.as_ref().map(CodebaseSourceType::as_str),
226            body.source_url.as_deref(),
227        )
228        .await?;
229
230    let codebase = state
231        .codebase_store
232        .get(&id)
233        .await?
234        .ok_or_else(|| ServerError::NotFound(format!("Codebase {} not found", id)))?;
235
236    Ok(Json(serde_json::json!({ "codebase": codebase })))
237}
238
239async fn delete_codebase(
240    State(state): State<AppState>,
241    axum::extract::Path(id): axum::extract::Path<String>,
242) -> Result<Json<serde_json::Value>, ServerError> {
243    // Clean up worktrees on disk before deleting the codebase
244    if let Ok(Some(codebase)) = state.codebase_store.get(&id).await {
245        let repo_path = &codebase.repo_path;
246
247        // Acquire repo lock to prevent races with concurrent worktree operations
248        let lock = {
249            let mut locks = crate::api::worktrees::get_repo_locks().lock().await;
250            locks
251                .entry(repo_path.to_string())
252                .or_insert_with(|| std::sync::Arc::new(tokio::sync::Mutex::new(())))
253                .clone()
254        };
255        let _guard = lock.lock().await;
256
257        let worktrees = state
258            .worktree_store
259            .list_by_codebase(&id)
260            .await
261            .map_err(|e| ServerError::Internal(format!("Failed to list worktrees: {}", e)))?;
262        for wt in &worktrees {
263            if let Err(e) = crate::git::worktree_remove(repo_path, &wt.worktree_path, true) {
264                tracing::warn!(
265                    "[Codebase DELETE] Failed to remove worktree {}: {}",
266                    wt.id,
267                    e
268                );
269            }
270        }
271        if !worktrees.is_empty() {
272            let _ = crate::git::worktree_prune(repo_path);
273        }
274    }
275
276    state.codebase_store.delete(&id).await?;
277    Ok(Json(serde_json::json!({ "deleted": true })))
278}
279
280async fn set_default_codebase(
281    State(state): State<AppState>,
282    axum::extract::Path(id): axum::extract::Path<String>,
283) -> Result<Json<serde_json::Value>, ServerError> {
284    let codebase = state
285        .codebase_store
286        .get(&id)
287        .await?
288        .ok_or_else(|| ServerError::NotFound(format!("Codebase {} not found", id)))?;
289
290    state
291        .codebase_store
292        .set_default(&codebase.workspace_id, &id)
293        .await?;
294
295    let updated = state
296        .codebase_store
297        .get(&id)
298        .await?
299        .ok_or_else(|| ServerError::NotFound(format!("Codebase {} not found", id)))?;
300
301    Ok(Json(serde_json::json!({ "codebase": updated })))
302}
303
304// ─── RepoSlide ──────────────────────────────────────────────────
305
306const IGNORE_DIRS: &[&str] = &[
307    "node_modules",
308    ".git",
309    ".next",
310    "dist",
311    "build",
312    "target",
313    ".routa",
314    ".worktrees",
315    "__pycache__",
316    ".tox",
317    ".venv",
318    "venv",
319    ".cache",
320];
321
322const MAX_DEPTH: usize = 4;
323const MAX_CHILDREN: usize = 50;
324const MAX_DIR_FOCUS_SLIDES: usize = 6;
325
326const ENTRY_POINT_FILES: &[&str] = &[
327    "README.md",
328    "AGENTS.md",
329    "package.json",
330    "Cargo.toml",
331    "go.mod",
332    "pyproject.toml",
333    "setup.py",
334    "pom.xml",
335    "build.gradle",
336    "Makefile",
337    "Dockerfile",
338    "docker-compose.yml",
339    "tsconfig.json",
340];
341
342const ANCHOR_DIRS: &[&str] = &[
343    "src/app",
344    "src/core",
345    "src/client",
346    "crates",
347    "apps",
348    "lib",
349    "pkg",
350    "cmd",
351    "internal",
352    "api",
353];
354
355const KEY_FILE_NAMES: &[&str] = &[
356    "README.md",
357    "AGENTS.md",
358    "ARCHITECTURE.md",
359    "CONTRIBUTING.md",
360    "LICENSE",
361    "CHANGELOG.md",
362];
363
364#[derive(Debug, Serialize)]
365#[serde(rename_all = "camelCase")]
366struct RepoTreeNode {
367    name: String,
368    path: String,
369    #[serde(rename = "type")]
370    node_type: String,
371    #[serde(skip_serializing_if = "Option::is_none")]
372    children: Option<Vec<RepoTreeNode>>,
373    #[serde(skip_serializing_if = "Option::is_none")]
374    file_count: Option<u64>,
375}
376
377#[derive(Debug, Serialize)]
378#[serde(rename_all = "camelCase")]
379struct RepoSummary {
380    total_files: u64,
381    total_directories: u64,
382    top_level_folders: Vec<String>,
383    source_type: String,
384    #[serde(skip_serializing_if = "Option::is_none")]
385    branch: Option<String>,
386}
387
388fn scan_repo_tree(repo_path: &str) -> RepoTreeNode {
389    let root_name = std::path::Path::new(repo_path)
390        .file_name()
391        .and_then(|n| n.to_str())
392        .unwrap_or(repo_path)
393        .to_string();
394    scan_dir(repo_path, &root_name, ".", 0)
395}
396
397fn scan_dir(abs_path: &str, name: &str, rel_path: &str, depth: usize) -> RepoTreeNode {
398    let mut node = RepoTreeNode {
399        name: name.to_string(),
400        path: rel_path.to_string(),
401        node_type: "directory".to_string(),
402        children: Some(Vec::new()),
403        file_count: Some(0),
404    };
405
406    if depth >= MAX_DEPTH {
407        return node;
408    }
409
410    let mut entries: Vec<std::fs::DirEntry> = match std::fs::read_dir(abs_path) {
411        Ok(rd) => rd.filter_map(|e| e.ok()).collect(),
412        Err(_) => return node,
413    };
414
415    entries.sort_by(|a, b| {
416        let a_dir = a.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
417        let b_dir = b.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
418        match (a_dir, b_dir) {
419            (true, false) => std::cmp::Ordering::Less,
420            (false, true) => std::cmp::Ordering::Greater,
421            _ => a.file_name().cmp(&b.file_name()),
422        }
423    });
424
425    let children = node.children.as_mut().unwrap();
426    let mut file_count: u64 = 0;
427    let mut child_count = 0;
428
429    for entry in entries {
430        if child_count >= MAX_CHILDREN {
431            break;
432        }
433        let entry_name = entry.file_name().to_string_lossy().to_string();
434        if IGNORE_DIRS.contains(&entry_name.as_str()) {
435            continue;
436        }
437        let ft = match entry.file_type() {
438            Ok(ft) => ft,
439            Err(_) => continue,
440        };
441        let child_rel = if rel_path == "." {
442            entry_name.clone()
443        } else {
444            format!("{}/{}", rel_path, entry_name)
445        };
446        let child_abs = format!("{}/{}", abs_path, entry_name);
447
448        if ft.is_dir() {
449            let child = scan_dir(&child_abs, &entry_name, &child_rel, depth + 1);
450            file_count += child.file_count.unwrap_or(0);
451            children.push(child);
452        } else if ft.is_file() {
453            children.push(RepoTreeNode {
454                name: entry_name,
455                path: child_rel,
456                node_type: "file".to_string(),
457                children: None,
458                file_count: None,
459            });
460            file_count += 1;
461        }
462
463        child_count += 1;
464    }
465
466    node.file_count = Some(file_count);
467    node
468}
469
470fn compute_summary(tree: &RepoTreeNode, source_type: &str, branch: Option<&str>) -> RepoSummary {
471    let (files, dirs) = count_tree(tree);
472    let top_level_folders = tree
473        .children
474        .as_ref()
475        .map(|c| {
476            c.iter()
477                .filter(|n| n.node_type == "directory")
478                .map(|n| n.name.clone())
479                .collect()
480        })
481        .unwrap_or_default();
482
483    RepoSummary {
484        total_files: files,
485        total_directories: dirs,
486        top_level_folders,
487        source_type: source_type.to_string(),
488        branch: branch.map(str::to_string),
489    }
490}
491
492fn count_tree(node: &RepoTreeNode) -> (u64, u64) {
493    if node.node_type == "file" {
494        return (1, 0);
495    }
496    let mut files = 0u64;
497    let mut dirs = 1u64;
498    for child in node.children.as_deref().unwrap_or(&[]) {
499        let (f, d) = count_tree(child);
500        files += f;
501        dirs += d;
502    }
503    (files, dirs)
504}
505
506fn detect_entry_points(tree: &RepoTreeNode) -> Vec<serde_json::Value> {
507    let mut found = Vec::new();
508
509    for child in tree.children.as_deref().unwrap_or(&[]) {
510        if child.node_type == "file" && ENTRY_POINT_FILES.contains(&child.name.as_str()) {
511            found.push(serde_json::json!({
512                "name": child.name,
513                "path": child.path,
514                "reason": format!("Project entry point ({})", child.name),
515            }));
516        }
517    }
518
519    for anchor in ANCHOR_DIRS {
520        if let Some(node) = find_node_by_path(tree, anchor) {
521            found.push(serde_json::json!({
522                "name": *anchor,
523                "path": node.path,
524                "reason": "Architecture anchor directory",
525            }));
526        }
527    }
528
529    found
530}
531
532fn detect_key_files(tree: &RepoTreeNode) -> Vec<serde_json::Value> {
533    tree.children
534        .as_deref()
535        .unwrap_or(&[])
536        .iter()
537        .filter(|c| c.node_type == "file" && KEY_FILE_NAMES.contains(&c.name.as_str()))
538        .map(|c| {
539            serde_json::json!({
540                "name": c.name,
541                "path": c.path,
542            })
543        })
544        .collect()
545}
546
547fn build_focus_directories(tree: &RepoTreeNode) -> Vec<serde_json::Value> {
548    let mut focus_dirs: Vec<&RepoTreeNode> = tree
549        .children
550        .as_deref()
551        .unwrap_or(&[])
552        .iter()
553        .filter(|c| c.node_type == "directory")
554        .collect();
555    focus_dirs.sort_by(|a, b| b.file_count.unwrap_or(0).cmp(&a.file_count.unwrap_or(0)));
556
557    focus_dirs
558        .into_iter()
559        .take(MAX_DIR_FOCUS_SLIDES)
560        .map(|dir| {
561            let children: Vec<serde_json::Value> = dir
562                .children
563                .as_deref()
564                .unwrap_or(&[])
565                .iter()
566                .map(|child| {
567                    let mut value = serde_json::json!({
568                        "name": child.name,
569                        "type": child.node_type,
570                    });
571                    if child.node_type == "directory" {
572                        value["fileCount"] = serde_json::json!(child.file_count.unwrap_or(0));
573                    }
574                    value
575                })
576                .collect();
577
578            serde_json::json!({
579                "name": dir.name,
580                "path": dir.path,
581                "fileCount": dir.file_count.unwrap_or(0),
582                "children": children,
583            })
584        })
585        .collect()
586}
587
588fn build_reposlide_prompt(
589    codebase: &Codebase,
590    summary: &RepoSummary,
591    root_files: &[String],
592    entry_points: &[serde_json::Value],
593    key_files: &[serde_json::Value],
594    focus_directories: &[serde_json::Value],
595) -> String {
596    let repo_label = codebase
597        .label
598        .clone()
599        .unwrap_or_else(|| repo_label_from_path(&codebase.repo_path));
600    let mut lines = vec![
601        format!(
602            "Create a presentation slide deck for the repository \"{}\".",
603            repo_label
604        ),
605        String::new(),
606        "Goal:".to_string(),
607        "- Explain what this repository is, how it is structured, and how an engineer should orient themselves quickly.".to_string(),
608        "- Keep the deck concise: target 6-8 slides.".to_string(),
609        "- Use evidence from the local repository only. If a conclusion is inferred, label it as an inference.".to_string(),
610        String::new(),
611        "Required coverage:".to_string(),
612        "- Repository purpose and audience.".to_string(),
613        "- Runtime or architecture overview.".to_string(),
614        "- Top-level structure and major subsystems.".to_string(),
615        "- Important entry points, docs, and operational files.".to_string(),
616        "- Notable risks, TODOs, or ambiguities if they materially affect understanding.".to_string(),
617        String::new(),
618        "Before drafting slides, inspect these first if they exist:".to_string(),
619        "- AGENTS.md".to_string(),
620        "- README.md".to_string(),
621        "- docs/ARCHITECTURE.md".to_string(),
622        "- docs/adr/README.md".to_string(),
623        "- package.json / Cargo.toml / pyproject.toml / go.mod".to_string(),
624        String::new(),
625        "Output:".to_string(),
626        "- Build the deck with slide-skill and save the final artifact as a PPTX.".to_string(),
627        "- In the final response, report the PPTX path and summarize the slide outline.".to_string(),
628        String::new(),
629        "Repository context:".to_string(),
630        format!("- Repo path: {}", codebase.repo_path),
631        format!(
632            "- Branch: {}",
633            codebase.branch.as_deref().unwrap_or("unknown")
634        ),
635        format!("- Source type: {}", summary.source_type),
636        format!("- Total files scanned: {}", summary.total_files),
637        format!("- Total directories scanned: {}", summary.total_directories),
638        format!(
639            "- Top-level folders: {}",
640            if summary.top_level_folders.is_empty() {
641                "(none detected)".to_string()
642            } else {
643                summary.top_level_folders.join(", ")
644            }
645        ),
646        format!(
647            "- Root files: {}",
648            if root_files.is_empty() {
649                "(none detected)".to_string()
650            } else {
651                root_files.join(", ")
652            }
653        ),
654    ];
655
656    if !entry_points.is_empty() {
657        lines.push(String::new());
658        lines.push("Entry points and architecture anchors:".to_string());
659        for item in entry_points {
660            let path = item
661                .get("path")
662                .and_then(|value| value.as_str())
663                .unwrap_or("(unknown)");
664            let reason = item
665                .get("reason")
666                .and_then(|value| value.as_str())
667                .unwrap_or("(no reason)");
668            lines.push(format!("- {}: {}", path, reason));
669        }
670    }
671
672    if !key_files.is_empty() {
673        lines.push(String::new());
674        lines.push("Key files worth reading:".to_string());
675        for item in key_files {
676            let path = item
677                .get("path")
678                .and_then(|value| value.as_str())
679                .unwrap_or("(unknown)");
680            lines.push(format!("- {}", path));
681        }
682    }
683
684    if !focus_directories.is_empty() {
685        lines.push(String::new());
686        lines.push("Largest top-level areas:".to_string());
687        for item in focus_directories {
688            let dir_path = item
689                .get("path")
690                .and_then(|value| value.as_str())
691                .unwrap_or("(unknown)");
692            let file_count = item
693                .get("fileCount")
694                .and_then(|value| value.as_u64())
695                .unwrap_or(0);
696            let preview = item
697                .get("children")
698                .and_then(|value| value.as_array())
699                .map(|children| {
700                    children
701                        .iter()
702                        .take(8)
703                        .map(|child| {
704                            let name = child
705                                .get("name")
706                                .and_then(|value| value.as_str())
707                                .unwrap_or("(unknown)");
708                            let node_type = child
709                                .get("type")
710                                .and_then(|value| value.as_str())
711                                .unwrap_or("file");
712                            if node_type == "directory" {
713                                let nested_count = child
714                                    .get("fileCount")
715                                    .and_then(|value| value.as_u64())
716                                    .unwrap_or(0);
717                                format!("{}/ ({} files)", name, nested_count)
718                            } else {
719                                name.to_string()
720                            }
721                        })
722                        .collect::<Vec<_>>()
723                        .join(", ")
724                })
725                .unwrap_or_default();
726            lines.push(format!(
727                "- {} ({} files): {}",
728                dir_path,
729                file_count,
730                if preview.is_empty() {
731                    "no immediate children scanned".to_string()
732                } else {
733                    preview
734                }
735            ));
736        }
737    }
738
739    lines.push(String::new());
740    lines.push(
741        "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(),
742    );
743
744    lines.join("\n")
745}
746
747fn resolve_reposlide_skill_repo_path() -> Option<String> {
748    let cwd = std::env::current_dir().ok()?;
749    let candidate = cwd.join("tools").join("ppt-template");
750    let skill_file = candidate
751        .join(".agents")
752        .join("skills")
753        .join("slide-skill")
754        .join("SKILL.md");
755
756    if skill_file.is_file() {
757        Some(candidate.to_string_lossy().to_string())
758    } else {
759        None
760    }
761}
762
763fn find_node_by_path<'a>(tree: &'a RepoTreeNode, target: &str) -> Option<&'a RepoTreeNode> {
764    let segments: Vec<&str> = target.split('/').collect();
765    let mut current = tree;
766    for seg in &segments {
767        current = current.children.as_ref()?.iter().find(|c| c.name == *seg)?;
768    }
769    Some(current)
770}
771
772async fn get_reposlide(
773    State(state): State<AppState>,
774    axum::extract::Path((workspace_id, codebase_id)): axum::extract::Path<(String, String)>,
775) -> Result<Json<serde_json::Value>, ServerError> {
776    let codebase = state
777        .codebase_store
778        .get(&codebase_id)
779        .await?
780        .ok_or_else(|| ServerError::NotFound(format!("Codebase {} not found", codebase_id)))?;
781
782    if codebase.workspace_id != workspace_id {
783        return Err(ServerError::NotFound(format!(
784            "Codebase {} not found in workspace {}",
785            codebase_id, workspace_id
786        )));
787    }
788
789    if codebase.repo_path.is_empty() {
790        return Err(ServerError::BadRequest(
791            "Codebase has no repository path".to_string(),
792        ));
793    }
794
795    let tree = scan_repo_tree(&codebase.repo_path);
796    let source_type = codebase
797        .source_type
798        .as_ref()
799        .map(CodebaseSourceType::as_str)
800        .unwrap_or("local");
801    let summary = compute_summary(&tree, source_type, codebase.branch.as_deref());
802    let root_files: Vec<String> = tree
803        .children
804        .as_deref()
805        .unwrap_or(&[])
806        .iter()
807        .filter(|c| c.node_type == "file")
808        .map(|c| c.name.clone())
809        .collect();
810    let entry_points = detect_entry_points(&tree);
811    let key_files = detect_key_files(&tree);
812    let focus_directories = build_focus_directories(&tree);
813    let skill_repo_path = resolve_reposlide_skill_repo_path();
814    let skill_available = skill_repo_path.is_some();
815    let prompt = build_reposlide_prompt(
816        &codebase,
817        &summary,
818        &root_files,
819        &entry_points,
820        &key_files,
821        &focus_directories,
822    );
823
824    Ok(Json(serde_json::json!({
825        "codebase": {
826            "id": codebase.id,
827            "label": codebase.label,
828            "repoPath": codebase.repo_path,
829            "sourceType": source_type,
830            "sourceUrl": codebase.source_url,
831            "branch": codebase.branch,
832        },
833        "summary": summary,
834        "context": {
835            "rootFiles": root_files,
836            "entryPoints": entry_points,
837            "keyFiles": key_files,
838            "focusDirectories": focus_directories,
839        },
840        "launch": {
841            "skillName": "slide-skill",
842            "skillRepoPath": skill_repo_path,
843            "skillAvailable": skill_available,
844            "unavailableReason": if skill_available {
845                serde_json::Value::Null
846            } else {
847                serde_json::Value::String("slide-skill could not be found relative to the current Routa installation.".to_string())
848            },
849            "prompt": prompt,
850        },
851    })))
852}