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 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 if let Ok(Some(codebase)) = state.codebase_store.get(&id).await {
245 let repo_path = &codebase.repo_path;
246
247 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
304const 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}