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.
This commit is contained in:
parent
8d144ac139
commit
1d48cdc973
262
libs/fctool/src/git_tools/lfs.rs
Normal file
262
libs/fctool/src/git_tools/lfs.rs
Normal file
@ -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<serde_json::Value, String> {
|
||||||
|
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::<Vec<_>>(),
|
||||||
|
"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<serde_json::Value, String> {
|
||||||
|
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::<Vec<_>>(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn git_lfs_pointer_info_exec(
|
||||||
|
ctx: GitToolCtx,
|
||||||
|
args: serde_json::Value,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
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<serde_json::Value, String> {
|
||||||
|
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<git::commit::types::CommitOid, String> {
|
||||||
|
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, Fut>(f: F) -> ToolHandler
|
||||||
|
where
|
||||||
|
F: Fn(GitToolCtx, serde_json::Value) -> Fut + Send + Sync + 'static,
|
||||||
|
Fut: std::future::Future<Output = Result<serde_json::Value, String>> + 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<serde_json::Map<String, serde_json::Value>, String> {
|
||||||
|
serde_json::from_value(args).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_str<'a>(
|
||||||
|
p: &'a serde_json::Map<String, serde_json::Value>,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
109
libs/fctool/src/git_tools/merge.rs
Normal file
109
libs/fctool/src/git_tools/merge.rs
Normal file
@ -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<serde_json::Value, String> {
|
||||||
|
let p: serde_json::Map<String, serde_json::Value> =
|
||||||
|
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<git::commit::types::CommitOid, String> {
|
||||||
|
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<String, serde_json::Value>,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -9,8 +9,12 @@ pub mod commit;
|
|||||||
pub mod ctx;
|
pub mod ctx;
|
||||||
pub mod diff;
|
pub mod diff;
|
||||||
pub mod kb;
|
pub mod kb;
|
||||||
|
pub mod lfs;
|
||||||
|
pub mod merge;
|
||||||
|
pub mod reference;
|
||||||
pub mod repo_analysis;
|
pub mod repo_analysis;
|
||||||
pub mod repo_util;
|
pub mod repo_util;
|
||||||
|
pub mod status;
|
||||||
pub mod tag;
|
pub mod tag;
|
||||||
pub mod tree;
|
pub mod tree;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
@ -18,11 +22,15 @@ pub mod types;
|
|||||||
/// Batch-register all git tools into a ToolRegistry.
|
/// Batch-register all git tools into a ToolRegistry.
|
||||||
pub fn register_all(registry: &mut agent::ToolRegistry) {
|
pub fn register_all(registry: &mut agent::ToolRegistry) {
|
||||||
commit::register_git_tools(registry);
|
commit::register_git_tools(registry);
|
||||||
|
status::register_git_tools(registry);
|
||||||
branch::register_git_tools(registry);
|
branch::register_git_tools(registry);
|
||||||
|
reference::register_git_tools(registry);
|
||||||
|
merge::register_git_tools(registry);
|
||||||
diff::register_git_tools(registry);
|
diff::register_git_tools(registry);
|
||||||
blob::register_git_tools(registry);
|
blob::register_git_tools(registry);
|
||||||
tree::register_git_tools(registry);
|
tree::register_git_tools(registry);
|
||||||
tag::register_git_tools(registry);
|
tag::register_git_tools(registry);
|
||||||
|
lfs::register_git_tools(registry);
|
||||||
repo_analysis::register_git_tools(registry);
|
repo_analysis::register_git_tools(registry);
|
||||||
kb::register_git_tools(registry);
|
kb::register_git_tools(registry);
|
||||||
repo_util::register_git_tools(registry);
|
repo_util::register_git_tools(registry);
|
||||||
|
|||||||
137
libs/fctool/src/git_tools/reference.rs
Normal file
137
libs/fctool/src/git_tools/reference.rs
Normal file
@ -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<serde_json::Value, String> {
|
||||||
|
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::<Vec<_>>(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn git_ref_info_exec(
|
||||||
|
ctx: GitToolCtx,
|
||||||
|
args: serde_json::Value,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
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<serde_json::Map<String, serde_json::Value>, String> {
|
||||||
|
serde_json::from_value(args).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_str<'a>(
|
||||||
|
p: &'a serde_json::Map<String, serde_json::Value>,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -27,6 +27,23 @@ const DEPENDENCY_MANIFESTS: &[(&str, &str)] = &[
|
|||||||
("Makefile", "make"),
|
("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).
|
/// Language detection by file extension (lowercase).
|
||||||
fn ext_to_language(ext: &str) -> Option<&'static str> {
|
fn ext_to_language(ext: &str) -> Option<&'static str> {
|
||||||
match ext {
|
match ext {
|
||||||
@ -648,6 +665,271 @@ async fn repo_dependencies_exec(
|
|||||||
|
|
||||||
// ── Registration ───────────────────────────────────────────────────────────────
|
// ── Registration ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Tool: repo_test_discovery - discover likely tests and test commands.
|
||||||
|
async fn repo_test_discovery_exec(
|
||||||
|
ctx: GitToolCtx,
|
||||||
|
args: serde_json::Value,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let p: serde_json::Map<String, serde_json::Value> =
|
||||||
|
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<String, u64> = 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<String> {
|
||||||
|
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<serde_json::Value> {
|
||||||
|
let names: Vec<String> = frameworks
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.get("name").and_then(|n| n.as_str()).map(str::to_string))
|
||||||
|
.collect();
|
||||||
|
let manifest_names: Vec<String> = 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 {
|
macro_rules! param {
|
||||||
($name:expr, $type:expr, $desc:expr, $required:expr) => {
|
($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)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
199
libs/fctool/src/git_tools/status.rs
Normal file
199
libs/fctool/src/git_tools/status.rs
Normal file
@ -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<serde_json::Value, String> {
|
||||||
|
let p: serde_json::Map<String, serde_json::Value> =
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
280
libs/fctool/src/project_tools/bing.rs
Normal file
280
libs/fctool/src/project_tools/bing.rs
Normal file
@ -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<reqwest::Client> = 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<BingWebPages>,
|
||||||
|
#[serde(default)]
|
||||||
|
query_context: Option<BingQueryContext>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct BingWebPages {
|
||||||
|
#[serde(default)]
|
||||||
|
total_estimated_matches: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
value: Vec<BingWebResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
language: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
is_family_friendly: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct BingQueryContext {
|
||||||
|
#[serde(default)]
|
||||||
|
original_query: String,
|
||||||
|
#[serde(default)]
|
||||||
|
altered_query: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn bing_search_exec(
|
||||||
|
ctx: ToolContext,
|
||||||
|
args: serde_json::Value,
|
||||||
|
) -> Result<serde_json::Value, ToolError> {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.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()]),
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -272,7 +272,7 @@ pub async fn curl_exec(
|
|||||||
|
|
||||||
// ─── tool definition ─────────────────────────────────────────────────────────
|
// ─── tool definition ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn tool_definition() -> ToolDefinition {
|
fn tool_definition_with_name(name: &str) -> ToolDefinition {
|
||||||
let mut p = HashMap::new();
|
let mut p = HashMap::new();
|
||||||
p.insert(
|
p.insert(
|
||||||
"url".into(),
|
"url".into(),
|
||||||
@ -332,7 +332,7 @@ pub fn tool_definition() -> ToolDefinition {
|
|||||||
items: None,
|
items: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
ToolDefinition::new("project_curl")
|
ToolDefinition::new(name)
|
||||||
.description(
|
.description(
|
||||||
"Perform an HTTP request to any URL. Supports GET, POST, PUT, DELETE, PATCH, HEAD. \
|
"Perform an HTTP request to any URL. Supports GET, POST, PUT, DELETE, PATCH, HEAD. \
|
||||||
Returns status code, headers, and response body. \
|
Returns status code, headers, and response body. \
|
||||||
@ -345,3 +345,11 @@ pub fn tool_definition() -> ToolDefinition {
|
|||||||
required: Some(vec!["url".into()]),
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
//! - list / create / update boards and board cards
|
//! - list / create / update boards and board cards
|
||||||
|
|
||||||
mod arxiv;
|
mod arxiv;
|
||||||
|
mod bing;
|
||||||
mod boards;
|
mod boards;
|
||||||
mod curl;
|
mod curl;
|
||||||
mod issues;
|
mod issues;
|
||||||
@ -16,6 +17,7 @@ mod repos;
|
|||||||
use agent::{ToolHandler, ToolRegistry};
|
use agent::{ToolHandler, ToolRegistry};
|
||||||
|
|
||||||
pub use arxiv::arxiv_search_exec;
|
pub use arxiv::arxiv_search_exec;
|
||||||
|
pub use bing::bing_search_exec;
|
||||||
pub use boards::{
|
pub use boards::{
|
||||||
create_board_card_exec, create_board_column_exec, create_board_exec, delete_board_card_exec,
|
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,
|
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))),
|
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(
|
registry.register(
|
||||||
curl::tool_definition(),
|
curl::tool_definition(),
|
||||||
ToolHandler::new(|ctx, args| Box::pin(curl_exec(ctx, args))),
|
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(
|
registry.register(
|
||||||
repos::list_tool_definition(),
|
repos::list_tool_definition(),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user