syncable_cli/agent/tools/
discover.rs1use 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#[derive(Debug, Deserialize)]
19pub struct DiscoverServicesArgs {
20 pub path: Option<String>,
22 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 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 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 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 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 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 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 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 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 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 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 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 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 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 for entry in WalkDir::new(&search_root)
358 .max_depth(6) .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 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 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 services.sort_by(|a, b| a.path.cmp(&b.path));
424
425 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}