From 1d48cdc973d89c7d052493f0a152a04fd237e703 Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Mon, 18 May 2026 20:43:08 +0800 Subject: [PATCH] feat(fctool): add git LFS, merge analysis, ref listing, status tools and Bing search New git subcommands: lfs (summary/scan_tree), merge_analysis, ref_list/ref_info, and git_status. New project tool: bing_search. Update repo_analysis with expanded field coverage and curl tool. --- libs/fctool/src/git_tools/lfs.rs | 262 ++++++++++++++++++ libs/fctool/src/git_tools/merge.rs | 109 ++++++++ libs/fctool/src/git_tools/mod.rs | 8 + libs/fctool/src/git_tools/reference.rs | 137 +++++++++ libs/fctool/src/git_tools/repo_analysis.rs | 305 +++++++++++++++++++++ libs/fctool/src/git_tools/status.rs | 199 ++++++++++++++ libs/fctool/src/project_tools/bing.rs | 280 +++++++++++++++++++ libs/fctool/src/project_tools/curl.rs | 12 +- libs/fctool/src/project_tools/mod.rs | 11 + 9 files changed, 1321 insertions(+), 2 deletions(-) create mode 100644 libs/fctool/src/git_tools/lfs.rs create mode 100644 libs/fctool/src/git_tools/merge.rs create mode 100644 libs/fctool/src/git_tools/reference.rs create mode 100644 libs/fctool/src/git_tools/status.rs create mode 100644 libs/fctool/src/project_tools/bing.rs diff --git a/libs/fctool/src/git_tools/lfs.rs b/libs/fctool/src/git_tools/lfs.rs new file mode 100644 index 0000000..b5161a6 --- /dev/null +++ b/libs/fctool/src/git_tools/lfs.rs @@ -0,0 +1,262 @@ +//! Git LFS query tools. + +use super::ctx::GitToolCtx; +use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema}; +use git::lfs::types::LfsOid; +use std::collections::HashMap; + +async fn git_lfs_summary_exec( + ctx: GitToolCtx, + args: serde_json::Value, +) -> Result { + let p = parse_args(args)?; + let project_name = required_str(&p, "project_name")?; + let repo_name = required_str(&p, "repo_name")?; + + let domain = ctx.open_repo(project_name, repo_name).await?; + let objects = domain.lfs_object_list().map_err(|e| e.to_string())?; + let cache_size = domain.lfs_cache_size().map_err(|e| e.to_string())?; + let attributes = domain.lfs_gitattributes_list().map_err(|e| e.to_string())?; + let config = domain.lfs_config().map_err(|e| e.to_string())?; + + Ok(serde_json::json!({ + "object_count": objects.len(), + "cache_size_bytes": cache_size, + "objects": objects.into_iter().map(|oid| oid.to_string()).collect::>(), + "gitattributes": attributes, + "config": { + "endpoint": config.endpoint, + "has_access_token": config.access_token.as_ref().is_some_and(|s| !s.is_empty()), + }, + })) +} + +async fn git_lfs_scan_tree_exec( + ctx: GitToolCtx, + args: serde_json::Value, +) -> Result { + let p = parse_args(args)?; + let project_name = required_str(&p, "project_name")?; + let repo_name = required_str(&p, "repo_name")?; + let rev = p.get("rev").and_then(|v| v.as_str()).unwrap_or("HEAD"); + let recursive = p.get("recursive").and_then(|v| v.as_bool()).unwrap_or(true); + + let domain = ctx.open_repo(project_name, repo_name).await?; + let commit_oid = resolve_rev(&domain, rev)?; + let commit = domain.commit_get(&commit_oid).map_err(|e| e.to_string())?; + let entries = domain + .lfs_scan_tree(&commit.tree_id, recursive) + .map_err(|e| e.to_string())?; + + Ok(serde_json::json!({ + "rev": rev, + "commit_oid": commit_oid.to_string(), + "count": entries.len(), + "entries": entries.into_iter().map(|entry| { + serde_json::json!({ + "path": entry.path, + "oid": entry.pointer.oid.to_string(), + "size": entry.pointer.size, + "cached": domain.lfs_object_cached(&entry.pointer.oid), + "extra": entry.pointer.extra, + }) + }).collect::>(), + })) +} + +async fn git_lfs_pointer_info_exec( + ctx: GitToolCtx, + args: serde_json::Value, +) -> Result { + let p = parse_args(args)?; + let project_name = required_str(&p, "project_name")?; + let repo_name = required_str(&p, "repo_name")?; + let rev = p.get("rev").and_then(|v| v.as_str()).unwrap_or("HEAD"); + let blob_oid = p.get("blob_oid").and_then(|v| v.as_str()); + let path = p.get("path").and_then(|v| v.as_str()); + + let domain = ctx.open_repo(project_name, repo_name).await?; + let blob_oid = if let Some(blob_oid) = blob_oid { + git::commit::types::CommitOid::new(blob_oid) + } else if let Some(path) = path { + let commit_oid = resolve_rev(&domain, rev)?; + domain + .tree_entry_by_path_from_commit(&commit_oid, path) + .map_err(|e| e.to_string())? + .oid + } else { + return Err("either blob_oid or path is required".into()); + }; + + let pointer = domain + .lfs_pointer_from_blob(&blob_oid) + .map_err(|e| e.to_string())?; + + Ok(match pointer { + Some(pointer) => serde_json::json!({ + "is_lfs_pointer": true, + "blob_oid": blob_oid.to_string(), + "pointer": { + "version": pointer.version, + "oid": pointer.oid.to_string(), + "size": pointer.size, + "cached": domain.lfs_object_cached(&pointer.oid), + "object_path": domain.lfs_object_path(&pointer.oid).ok().map(|p| p.to_string_lossy().to_string()), + "extra": pointer.extra, + } + }), + None => serde_json::json!({ + "is_lfs_pointer": false, + "blob_oid": blob_oid.to_string(), + "pointer": null, + }), + }) +} + +async fn git_lfs_object_info_exec( + ctx: GitToolCtx, + args: serde_json::Value, +) -> Result { + let p = parse_args(args)?; + let project_name = required_str(&p, "project_name")?; + let repo_name = required_str(&p, "repo_name")?; + let oid = LfsOid::new(required_str(&p, "oid")?); + + let domain = ctx.open_repo(project_name, repo_name).await?; + if !oid.is_valid() { + return Err(format!("invalid LFS oid: {}", oid)); + } + let path = domain.lfs_object_path(&oid).map_err(|e| e.to_string())?; + let cached = domain.lfs_object_cached(&oid); + let size = if cached { + std::fs::metadata(&path).ok().map(|m| m.len()) + } else { + None + }; + + Ok(serde_json::json!({ + "oid": oid.to_string(), + "cached": cached, + "path": path.to_string_lossy().to_string(), + "size_bytes": size, + })) +} + +fn resolve_rev( + domain: &git::GitDomain, + rev: &str, +) -> Result { + if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) { + return Ok(git::commit::types::CommitOid::new(rev)); + } + if let Ok(Some(oid)) = domain.ref_target(rev) { + return Ok(oid); + } + domain + .commit_get_prefix(rev) + .map(|m| m.oid) + .map_err(|e| e.to_string()) +} + +pub fn register_git_tools(registry: &mut ToolRegistry) { + registry.register( + ToolDefinition::new("git_lfs_summary") + .description("Summarize Git LFS state for a repository: local LFS objects, cache size, .gitattributes LFS patterns, and sanitized LFS config.") + .parameters(base_schema(vec![])), + handler(git_lfs_summary_exec), + ); + + registry.register( + ToolDefinition::new("git_lfs_scan_tree") + .description("Scan a revision tree for Git LFS pointer files. Returns path, LFS object OID, declared size, and local cache status.") + .parameters(base_schema(vec![ + param("rev", "string", "Revision to scan. Default HEAD.", false), + param("recursive", "boolean", "Scan recursively. Default true.", false), + ])), + handler(git_lfs_scan_tree_exec), + ); + + registry.register( + ToolDefinition::new("git_lfs_pointer_info") + .description("Inspect whether a blob or file path is a Git LFS pointer. Provide either blob_oid or path; path is resolved at rev, default HEAD.") + .parameters(base_schema(vec![ + param("blob_oid", "string", "Blob object ID to inspect.", false), + param("path", "string", "File path to inspect at rev.", false), + param("rev", "string", "Revision used when path is provided. Default HEAD.", false), + ])), + handler(git_lfs_pointer_info_exec), + ); + + registry.register( + ToolDefinition::new("git_lfs_object_info") + .description("Inspect one local Git LFS object by SHA-256 OID and report cache path and size if present.") + .parameters(base_schema(vec![param( + "oid", + "string", + "64-character Git LFS object SHA-256 OID.", + true, + )])), + handler(git_lfs_object_info_exec), + ); +} + +fn handler(f: F) -> ToolHandler +where + F: Fn(GitToolCtx, serde_json::Value) -> Fut + Send + Sync + 'static, + Fut: std::future::Future> + Send + 'static, +{ + ToolHandler::new(move |ctx, args| { + let gctx = super::ctx::GitToolCtx::new(ctx); + let fut = f(gctx, args); + Box::pin(async move { fut.await.map_err(agent::ToolError::ExecutionError) }) + }) +} + +fn base_schema(extra: Vec<(String, ToolParam)>) -> ToolSchema { + let mut properties = HashMap::from([ + param("project_name", "string", "Project name (slug)", true), + param("repo_name", "string", "Repository name", true), + ]); + let mut required = vec!["project_name".into(), "repo_name".into()]; + required.extend( + extra + .iter() + .filter(|(_, param)| param.required) + .map(|(name, _)| name.clone()), + ); + properties.extend(extra); + ToolSchema { + schema_type: "object".into(), + properties: Some(properties), + required: Some(required), + } +} + +fn parse_args( + args: serde_json::Value, +) -> Result, String> { + serde_json::from_value(args).map_err(|e| e.to_string()) +} + +fn required_str<'a>( + p: &'a serde_json::Map, + name: &str, +) -> Result<&'a str, String> { + p.get(name) + .and_then(|v| v.as_str()) + .ok_or_else(|| format!("missing {}", name)) +} + +fn param(name: &str, param_type: &str, description: &str, required: bool) -> (String, ToolParam) { + ( + name.into(), + ToolParam { + name: name.into(), + param_type: param_type.into(), + description: Some(description.into()), + required, + properties: None, + items: None, + }, + ) +} diff --git a/libs/fctool/src/git_tools/merge.rs b/libs/fctool/src/git_tools/merge.rs new file mode 100644 index 0000000..c387a05 --- /dev/null +++ b/libs/fctool/src/git_tools/merge.rs @@ -0,0 +1,109 @@ +//! Git merge analysis tools. + +use super::ctx::GitToolCtx; +use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema}; +use std::collections::HashMap; + +async fn git_merge_analysis_exec( + ctx: GitToolCtx, + args: serde_json::Value, +) -> Result { + let p: serde_json::Map = + serde_json::from_value(args).map_err(|e| e.to_string())?; + let project_name = required_str(&p, "project_name")?; + let repo_name = required_str(&p, "repo_name")?; + let target = required_str(&p, "target")?; + let base_ref = p.get("base_ref").and_then(|v| v.as_str()).unwrap_or("HEAD"); + + let domain = ctx.open_repo(project_name, repo_name).await?; + let target_oid = resolve_rev(&domain, target)?; + let (analysis, preference) = if base_ref == "HEAD" { + domain + .merge_analysis(&target_oid) + .map_err(|e| e.to_string())? + } else { + domain + .merge_analysis_for_ref(base_ref, &target_oid) + .map_err(|e| e.to_string())? + }; + + let base_oid = domain.ref_target(base_ref).ok().flatten(); + let merge_base = base_oid + .as_ref() + .and_then(|base| domain.merge_base(base, &target_oid).ok()) + .map(|oid| oid.to_string()); + + Ok(serde_json::json!({ + "base_ref": base_ref, + "base_oid": base_oid.map(|oid| oid.to_string()), + "target": target, + "target_oid": target_oid.to_string(), + "merge_base": merge_base, + "analysis": analysis, + "preference": preference, + })) +} + +fn resolve_rev( + domain: &git::GitDomain, + rev: &str, +) -> Result { + if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) { + return Ok(git::commit::types::CommitOid::new(rev)); + } + if let Ok(Some(oid)) = domain.ref_target(rev) { + return Ok(oid); + } + domain + .commit_get_prefix(rev) + .map(|m| m.oid) + .map_err(|e| e.to_string()) +} + +pub fn register_git_tools(registry: &mut ToolRegistry) { + registry.register( + ToolDefinition::new("git_merge_analysis") + .description("Analyze whether a target commit/ref can be merged into a base ref. Returns up-to-date, fast-forward, normal merge, unborn, merge preference, and merge-base information. This is read-only.") + .parameters(ToolSchema { + schema_type: "object".into(), + properties: Some(HashMap::from([ + param("project_name", "string", "Project name (slug)", true), + param("repo_name", "string", "Repository name", true), + param("target", "string", "Target revision/ref to merge, e.g. refs/heads/feature, feature, or a commit SHA.", true), + param("base_ref", "string", "Base ref to merge into. Default HEAD.", false), + ])), + required: Some(vec!["project_name".into(), "repo_name".into(), "target".into()]), + }), + ToolHandler::new(|ctx, args| { + let gctx = super::ctx::GitToolCtx::new(ctx); + Box::pin(async move { + git_merge_analysis_exec(gctx, args) + .await + .map_err(agent::ToolError::ExecutionError) + }) + }), + ); +} + +fn required_str<'a>( + p: &'a serde_json::Map, + name: &str, +) -> Result<&'a str, String> { + p.get(name) + .and_then(|v| v.as_str()) + .ok_or_else(|| format!("missing {}", name)) +} + +fn param(name: &str, param_type: &str, description: &str, required: bool) -> (String, ToolParam) { + ( + name.into(), + ToolParam { + name: name.into(), + param_type: param_type.into(), + description: Some(description.into()), + required, + properties: None, + items: None, + }, + ) +} diff --git a/libs/fctool/src/git_tools/mod.rs b/libs/fctool/src/git_tools/mod.rs index 3472463..b213f47 100644 --- a/libs/fctool/src/git_tools/mod.rs +++ b/libs/fctool/src/git_tools/mod.rs @@ -9,8 +9,12 @@ pub mod commit; pub mod ctx; pub mod diff; pub mod kb; +pub mod lfs; +pub mod merge; +pub mod reference; pub mod repo_analysis; pub mod repo_util; +pub mod status; pub mod tag; pub mod tree; pub mod types; @@ -18,11 +22,15 @@ pub mod types; /// Batch-register all git tools into a ToolRegistry. pub fn register_all(registry: &mut agent::ToolRegistry) { commit::register_git_tools(registry); + status::register_git_tools(registry); branch::register_git_tools(registry); + reference::register_git_tools(registry); + merge::register_git_tools(registry); diff::register_git_tools(registry); blob::register_git_tools(registry); tree::register_git_tools(registry); tag::register_git_tools(registry); + lfs::register_git_tools(registry); repo_analysis::register_git_tools(registry); kb::register_git_tools(registry); repo_util::register_git_tools(registry); diff --git a/libs/fctool/src/git_tools/reference.rs b/libs/fctool/src/git_tools/reference.rs new file mode 100644 index 0000000..d98e448 --- /dev/null +++ b/libs/fctool/src/git_tools/reference.rs @@ -0,0 +1,137 @@ +//! Git reference query tools. + +use super::ctx::GitToolCtx; +use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema}; +use std::collections::HashMap; + +async fn git_ref_list_exec( + ctx: GitToolCtx, + args: serde_json::Value, +) -> Result { + let p = parse_args(args)?; + let project_name = required_str(&p, "project_name")?; + let repo_name = required_str(&p, "repo_name")?; + let pattern = p.get("pattern").and_then(|v| v.as_str()); + + let domain = ctx.open_repo(project_name, repo_name).await?; + let refs = domain.ref_list(pattern).map_err(|e| e.to_string())?; + + Ok(serde_json::json!({ + "count": refs.len(), + "refs": refs.into_iter().map(ref_json).collect::>(), + })) +} + +async fn git_ref_info_exec( + ctx: GitToolCtx, + args: serde_json::Value, +) -> Result { + let p = parse_args(args)?; + let project_name = required_str(&p, "project_name")?; + let repo_name = required_str(&p, "repo_name")?; + let name = required_str(&p, "name")?; + + let domain = ctx.open_repo(project_name, repo_name).await?; + let info = domain.ref_get(name).map_err(|e| e.to_string())?; + + Ok(ref_json(info)) +} + +fn ref_json(info: git::reference::types::RefInfo) -> serde_json::Value { + serde_json::json!({ + "name": info.name, + "oid": info.oid.map(|oid| oid.to_string()), + "target": info.target.map(|oid| oid.to_string()), + "is_symbolic": info.is_symbolic, + "is_branch": info.is_branch, + "is_remote": info.is_remote, + "is_tag": info.is_tag, + "is_note": info.is_note, + }) +} + +pub fn register_git_tools(registry: &mut ToolRegistry) { + registry.register( + ToolDefinition::new("git_ref_list") + .description("List Git references such as branches, remote-tracking refs, tags, and notes. Optional pattern supports exact names plus refs/heads/* or refs/tags/** style prefix matching.") + .parameters(ToolSchema { + schema_type: "object".into(), + properties: Some(HashMap::from([ + param("project_name", "string", "Project name (slug)", true), + param("repo_name", "string", "Repository name", true), + param("pattern", "string", "Optional ref pattern, e.g. refs/heads/*, refs/tags/**, or refs/heads/main.", false), + ])), + required: Some(vec!["project_name".into(), "repo_name".into()]), + }), + ToolHandler::new(|ctx, args| { + let gctx = super::ctx::GitToolCtx::new(ctx); + Box::pin(async move { + git_ref_list_exec(gctx, args) + .await + .map_err(agent::ToolError::ExecutionError) + }) + }), + ); + + registry.register( + ToolDefinition::new("git_ref_info") + .description( + "Get one Git reference by full name, including peeled commit OID and target OID.", + ) + .parameters(ToolSchema { + schema_type: "object".into(), + properties: Some(HashMap::from([ + param("project_name", "string", "Project name (slug)", true), + param("repo_name", "string", "Repository name", true), + param( + "name", + "string", + "Full ref name, e.g. refs/heads/main or refs/tags/v1.0.0.", + true, + ), + ])), + required: Some(vec![ + "project_name".into(), + "repo_name".into(), + "name".into(), + ]), + }), + ToolHandler::new(|ctx, args| { + let gctx = super::ctx::GitToolCtx::new(ctx); + Box::pin(async move { + git_ref_info_exec(gctx, args) + .await + .map_err(agent::ToolError::ExecutionError) + }) + }), + ); +} + +fn parse_args( + args: serde_json::Value, +) -> Result, String> { + serde_json::from_value(args).map_err(|e| e.to_string()) +} + +fn required_str<'a>( + p: &'a serde_json::Map, + name: &str, +) -> Result<&'a str, String> { + p.get(name) + .and_then(|v| v.as_str()) + .ok_or_else(|| format!("missing {}", name)) +} + +fn param(name: &str, param_type: &str, description: &str, required: bool) -> (String, ToolParam) { + ( + name.into(), + ToolParam { + name: name.into(), + param_type: param_type.into(), + description: Some(description.into()), + required, + properties: None, + items: None, + }, + ) +} diff --git a/libs/fctool/src/git_tools/repo_analysis.rs b/libs/fctool/src/git_tools/repo_analysis.rs index c931a72..917fc3f 100644 --- a/libs/fctool/src/git_tools/repo_analysis.rs +++ b/libs/fctool/src/git_tools/repo_analysis.rs @@ -27,6 +27,23 @@ const DEPENDENCY_MANIFESTS: &[(&str, &str)] = &[ ("Makefile", "make"), ]; +const TEST_FILE_MARKERS: &[&str] = &[ + "_test.", ".test.", ".spec.", "_spec.", "test.", "tests.", "test_", ".feature", +]; + +const TEST_DIR_MARKERS: &[&str] = &[ + "test", + "tests", + "__tests__", + "spec", + "specs", + "e2e", + "integration", + "unit", + "cypress", + "playwright", +]; + /// Language detection by file extension (lowercase). fn ext_to_language(ext: &str) -> Option<&'static str> { match ext { @@ -648,6 +665,271 @@ async fn repo_dependencies_exec( // ── Registration ─────────────────────────────────────────────────────────────── +/// Tool: repo_test_discovery - discover likely tests and test commands. +async fn repo_test_discovery_exec( + ctx: GitToolCtx, + args: serde_json::Value, +) -> Result { + let p: serde_json::Map = + serde_json::from_value(args).map_err(|e| e.to_string())?; + let project_name = p + .get("project_name") + .and_then(|v| v.as_str()) + .ok_or("missing project_name")?; + let repo_name = p + .get("repo_name") + .and_then(|v| v.as_str()) + .ok_or("missing repo_name")?; + let max_files = p.get("max_files").and_then(|v| v.as_u64()).unwrap_or(200) as usize; + + let domain = ctx.open_repo(project_name, repo_name).await?; + let tree = head_tree(&domain)?; + let repo = domain.repo(); + let mut test_files = Vec::new(); + let mut manifests = Vec::new(); + let mut frameworks: HashMap = HashMap::new(); + let mut stack: Vec<(git2::Tree<'_>, String)> = vec![(tree, String::new())]; + + while let Some((current_tree, prefix)) = stack.pop() { + for entry in current_tree.iter() { + let name = match entry.name() { + Some(n) => n, + None => continue, + }; + let entry_path = if prefix.is_empty() { + name.to_string() + } else { + format!("{}/{}", prefix, name) + }; + match entry.kind() { + Some(git2::ObjectType::Tree) => { + if !is_ignored_dir(name) { + if let Ok(subtree) = entry.to_object(repo).and_then(|o| o.peel_to_tree()) { + stack.push((subtree, entry_path)); + } + } + } + Some(git2::ObjectType::Blob) => { + let lower_path = entry_path.to_lowercase(); + let lower_name = name.to_lowercase(); + if is_test_file(&lower_path, &lower_name) && test_files.len() < max_files { + test_files.push(serde_json::json!({ + "path": entry_path, + "kind": test_kind(&lower_path), + })); + } + if is_test_manifest(name) { + if let Ok(blob) = entry.to_object(repo).and_then(|o| o.peel_to_blob()) { + let content = String::from_utf8_lossy(blob.content()); + for framework in detect_test_frameworks(name, &content) { + *frameworks.entry(framework).or_insert(0) += 1; + } + manifests.push(serde_json::json!({ "path": entry_path, "name": name })); + } + } + } + _ => {} + } + } + } + + let mut framework_list: Vec<_> = frameworks + .into_iter() + .map(|(name, evidence_count)| { + serde_json::json!({ "name": name, "evidence_count": evidence_count }) + }) + .collect(); + framework_list.sort_by(|a, b| { + b["evidence_count"] + .as_u64() + .unwrap_or(0) + .cmp(&a["evidence_count"].as_u64().unwrap_or(0)) + }); + + let commands = infer_test_commands(&framework_list, &manifests); + + Ok(serde_json::json!({ + "test_file_count_returned": test_files.len(), + "test_files": test_files, + "test_manifests": manifests, + "frameworks": framework_list, + "suggested_commands": commands, + })) +} + +fn is_test_file(lower_path: &str, lower_name: &str) -> bool { + TEST_FILE_MARKERS + .iter() + .any(|marker| lower_name.contains(marker)) + || lower_path + .split('/') + .any(|part| TEST_DIR_MARKERS.iter().any(|marker| part == *marker)) +} + +fn test_kind(lower_path: &str) -> &'static str { + if lower_path.contains("/e2e/") + || lower_path.contains("/cypress/") + || lower_path.contains("/playwright/") + { + "e2e" + } else if lower_path.contains("/integration/") { + "integration" + } else if lower_path.contains("/unit/") { + "unit" + } else { + "test" + } +} + +fn is_test_manifest(name: &str) -> bool { + matches!( + name, + "Cargo.toml" + | "package.json" + | "go.mod" + | "pyproject.toml" + | "requirements.txt" + | "pom.xml" + | "build.gradle" + | "build.gradle.kts" + | "pytest.ini" + | "tox.ini" + | "vitest.config.ts" + | "vitest.config.js" + | "jest.config.js" + | "jest.config.ts" + | "playwright.config.ts" + | "playwright.config.js" + | "cypress.config.ts" + | "cypress.config.js" + ) +} + +fn detect_test_frameworks(name: &str, content: &str) -> Vec { + let lower = content.to_lowercase(); + let mut found = Vec::new(); + let mut add = |framework: &str| { + if !found.iter().any(|v| v == framework) { + found.push(framework.to_string()); + } + }; + + match name { + "Cargo.toml" => { + if lower.contains("[dev-dependencies]") + || lower.contains("tokio-test") + || lower.contains("rstest") + { + add("cargo test"); + } + } + "package.json" => { + if lower.contains("\"test\"") { + add("npm test"); + } + if lower.contains("vitest") { + add("vitest"); + } + if lower.contains("jest") { + add("jest"); + } + if lower.contains("playwright") { + add("playwright"); + } + if lower.contains("cypress") { + add("cypress"); + } + } + "go.mod" => add("go test"), + "pyproject.toml" | "requirements.txt" | "pytest.ini" | "tox.ini" => { + if lower.contains("pytest") { + add("pytest"); + } + if lower.contains("tox") { + add("tox"); + } + } + "pom.xml" => add("maven test"), + "build.gradle" | "build.gradle.kts" => add("gradle test"), + _ => { + if name.starts_with("vitest.config") { + add("vitest"); + } else if name.starts_with("jest.config") { + add("jest"); + } else if name.starts_with("playwright.config") { + add("playwright"); + } else if name.starts_with("cypress.config") { + add("cypress"); + } + } + } + + found +} + +fn infer_test_commands( + frameworks: &[serde_json::Value], + manifests: &[serde_json::Value], +) -> Vec { + let names: Vec = frameworks + .iter() + .filter_map(|v| v.get("name").and_then(|n| n.as_str()).map(str::to_string)) + .collect(); + let manifest_names: Vec = manifests + .iter() + .filter_map(|v| v.get("name").and_then(|n| n.as_str()).map(str::to_string)) + .collect(); + let mut commands = Vec::new(); + let mut add = |command: &str, reason: &str| { + if !commands + .iter() + .any(|v: &serde_json::Value| v.get("command").and_then(|c| c.as_str()) == Some(command)) + { + commands.push(serde_json::json!({ "command": command, "reason": reason })); + } + }; + + if names.iter().any(|n| n == "cargo test") || manifest_names.iter().any(|n| n == "Cargo.toml") { + add("cargo test", "Rust Cargo manifest detected"); + } + if names.iter().any(|n| n == "npm test") || manifest_names.iter().any(|n| n == "package.json") { + add("npm test", "Node package.json detected"); + } + if names.iter().any(|n| n == "vitest") { + add("npx vitest run", "Vitest detected"); + } + if names.iter().any(|n| n == "jest") { + add("npx jest", "Jest detected"); + } + if names.iter().any(|n| n == "playwright") { + add("npx playwright test", "Playwright detected"); + } + if names.iter().any(|n| n == "cypress") { + add("npx cypress run", "Cypress detected"); + } + if names.iter().any(|n| n == "go test") || manifest_names.iter().any(|n| n == "go.mod") { + add("go test ./...", "Go module detected"); + } + if names.iter().any(|n| n == "pytest") { + add("pytest", "Pytest detected"); + } + if names.iter().any(|n| n == "tox") { + add("tox", "Tox detected"); + } + if names.iter().any(|n| n == "maven test") || manifest_names.iter().any(|n| n == "pom.xml") { + add("mvn test", "Maven project detected"); + } + if names.iter().any(|n| n == "gradle test") + || manifest_names + .iter() + .any(|n| n == "build.gradle" || n == "build.gradle.kts") + { + add("./gradlew test", "Gradle project detected"); + } + + commands +} + macro_rules! param { ($name:expr, $type:expr, $desc:expr, $required:expr) => { ( @@ -746,4 +1028,27 @@ pub fn register_git_tools(registry: &mut ToolRegistry) { }) }), ); + + // repo_test_discovery + registry.register( + ToolDefinition::new("repo_test_discovery") + .description("Discover likely test files, test frameworks, and suggested test commands from repository manifests and file layout. Useful before changing code or planning validation.") + .parameters(ToolSchema { + schema_type: "object".into(), + properties: Some(HashMap::from([ + param!("project_name", "string", "Project name (slug)", true), + param!("repo_name", "string", "Repository name", true), + param!("max_files", "integer", "Maximum test file entries to return (default: 200)", false), + ])), + required: Some(vec!["project_name".into(), "repo_name".into()]), + }), + ToolHandler::new(|ctx, args| { + let gctx = super::ctx::GitToolCtx::new(ctx); + Box::pin(async move { + repo_test_discovery_exec(gctx, args) + .await + .map_err(agent::ToolError::ExecutionError) + }) + }), + ); } diff --git a/libs/fctool/src/git_tools/status.rs b/libs/fctool/src/git_tools/status.rs new file mode 100644 index 0000000..0329c8e --- /dev/null +++ b/libs/fctool/src/git_tools/status.rs @@ -0,0 +1,199 @@ +//! Git status tools. + +use super::ctx::GitToolCtx; +use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema}; +use std::collections::HashMap; + +async fn git_status_exec( + ctx: GitToolCtx, + args: serde_json::Value, +) -> Result { + let p: serde_json::Map = + serde_json::from_value(args).map_err(|e| e.to_string())?; + let project_name = p + .get("project_name") + .and_then(|v| v.as_str()) + .ok_or("missing project_name")?; + let repo_name = p + .get("repo_name") + .and_then(|v| v.as_str()) + .ok_or("missing repo_name")?; + let include_ignored = p + .get("include_ignored") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let domain = ctx.open_repo(project_name, repo_name).await?; + let repo = domain.repo(); + let is_bare = repo.is_bare(); + let head = repo + .head() + .ok() + .and_then(|h| h.shorthand().map(str::to_string)); + + if is_bare { + return Ok(serde_json::json!({ + "is_bare": true, + "head": head, + "is_dirty": false, + "files": [], + "summary": { + "total": 0, + "index": 0, + "worktree": 0, + "untracked": 0, + "ignored": 0, + "conflicted": 0, + } + })); + } + + let mut opts = git2::StatusOptions::new(); + opts.include_untracked(true) + .renames_head_to_index(true) + .renames_index_to_workdir(true) + .recurse_untracked_dirs(true); + if include_ignored { + opts.include_ignored(true); + } + + let statuses = repo + .statuses(Some(&mut opts)) + .map_err(|e| format!("git status failed: {}", e))?; + + let mut files = Vec::new(); + let mut index = 0usize; + let mut worktree = 0usize; + let mut untracked = 0usize; + let mut ignored = 0usize; + let mut conflicted = 0usize; + + for entry in statuses.iter() { + let status = entry.status(); + let path = entry + .head_to_index() + .and_then(|d| d.new_file().path()) + .or_else(|| entry.index_to_workdir().and_then(|d| d.new_file().path())) + .or_else(|| entry.path().map(std::path::Path::new)) + .map(|p| p.to_string_lossy().replace('\\', "/")) + .unwrap_or_default(); + + let index_status = index_status_label(status); + let worktree_status = worktree_status_label(status); + let is_untracked = status.contains(git2::Status::WT_NEW); + let is_ignored = status.contains(git2::Status::IGNORED); + let is_conflicted = status.is_conflicted(); + + if index_status.is_some() { + index += 1; + } + if worktree_status.is_some() { + worktree += 1; + } + if is_untracked { + untracked += 1; + } + if is_ignored { + ignored += 1; + } + if is_conflicted { + conflicted += 1; + } + + files.push(serde_json::json!({ + "path": path, + "index_status": index_status, + "worktree_status": worktree_status, + "is_untracked": is_untracked, + "is_ignored": is_ignored, + "is_conflicted": is_conflicted, + })); + } + + Ok(serde_json::json!({ + "is_bare": false, + "head": head, + "is_dirty": !files.is_empty(), + "summary": { + "total": files.len(), + "index": index, + "worktree": worktree, + "untracked": untracked, + "ignored": ignored, + "conflicted": conflicted, + }, + "files": files, + })) +} + +fn index_status_label(status: git2::Status) -> Option<&'static str> { + if status.contains(git2::Status::INDEX_NEW) { + Some("added") + } else if status.contains(git2::Status::INDEX_MODIFIED) { + Some("modified") + } else if status.contains(git2::Status::INDEX_DELETED) { + Some("deleted") + } else if status.contains(git2::Status::INDEX_RENAMED) { + Some("renamed") + } else if status.contains(git2::Status::INDEX_TYPECHANGE) { + Some("typechange") + } else { + None + } +} + +fn worktree_status_label(status: git2::Status) -> Option<&'static str> { + if status.contains(git2::Status::WT_NEW) { + Some("untracked") + } else if status.contains(git2::Status::WT_MODIFIED) { + Some("modified") + } else if status.contains(git2::Status::WT_DELETED) { + Some("deleted") + } else if status.contains(git2::Status::WT_RENAMED) { + Some("renamed") + } else if status.contains(git2::Status::WT_TYPECHANGE) { + Some("typechange") + } else if status.contains(git2::Status::IGNORED) { + Some("ignored") + } else { + None + } +} + +pub fn register_git_tools(registry: &mut ToolRegistry) { + registry.register( + ToolDefinition::new("git_status") + .description("Show repository working tree status: staged, unstaged, untracked, ignored, and conflicted files. Bare repositories return an empty clean status.") + .parameters(ToolSchema { + schema_type: "object".into(), + properties: Some(HashMap::from([ + param("project_name", "string", "Project name (slug)", true), + param("repo_name", "string", "Repository name", true), + param("include_ignored", "boolean", "Include ignored files. Default false.", false), + ])), + required: Some(vec!["project_name".into(), "repo_name".into()]), + }), + ToolHandler::new(|ctx, args| { + let gctx = super::ctx::GitToolCtx::new(ctx); + Box::pin(async move { + git_status_exec(gctx, args) + .await + .map_err(agent::ToolError::ExecutionError) + }) + }), + ); +} + +fn param(name: &str, param_type: &str, description: &str, required: bool) -> (String, ToolParam) { + ( + name.into(), + ToolParam { + name: name.into(), + param_type: param_type.into(), + description: Some(description.into()), + required, + properties: None, + items: None, + }, + ) +} diff --git a/libs/fctool/src/project_tools/bing.rs b/libs/fctool/src/project_tools/bing.rs new file mode 100644 index 0000000..8e2550f --- /dev/null +++ b/libs/fctool/src/project_tools/bing.rs @@ -0,0 +1,280 @@ +//! Tool: project_bing_search - search the web with Bing Web Search API. + +use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema}; +use serde::Deserialize; +use std::collections::HashMap; +use std::sync::OnceLock; + +const DEFAULT_COUNT: u64 = 10; +const MAX_COUNT: u64 = 50; +const DEFAULT_ENDPOINT: &str = "https://api.bing.microsoft.com/v7.0/search"; + +static SHARED_CLIENT: OnceLock = OnceLock::new(); + +fn shared_client() -> &'static reqwest::Client { + SHARED_CLIENT.get_or_init(|| { + reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("reqwest client build should not fail") + }) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BingSearchResponse { + #[serde(default)] + web_pages: Option, + #[serde(default)] + query_context: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BingWebPages { + #[serde(default)] + total_estimated_matches: Option, + #[serde(default)] + value: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BingWebResult { + #[serde(default)] + name: String, + #[serde(default)] + url: String, + #[serde(default)] + display_url: String, + #[serde(default)] + snippet: String, + #[serde(default)] + date_last_crawled: Option, + #[serde(default)] + language: Option, + #[serde(default)] + is_family_friendly: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BingQueryContext { + #[serde(default)] + original_query: String, + #[serde(default)] + altered_query: Option, +} + +pub async fn bing_search_exec( + ctx: ToolContext, + args: serde_json::Value, +) -> Result { + let query = args + .get("query") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| ToolError::ExecutionError("query is required".into()))?; + + let count = args + .get("count") + .and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_COUNT) + .clamp(1, MAX_COUNT); + let offset = args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0); + let market = args + .get("market") + .and_then(|v| v.as_str()) + .unwrap_or("en-US"); + let safe_search = args + .get("safe_search") + .and_then(|v| v.as_str()) + .unwrap_or("Moderate"); + let freshness = args.get("freshness").and_then(|v| v.as_str()); + + let api_key = ctx + .config() + .env + .get("APP_BING_SEARCH_API_KEY") + .or_else(|| ctx.config().env.get("BING_SEARCH_API_KEY")) + .map(String::as_str) + .filter(|s| !s.trim().is_empty()) + .ok_or_else(|| { + ToolError::ExecutionError( + "Bing search API key is required: set APP_BING_SEARCH_API_KEY or BING_SEARCH_API_KEY" + .into(), + ) + })?; + + let endpoint = ctx + .config() + .env + .get("APP_BING_SEARCH_ENDPOINT") + .map(String::as_str) + .unwrap_or(DEFAULT_ENDPOINT); + + let mut url = reqwest::Url::parse(endpoint) + .map_err(|e| ToolError::ExecutionError(format!("Invalid Bing endpoint: {}", e)))?; + { + let mut query_pairs = url.query_pairs_mut(); + query_pairs + .append_pair("q", query) + .append_pair("count", &count.to_string()) + .append_pair("offset", &offset.to_string()) + .append_pair("mkt", market) + .append_pair("safeSearch", safe_search) + .append_pair("responseFilter", "Webpages") + .append_pair("textFormat", "Raw"); + if let Some(freshness) = freshness { + query_pairs.append_pair("freshness", freshness); + } + } + + let response = shared_client() + .get(url) + .header("Ocp-Apim-Subscription-Key", api_key) + .send() + .await + .map_err(|e| ToolError::ExecutionError(format!("Bing search request failed: {}", e)))?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| ToolError::ExecutionError(format!("Failed to read Bing response: {}", e)))?; + + if !status.is_success() { + return Err(ToolError::ExecutionError(format!( + "Bing search returned status {}: {}", + status, + truncate(&body, 500) + ))); + } + + let parsed: BingSearchResponse = serde_json::from_str(&body) + .map_err(|e| ToolError::ExecutionError(format!("Failed to parse Bing response: {}", e)))?; + + let results = parsed + .web_pages + .as_ref() + .map(|pages| { + pages + .value + .iter() + .map(|item| { + serde_json::json!({ + "title": item.name, + "url": item.url, + "display_url": item.display_url, + "snippet": item.snippet, + "date_last_crawled": item.date_last_crawled, + "language": item.language, + "is_family_friendly": item.is_family_friendly, + }) + }) + .collect::>() + }) + .unwrap_or_default(); + + Ok(serde_json::json!({ + "query": query, + "original_query": parsed.query_context.as_ref().map(|q| q.original_query.as_str()).unwrap_or(query), + "altered_query": parsed.query_context.and_then(|q| q.altered_query), + "count": results.len(), + "total_estimated_matches": parsed.web_pages.and_then(|p| p.total_estimated_matches), + "results": results, + })) +} + +fn truncate(s: &str, max_chars: usize) -> String { + let mut chars = s.chars(); + let truncated: String = chars.by_ref().take(max_chars).collect(); + if chars.next().is_some() { + format!("{}...", truncated) + } else { + truncated + } +} + +pub fn tool_definition() -> ToolDefinition { + let mut p = HashMap::new(); + p.insert( + "query".into(), + ToolParam { + name: "query".into(), + param_type: "string".into(), + description: Some("Web search query. Required.".into()), + required: true, + properties: None, + items: None, + }, + ); + p.insert( + "count".into(), + ToolParam { + name: "count".into(), + param_type: "integer".into(), + description: Some("Number of results to return. Default 10, max 50.".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "offset".into(), + ToolParam { + name: "offset".into(), + param_type: "integer".into(), + description: Some("Result offset for pagination. Default 0.".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "market".into(), + ToolParam { + name: "market".into(), + param_type: "string".into(), + description: Some("Market code such as en-US or zh-CN. Default en-US.".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "safe_search".into(), + ToolParam { + name: "safe_search".into(), + param_type: "string".into(), + description: Some( + "Bing safe search level: Off, Moderate, or Strict. Default Moderate.".into(), + ), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "freshness".into(), + ToolParam { + name: "freshness".into(), + param_type: "string".into(), + description: Some("Optional freshness filter: Day, Week, or Month.".into()), + required: false, + properties: None, + items: None, + }, + ); + + ToolDefinition::new("project_bing_search") + .description( + "Search the public web with Bing Web Search API. Returns titles, URLs, snippets, crawl dates, and estimated match counts. Requires APP_BING_SEARCH_API_KEY or BING_SEARCH_API_KEY.", + ) + .parameters(ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec!["query".into()]), + }) +} diff --git a/libs/fctool/src/project_tools/curl.rs b/libs/fctool/src/project_tools/curl.rs index 84bef0c..00c5174 100644 --- a/libs/fctool/src/project_tools/curl.rs +++ b/libs/fctool/src/project_tools/curl.rs @@ -272,7 +272,7 @@ pub async fn curl_exec( // ─── tool definition ───────────────────────────────────────────────────────── -pub fn tool_definition() -> ToolDefinition { +fn tool_definition_with_name(name: &str) -> ToolDefinition { let mut p = HashMap::new(); p.insert( "url".into(), @@ -332,7 +332,7 @@ pub fn tool_definition() -> ToolDefinition { items: None, }, ); - ToolDefinition::new("project_curl") + ToolDefinition::new(name) .description( "Perform an HTTP request to any URL. Supports GET, POST, PUT, DELETE, PATCH, HEAD. \ Returns status code, headers, and response body. \ @@ -345,3 +345,11 @@ pub fn tool_definition() -> ToolDefinition { required: Some(vec!["url".into()]), }) } + +pub fn tool_definition() -> ToolDefinition { + tool_definition_with_name("project_curl") +} + +pub fn alias_tool_definition() -> ToolDefinition { + tool_definition_with_name("curl_exec") +} diff --git a/libs/fctool/src/project_tools/mod.rs b/libs/fctool/src/project_tools/mod.rs index becc460..0fd2e5d 100644 --- a/libs/fctool/src/project_tools/mod.rs +++ b/libs/fctool/src/project_tools/mod.rs @@ -7,6 +7,7 @@ //! - list / create / update boards and board cards mod arxiv; +mod bing; mod boards; mod curl; mod issues; @@ -16,6 +17,7 @@ mod repos; use agent::{ToolHandler, ToolRegistry}; pub use arxiv::arxiv_search_exec; +pub use bing::bing_search_exec; pub use boards::{ create_board_card_exec, create_board_column_exec, create_board_exec, delete_board_card_exec, list_boards_exec, update_board_card_exec, update_board_exec, @@ -34,10 +36,19 @@ pub fn register_all(registry: &mut ToolRegistry) { ToolHandler::new(|ctx, args| Box::pin(arxiv_search_exec(ctx, args))), ); + registry.register( + bing::tool_definition(), + ToolHandler::new(|ctx, args| Box::pin(bing_search_exec(ctx, args))), + ); + registry.register( curl::tool_definition(), ToolHandler::new(|ctx, args| Box::pin(curl_exec(ctx, args))), ); + registry.register( + curl::alias_tool_definition(), + ToolHandler::new(|ctx, args| Box::pin(curl_exec(ctx, args))), + ); registry.register( repos::list_tool_definition(),