syncable_cli/agent/tools/platform/
analyze_codebase.rs1use rig::completion::ToolDefinition;
8use rig::tool::Tool;
9use serde::{Deserialize, Serialize};
10use serde_json::json;
11use std::path::Path;
12
13use crate::agent::tools::error::{ErrorCategory, format_error_for_llm};
14use crate::analyzer::{
15 AnalysisConfig, ProjectAnalysis, ProjectType, TechnologyCategory, analyze_project_with_config,
16};
17
18#[derive(Debug, Deserialize)]
20pub struct AnalyzeCodebaseArgs {
21 #[serde(default = "default_project_path")]
23 pub project_path: String,
24 #[serde(default)]
26 pub include_dev_dependencies: bool,
27}
28
29fn default_project_path() -> String {
30 ".".to_string()
31}
32
33#[derive(Debug, thiserror::Error)]
35#[error("Analyze codebase error: {0}")]
36pub struct AnalyzeCodebaseError(String);
37
38#[derive(Debug, Clone, Serialize, Deserialize, Default)]
43pub struct AnalyzeCodebaseTool;
44
45impl AnalyzeCodebaseTool {
46 pub fn new() -> Self {
48 Self
49 }
50}
51
52impl Tool for AnalyzeCodebaseTool {
53 const NAME: &'static str = "analyze_codebase";
54
55 type Error = AnalyzeCodebaseError;
56 type Args = AnalyzeCodebaseArgs;
57 type Output = String;
58
59 async fn definition(&self, _prompt: String) -> ToolDefinition {
60 ToolDefinition {
61 name: Self::NAME.to_string(),
62 description: r#"Perform comprehensive analysis of a codebase to understand its technology stack and deployment requirements.
63
64**Use this tool to understand HOW to configure a deployment.** For quick Dockerfile discovery, use `analyze_project` instead.
65
66**What it detects:**
67- Programming languages with versions and confidence scores
68- Frameworks and libraries (React, Next.js, Express, Django, etc.)
69- Entry points and exposed ports
70- Environment variables the application needs
71- Build scripts (npm run build, etc.)
72- Docker configuration if present
73
74**Parameters:**
75- project_path: Path to the project directory (defaults to ".")
76- include_dev_dependencies: Include dev dependencies in analysis (default: false)
77
78**Use Cases:**
79- Understanding a project's technology stack before configuring deployment
80- Discovering required environment variables for secrets setup
81- Finding available build scripts for CI/CD configuration
82- Recommending appropriate Dockerfile base images
83
84**Returns:**
85- languages: Detected languages with versions
86- technologies: Frameworks, libraries, and tools
87- ports: Exposed ports from various sources
88- environment_variables: Environment variables the app needs
89- build_scripts: Available build commands
90- deployment_hints: Derived recommendations for deployment
91- next_steps: Guidance on what to do next
92
93**Comparison with analyze_project:**
94- `analyze_project`: Fast, focused on Dockerfiles only - "what can I deploy?"
95- `analyze_codebase`: Comprehensive analysis - "how should I configure deployment?""#
96 .to_string(),
97 parameters: json!({
98 "type": "object",
99 "properties": {
100 "project_path": {
101 "type": "string",
102 "description": "Path to the project directory to analyze (defaults to current directory)",
103 "default": "."
104 },
105 "include_dev_dependencies": {
106 "type": "boolean",
107 "description": "Include dev dependencies in analysis (default: false)",
108 "default": false
109 }
110 },
111 "required": []
112 }),
113 }
114 }
115
116 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
117 let project_path = Path::new(&args.project_path);
118
119 if !project_path.exists() {
121 return Ok(format_error_for_llm(
122 "analyze_codebase",
123 ErrorCategory::FileNotFound,
124 &format!("Project path does not exist: {}", args.project_path),
125 Some(vec![
126 "Check that the path is correct",
127 "Use an absolute path or path relative to current directory",
128 ]),
129 ));
130 }
131
132 if !project_path.is_dir() {
133 return Ok(format_error_for_llm(
134 "analyze_codebase",
135 ErrorCategory::ValidationFailed,
136 &format!("Path is not a directory: {}", args.project_path),
137 Some(vec!["Provide a directory path, not a file path"]),
138 ));
139 }
140
141 let config = AnalysisConfig {
143 include_dev_dependencies: args.include_dev_dependencies,
144 deep_analysis: true,
145 ..Default::default()
146 };
147
148 match analyze_project_with_config(project_path, &config) {
150 Ok(analysis) => {
151 let result = format_analysis_for_llm(&args.project_path, &analysis);
152 serde_json::to_string_pretty(&result)
153 .map_err(|e| AnalyzeCodebaseError(format!("Failed to serialize: {}", e)))
154 }
155 Err(e) => Ok(format_error_for_llm(
156 "analyze_codebase",
157 ErrorCategory::InternalError,
158 &format!("Failed to analyze codebase: {}", e),
159 Some(vec![
160 "Check that you have read permissions for the project directory",
161 "Ensure the path is accessible",
162 "Try running from the project root directory",
163 ]),
164 )),
165 }
166 }
167}
168
169fn format_analysis_for_llm(project_path: &str, analysis: &ProjectAnalysis) -> serde_json::Value {
171 let languages: Vec<serde_json::Value> = analysis
173 .languages
174 .iter()
175 .map(|lang| {
176 json!({
177 "name": lang.name,
178 "version": lang.version,
179 "confidence": lang.confidence,
180 "package_manager": lang.package_manager,
181 })
182 })
183 .collect();
184
185 let technologies: Vec<serde_json::Value> = analysis
187 .technologies
188 .iter()
189 .map(|tech| {
190 json!({
191 "name": tech.name,
192 "version": tech.version,
193 "category": format_category(&tech.category),
194 "is_primary": tech.is_primary,
195 "confidence": tech.confidence,
196 })
197 })
198 .collect();
199
200 let ports: Vec<serde_json::Value> = analysis
202 .ports
203 .iter()
204 .map(|port| {
205 json!({
206 "number": port.number,
207 "protocol": format!("{:?}", port.protocol),
208 "description": port.description,
209 })
210 })
211 .collect();
212
213 let env_vars: Vec<serde_json::Value> = analysis
215 .environment_variables
216 .iter()
217 .map(|env| {
218 json!({
219 "name": env.name,
220 "required": env.required,
221 "default_value": env.default_value,
222 "description": env.description,
223 })
224 })
225 .collect();
226
227 let build_scripts: Vec<serde_json::Value> = analysis
229 .build_scripts
230 .iter()
231 .map(|script| {
232 json!({
233 "name": script.name,
234 "command": script.command,
235 "description": script.description,
236 "is_default": script.is_default,
237 })
238 })
239 .collect();
240
241 let deployment_hints = derive_deployment_hints(analysis);
243
244 let next_steps = determine_next_steps(analysis);
246
247 json!({
248 "success": true,
249 "project_path": project_path,
250 "languages": languages,
251 "technologies": technologies,
252 "ports": ports,
253 "environment_variables": env_vars,
254 "build_scripts": build_scripts,
255 "project_type": format!("{:?}", analysis.project_type),
256 "architecture_type": format!("{:?}", analysis.architecture_type),
257 "analysis_metadata": {
258 "confidence_score": analysis.analysis_metadata.confidence_score,
259 "files_analyzed": analysis.analysis_metadata.files_analyzed,
260 "duration_ms": analysis.analysis_metadata.analysis_duration_ms,
261 },
262 "deployment_hints": deployment_hints,
263 "summary": format_summary(analysis),
264 "next_steps": next_steps,
265 })
266}
267
268fn format_category(category: &TechnologyCategory) -> String {
270 match category {
271 TechnologyCategory::MetaFramework => "MetaFramework".to_string(),
272 TechnologyCategory::FrontendFramework => "FrontendFramework".to_string(),
273 TechnologyCategory::BackendFramework => "BackendFramework".to_string(),
274 TechnologyCategory::Library(lib_type) => format!("Library:{:?}", lib_type),
275 TechnologyCategory::BuildTool => "BuildTool".to_string(),
276 TechnologyCategory::Database => "Database".to_string(),
277 TechnologyCategory::Testing => "Testing".to_string(),
278 TechnologyCategory::Runtime => "Runtime".to_string(),
279 TechnologyCategory::PackageManager => "PackageManager".to_string(),
280 }
281}
282
283fn derive_deployment_hints(analysis: &ProjectAnalysis) -> serde_json::Value {
285 let suggested_port = analysis
287 .ports
288 .first()
289 .map(|p| p.number)
290 .or_else(|| infer_default_port(analysis));
291
292 let needs_build_step = !analysis.build_scripts.is_empty()
294 || analysis.technologies.iter().any(|t| {
295 matches!(
296 t.category,
297 TechnologyCategory::MetaFramework | TechnologyCategory::FrontendFramework
298 )
299 });
300
301 let recommended_dockerfile_base = infer_dockerfile_base(analysis);
303
304 let has_dockerfile = analysis
306 .docker_analysis
307 .as_ref()
308 .map(|d| !d.dockerfiles.is_empty())
309 .unwrap_or(false);
310
311 json!({
312 "suggested_port": suggested_port,
313 "needs_build_step": needs_build_step,
314 "recommended_dockerfile_base": recommended_dockerfile_base,
315 "has_existing_dockerfile": has_dockerfile,
316 "required_env_vars": analysis.environment_variables.iter()
317 .filter(|e| e.required)
318 .map(|e| e.name.clone())
319 .collect::<Vec<_>>(),
320 })
321}
322
323fn infer_default_port(analysis: &ProjectAnalysis) -> Option<u16> {
325 for tech in &analysis.technologies {
326 let name_lower = tech.name.to_lowercase();
327 if name_lower.contains("next") || name_lower.contains("nuxt") {
328 return Some(3000);
329 }
330 if name_lower.contains("vite") || name_lower.contains("vue") {
331 return Some(5173);
332 }
333 if name_lower.contains("angular") {
334 return Some(4200);
335 }
336 if name_lower.contains("django") {
337 return Some(8000);
338 }
339 if name_lower.contains("flask") {
340 return Some(5000);
341 }
342 if name_lower.contains("express") || name_lower.contains("fastify") {
343 return Some(3000);
344 }
345 if name_lower.contains("spring") {
346 return Some(8080);
347 }
348 if name_lower.contains("actix") || name_lower.contains("axum") {
349 return Some(8080);
350 }
351 }
352
353 for lang in &analysis.languages {
355 match lang.name.to_lowercase().as_str() {
356 "python" => return Some(8000),
357 "go" => return Some(8080),
358 "rust" => return Some(8080),
359 "java" | "kotlin" => return Some(8080),
360 "javascript" | "typescript" => return Some(3000),
361 _ => {}
362 }
363 }
364
365 None
366}
367
368fn infer_dockerfile_base(analysis: &ProjectAnalysis) -> Option<String> {
370 for lang in &analysis.languages {
372 match lang.name.to_lowercase().as_str() {
373 "javascript" | "typescript" => {
374 if analysis
376 .technologies
377 .iter()
378 .any(|t| t.name.to_lowercase() == "bun")
379 {
380 return Some("oven/bun:1-alpine".to_string());
381 }
382 return Some("node:20-alpine".to_string());
383 }
384 "python" => return Some("python:3.12-slim".to_string()),
385 "go" => return Some("golang:1.22-alpine".to_string()),
386 "rust" => return Some("rust:1.75-alpine".to_string()),
387 "java" => return Some("eclipse-temurin:21-jre-alpine".to_string()),
388 "kotlin" => return Some("eclipse-temurin:21-jre-alpine".to_string()),
389 _ => {}
390 }
391 }
392
393 None
394}
395
396fn determine_next_steps(analysis: &ProjectAnalysis) -> Vec<String> {
398 let mut steps = Vec::new();
399
400 let has_dockerfile = analysis
401 .docker_analysis
402 .as_ref()
403 .map(|d| !d.dockerfiles.is_empty())
404 .unwrap_or(false);
405
406 if has_dockerfile {
407 steps.push("Use analyze_project to get specific Dockerfile details".to_string());
408 steps.push(
409 "Use list_deployment_capabilities to see available deployment targets".to_string(),
410 );
411 steps.push("Use create_deployment_config to create a deployment configuration".to_string());
412 } else {
413 steps.push(
414 "Create a Dockerfile for your application (recommended base image in deployment_hints)"
415 .to_string(),
416 );
417 steps.push(
418 "After creating Dockerfile, use analyze_project to verify it's detected".to_string(),
419 );
420 }
421
422 if !analysis.environment_variables.is_empty() {
423 let required_count = analysis
424 .environment_variables
425 .iter()
426 .filter(|e| e.required)
427 .count();
428 if required_count > 0 {
429 steps.push(format!(
430 "Configure {} required environment variable{} before deployment",
431 required_count,
432 if required_count == 1 { "" } else { "s" }
433 ));
434 }
435 }
436
437 steps
438}
439
440fn format_summary(analysis: &ProjectAnalysis) -> String {
442 let lang_names: Vec<&str> = analysis.languages.iter().map(|l| l.name.as_str()).collect();
443
444 let primary_tech: Vec<&str> = analysis
445 .technologies
446 .iter()
447 .filter(|t| t.is_primary)
448 .map(|t| t.name.as_str())
449 .collect();
450
451 let project_type = match analysis.project_type {
452 ProjectType::WebApplication => "web application",
453 ProjectType::ApiService => "API service",
454 ProjectType::CliTool => "CLI tool",
455 ProjectType::Library => "library",
456 ProjectType::MobileApp => "mobile app",
457 ProjectType::DesktopApp => "desktop app",
458 ProjectType::Microservice => "microservice",
459 ProjectType::StaticSite => "static site",
460 ProjectType::Hybrid => "hybrid project",
461 ProjectType::Unknown => "project",
462 };
463
464 let lang_str = if lang_names.is_empty() {
465 "Unknown language".to_string()
466 } else {
467 lang_names.join(", ")
468 };
469
470 let tech_str = if primary_tech.is_empty() {
471 String::new()
472 } else {
473 format!(" using {}", primary_tech.join(", "))
474 };
475
476 format!("{} {}{}", lang_str, project_type, tech_str)
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482
483 #[test]
484 fn test_tool_name() {
485 assert_eq!(AnalyzeCodebaseTool::NAME, "analyze_codebase");
486 }
487
488 #[test]
489 fn test_tool_creation() {
490 let tool = AnalyzeCodebaseTool::new();
491 assert!(format!("{:?}", tool).contains("AnalyzeCodebaseTool"));
492 }
493
494 #[test]
495 fn test_default_project_path() {
496 assert_eq!(default_project_path(), ".");
497 }
498
499 #[test]
500 fn test_format_category() {
501 assert_eq!(
502 format_category(&TechnologyCategory::MetaFramework),
503 "MetaFramework"
504 );
505 assert_eq!(
506 format_category(&TechnologyCategory::BackendFramework),
507 "BackendFramework"
508 );
509 }
510}