syncable_cli/agent/tools/
discover.rs

1//! Service/Package discovery tool for monorepo exploration
2//!
3//! Helps the agent discover and understand the structure of monorepos.
4
5use rig::completion::ToolDefinition;
6use rig::tool::Tool;
7use serde::{Deserialize, Serialize};
8use serde_json::json;
9use std::collections::HashMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12use walkdir::WalkDir;
13
14// ============================================================================
15// Discover Services Tool
16// ============================================================================
17
18#[derive(Debug, Deserialize)]
19pub struct DiscoverServicesArgs {
20    /// Optional subdirectory to search within
21    pub path: Option<String>,
22    /// Include detailed package info (dependencies, scripts)
23    pub detailed: Option<bool>,
24}
25
26#[derive(Debug, thiserror::Error)]
27#[error("Discovery error: {0}")]
28pub struct DiscoverServicesError(String);
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct DiscoverServicesTool {
32    project_path: PathBuf,
33}
34
35impl DiscoverServicesTool {
36    pub fn new(project_path: PathBuf) -> Self {
37        Self { project_path }
38    }
39
40    fn should_skip_dir(name: &str) -> bool {
41        matches!(
42            name,
43            "node_modules"
44                | ".git"
45                | "target"
46                | "__pycache__"
47                | ".venv"
48                | "dist"
49                | "build"
50                | ".next"
51                | ".nuxt"
52                | "vendor"
53                | ".cache"
54                | "coverage"
55                | "tmp"
56                | "temp"
57                | ".turbo"
58                | ".pnpm"
59        )
60    }
61
62    fn detect_package_type(path: &Path) -> Option<(&'static str, PathBuf)> {
63        let indicators = [
64            ("package.json", "node"),
65            ("Cargo.toml", "rust"),
66            ("go.mod", "go"),
67            ("pyproject.toml", "python"),
68            ("requirements.txt", "python"),
69            ("pom.xml", "java"),
70            ("build.gradle", "java"),
71            ("build.gradle.kts", "kotlin"),
72            ("composer.json", "php"),
73            ("Gemfile", "ruby"),
74            ("pubspec.yaml", "dart"),
75        ];
76
77        for (file, pkg_type) in indicators {
78            let manifest = path.join(file);
79            if manifest.exists() {
80                return Some((pkg_type, manifest));
81            }
82        }
83        None
84    }
85
86    fn parse_package_json(path: &Path, detailed: bool) -> Option<serde_json::Value> {
87        let content = fs::read_to_string(path).ok()?;
88        let json: serde_json::Value = serde_json::from_str(&content).ok()?;
89
90        let name = json.get("name").and_then(|v| v.as_str()).unwrap_or("unknown");
91        let version = json.get("version").and_then(|v| v.as_str()).unwrap_or("0.0.0");
92        let description = json.get("description").and_then(|v| v.as_str());
93        let private = json.get("private").and_then(|v| v.as_bool()).unwrap_or(false);
94        
95        // Detect project type from dependencies
96        let deps = json.get("dependencies").and_then(|v| v.as_object());
97        let dev_deps = json.get("devDependencies").and_then(|v| v.as_object());
98        
99        let mut project_type = "unknown";
100        let mut frameworks: Vec<&str> = Vec::new();
101        
102        if let Some(d) = deps {
103            if d.contains_key("next") {
104                project_type = "Next.js App";
105                frameworks.push("Next.js");
106            } else if d.contains_key("react") {
107                project_type = "React App";
108                frameworks.push("React");
109            } else if d.contains_key("vue") {
110                project_type = "Vue App";
111                frameworks.push("Vue");
112            } else if d.contains_key("svelte") || d.contains_key("@sveltejs/kit") {
113                project_type = "Svelte App";
114                frameworks.push("Svelte");
115            } else if d.contains_key("express") {
116                project_type = "Express API";
117                frameworks.push("Express");
118            } else if d.contains_key("fastify") {
119                project_type = "Fastify API";
120                frameworks.push("Fastify");
121            } else if d.contains_key("hono") {
122                project_type = "Hono API";
123                frameworks.push("Hono");
124            } else if d.contains_key("@nestjs/core") {
125                project_type = "NestJS API";
126                frameworks.push("NestJS");
127            }
128            
129            // Detect additional frameworks
130            if d.contains_key("prisma") || d.contains_key("@prisma/client") {
131                frameworks.push("Prisma");
132            }
133            if d.contains_key("drizzle-orm") {
134                frameworks.push("Drizzle");
135            }
136            if d.contains_key("tailwindcss") {
137                frameworks.push("Tailwind");
138            }
139            if d.contains_key("trpc") || d.contains_key("@trpc/server") {
140                frameworks.push("tRPC");
141            }
142        }
143
144        let mut result = json!({
145            "name": name,
146            "version": version,
147            "type": project_type,
148            "frameworks": frameworks,
149            "private": private,
150        });
151
152        if let Some(desc) = description {
153            result["description"] = json!(desc);
154        }
155
156        if detailed {
157            // Add scripts
158            if let Some(scripts) = json.get("scripts").and_then(|v| v.as_object()) {
159                let script_names: Vec<&str> = scripts.keys().map(|s| s.as_str()).collect();
160                result["scripts"] = json!(script_names);
161            }
162
163            // Add key dependencies count
164            if let Some(d) = deps {
165                result["dependencies_count"] = json!(d.len());
166            }
167            if let Some(d) = dev_deps {
168                result["dev_dependencies_count"] = json!(d.len());
169            }
170
171            // Check for workspaces
172            if let Some(workspaces) = json.get("workspaces") {
173                result["workspaces"] = workspaces.clone();
174            }
175        }
176
177        Some(result)
178    }
179
180    fn parse_cargo_toml(path: &Path, detailed: bool) -> Option<serde_json::Value> {
181        let content = fs::read_to_string(path).ok()?;
182        let toml: toml::Value = toml::from_str(&content).ok()?;
183
184        let package = toml.get("package")?;
185        let name = package.get("name").and_then(|v| v.as_str()).unwrap_or("unknown");
186        let version = package.get("version").and_then(|v| v.as_str()).unwrap_or("0.0.0");
187        let description = package.get("description").and_then(|v| v.as_str());
188
189        // Detect project type
190        let project_type = if path.parent().map(|p| p.join("src/main.rs").exists()).unwrap_or(false) {
191            "binary"
192        } else if path.parent().map(|p| p.join("src/lib.rs").exists()).unwrap_or(false) {
193            "library"
194        } else {
195            "unknown"
196        };
197
198        let mut frameworks: Vec<&str> = Vec::new();
199        
200        // Check dependencies for frameworks
201        if let Some(deps) = toml.get("dependencies").and_then(|v| v.as_table()) {
202            if deps.contains_key("actix-web") {
203                frameworks.push("Actix-web");
204            }
205            if deps.contains_key("axum") {
206                frameworks.push("Axum");
207            }
208            if deps.contains_key("rocket") {
209                frameworks.push("Rocket");
210            }
211            if deps.contains_key("tokio") {
212                frameworks.push("Tokio");
213            }
214            if deps.contains_key("sqlx") {
215                frameworks.push("SQLx");
216            }
217            if deps.contains_key("diesel") {
218                frameworks.push("Diesel");
219            }
220        }
221
222        let mut result = json!({
223            "name": name,
224            "version": version,
225            "type": project_type,
226            "frameworks": frameworks,
227        });
228
229        if let Some(desc) = description {
230            result["description"] = json!(desc);
231        }
232
233        if detailed {
234            // Check for workspace members
235            if let Some(workspace) = toml.get("workspace") {
236                if let Some(members) = workspace.get("members").and_then(|v| v.as_array()) {
237                    let member_strs: Vec<&str> = members
238                        .iter()
239                        .filter_map(|v| v.as_str())
240                        .collect();
241                    result["workspace_members"] = json!(member_strs);
242                }
243            }
244
245            // Count dependencies
246            if let Some(deps) = toml.get("dependencies").and_then(|v| v.as_table()) {
247                result["dependencies_count"] = json!(deps.len());
248            }
249        }
250
251        Some(result)
252    }
253
254    fn parse_go_mod(path: &Path, _detailed: bool) -> Option<serde_json::Value> {
255        let content = fs::read_to_string(path).ok()?;
256        
257        // Extract module name from first line
258        let module_name = content
259            .lines()
260            .find(|l| l.starts_with("module "))
261            .map(|l| l.trim_start_matches("module ").trim())
262            .unwrap_or("unknown");
263
264        // Extract Go version
265        let go_version = content
266            .lines()
267            .find(|l| l.starts_with("go "))
268            .map(|l| l.trim_start_matches("go ").trim());
269
270        let mut result = json!({
271            "name": module_name,
272            "type": "go module",
273        });
274
275        if let Some(v) = go_version {
276            result["go_version"] = json!(v);
277        }
278
279        Some(result)
280    }
281}
282
283#[derive(Debug, Serialize)]
284struct ServiceInfo {
285    name: String,
286    path: String,
287    package_type: String,
288    info: serde_json::Value,
289}
290
291impl Tool for DiscoverServicesTool {
292    const NAME: &'static str = "discover_services";
293
294    type Error = DiscoverServicesError;
295    type Args = DiscoverServicesArgs;
296    type Output = String;
297
298    async fn definition(&self, _prompt: String) -> ToolDefinition {
299        ToolDefinition {
300            name: Self::NAME.to_string(),
301            description: r#"Discover all services, packages, and projects in a monorepo. 
302Returns a list of all packages with their names, types, frameworks, and locations.
303Use this FIRST when exploring a monorepo to understand its structure.
304Then use analyze_project with specific paths to deep-dive into individual services."#.to_string(),
305            parameters: json!({
306                "type": "object",
307                "properties": {
308                    "path": {
309                        "type": "string",
310                        "description": "Subdirectory to search within (e.g., 'apps', 'packages', 'services')"
311                    },
312                    "detailed": {
313                        "type": "boolean",
314                        "description": "Include detailed info like scripts, workspace config. Default: true"
315                    }
316                }
317            }),
318        }
319    }
320
321    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
322        let search_root = if let Some(ref subpath) = args.path {
323            self.project_path.join(subpath)
324        } else {
325            self.project_path.clone()
326        };
327
328        if !search_root.exists() {
329            return Err(DiscoverServicesError(format!(
330                "Path does not exist: {}",
331                args.path.unwrap_or_default()
332            )));
333        }
334
335        let detailed = args.detailed.unwrap_or(true);
336        let mut services: Vec<ServiceInfo> = Vec::new();
337        let mut workspace_roots: HashMap<String, serde_json::Value> = HashMap::new();
338
339        // First check root for workspace config
340        if let Some((pkg_type, manifest_path)) = Self::detect_package_type(&search_root) {
341            let info = match pkg_type {
342                "node" => Self::parse_package_json(&manifest_path, true),
343                "rust" => Self::parse_cargo_toml(&manifest_path, true),
344                "go" => Self::parse_go_mod(&manifest_path, detailed),
345                _ => None,
346            };
347
348            if let Some(info) = info {
349                // Check if this is a workspace root
350                if info.get("workspaces").is_some() || info.get("workspace_members").is_some() {
351                    workspace_roots.insert("root".to_string(), info);
352                }
353            }
354        }
355
356        // Walk the directory tree
357        for entry in WalkDir::new(&search_root)
358            .max_depth(6)  // Deep enough for nested monorepos
359            .into_iter()
360            .filter_entry(|e| {
361                if e.file_type().is_dir() {
362                    if let Some(name) = e.file_name().to_str() {
363                        return !Self::should_skip_dir(name);
364                    }
365                }
366                true
367            })
368            .filter_map(|e| e.ok())
369        {
370            let path = entry.path();
371            if !path.is_dir() {
372                continue;
373            }
374
375            // Skip the root - we already checked it
376            if path == search_root {
377                continue;
378            }
379
380            if let Some((pkg_type, manifest_path)) = Self::detect_package_type(path) {
381                let info = match pkg_type {
382                    "node" => Self::parse_package_json(&manifest_path, detailed),
383                    "rust" => Self::parse_cargo_toml(&manifest_path, detailed),
384                    "go" => Self::parse_go_mod(&manifest_path, detailed),
385                    _ => Some(json!({"type": pkg_type})),
386                };
387
388                if let Some(info) = info {
389                    // Skip template placeholders
390                    if let Some(name) = info.get("name").and_then(|v| v.as_str()) {
391                        if name.contains("${") || name.contains("{{") {
392                            continue;
393                        }
394                    }
395
396                    let relative_path = path
397                        .strip_prefix(&self.project_path)
398                        .unwrap_or(path)
399                        .to_string_lossy()
400                        .to_string();
401
402                    let name = info
403                        .get("name")
404                        .and_then(|v| v.as_str())
405                        .unwrap_or_else(|| {
406                            path.file_name()
407                                .and_then(|n| n.to_str())
408                                .unwrap_or("unknown")
409                        })
410                        .to_string();
411
412                    services.push(ServiceInfo {
413                        name,
414                        path: relative_path,
415                        package_type: pkg_type.to_string(),
416                        info,
417                    });
418                }
419            }
420        }
421
422        // Sort by path for consistent output
423        services.sort_by(|a, b| a.path.cmp(&b.path));
424
425        // Categorize services
426        let mut categorized: HashMap<&str, Vec<&ServiceInfo>> = HashMap::new();
427        for service in &services {
428            let category = if service.path.starts_with("apps/") || service.path.starts_with("packages/apps/") {
429                "apps"
430            } else if service.path.starts_with("packages/") || service.path.starts_with("libs/") {
431                "packages"
432            } else if service.path.starts_with("services/") {
433                "services"
434            } else if service.path.starts_with("tools/") {
435                "tools"
436            } else {
437                "other"
438            };
439            categorized.entry(category).or_default().push(service);
440        }
441
442        let result = json!({
443            "total_services": services.len(),
444            "categorized": {
445                "apps": categorized.get("apps").map(|v| v.len()).unwrap_or(0),
446                "packages": categorized.get("packages").map(|v| v.len()).unwrap_or(0),
447                "services": categorized.get("services").map(|v| v.len()).unwrap_or(0),
448                "tools": categorized.get("tools").map(|v| v.len()).unwrap_or(0),
449                "other": categorized.get("other").map(|v| v.len()).unwrap_or(0),
450            },
451            "workspace_config": workspace_roots,
452            "services": services,
453            "tip": "Use analyze_project with path='<service_path>' to get detailed analysis of each service"
454        });
455
456        serde_json::to_string_pretty(&result)
457            .map_err(|e| DiscoverServicesError(format!("Serialization error: {}", e)))
458    }
459}