From 2dcb5b302869dd8c22a6978eeb0d1c5f2afb9831 Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Thu, 14 May 2026 10:01:52 +0800 Subject: [PATCH] refactor(fctool): apply rustfmt formatting --- libs/fctool/src/chat_tools/retract_message.rs | 18 +- libs/fctool/src/chat_tools/send_message.rs | 10 +- libs/fctool/src/chat_tools/title.rs | 2 +- libs/fctool/src/file_tools/grep.rs | 263 +++++--- libs/fctool/src/file_tools/json.rs | 25 +- libs/fctool/src/file_tools/mod.rs | 2 +- libs/fctool/src/file_tools/sql.rs | 93 ++- libs/fctool/src/git_tools/blob.rs | 320 ++++++--- libs/fctool/src/git_tools/branch.rs | 362 +++++++++-- libs/fctool/src/git_tools/commit.rs | 605 +++++++++++++----- libs/fctool/src/git_tools/ctx.rs | 13 +- libs/fctool/src/git_tools/diff.rs | 415 ++++++++++-- libs/fctool/src/git_tools/kb.rs | 113 +++- libs/fctool/src/git_tools/repo_analysis.rs | 226 +++++-- libs/fctool/src/git_tools/repo_util.rs | 192 ++++-- libs/fctool/src/git_tools/tag.rs | 273 ++++++-- libs/fctool/src/git_tools/tree.rs | 411 ++++++++++-- libs/fctool/src/git_tools/types.rs | 116 +++- libs/fctool/src/lib.rs | 6 +- libs/fctool/src/project_tools/arxiv.rs | 46 +- libs/fctool/src/project_tools/boards.rs | 421 ++++++++---- libs/fctool/src/project_tools/curl.rs | 139 ++-- libs/fctool/src/project_tools/issues.rs | 391 +++++++---- libs/fctool/src/project_tools/members.rs | 3 +- libs/fctool/src/project_tools/mod.rs | 10 +- libs/fctool/src/project_tools/repos.rs | 318 ++++++--- 26 files changed, 3575 insertions(+), 1218 deletions(-) diff --git a/libs/fctool/src/chat_tools/retract_message.rs b/libs/fctool/src/chat_tools/retract_message.rs index 088d602..1b9aa81 100644 --- a/libs/fctool/src/chat_tools/retract_message.rs +++ b/libs/fctool/src/chat_tools/retract_message.rs @@ -64,7 +64,8 @@ pub async fn retract_message_exec( if !ctx.is_sent_in_turn(message_id) { return Err(ToolError::ExecutionError( "can only retract messages sent in the current turn — \ - cross-turn retraction is not allowed".into(), + cross-turn retraction is not allowed" + .into(), )); } @@ -106,16 +107,25 @@ async fn is_room_admin( ) -> bool { use models::rooms::room; let room_model = room::Entity::find_by_id(room_id) - .one(db.reader()).await.ok().flatten(); + .one(db.reader()) + .await + .ok() + .flatten(); match room_model { Some(r) if r.created_by == user_id => true, Some(_) => { let member = project_members::Entity::find() .filter(project_members::Column::Project.eq(project_id)) .filter(project_members::Column::User.eq(user_id)) - .one(db.reader()).await.ok().flatten(); + .one(db.reader()) + .await + .ok() + .flatten(); match member { - Some(m) => matches!(m.scope_role(), Ok(models::projects::MemberRole::Admin | models::projects::MemberRole::Owner)), + Some(m) => matches!( + m.scope_role(), + Ok(models::projects::MemberRole::Admin | models::projects::MemberRole::Owner) + ), None => false, } } diff --git a/libs/fctool/src/chat_tools/send_message.rs b/libs/fctool/src/chat_tools/send_message.rs index bf12ed0..dc0347b 100644 --- a/libs/fctool/src/chat_tools/send_message.rs +++ b/libs/fctool/src/chat_tools/send_message.rs @@ -9,15 +9,13 @@ use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema}; use chrono::Utc; -use models::rooms::{ - room_message, MessageContentType, MessageSenderType, -}; +use models::rooms::{MessageContentType, MessageSenderType, room_message}; use queue::{ProjectRoomEvent, RoomMessageEnvelope}; use sea_orm::*; use std::collections::HashMap; use uuid::Uuid; -const MAX_CONTENT_LEN: usize = 10000; +const MAX_CONTENT_LEN: usize = 600; /// Send a brief message to the specified room. /// The message content may include mentions in `@[type:id:label]` format. @@ -135,7 +133,9 @@ pub async fn send_message_exec( seq: Some(seq), timestamp: now, }; - producer.publish_project_room_event(ctx.project_id(), project_event).await; + producer + .publish_project_room_event(ctx.project_id(), project_event) + .await; } Ok(serde_json::json!({ diff --git a/libs/fctool/src/chat_tools/title.rs b/libs/fctool/src/chat_tools/title.rs index 336d4e2..d379d71 100644 --- a/libs/fctool/src/chat_tools/title.rs +++ b/libs/fctool/src/chat_tools/title.rs @@ -2,7 +2,7 @@ use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema}; use chrono::Utc; -use models::ai::{ai_conversation, ai_message, AiMessage}; +use models::ai::{AiMessage, ai_conversation, ai_message}; use sea_orm::*; use std::collections::HashMap; diff --git a/libs/fctool/src/file_tools/grep.rs b/libs/fctool/src/file_tools/grep.rs index 5f06374..d0adc87 100644 --- a/libs/fctool/src/file_tools/grep.rs +++ b/libs/fctool/src/file_tools/grep.rs @@ -8,18 +8,71 @@ use std::collections::HashMap; /// Text file extensions to search (skip binary files). const TEXT_EXTS: &[&str] = &[ - "rs", "toml", "yaml", "yml", "json", "jsonc", "js", "jsx", "ts", "tsx", - "css", "scss", "less", "html", "htm", "xml", "svg", "vue", "svelte", - "py", "rb", "go", "java", "kt", "swift", "c", "cpp", "h", "hpp", - "cs", "php", "pl", "sh", "bash", "zsh", "fish", "ps1", "bat", "cmd", - "sql", "md", "markdown", "rst", "txt", "log", "ini", "cfg", "conf", - "dockerfile", "makefile", "cmake", "gradle", "properties", "env", - "proto", "graphql", "vue", "lock", + "rs", + "toml", + "yaml", + "yml", + "json", + "jsonc", + "js", + "jsx", + "ts", + "tsx", + "css", + "scss", + "less", + "html", + "htm", + "xml", + "svg", + "vue", + "svelte", + "py", + "rb", + "go", + "java", + "kt", + "swift", + "c", + "cpp", + "h", + "hpp", + "cs", + "php", + "pl", + "sh", + "bash", + "zsh", + "fish", + "ps1", + "bat", + "cmd", + "sql", + "md", + "markdown", + "rst", + "txt", + "log", + "ini", + "cfg", + "conf", + "dockerfile", + "makefile", + "cmake", + "gradle", + "properties", + "env", + "proto", + "graphql", + "vue", + "lock", ]; fn is_text_ext(path: &str) -> bool { let lower = path.to_lowercase(); - TEXT_EXTS.iter().any(|&e| lower.ends_with(&format!(".{}", e))) + TEXT_EXTS + .iter() + .any(|&e| lower.ends_with(&format!(".{}", e))) } fn is_binary_content(data: &[u8]) -> bool { @@ -51,18 +104,9 @@ async fn git_grep_exec( .and_then(|v| v.as_str()) .ok_or("missing pattern")?; let glob = p.get("glob").and_then(|v| v.as_str()).map(String::from); - let is_regex = p - .get("is_regex") - .and_then(|v| v.as_bool()) - .unwrap_or(true); - let context_lines = p - .get("context_lines") - .and_then(|v| v.as_u64()) - .unwrap_or(0) as usize; - let max_results = p - .get("max_results") - .and_then(|v| v.as_u64()) - .unwrap_or(100) as usize; + let is_regex = p.get("is_regex").and_then(|v| v.as_bool()).unwrap_or(true); + let context_lines = p.get("context_lines").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + let max_results = p.get("max_results").and_then(|v| v.as_u64()).unwrap_or(100) as usize; let domain = ctx.open_repo(project_name, repo_name).await?; @@ -115,7 +159,8 @@ async fn git_grep_exec( }; if entry.kind() == Some(git2::ObjectType::Tree) { - if let Some(subtree) = entry.to_object(&repo).ok().and_then(|o| o.into_tree().ok()) { + if let Some(subtree) = entry.to_object(&repo).ok().and_then(|o| o.into_tree().ok()) + { stack.push((subtree, path)); } continue; @@ -217,7 +262,11 @@ fn glob_match(path: &str, pattern: &str) -> bool { } if let Some(star) = pattern_part.find('*') { let (prefix, suffix) = pattern_part.split_at(star); - let suffix = if suffix.starts_with('*') { &suffix[1..] } else { suffix }; + let suffix = if suffix.starts_with('*') { + &suffix[1..] + } else { + suffix + }; if !prefix.is_empty() && !path_part.starts_with(prefix) { return false; } @@ -246,7 +295,11 @@ fn glob_match(path: &str, pattern: &str) -> bool { break; } // Try skipping segments until the next pattern part matches - let next_part = parts.iter().skip_while(|p| **p == "**").next().unwrap_or(&"*"); + let next_part = parts + .iter() + .skip_while(|p| **p == "**") + .next() + .unwrap_or(&"*"); while pi < path_parts.len() && !matches_part(path_parts[pi], next_part) { pi += 1; } @@ -263,76 +316,110 @@ fn glob_match(path: &str, pattern: &str) -> bool { pub fn register_grep_tools(registry: &mut ToolRegistry) { let p = HashMap::from([ - ("project_name".into(), ToolParam { - name: "project_name".into(), - param_type: "string".into(), - description: Some("Project name (slug)".into()), - required: true, - properties: None, - items: None, - }), - ("repo_name".into(), ToolParam { - name: "repo_name".into(), - param_type: "string".into(), - description: Some("Repository name".into()), - required: true, - properties: None, - items: None, - }), - ("pattern".into(), ToolParam { - name: "pattern".into(), - param_type: "string".into(), - description: Some("Search pattern (regex or literal string)".into()), - required: true, - properties: None, - items: None, - }), - ("rev".into(), ToolParam { - name: "rev".into(), - param_type: "string".into(), - description: Some("Git revision to search in (branch, tag, commit). Default: HEAD".into()), - required: false, - properties: None, - items: None, - }), - ("glob".into(), ToolParam { - name: "glob".into(), - param_type: "string".into(), - description: Some("File glob pattern to filter (e.g. *.rs, src/**/*.ts)".into()), - required: false, - properties: None, - items: None, - }), - ("is_regex".into(), ToolParam { - name: "is_regex".into(), - param_type: "boolean".into(), - description: Some("If true, pattern is a regex. If false, literal string. Default: true".into()), - required: false, - properties: None, - items: None, - }), - ("context_lines".into(), ToolParam { - name: "context_lines".into(), - param_type: "integer".into(), - description: Some("Number of surrounding lines to include for each match. Default: 0".into()), - required: false, - properties: None, - items: None, - }), - ("max_results".into(), ToolParam { - name: "max_results".into(), - param_type: "integer".into(), - description: Some("Maximum number of matches to return. Default: 100".into()), - required: false, - properties: None, - items: None, - }), + ( + "project_name".into(), + ToolParam { + name: "project_name".into(), + param_type: "string".into(), + description: Some("Project name (slug)".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "repo_name".into(), + ToolParam { + name: "repo_name".into(), + param_type: "string".into(), + description: Some("Repository name".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "pattern".into(), + ToolParam { + name: "pattern".into(), + param_type: "string".into(), + description: Some("Search pattern (regex or literal string)".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "rev".into(), + ToolParam { + name: "rev".into(), + param_type: "string".into(), + description: Some( + "Git revision to search in (branch, tag, commit). Default: HEAD".into(), + ), + required: false, + properties: None, + items: None, + }, + ), + ( + "glob".into(), + ToolParam { + name: "glob".into(), + param_type: "string".into(), + description: Some("File glob pattern to filter (e.g. *.rs, src/**/*.ts)".into()), + required: false, + properties: None, + items: None, + }, + ), + ( + "is_regex".into(), + ToolParam { + name: "is_regex".into(), + param_type: "boolean".into(), + description: Some( + "If true, pattern is a regex. If false, literal string. Default: true".into(), + ), + required: false, + properties: None, + items: None, + }, + ), + ( + "context_lines".into(), + ToolParam { + name: "context_lines".into(), + param_type: "integer".into(), + description: Some( + "Number of surrounding lines to include for each match. Default: 0".into(), + ), + required: false, + properties: None, + items: None, + }, + ), + ( + "max_results".into(), + ToolParam { + name: "max_results".into(), + param_type: "integer".into(), + description: Some("Maximum number of matches to return. Default: 100".into()), + required: false, + properties: None, + items: None, + }, + ), ]); let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), - required: Some(vec!["project_name".into(), "repo_name".into(), "pattern".into()]), + required: Some(vec![ + "project_name".into(), + "repo_name".into(), + "pattern".into(), + ]), }; registry.register( diff --git a/libs/fctool/src/file_tools/json.rs b/libs/fctool/src/file_tools/json.rs index d3a48fb..c16c9e0 100644 --- a/libs/fctool/src/file_tools/json.rs +++ b/libs/fctool/src/file_tools/json.rs @@ -118,7 +118,10 @@ async fn read_json_exec( .get("repo_name") .and_then(|v| v.as_str()) .ok_or("missing repo_name")?; - let path = p.get("path").and_then(|v| v.as_str()).ok_or("missing path")?; + let path = p + .get("path") + .and_then(|v| v.as_str()) + .ok_or("missing path")?; let rev = p .get("rev") .and_then(|v| v.as_str()) @@ -213,7 +216,11 @@ fn query_json(value: &JsonValue, query: &str) -> Result { let mut current = value.clone(); // Parse into access segments: Key("name"), Index(0), BracketKey("key.with.dots") - enum Segment { Key(String), Index(usize), BracketKey(String) } + enum Segment { + Key(String), + Index(usize), + BracketKey(String), + } let mut segments: Vec = Vec::new(); let mut i = 0; let q_chars: Vec = query.chars().collect(); @@ -230,10 +237,10 @@ fn query_json(value: &JsonValue, query: &str) -> Result { let content = bracket_content.trim(); // Check if it's a quoted string key or a numeric index if content.starts_with('"') && content.ends_with('"') { - let key = content[1..content.len()-1].to_string(); + let key = content[1..content.len() - 1].to_string(); segments.push(Segment::BracketKey(key)); } else if content.starts_with("'") && content.ends_with("'") { - let key = content[1..content.len()-1].to_string(); + let key = content[1..content.len() - 1].to_string(); segments.push(Segment::BracketKey(key)); } else if let Ok(idx) = content.parse::() { segments.push(Segment::Index(idx)); @@ -297,7 +304,15 @@ pub fn register_json_tools(registry: &mut ToolRegistry) { ("schema_depth".into(), ToolParam { name: "schema_depth".into(), param_type: "integer".into(), description: Some("How deep to infer the JSON schema (default: 4)".into()), required: false, properties: None, items: None }), ("pretty".into(), ToolParam { name: "pretty".into(), param_type: "boolean".into(), description: Some("Pretty-print the output (default: false)".into()), required: false, properties: None, items: None }), ]); - let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into(), "path".into()]) }; + let schema = ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec![ + "project_name".into(), + "repo_name".into(), + "path".into(), + ]), + }; registry.register( ToolDefinition::new("read_json") .description("Parse, validate, and query JSON and JSONC files. Supports JSONPath-like queries ($.key, $.arr[0]), schema inference, and pretty-printing. Automatically detects JSONC (with // comments).") diff --git a/libs/fctool/src/file_tools/mod.rs b/libs/fctool/src/file_tools/mod.rs index 40e14ab..398a251 100644 --- a/libs/fctool/src/file_tools/mod.rs +++ b/libs/fctool/src/file_tools/mod.rs @@ -24,4 +24,4 @@ pub fn register_all(registry: &mut ToolRegistry) { markdown::register_markdown_tools(registry); sql::register_sql_tools(registry); json::register_json_tools(registry); -} \ No newline at end of file +} diff --git a/libs/fctool/src/file_tools/sql.rs b/libs/fctool/src/file_tools/sql.rs index 4187bca..4addec0 100644 --- a/libs/fctool/src/file_tools/sql.rs +++ b/libs/fctool/src/file_tools/sql.rs @@ -3,7 +3,7 @@ use crate::file_tools::MAX_FILE_SIZE; use crate::git_tools::ctx::GitToolCtx; use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema}; -use sqlparser::ast::{Statement, ColumnDef}; +use sqlparser::ast::{ColumnDef, Statement}; use sqlparser::dialect::{GenericDialect, MySqlDialect, PostgreSqlDialect, SQLiteDialect}; use sqlparser::parser::Parser; use std::collections::HashMap; @@ -23,13 +23,19 @@ async fn read_sql_exec( .get("repo_name") .and_then(|v| v.as_str()) .ok_or("missing repo_name")?; - let path = p.get("path").and_then(|v| v.as_str()).ok_or("missing path")?; + let path = p + .get("path") + .and_then(|v| v.as_str()) + .ok_or("missing path")?; let rev = p .get("rev") .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_else(|| "HEAD".to_string()); - let dialect = p.get("dialect").and_then(|v| v.as_str()).unwrap_or("generic"); + let dialect = p + .get("dialect") + .and_then(|v| v.as_str()) + .unwrap_or("generic"); let domain = ctx.open_repo(project_name, repo_name).await?; @@ -74,10 +80,15 @@ async fn read_sql_exec( let mut views: Vec = Vec::new(); let mut functions: Vec = Vec::new(); let mut indexes: Vec = Vec::new(); - let mut statement_kinds: std::collections::HashMap = std::collections::HashMap::new(); + let mut statement_kinds: std::collections::HashMap = + std::collections::HashMap::new(); for statement in &statements { - let kind = format!("{:?}", statement).split('{').next().unwrap_or("unknown").to_string(); + let kind = format!("{:?}", statement) + .split('{') + .next() + .unwrap_or("unknown") + .to_string(); *statement_kinds.entry(kind).or_insert(0) += 1; match statement { @@ -135,13 +146,73 @@ fn format_column_def(col: &ColumnDef) -> String { pub fn register_sql_tools(registry: &mut ToolRegistry) { let p = HashMap::from([ - ("project_name".into(), ToolParam { name: "project_name".into(), param_type: "string".into(), description: Some("Project name (slug)".into()), required: true, properties: None, items: None }), - ("repo_name".into(), ToolParam { name: "repo_name".into(), param_type: "string".into(), description: Some("Repository name".into()), required: true, properties: None, items: None }), - ("path".into(), ToolParam { name: "path".into(), param_type: "string".into(), description: Some("File path to the SQL file".into()), required: true, properties: None, items: None }), - ("rev".into(), ToolParam { name: "rev".into(), param_type: "string".into(), description: Some("Git revision (default: HEAD)".into()), required: false, properties: None, items: None }), - ("dialect".into(), ToolParam { name: "dialect".into(), param_type: "string".into(), description: Some("SQL dialect: generic, mysql, postgresql, sqlite. Default: generic".into()), required: false, properties: None, items: None }), + ( + "project_name".into(), + ToolParam { + name: "project_name".into(), + param_type: "string".into(), + description: Some("Project name (slug)".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "repo_name".into(), + ToolParam { + name: "repo_name".into(), + param_type: "string".into(), + description: Some("Repository name".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "path".into(), + ToolParam { + name: "path".into(), + param_type: "string".into(), + description: Some("File path to the SQL file".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "rev".into(), + ToolParam { + name: "rev".into(), + param_type: "string".into(), + description: Some("Git revision (default: HEAD)".into()), + required: false, + properties: None, + items: None, + }, + ), + ( + "dialect".into(), + ToolParam { + name: "dialect".into(), + param_type: "string".into(), + description: Some( + "SQL dialect: generic, mysql, postgresql, sqlite. Default: generic".into(), + ), + required: false, + properties: None, + items: None, + }, + ), ]); - let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into(), "path".into()]) }; + let schema = ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec![ + "project_name".into(), + "repo_name".into(), + "path".into(), + ]), + }; registry.register( ToolDefinition::new("read_sql") .description("Parse and analyze a SQL file. Extracts CREATE TABLE statements (with columns and types), CREATE VIEW, CREATE INDEX, CREATE FUNCTION, and counts all statement types.") diff --git a/libs/fctool/src/git_tools/blob.rs b/libs/fctool/src/git_tools/blob.rs index 66fb932..3f3e6b1 100644 --- a/libs/fctool/src/git_tools/blob.rs +++ b/libs/fctool/src/git_tools/blob.rs @@ -11,8 +11,14 @@ async fn git_blob_info_exec( ) -> 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 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 oid = p.get("oid").and_then(|v| v.as_str()).ok_or("missing oid")?; let domain = ctx.open_repo(project_name, repo_name).await?; @@ -32,8 +38,14 @@ async fn git_blob_exists_exec( ) -> 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 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 oid = p.get("oid").and_then(|v| v.as_str()).ok_or("missing oid")?; let domain = ctx.open_repo(project_name, repo_name).await?; @@ -49,10 +61,19 @@ async fn git_blob_content_exec( ) -> 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 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 oid = p.get("oid").and_then(|v| v.as_str()).ok_or("missing oid")?; - let max_size = p.get("max_size").and_then(|v| v.as_u64()).unwrap_or(1_048_576) as usize; // 1MB default + let max_size = p + .get("max_size") + .and_then(|v| v.as_u64()) + .unwrap_or(1_048_576) as usize; // 1MB default let domain = ctx.open_repo(project_name, repo_name).await?; let blob_oid = git::commit::types::CommitOid::new(oid); @@ -66,7 +87,10 @@ async fn git_blob_content_exec( } let (content, is_binary) = if blob.is_binary { - (base64::engine::general_purpose::STANDARD.encode(&blob.content), true) + ( + base64::engine::general_purpose::STANDARD.encode(&blob.content), + true, + ) } else { (String::from_utf8_lossy(&blob.content).to_string(), false) }; @@ -85,17 +109,34 @@ async fn git_blob_create_exec( ) -> 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 content = p.get("content").and_then(|v| v.as_str()).ok_or("missing content")?; - let encoding = p.get("encoding").and_then(|v| v.as_str()).unwrap_or("utf-8"); + 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 content = p + .get("content") + .and_then(|v| v.as_str()) + .ok_or("missing content")?; + let encoding = p + .get("encoding") + .and_then(|v| v.as_str()) + .unwrap_or("utf-8"); let data = match encoding { "base64" => base64::engine::general_purpose::STANDARD .decode(content) .map_err(|e| format!("invalid base64: {}", e))?, "utf-8" => content.as_bytes().to_vec(), - other => return Err(format!("unsupported encoding '{}'. Use 'utf-8' or 'base64'.", other)), + other => { + return Err(format!( + "unsupported encoding '{}'. Use 'utf-8' or 'base64'.", + other + )); + } }; let domain = ctx.open_repo(project_name, repo_name).await?; @@ -109,30 +150,51 @@ async fn git_blob_create_exec( })) } - pub fn register_git_tools(registry: &mut ToolRegistry) { // git_blob_info let p = HashMap::from([ - ("project_name".into(), ToolParam { - name: "project_name".into(), param_type: "string".into(), - description: Some("Project name (slug)".into()), - required: true, properties: None, items: None, - }), - ("repo_name".into(), ToolParam { - name: "repo_name".into(), param_type: "string".into(), - description: Some("Repository name".into()), - required: true, properties: None, items: None, - }), - ("oid".into(), ToolParam { - name: "oid".into(), param_type: "string".into(), - description: Some("Blob OID (full 40-char hex or short prefix)".into()), - required: true, properties: None, items: None, - }), + ( + "project_name".into(), + ToolParam { + name: "project_name".into(), + param_type: "string".into(), + description: Some("Project name (slug)".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "repo_name".into(), + ToolParam { + name: "repo_name".into(), + param_type: "string".into(), + description: Some("Repository name".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "oid".into(), + ToolParam { + name: "oid".into(), + param_type: "string".into(), + description: Some("Blob OID (full 40-char hex or short prefix)".into()), + required: true, + properties: None, + items: None, + }, + ), ]); let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), - required: Some(vec!["project_name".into(), "repo_name".into(), "oid".into()]), + required: Some(vec![ + "project_name".into(), + "repo_name".into(), + "oid".into(), + ]), }; registry.register( ToolDefinition::new("git_blob_info") @@ -148,26 +210,48 @@ pub fn register_git_tools(registry: &mut ToolRegistry) { // git_blob_exists let p = HashMap::from([ - ("project_name".into(), ToolParam { - name: "project_name".into(), param_type: "string".into(), - description: Some("Project name (slug)".into()), - required: true, properties: None, items: None, - }), - ("repo_name".into(), ToolParam { - name: "repo_name".into(), param_type: "string".into(), - description: Some("Repository name".into()), - required: true, properties: None, items: None, - }), - ("oid".into(), ToolParam { - name: "oid".into(), param_type: "string".into(), - description: Some("Blob OID to check".into()), - required: true, properties: None, items: None, - }), + ( + "project_name".into(), + ToolParam { + name: "project_name".into(), + param_type: "string".into(), + description: Some("Project name (slug)".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "repo_name".into(), + ToolParam { + name: "repo_name".into(), + param_type: "string".into(), + description: Some("Repository name".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "oid".into(), + ToolParam { + name: "oid".into(), + param_type: "string".into(), + description: Some("Blob OID to check".into()), + required: true, + properties: None, + items: None, + }, + ), ]); let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), - required: Some(vec!["project_name".into(), "repo_name".into(), "oid".into()]), + required: Some(vec![ + "project_name".into(), + "repo_name".into(), + "oid".into(), + ]), }; registry.register( ToolDefinition::new("git_blob_exists") @@ -176,38 +260,68 @@ pub fn register_git_tools(registry: &mut ToolRegistry) { ToolHandler::new(|ctx, args| { let gctx = GitToolCtx::new(ctx); Box::pin(async move { - git_blob_exists_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError) + git_blob_exists_exec(gctx, args) + .await + .map_err(agent::ToolError::ExecutionError) }) }), ); // git_blob_content let p = HashMap::from([ - ("project_name".into(), ToolParam { - name: "project_name".into(), param_type: "string".into(), - description: Some("Project name (slug)".into()), - required: true, properties: None, items: None, - }), - ("repo_name".into(), ToolParam { - name: "repo_name".into(), param_type: "string".into(), - description: Some("Repository name".into()), - required: true, properties: None, items: None, - }), - ("oid".into(), ToolParam { - name: "oid".into(), param_type: "string".into(), - description: Some("Blob OID to retrieve content for".into()), - required: true, properties: None, items: None, - }), - ("max_size".into(), ToolParam { - name: "max_size".into(), param_type: "integer".into(), - description: Some("Maximum blob size in bytes (default: 1MB)".into()), - required: false, properties: None, items: None, - }), + ( + "project_name".into(), + ToolParam { + name: "project_name".into(), + param_type: "string".into(), + description: Some("Project name (slug)".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "repo_name".into(), + ToolParam { + name: "repo_name".into(), + param_type: "string".into(), + description: Some("Repository name".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "oid".into(), + ToolParam { + name: "oid".into(), + param_type: "string".into(), + description: Some("Blob OID to retrieve content for".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "max_size".into(), + ToolParam { + name: "max_size".into(), + param_type: "integer".into(), + description: Some("Maximum blob size in bytes (default: 1MB)".into()), + required: false, + properties: None, + items: None, + }, + ), ]); let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), - required: Some(vec!["project_name".into(), "repo_name".into(), "oid".into()]), + required: Some(vec![ + "project_name".into(), + "repo_name".into(), + "oid".into(), + ]), }; registry.register( ToolDefinition::new("git_blob_content") @@ -223,31 +337,59 @@ pub fn register_git_tools(registry: &mut ToolRegistry) { // git_blob_create let p = HashMap::from([ - ("project_name".into(), ToolParam { - name: "project_name".into(), param_type: "string".into(), - description: Some("Project name (slug)".into()), - required: true, properties: None, items: None, - }), - ("repo_name".into(), ToolParam { - name: "repo_name".into(), param_type: "string".into(), - description: Some("Repository name".into()), - required: true, properties: None, items: None, - }), - ("content".into(), ToolParam { - name: "content".into(), param_type: "string".into(), - description: Some("Blob content (utf-8 string or base64-encoded bytes)".into()), - required: true, properties: None, items: None, - }), - ("encoding".into(), ToolParam { - name: "encoding".into(), param_type: "string".into(), - description: Some("Encoding of content: 'utf-8' (default) or 'base64'".into()), - required: false, properties: None, items: None, - }), + ( + "project_name".into(), + ToolParam { + name: "project_name".into(), + param_type: "string".into(), + description: Some("Project name (slug)".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "repo_name".into(), + ToolParam { + name: "repo_name".into(), + param_type: "string".into(), + description: Some("Repository name".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "content".into(), + ToolParam { + name: "content".into(), + param_type: "string".into(), + description: Some("Blob content (utf-8 string or base64-encoded bytes)".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "encoding".into(), + ToolParam { + name: "encoding".into(), + param_type: "string".into(), + description: Some("Encoding of content: 'utf-8' (default) or 'base64'".into()), + required: false, + properties: None, + items: None, + }, + ), ]); let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), - required: Some(vec!["project_name".into(), "repo_name".into(), "content".into()]), + required: Some(vec![ + "project_name".into(), + "repo_name".into(), + "content".into(), + ]), }; registry.register( ToolDefinition::new("git_blob_create") diff --git a/libs/fctool/src/git_tools/branch.rs b/libs/fctool/src/git_tools/branch.rs index 1ae5887..2ea4bb9 100644 --- a/libs/fctool/src/git_tools/branch.rs +++ b/libs/fctool/src/git_tools/branch.rs @@ -4,11 +4,24 @@ use super::ctx::GitToolCtx; use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema}; use std::collections::HashMap; -async fn git_branch_list_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 remote_only = p.get("remote_only").and_then(|v| v.as_bool()).unwrap_or(false); +async fn git_branch_list_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 remote_only = p + .get("remote_only") + .and_then(|v| v.as_bool()) + .unwrap_or(false); let domain = ctx.open_repo(project_name, repo_name).await?; let branches = domain.branch_list(remote_only).map_err(|e| e.to_string())?; @@ -25,11 +38,24 @@ async fn git_branch_list_exec(ctx: GitToolCtx, args: serde_json::Value) -> Resul Ok(serde_json::to_value(result).map_err(|e| e.to_string())?) } -async fn git_branch_info_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 name = p.get("name").and_then(|v| v.as_str()).ok_or("missing name")?; +async fn git_branch_info_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 name = p + .get("name") + .and_then(|v| v.as_str()) + .ok_or("missing name")?; let domain = ctx.open_repo(project_name, repo_name).await?; let info = domain.branch_get(name).map_err(|e| e.to_string())?; @@ -39,7 +65,9 @@ async fn git_branch_info_exec(ctx: GitToolCtx, args: serde_json::Value) -> Resul Ok((ahead, behind)) => Some(serde_json::json!({ "ahead": ahead, "behind": behind })), Err(e) => Some(serde_json::json!({ "error": e.to_string() })), } - } else { None }; + } else { + None + }; Ok(serde_json::json!({ "branch": { "name": info.name, "oid": info.oid.to_string(), "is_head": info.is_head, @@ -48,84 +76,188 @@ async fn git_branch_info_exec(ctx: GitToolCtx, args: serde_json::Value) -> Resul })) } -async fn git_branches_merged_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 branch = p.get("branch").and_then(|v| v.as_str()).ok_or("missing branch")?; - let into = p.get("into").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| "main".to_string()); +async fn git_branches_merged_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 branch = p + .get("branch") + .and_then(|v| v.as_str()) + .ok_or("missing branch")?; + let into = p + .get("into") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "main".to_string()); let domain = ctx.open_repo(project_name, repo_name).await?; - let is_merged = domain.branch_is_merged(branch, &into).map_err(|e| e.to_string())?; + let is_merged = domain + .branch_is_merged(branch, &into) + .map_err(|e| e.to_string())?; // Resolve branch names to commit OIDs before calling merge_base - let branch_oid = domain.branch_target(branch) + let branch_oid = domain + .branch_target(branch) .map_err(|e| e.to_string())? .ok_or_else(|| format!("branch '{}' not found or has no target", branch))?; - let into_oid = domain.branch_target(&into) + let into_oid = domain + .branch_target(&into) .map_err(|e| e.to_string())? .ok_or_else(|| format!("branch '{}' not found or has no target", into))?; - let merge_base = domain.merge_base(&branch_oid, &into_oid) - .map(|oid| oid.to_string()).ok(); + let merge_base = domain + .merge_base(&branch_oid, &into_oid) + .map(|oid| oid.to_string()) + .ok(); - Ok(serde_json::json!({ "branch": branch, "into": into, "is_merged": is_merged, "merge_base": merge_base })) + Ok( + serde_json::json!({ "branch": branch, "into": into, "is_merged": is_merged, "merge_base": merge_base }), + ) } -async fn git_branch_diff_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 local = p.get("local").and_then(|v| v.as_str()).ok_or("missing local")?; - let remote = p.get("remote").and_then(|v| v.as_str()).unwrap_or(local).to_string(); +async fn git_branch_diff_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 local = p + .get("local") + .and_then(|v| v.as_str()) + .ok_or("missing local")?; + let remote = p + .get("remote") + .and_then(|v| v.as_str()) + .unwrap_or(local) + .to_string(); let domain = ctx.open_repo(project_name, repo_name).await?; - let diff = domain.branch_diff(local, &remote).map_err(|e| e.to_string())?; + let diff = domain + .branch_diff(local, &remote) + .map_err(|e| e.to_string())?; Ok(serde_json::json!({ "ahead": diff.ahead, "behind": diff.behind, "diverged": diff.diverged })) } pub fn register_git_tools(registry: &mut ToolRegistry) { let mut p = HashMap::from([ - ("project_name".into(), ToolParam { - name: "project_name".into(), param_type: "string".into(), - description: Some("Project name (slug)".into()), - required: true, properties: None, items: None, - }), - ("repo_name".into(), ToolParam { - name: "repo_name".into(), param_type: "string".into(), - description: Some("Repository name".into()), - required: true, properties: None, items: None, - }), + ( + "project_name".into(), + ToolParam { + name: "project_name".into(), + param_type: "string".into(), + description: Some("Project name (slug)".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "repo_name".into(), + ToolParam { + name: "repo_name".into(), + param_type: "string".into(), + description: Some("Repository name".into()), + required: true, + properties: None, + items: None, + }, + ), ]); // git_branch_list - p.insert("remote_only".into(), ToolParam { - name: "remote_only".into(), param_type: "boolean".into(), - description: Some("If true, list only remote-tracking branches".into()), - required: false, properties: None, items: None, - }); - let schema = ToolSchema { schema_type: "object".into(), properties: Some(std::mem::take(&mut p)), required: Some(vec!["project_name".into(), "repo_name".into()]) }; + p.insert( + "remote_only".into(), + ToolParam { + name: "remote_only".into(), + param_type: "boolean".into(), + description: Some("If true, list only remote-tracking branches".into()), + required: false, + properties: None, + items: None, + }, + ); + let schema = ToolSchema { + schema_type: "object".into(), + properties: Some(std::mem::take(&mut p)), + required: Some(vec!["project_name".into(), "repo_name".into()]), + }; p = HashMap::from([ - ("project_name".into(), ToolParam { name: "project_name".into(), param_type: "string".into(), description: Some("Project name (slug)".into()), required: true, properties: None, items: None }), - ("repo_name".into(), ToolParam { name: "repo_name".into(), param_type: "string".into(), description: Some("Repository name".into()), required: true, properties: None, items: None }), + ( + "project_name".into(), + ToolParam { + name: "project_name".into(), + param_type: "string".into(), + description: Some("Project name (slug)".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "repo_name".into(), + ToolParam { + name: "repo_name".into(), + param_type: "string".into(), + description: Some("Repository name".into()), + required: true, + properties: None, + items: None, + }, + ), ]); registry.register( - ToolDefinition::new("git_branch_list").description("List all local or remote branches with their current HEAD commit.").parameters(schema), + ToolDefinition::new("git_branch_list") + .description("List all local or remote branches with their current HEAD commit.") + .parameters(schema), ToolHandler::new(move |ctx, args| { let gctx = super::ctx::GitToolCtx::new(ctx); Box::pin(async move { - git_branch_list_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError) + git_branch_list_exec(gctx, args) + .await + .map_err(agent::ToolError::ExecutionError) }) }), ); // git_branch_info - p.insert("name".into(), ToolParam { - name: "name".into(), param_type: "string".into(), - description: Some("Branch name (e.g. main, feature/my-branch)".into()), - required: true, properties: None, items: None, - }); - let schema = ToolSchema { schema_type: "object".into(), properties: Some(std::mem::take(&mut p)), required: Some(vec!["project_name".into(), "repo_name".into(), "name".into()]) }; + p.insert( + "name".into(), + ToolParam { + name: "name".into(), + param_type: "string".into(), + description: Some("Branch name (e.g. main, feature/my-branch)".into()), + required: true, + properties: None, + items: None, + }, + ); + let schema = ToolSchema { + schema_type: "object".into(), + properties: Some(std::mem::take(&mut p)), + required: Some(vec![ + "project_name".into(), + "repo_name".into(), + "name".into(), + ]), + }; registry.register( ToolDefinition::new("git_branch_info").description("Get detailed information about a specific branch including ahead/behind status vs upstream.").parameters(schema), ToolHandler::new(move |ctx, args| { @@ -138,12 +270,60 @@ pub fn register_git_tools(registry: &mut ToolRegistry) { // git_branches_merged p = HashMap::from([ - ("project_name".into(), ToolParam { name: "project_name".into(), param_type: "string".into(), description: Some("Project name (slug)".into()), required: true, properties: None, items: None }), - ("repo_name".into(), ToolParam { name: "repo_name".into(), param_type: "string".into(), description: Some("Repository name".into()), required: true, properties: None, items: None }), - ("branch".into(), ToolParam { name: "branch".into(), param_type: "string".into(), description: Some("Branch to check (source)".into()), required: true, properties: None, items: None }), - ("into".into(), ToolParam { name: "into".into(), param_type: "string".into(), description: Some("Branch to check against (target, default: main)".into()), required: false, properties: None, items: None }), + ( + "project_name".into(), + ToolParam { + name: "project_name".into(), + param_type: "string".into(), + description: Some("Project name (slug)".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "repo_name".into(), + ToolParam { + name: "repo_name".into(), + param_type: "string".into(), + description: Some("Repository name".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "branch".into(), + ToolParam { + name: "branch".into(), + param_type: "string".into(), + description: Some("Branch to check (source)".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "into".into(), + ToolParam { + name: "into".into(), + param_type: "string".into(), + description: Some("Branch to check against (target, default: main)".into()), + required: false, + properties: None, + items: None, + }, + ), ]); - let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into(), "branch".into()]) }; + let schema = ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec![ + "project_name".into(), + "repo_name".into(), + "branch".into(), + ]), + }; registry.register( ToolDefinition::new("git_branches_merged").description("Check whether a branch has been merged into another branch, and find the merge base.").parameters(schema), ToolHandler::new(move |ctx, args| { @@ -156,12 +336,60 @@ pub fn register_git_tools(registry: &mut ToolRegistry) { // git_branch_diff let p = HashMap::from([ - ("project_name".into(), ToolParam { name: "project_name".into(), param_type: "string".into(), description: Some("Project name (slug)".into()), required: true, properties: None, items: None }), - ("repo_name".into(), ToolParam { name: "repo_name".into(), param_type: "string".into(), description: Some("Repository name".into()), required: true, properties: None, items: None }), - ("local".into(), ToolParam { name: "local".into(), param_type: "string".into(), description: Some("Local branch name".into()), required: true, properties: None, items: None }), - ("remote".into(), ToolParam { name: "remote".into(), param_type: "string".into(), description: Some("Remote branch name (defaults to local)".into()), required: false, properties: None, items: None }), + ( + "project_name".into(), + ToolParam { + name: "project_name".into(), + param_type: "string".into(), + description: Some("Project name (slug)".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "repo_name".into(), + ToolParam { + name: "repo_name".into(), + param_type: "string".into(), + description: Some("Repository name".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "local".into(), + ToolParam { + name: "local".into(), + param_type: "string".into(), + description: Some("Local branch name".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "remote".into(), + ToolParam { + name: "remote".into(), + param_type: "string".into(), + description: Some("Remote branch name (defaults to local)".into()), + required: false, + properties: None, + items: None, + }, + ), ]); - let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into(), "local".into()]) }; + let schema = ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec![ + "project_name".into(), + "repo_name".into(), + "local".into(), + ]), + }; registry.register( ToolDefinition::new("git_branch_diff").description("Compare a local branch against its remote counterpart to see how many commits ahead/behind they are.").parameters(schema), ToolHandler::new(move |ctx, args| { @@ -171,4 +399,4 @@ pub fn register_git_tools(registry: &mut ToolRegistry) { }) }), ); -} \ No newline at end of file +} diff --git a/libs/fctool/src/git_tools/commit.rs b/libs/fctool/src/git_tools/commit.rs index 299e5b5..0106fb2 100644 --- a/libs/fctool/src/git_tools/commit.rs +++ b/libs/fctool/src/git_tools/commit.rs @@ -7,57 +7,87 @@ use std::collections::HashMap; // --- Execution functions for each tool --- -async fn git_log_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")?; +async fn git_log_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 rev = p.get("rev").and_then(|v| v.as_str()).map(|s| s.to_string()); let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize; let skip = p.get("skip").and_then(|v| v.as_u64()).unwrap_or(0) as usize; let domain = ctx.open_repo(project_name, repo_name).await?; - let commits = domain.commit_log(rev.as_deref(), skip, limit) + let commits = domain + .commit_log(rev.as_deref(), skip, limit) .map_err(|e| e.to_string())?; // Flatten to simple JSON - let result: Vec<_> = commits.iter().map(|c| { - use chrono::TimeZone; - let ts = c.author.time_secs - (c.author.offset_minutes as i64 * 60); - let time_str = chrono::Utc.timestamp_opt(ts, 0).single() - .map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", c.author.time_secs)); + let result: Vec<_> = commits + .iter() + .map(|c| { + use chrono::TimeZone; + let ts = c.author.time_secs - (c.author.offset_minutes as i64 * 60); + let time_str = chrono::Utc + .timestamp_opt(ts, 0) + .single() + .map(|dt| dt.to_rfc3339()) + .unwrap_or_else(|| format!("{}", c.author.time_secs)); - let oid = c.oid.to_string(); - let short_oid = oid.get(..7).unwrap_or(&oid).to_string(); + let oid = c.oid.to_string(); + let short_oid = oid.get(..7).unwrap_or(&oid).to_string(); - serde_json::json!({ - "oid": oid, - "short_oid": short_oid, - "message": c.message, - "summary": c.summary, - "author_name": c.author.name, - "author_email": c.author.email, - "author_time": time_str, - "committer_name": c.committer.name, - "committer_email": c.committer.email, - "parent_oids": c.parent_ids.iter().map(|p| p.to_string()).collect::>(), - "tree_oid": c.tree_id.to_string() + serde_json::json!({ + "oid": oid, + "short_oid": short_oid, + "message": c.message, + "summary": c.summary, + "author_name": c.author.name, + "author_email": c.author.email, + "author_time": time_str, + "committer_name": c.committer.name, + "committer_email": c.committer.email, + "parent_oids": c.parent_ids.iter().map(|p| p.to_string()).collect::>(), + "tree_oid": c.tree_id.to_string() + }) }) - }).collect(); + .collect(); Ok(serde_json::to_value(result).map_err(|e| e.to_string())?) } /// Resolve a rev string to commit metadata using the full rev-parse machinery /// (branch names, tags, HEAD, hex prefixes, etc.). -fn resolve_commit(domain: &git::GitDomain, rev: &str) -> Result { +fn resolve_commit( + domain: &git::GitDomain, + rev: &str, +) -> Result { let oid = domain.resolve_rev(rev).map_err(|e| e.to_string())?; domain.commit_get(&oid).map_err(|e| e.to_string()) } -async fn git_show_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")?; +async fn git_show_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 rev = p.get("rev").and_then(|v| v.as_str()).ok_or("missing rev")?; let domain = ctx.open_repo(project_name, repo_name).await?; @@ -67,8 +97,11 @@ async fn git_show_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result Result 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 query = p.get("query").and_then(|v| v.as_str()).ok_or("missing query")?; +async fn git_search_commits_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 query = p + .get("query") + .and_then(|v| v.as_str()) + .ok_or("missing query")?; let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize; let domain = ctx.open_repo(project_name, repo_name).await?; // Fetch extra commits to have enough candidates after filtering let walk_limit = limit.saturating_mul(2).max(100); - let commits = domain.commit_log(Some("HEAD"), 0, walk_limit).map_err(|e| e.to_string())?; + let commits = domain + .commit_log(Some("HEAD"), 0, walk_limit) + .map_err(|e| e.to_string())?; let q = query.to_lowercase(); - let result: Vec<_> = commits.iter() + let result: Vec<_> = commits + .iter() .filter(|c| c.message.to_lowercase().contains(&q)) .take(limit) .map(|c| flatten_commit(c)) @@ -110,8 +159,11 @@ async fn git_search_commits_exec(ctx: GitToolCtx, args: serde_json::Value) -> Re fn flatten_commit(c: &git::commit::types::CommitMeta) -> serde_json::Value { use chrono::TimeZone; let ts = c.author.time_secs - (c.author.offset_minutes as i64 * 60); - let author_time = chrono::Utc.timestamp_opt(ts, 0).single() - .map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", c.author.time_secs)); + let author_time = chrono::Utc + .timestamp_opt(ts, 0) + .single() + .map(|dt| dt.to_rfc3339()) + .unwrap_or_else(|| format!("{}", c.author.time_secs)); let oid = c.oid.to_string(); serde_json::json!({ "oid": oid.clone(), @@ -124,10 +176,20 @@ fn flatten_commit(c: &git::commit::types::CommitMeta) -> serde_json::Value { }) } -async fn git_commit_info_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")?; +async fn git_commit_info_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 rev = p.get("rev").and_then(|v| v.as_str()).ok_or("missing rev")?; let domain = ctx.open_repo(project_name, repo_name).await?; @@ -136,69 +198,118 @@ async fn git_commit_info_exec(ctx: GitToolCtx, args: serde_json::Value) -> Resul Ok(flatten_commit(&meta)) } -async fn git_graph_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")?; +async fn git_graph_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 rev = p.get("rev").and_then(|v| v.as_str()).map(|s| s.to_string()); let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize; let domain = ctx.open_repo(project_name, repo_name).await?; - let commits = domain.commit_log(rev.as_deref(), 0, limit).map_err(|e| e.to_string())?; + let commits = domain + .commit_log(rev.as_deref(), 0, limit) + .map_err(|e| e.to_string())?; let mut col_map: std::collections::HashMap = std::collections::HashMap::new(); - let lines: Vec<_> = commits.iter().map(|m| { - let lane_index = *col_map.get(m.oid.as_str()).unwrap_or(&0); - let oid = m.oid.to_string(); - let refs = match domain.commit_refs(&m.oid) { - Ok(refs) => refs.iter().map(|r| { - if r.is_tag { format!("tag: {}", r.name.trim_start_matches("refs/tags/")) } - else if r.is_remote { r.name.trim_start_matches("refs/remotes/").to_string() } - else { r.name.trim_start_matches("refs/heads/").to_string() } - }).collect::>().join(", "), - Err(_) => String::new(), - }; - for (i, p) in m.parent_ids.iter().enumerate() { - if i == 0 { col_map.insert(p.to_string(), lane_index); } else { col_map.remove(p.as_str()); } - } - let ts = m.author.time_secs - (m.author.offset_minutes as i64 * 60); - let author_time = chrono::Utc.timestamp_opt(ts, 0).single() - .map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", m.author.time_secs)); + let lines: Vec<_> = commits + .iter() + .map(|m| { + let lane_index = *col_map.get(m.oid.as_str()).unwrap_or(&0); + let oid = m.oid.to_string(); + let refs = match domain.commit_refs(&m.oid) { + Ok(refs) => refs + .iter() + .map(|r| { + if r.is_tag { + format!("tag: {}", r.name.trim_start_matches("refs/tags/")) + } else if r.is_remote { + r.name.trim_start_matches("refs/remotes/").to_string() + } else { + r.name.trim_start_matches("refs/heads/").to_string() + } + }) + .collect::>() + .join(", "), + Err(_) => String::new(), + }; + for (i, p) in m.parent_ids.iter().enumerate() { + if i == 0 { + col_map.insert(p.to_string(), lane_index); + } else { + col_map.remove(p.as_str()); + } + } + let ts = m.author.time_secs - (m.author.offset_minutes as i64 * 60); + let author_time = chrono::Utc + .timestamp_opt(ts, 0) + .single() + .map(|dt| dt.to_rfc3339()) + .unwrap_or_else(|| format!("{}", m.author.time_secs)); - serde_json::json!({ - "oid": oid.clone(), - "short_oid": oid.get(..7).unwrap_or(&oid).to_string(), - "refs": refs, - "short_message": m.summary, - "lane_index": lane_index, - "author_name": m.author.name, - "author_email": m.author.email, - "author_time": author_time, - "parent_oids": m.parent_ids.iter().map(|p| p.to_string()).collect::>() + serde_json::json!({ + "oid": oid.clone(), + "short_oid": oid.get(..7).unwrap_or(&oid).to_string(), + "refs": refs, + "short_message": m.summary, + "lane_index": lane_index, + "author_name": m.author.name, + "author_email": m.author.email, + "author_time": author_time, + "parent_oids": m.parent_ids.iter().map(|p| p.to_string()).collect::>() + }) }) - }).collect(); + .collect(); Ok(serde_json::to_value(lines).map_err(|e| e.to_string())?) } -async fn git_reflog_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 ref_name = p.get("ref_name").and_then(|v| v.as_str()).map(|s| s.to_string()); +async fn git_reflog_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 ref_name = p + .get("ref_name") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize; let domain = ctx.open_repo(project_name, repo_name).await?; - let entries = domain.reflog_entries(ref_name.as_deref()).map_err(|e| e.to_string())?; + let entries = domain + .reflog_entries(ref_name.as_deref()) + .map_err(|e| e.to_string())?; - let result: Vec<_> = entries.iter() + let result: Vec<_> = entries + .iter() .take(limit) .map(|e| { // Convert to UTC by subtracting the timezone offset, consistent // with all other timestamp conversions in this module. let ts = e.time_secs - (e.offset_minutes as i64 * 60); - let time_str = chrono::Utc.timestamp_opt(ts, 0).single() - .map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", e.time_secs)); + let time_str = chrono::Utc + .timestamp_opt(ts, 0) + .single() + .map(|dt| dt.to_rfc3339()) + .unwrap_or_else(|| format!("{}", e.time_secs)); serde_json::json!({ "oid_new": e.oid_new.to_string(), "oid_old": e.oid_old.to_string(), "committer_name": e.committer_name, "committer_email": e.committer_email, @@ -213,155 +324,297 @@ async fn git_reflog_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result HashMap { HashMap::from([ - ("project_name".into(), ToolParam { - name: "project_name".into(), - param_type: "string".into(), - description: Some("Project name (slug)".into()), - required: true, - properties: None, - items: None, - }), - ("repo_name".into(), ToolParam { - name: "repo_name".into(), - param_type: "string".into(), - description: Some("Repository name".into()), - required: true, - properties: None, - items: None, - }), + ( + "project_name".into(), + ToolParam { + name: "project_name".into(), + param_type: "string".into(), + description: Some("Project name (slug)".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "repo_name".into(), + ToolParam { + name: "repo_name".into(), + param_type: "string".into(), + description: Some("Repository name".into()), + required: true, + properties: None, + items: None, + }, + ), ]) } pub fn register_git_tools(registry: &mut ToolRegistry) { // git_log let mut p = common_params(); - p.insert("rev".into(), ToolParam { - name: "rev".into(), param_type: "string".into(), - description: Some("Revision/range specifier (branch name, commit hash, etc.)".into()), - required: false, properties: None, items: None, - }); - p.insert("limit".into(), ToolParam { - name: "limit".into(), param_type: "integer".into(), - description: Some("Maximum number of commits to return".into()), - required: false, properties: None, items: None, - }); - p.insert("skip".into(), ToolParam { - name: "skip".into(), param_type: "integer".into(), - description: Some("Number of commits to skip".into()), - required: false, properties: None, items: None, - }); - let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into()]) }; + p.insert( + "rev".into(), + ToolParam { + name: "rev".into(), + param_type: "string".into(), + description: Some("Revision/range specifier (branch name, commit hash, etc.)".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "limit".into(), + ToolParam { + name: "limit".into(), + param_type: "integer".into(), + description: Some("Maximum number of commits to return".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "skip".into(), + ToolParam { + name: "skip".into(), + param_type: "integer".into(), + description: Some("Number of commits to skip".into()), + required: false, + properties: None, + items: None, + }, + ); + let schema = ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec!["project_name".into(), "repo_name".into()]), + }; registry.register( - ToolDefinition::new("git_log").description("List commits in a repository, optionally filtered by revision range.").parameters(schema), + ToolDefinition::new("git_log") + .description("List commits in a repository, optionally filtered by revision range.") + .parameters(schema), ToolHandler::new(|ctx, args| { let gctx = super::ctx::GitToolCtx::new(ctx); Box::pin(async move { - git_log_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError) + git_log_exec(gctx, args) + .await + .map_err(agent::ToolError::ExecutionError) }) }), ); // git_show let mut p = common_params(); - p.insert("rev".into(), ToolParam { - name: "rev".into(), param_type: "string".into(), - description: Some("Revision to show (commit hash, branch, tag)".into()), - required: true, properties: None, items: None, - }); - let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into(), "rev".into()]) }; + p.insert( + "rev".into(), + ToolParam { + name: "rev".into(), + param_type: "string".into(), + description: Some("Revision to show (commit hash, branch, tag)".into()), + required: true, + properties: None, + items: None, + }, + ); + let schema = ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec![ + "project_name".into(), + "repo_name".into(), + "rev".into(), + ]), + }; registry.register( - ToolDefinition::new("git_show").description("Show detailed commit information including message, author, refs, and diff stats.").parameters(schema), + ToolDefinition::new("git_show") + .description( + "Show detailed commit information including message, author, refs, and diff stats.", + ) + .parameters(schema), ToolHandler::new(|ctx, args| { let gctx = super::ctx::GitToolCtx::new(ctx); Box::pin(async move { - git_show_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError) + git_show_exec(gctx, args) + .await + .map_err(agent::ToolError::ExecutionError) }) }), ); // git_search_commits let mut p = common_params(); - p.insert("query".into(), ToolParam { - name: "query".into(), param_type: "string".into(), - description: Some("Keyword to search in commit messages".into()), - required: true, properties: None, items: None, - }); - p.insert("limit".into(), ToolParam { - name: "limit".into(), param_type: "integer".into(), - description: Some("Maximum results to return".into()), - required: false, properties: None, items: None, - }); - let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into(), "query".into()]) }; + p.insert( + "query".into(), + ToolParam { + name: "query".into(), + param_type: "string".into(), + description: Some("Keyword to search in commit messages".into()), + required: true, + properties: None, + items: None, + }, + ); + p.insert( + "limit".into(), + ToolParam { + name: "limit".into(), + param_type: "integer".into(), + description: Some("Maximum results to return".into()), + required: false, + properties: None, + items: None, + }, + ); + let schema = ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec![ + "project_name".into(), + "repo_name".into(), + "query".into(), + ]), + }; registry.register( - ToolDefinition::new("git_search_commits").description("Search commit messages for a keyword and return matching commits.").parameters(schema), + ToolDefinition::new("git_search_commits") + .description("Search commit messages for a keyword and return matching commits.") + .parameters(schema), ToolHandler::new(|ctx, args| { let gctx = super::ctx::GitToolCtx::new(ctx); Box::pin(async move { - git_search_commits_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError) + git_search_commits_exec(gctx, args) + .await + .map_err(agent::ToolError::ExecutionError) }) }), ); // git_commit_info let mut p = common_params(); - p.insert("rev".into(), ToolParam { - name: "rev".into(), param_type: "string".into(), - description: Some("Revision to look up (full or short commit hash, branch, tag)".into()), - required: true, properties: None, items: None, - }); - let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into(), "rev".into()]) }; + p.insert( + "rev".into(), + ToolParam { + name: "rev".into(), + param_type: "string".into(), + description: Some( + "Revision to look up (full or short commit hash, branch, tag)".into(), + ), + required: true, + properties: None, + items: None, + }, + ); + let schema = ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec![ + "project_name".into(), + "repo_name".into(), + "rev".into(), + ]), + }; registry.register( - ToolDefinition::new("git_commit_info").description("Get detailed metadata for a specific commit (author, committer, parents, tree)." ).parameters(schema), + ToolDefinition::new("git_commit_info") + .description( + "Get detailed metadata for a specific commit (author, committer, parents, tree).", + ) + .parameters(schema), ToolHandler::new(|ctx, args| { let gctx = super::ctx::GitToolCtx::new(ctx); Box::pin(async move { - git_commit_info_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError) + git_commit_info_exec(gctx, args) + .await + .map_err(agent::ToolError::ExecutionError) }) }), ); // git_graph let mut p = common_params(); - p.insert("rev".into(), ToolParam { - name: "rev".into(), param_type: "string".into(), - description: Some("Starting revision (default: HEAD)".into()), - required: false, properties: None, items: None, - }); - p.insert("limit".into(), ToolParam { - name: "limit".into(), param_type: "integer".into(), - description: Some("Maximum number of commits (default: 20)".into()), - required: false, properties: None, items: None, - }); - let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into()]) }; + p.insert( + "rev".into(), + ToolParam { + name: "rev".into(), + param_type: "string".into(), + description: Some("Starting revision (default: HEAD)".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "limit".into(), + ToolParam { + name: "limit".into(), + param_type: "integer".into(), + description: Some("Maximum number of commits (default: 20)".into()), + required: false, + properties: None, + items: None, + }, + ); + let schema = ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec!["project_name".into(), "repo_name".into()]), + }; registry.register( - ToolDefinition::new("git_graph").description("Show an ASCII commit graph with branch lanes and refs.").parameters(schema), + ToolDefinition::new("git_graph") + .description("Show an ASCII commit graph with branch lanes and refs.") + .parameters(schema), ToolHandler::new(|ctx, args| { let gctx = super::ctx::GitToolCtx::new(ctx); Box::pin(async move { - git_graph_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError) + git_graph_exec(gctx, args) + .await + .map_err(agent::ToolError::ExecutionError) }) }), ); // git_reflog let mut p = common_params(); - p.insert("ref_name".into(), ToolParam { - name: "ref_name".into(), param_type: "string".into(), - description: Some("Reference name (e.g. refs/heads/main). Defaults to all refs.".into()), - required: false, properties: None, items: None, - }); - p.insert("limit".into(), ToolParam { - name: "limit".into(), param_type: "integer".into(), - description: Some("Maximum number of entries (default: 50)".into()), - required: false, properties: None, items: None, - }); - let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into()]) }; + p.insert( + "ref_name".into(), + ToolParam { + name: "ref_name".into(), + param_type: "string".into(), + description: Some( + "Reference name (e.g. refs/heads/main). Defaults to all refs.".into(), + ), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "limit".into(), + ToolParam { + name: "limit".into(), + param_type: "integer".into(), + description: Some("Maximum number of entries (default: 50)".into()), + required: false, + properties: None, + items: None, + }, + ); + let schema = ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec!["project_name".into(), "repo_name".into()]), + }; registry.register( - ToolDefinition::new("git_reflog").description("Show the reference log (reflog) recording when branch tips and refs were updated.").parameters(schema), + ToolDefinition::new("git_reflog") + .description( + "Show the reference log (reflog) recording when branch tips and refs were updated.", + ) + .parameters(schema), ToolHandler::new(|ctx, args| { let gctx = super::ctx::GitToolCtx::new(ctx); Box::pin(async move { - git_reflog_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError) + git_reflog_exec(gctx, args) + .await + .map_err(agent::ToolError::ExecutionError) }) }), ); diff --git a/libs/fctool/src/git_tools/ctx.rs b/libs/fctool/src/git_tools/ctx.rs index 9bd126a..c446f64 100644 --- a/libs/fctool/src/git_tools/ctx.rs +++ b/libs/fctool/src/git_tools/ctx.rs @@ -20,7 +20,11 @@ impl GitToolCtx { } /// Opens a git repository by project name and repo name. - pub async fn open_repo(&self, project_name: &str, repo_name: &str) -> Result { + pub async fn open_repo( + &self, + project_name: &str, + repo_name: &str, + ) -> Result { let db = self.ctx.db(); resolve_project_and_repo(db, project_name, repo_name) .await @@ -46,7 +50,12 @@ async fn resolve_project_and_repo( .filter(repo::Column::RepoName.eq(repo_name)) .one(db) .await - .map_err(|e| format!("DB error looking up repo '{}/{}': {}", project_name, repo_name, e))? + .map_err(|e| { + format!( + "DB error looking up repo '{}/{}': {}", + project_name, repo_name, e + ) + })? .ok_or_else(|| format!("repo '{}/{}' not found", project_name, repo_name))?; Ok((project.id, repo_model.storage_path)) diff --git a/libs/fctool/src/git_tools/diff.rs b/libs/fctool/src/git_tools/diff.rs index b5514f9..f29afc6 100644 --- a/libs/fctool/src/git_tools/diff.rs +++ b/libs/fctool/src/git_tools/diff.rs @@ -4,14 +4,32 @@ use super::ctx::GitToolCtx; use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema}; use std::collections::HashMap; -async fn git_diff_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 base = p.get("base").and_then(|v| v.as_str()).map(|s| s.to_string()); - let head = p.get("head").and_then(|v| v.as_str()).map(|s| s.to_string()); +async fn git_diff_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 base = p + .get("base") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let head = p + .get("head") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); let paths = p.get("paths").and_then(|v| v.as_array()).map(|a| { - a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect::>() + a.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect::>() }); let domain = ctx.open_repo(project_name, repo_name).await?; @@ -22,7 +40,10 @@ async fn git_diff_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result Result None, }; - let opts = paths.map(|ps| { - let mut o = git::diff::types::DiffOptions::new(); - for p in ps { o = o.pathspec(&p); } - Some(o) - }).flatten(); + let opts = paths + .map(|ps| { + let mut o = git::diff::types::DiffOptions::new(); + for p in ps { + o = o.pathspec(&p); + } + Some(o) + }) + .flatten(); let result = match (&base_oid, &head_oid) { (None, None) => { @@ -47,25 +72,35 @@ async fn git_diff_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result { if domain.repo().is_bare() { - domain.diff_tree_to_tree(Some(base), None, opts).map_err(|e| e.to_string())? + domain + .diff_tree_to_tree(Some(base), None, opts) + .map_err(|e| e.to_string())? } else { - domain.diff_commit_to_workdir(base, opts).map_err(|e| e.to_string())? + domain + .diff_commit_to_workdir(base, opts) + .map_err(|e| e.to_string())? } } - (Some(base), Some(head_oid_val)) => { - domain.diff_tree_to_tree(Some(base), Some(head_oid_val), opts).map_err(|e| e.to_string())? - } + (Some(base), Some(head_oid_val)) => domain + .diff_tree_to_tree(Some(base), Some(head_oid_val), opts) + .map_err(|e| e.to_string())?, (None, Some(_)) => { return Err("base revision required when head is specified".into()); } @@ -87,12 +122,28 @@ async fn git_diff_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result 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 base = p.get("base").and_then(|v| v.as_str()).ok_or("missing base")?; - let head = p.get("head").and_then(|v| v.as_str()).ok_or("missing head")?; +async fn git_diff_stats_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 base = p + .get("base") + .and_then(|v| v.as_str()) + .ok_or("missing base")?; + let head = p + .get("head") + .and_then(|v| v.as_str()) + .ok_or("missing head")?; let domain = ctx.open_repo(project_name, repo_name).await?; @@ -102,7 +153,10 @@ async fn git_diff_stats_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result } else if let Ok(Some(oid)) = domain.ref_target(rev) { Ok(oid) } else { - domain.commit_get_prefix(rev).map_err(|e| e.to_string()).map(|m| m.oid) + domain + .commit_get_prefix(rev) + .map_err(|e| e.to_string()) + .map(|m| m.oid) } }; let b = resolve(base).map_err(|e| e.to_string())?; @@ -116,13 +170,32 @@ async fn git_diff_stats_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result })) } -async fn git_blame_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 path = p.get("path").and_then(|v| v.as_str()).ok_or("missing path")?; - let rev = p.get("rev").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| "HEAD".to_string()); - let from_line = p.get("from_line").and_then(|v| v.as_u64().map(|n| n as u32)); +async fn git_blame_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 path = p + .get("path") + .and_then(|v| v.as_str()) + .ok_or("missing path")?; + let rev = p + .get("rev") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "HEAD".to_string()); + let from_line = p + .get("from_line") + .and_then(|v| v.as_u64().map(|n| n as u32)); let to_line = p.get("to_line").and_then(|v| v.as_u64().map(|n| n as u32)); let domain = ctx.open_repo(project_name, repo_name).await?; @@ -131,28 +204,40 @@ async fn git_blame_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result = hunks.iter().map(|h| { - let oid = h.commit_oid.to_string(); - serde_json::json!({ - "commit_oid": oid.clone(), - "short_oid": oid.get(..7).unwrap_or(&oid).to_string(), - "final_start_line": h.final_start_line, - "final_lines": h.final_lines, - "orig_start_line": h.orig_start_line, - "orig_path": h.orig_path, - "boundary": h.boundary + let result: Vec<_> = hunks + .iter() + .map(|h| { + let oid = h.commit_oid.to_string(); + serde_json::json!({ + "commit_oid": oid.clone(), + "short_oid": oid.get(..7).unwrap_or(&oid).to_string(), + "final_start_line": h.final_start_line, + "final_lines": h.final_lines, + "orig_start_line": h.orig_start_line, + "orig_path": h.orig_path, + "boundary": h.boundary + }) }) - }).collect(); + .collect(); Ok(serde_json::to_value(result).map_err(|e| e.to_string())?) } @@ -160,13 +245,78 @@ async fn git_blame_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result Result, String> { // ── Tool executors ───────────────────────────────────────────────────────────── /// Tool: repo_doc_index — list all markdown docs with frontmatter -async fn repo_doc_index_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")?; +async fn repo_doc_index_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 domain = ctx.open_repo(project_name, repo_name).await?; let repo = domain.repo(); @@ -79,14 +89,22 @@ async fn repo_doc_index_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result }; match entry.kind() { Some(git2::ObjectType::Tree) => { - if !name.starts_with('.') && !matches!(name, "node_modules" | "target" | ".git" | ".github" | ".next" | "dist") { + if !name.starts_with('.') + && !matches!( + name, + "node_modules" | "target" | ".git" | ".github" | ".next" | "dist" + ) + { if let Ok(subtree) = entry.to_object(repo).and_then(|o| o.peel_to_tree()) { stack.push((subtree, entry_path)); } } } Some(git2::ObjectType::Blob) => { - if name.ends_with(".md") || name.ends_with(".mdx") || name.ends_with(".markdown") { + if name.ends_with(".md") + || name.ends_with(".mdx") + || name.ends_with(".markdown") + { if let Ok(blob) = entry.to_object(repo).and_then(|o| o.peel_to_blob()) { let raw = String::from_utf8_lossy(blob.content()); let (fm_raw, body) = extract_frontmatter(&raw); @@ -122,7 +140,11 @@ async fn repo_doc_index_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result let tags: Vec = metadata .get("tags") .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) .unwrap_or_default(); let headings = extract_headings(body); @@ -145,7 +167,10 @@ async fn repo_doc_index_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result // Sort by path for consistent ordering docs.sort_by(|a, b| { - a["path"].as_str().unwrap_or("").cmp(b["path"].as_str().unwrap_or("")) + a["path"] + .as_str() + .unwrap_or("") + .cmp(b["path"].as_str().unwrap_or("")) }); Ok(serde_json::json!({ @@ -155,20 +180,36 @@ async fn repo_doc_index_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result } /// Tool: repo_doc_read — read a specific document with structure -async fn repo_doc_read_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 path = p.get("path").and_then(|v| v.as_str()).ok_or("missing path")?; +async fn repo_doc_read_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 path = p + .get("path") + .and_then(|v| v.as_str()) + .ok_or("missing path")?; let domain = ctx.open_repo(project_name, repo_name).await?; let repo = domain.repo(); let tree = head_tree(&domain)?; // Navigate to the file using git2 path lookup - let entry = tree.get_path(std::path::Path::new(path)) + let entry = tree + .get_path(std::path::Path::new(path)) .map_err(|e| format!("file '{}' not found: {e}", path))?; - let blob = entry.to_object(repo).and_then(|o| o.peel_to_blob()) + let blob = entry + .to_object(repo) + .and_then(|o| o.peel_to_blob()) .map_err(|e| format!("not a blob: {e}"))?; let raw = String::from_utf8_lossy(blob.content()); @@ -200,11 +241,24 @@ async fn repo_doc_read_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result< } /// Tool: repo_doc_search — search through docs content -async fn repo_doc_search_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 keyword = p.get("keyword").and_then(|v| v.as_str()).ok_or("missing keyword")?; +async fn repo_doc_search_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 keyword = p + .get("keyword") + .and_then(|v| v.as_str()) + .ok_or("missing keyword")?; let context_lines = p.get("context_lines").and_then(|v| v.as_u64()).unwrap_or(2) as usize; let keyword_lower = keyword.to_lowercase(); @@ -230,14 +284,23 @@ async fn repo_doc_search_exec(ctx: GitToolCtx, args: serde_json::Value) -> Resul }; match entry.kind() { Some(git2::ObjectType::Tree) => { - if !name.starts_with('.') && !matches!(name, "node_modules" | "target" | ".git" | ".github" | ".next" | "dist") { + if !name.starts_with('.') + && !matches!( + name, + "node_modules" | "target" | ".git" | ".github" | ".next" | "dist" + ) + { if let Ok(subtree) = entry.to_object(repo).and_then(|o| o.peel_to_tree()) { stack.push((subtree, entry_path)); } } } Some(git2::ObjectType::Blob) => { - if name.ends_with(".md") || name.ends_with(".mdx") || name.ends_with(".markdown") || name.ends_with(".txt") { + if name.ends_with(".md") + || name.ends_with(".mdx") + || name.ends_with(".markdown") + || name.ends_with(".txt") + { if let Ok(blob) = entry.to_object(repo).and_then(|o| o.peel_to_blob()) { let content = String::from_utf8_lossy(blob.content()); let lines: Vec<&str> = content.lines().collect(); @@ -267,10 +330,8 @@ async fn repo_doc_search_exec(ctx: GitToolCtx, args: serde_json::Value) -> Resul } for (start, end) in windows { - let snippet: Vec = lines[start..end] - .iter() - .map(|l| l.to_string()) - .collect(); + let snippet: Vec = + lines[start..end].iter().map(|l| l.to_string()).collect(); file_hits.push(serde_json::json!({ "line_start": start + 1, "line_end": end, diff --git a/libs/fctool/src/git_tools/repo_analysis.rs b/libs/fctool/src/git_tools/repo_analysis.rs index f7fdf32..c931a72 100644 --- a/libs/fctool/src/git_tools/repo_analysis.rs +++ b/libs/fctool/src/git_tools/repo_analysis.rs @@ -77,12 +77,34 @@ fn ext_to_language(ext: &str) -> Option<&'static str> { fn is_ignored_dir(name: &str) -> bool { matches!( name, - ".git" | "node_modules" | "target" | "dist" | "build" | ".next" - | ".nuxt" | ".output" | ".cache" | "__pycache__" | ".tox" - | "vendor" | ".bundle" | ".gradle" | "bin" | "obj" - | ".svn" | ".hg" | ".idea" | ".vscode" | "coverage" - | ".terraform" | ".serverless" | "deps" | "_build" - | "elm-stuff" | ".stack-work" | ".pytest_cache" + ".git" + | "node_modules" + | "target" + | "dist" + | "build" + | ".next" + | ".nuxt" + | ".output" + | ".cache" + | "__pycache__" + | ".tox" + | "vendor" + | ".bundle" + | ".gradle" + | "bin" + | "obj" + | ".svn" + | ".hg" + | ".idea" + | ".vscode" + | "coverage" + | ".terraform" + | ".serverless" + | "deps" + | "_build" + | "elm-stuff" + | ".stack-work" + | ".pytest_cache" ) } @@ -114,9 +136,7 @@ fn collect_languages( match entry.kind() { Some(git2::ObjectType::Tree) => { if !is_ignored_dir(name) && !name.starts_with('.') { - if let Ok(subtree) = - entry.to_object(repo).and_then(|o| o.peel_to_tree()) - { + if let Ok(subtree) = entry.to_object(repo).and_then(|o| o.peel_to_tree()) { stack.push((subtree, entry_path)); } } @@ -126,9 +146,7 @@ fn collect_languages( if let Some(ext) = name.rsplit('.').next() { let ext = ext.to_lowercase(); if let Some(lang) = ext_to_language(&ext) { - let entry = stats - .entry(lang.to_string()) - .or_insert_with(|| (ext, 0)); + let entry = stats.entry(lang.to_string()).or_insert_with(|| (ext, 0)); entry.1 += 1; } } @@ -173,7 +191,15 @@ fn collect_file_tree( "kind": "dir" })); if let Ok(subtree) = entry.to_object(repo).and_then(|o| o.peel_to_tree()) { - collect_file_tree(repo, &subtree, &entry_path, depth + 1, max_depth, max_files, files); + collect_file_tree( + repo, + &subtree, + &entry_path, + depth + 1, + max_depth, + max_files, + files, + ); } } } @@ -192,22 +218,55 @@ fn collect_file_tree( fn detect_config_files(tree: &git2::Tree) -> Vec { let mut configs = Vec::new(); let known_configs = [ - "Cargo.toml", "package.json", "go.mod", "Gemfile", "README.md", - "Dockerfile", "docker-compose.yml", "docker-compose.yaml", - ".github/workflows", ".gitignore", ".gitattributes", - "Makefile", "CMakeLists.txt", "composer.json", "pyproject.toml", - "requirements.txt", "Pipfile", "pom.xml", "build.gradle", - "build.gradle.kts", "settings.gradle", "settings.gradle.kts", - "tsconfig.json", ".eslintrc.js", ".eslintrc.json", - "prettier.config.js", "prettierrc", "webpack.config.js", - "vite.config.ts", "vite.config.js", "next.config.js", - "nuxt.config.ts", "svelte.config.js", - "rust-toolchain", "rust-toolchain.toml", - "clippy.toml", ".rustfmt.toml", "rustfmt.toml", - "renovate.json", ".renovaterc", ".mergify.yml", - "docker-bake.hcl", ".dockerignore", - "Cargo.lock", "yarn.lock", "package-lock.json", "pnpm-lock.yaml", - "Gemfile.lock", "Cargo.lock", + "Cargo.toml", + "package.json", + "go.mod", + "Gemfile", + "README.md", + "Dockerfile", + "docker-compose.yml", + "docker-compose.yaml", + ".github/workflows", + ".gitignore", + ".gitattributes", + "Makefile", + "CMakeLists.txt", + "composer.json", + "pyproject.toml", + "requirements.txt", + "Pipfile", + "pom.xml", + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts", + "tsconfig.json", + ".eslintrc.js", + ".eslintrc.json", + "prettier.config.js", + "prettierrc", + "webpack.config.js", + "vite.config.ts", + "vite.config.js", + "next.config.js", + "nuxt.config.ts", + "svelte.config.js", + "rust-toolchain", + "rust-toolchain.toml", + "clippy.toml", + ".rustfmt.toml", + "rustfmt.toml", + "renovate.json", + ".renovaterc", + ".mergify.yml", + "docker-bake.hcl", + ".dockerignore", + "Cargo.lock", + "yarn.lock", + "package-lock.json", + "pnpm-lock.yaml", + "Gemfile.lock", + "Cargo.lock", ]; for entry in tree.iter() { let name = match entry.name() { @@ -243,7 +302,11 @@ fn parse_dependencies(content: &str, manifest_name: &str) -> serde_json::Value { if in_deps { if let Some(eq_pos) = trimmed.find('=') { let name = trimmed[..eq_pos].trim().to_string(); - let version = trimmed[eq_pos + 1..].trim().trim_matches('"').trim_matches('\'').to_string(); + let version = trimmed[eq_pos + 1..] + .trim() + .trim_matches('"') + .trim_matches('\'') + .to_string(); if !name.is_empty() && !name.starts_with('#') { deps.push(serde_json::json!({ "name": name, "version": version })); } @@ -300,8 +363,17 @@ fn parse_dependencies(content: &str, manifest_name: &str) -> serde_json::Value { let trimmed = line.trim(); if trimmed.starts_with("gem ") { let rest = trimmed.trim_start_matches("gem "); - let name = rest.split(',').next().unwrap_or(rest).trim().trim_matches('"').trim_matches('\''); - let version = rest.split(',').nth(1).map(|v| v.trim().trim_matches('"').trim_matches('\'')); + let name = rest + .split(',') + .next() + .unwrap_or(rest) + .trim() + .trim_matches('"') + .trim_matches('\''); + let version = rest + .split(',') + .nth(1) + .map(|v| v.trim().trim_matches('"').trim_matches('\'')); deps.push(serde_json::json!({ "name": name, "version": version })); } } @@ -311,7 +383,11 @@ fn parse_dependencies(content: &str, manifest_name: &str) -> serde_json::Value { let mut deps = Vec::new(); for line in content.lines() { let trimmed = line.trim(); - if !trimmed.is_empty() && !trimmed.starts_with('#') && !trimmed.starts_with("-r") && !trimmed.starts_with("--") { + if !trimmed.is_empty() + && !trimmed.starts_with('#') + && !trimmed.starts_with("-r") + && !trimmed.starts_with("--") + { if let Some(eq_eq) = trimmed.find("==") { let name = trimmed[..eq_eq].trim().to_string(); let version = trimmed[eq_eq + 2..].trim().to_string(); @@ -323,7 +399,9 @@ fn parse_dependencies(content: &str, manifest_name: &str) -> serde_json::Value { } serde_json::json!({ "manifest": "requirements.txt", "ecosystem": "python", "dependencies": deps }) } - _ => serde_json::json!({ "manifest": manifest_name, "ecosystem": "unknown", "dependencies": [] }), + _ => { + serde_json::json!({ "manifest": manifest_name, "ecosystem": "unknown", "dependencies": [] }) + } } } @@ -346,10 +424,20 @@ fn head_oid(domain: &git::GitDomain) -> Result { } /// Tool: repo_overview — quick project overview -async fn repo_overview_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")?; +async fn repo_overview_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 domain = ctx.open_repo(project_name, repo_name).await?; let repo = domain.repo(); @@ -373,7 +461,9 @@ async fn repo_overview_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result< .map(|(lang, (_ext, count))| serde_json::json!({ "language": lang, "file_count": count })) .collect(); languages.sort_by(|a, b| { - b["file_count"].as_u64().unwrap_or(0) + b["file_count"] + .as_u64() + .unwrap_or(0) .cmp(&a["file_count"].as_u64().unwrap_or(0)) }); @@ -413,10 +503,20 @@ async fn repo_overview_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result< } /// Tool: repo_file_tree — recursive file tree with depth/ignore -async fn repo_file_tree_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")?; +async fn repo_file_tree_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_depth = p.get("max_depth").and_then(|v| v.as_u64()).unwrap_or(3) as usize; let max_files = p.get("max_files").and_then(|v| v.as_u64()).unwrap_or(200); @@ -434,10 +534,20 @@ async fn repo_file_tree_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result } /// Tool: repo_languages — detailed language breakdown -async fn repo_languages_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")?; +async fn repo_languages_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 domain = ctx.open_repo(project_name, repo_name).await?; let repo = domain.repo(); @@ -451,7 +561,9 @@ async fn repo_languages_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result .map(|(lang, (_ext, count))| serde_json::json!({ "language": lang, "file_count": count })) .collect(); languages.sort_by(|a, b| { - b["file_count"].as_u64().unwrap_or(0) + b["file_count"] + .as_u64() + .unwrap_or(0) .cmp(&a["file_count"].as_u64().unwrap_or(0)) }); @@ -462,10 +574,20 @@ async fn repo_languages_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result } /// Tool: repo_dependencies — parse dependency manifests -async fn repo_dependencies_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")?; +async fn repo_dependencies_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 domain = ctx.open_repo(project_name, repo_name).await?; let tree = head_tree(&domain)?; diff --git a/libs/fctool/src/git_tools/repo_util.rs b/libs/fctool/src/git_tools/repo_util.rs index 512b689..f4b8b9d 100644 --- a/libs/fctool/src/git_tools/repo_util.rs +++ b/libs/fctool/src/git_tools/repo_util.rs @@ -25,42 +25,77 @@ fn head_oid(domain: &git::GitDomain) -> Result { fn is_ignored_dir(name: &str) -> bool { matches!( name, - ".git" | "node_modules" | "target" | "dist" | "build" | ".next" - | ".nuxt" | ".output" | ".cache" | "__pycache__" | ".tox" - | "vendor" | ".bundle" | ".gradle" | "bin" | "obj" - | ".svn" | ".hg" | ".idea" | ".vscode" | "coverage" - | ".terraform" | ".serverless" | "deps" | "_build" - | "elm-stuff" | ".stack-work" | ".pytest_cache" + ".git" + | "node_modules" + | "target" + | "dist" + | "build" + | ".next" + | ".nuxt" + | ".output" + | ".cache" + | "__pycache__" + | ".tox" + | "vendor" + | ".bundle" + | ".gradle" + | "bin" + | "obj" + | ".svn" + | ".hg" + | ".idea" + | ".vscode" + | "coverage" + | ".terraform" + | ".serverless" + | "deps" + | "_build" + | "elm-stuff" + | ".stack-work" + | ".pytest_cache" ) } fn is_binary_ext(name: &str) -> bool { match name.rsplit('.').next().unwrap_or("") { - "png" | "jpg" | "jpeg" | "gif" | "webp" | "ico" | "svg" | "bmp" - | "mp3" | "mp4" | "wav" | "avi" | "mov" | "mkv" | "webm" - | "zip" | "tar" | "gz" | "bz2" | "xz" | "7z" | "rar" - | "exe" | "dll" | "so" | "dylib" | "o" | "a" | "lib" - | "woff" | "woff2" | "ttf" | "otf" | "eot" - | "pdf" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx" - | "sqlite" | "db" | "bin" | "dat" | "pyc" | "class" - | "wasm" | "node" => true, + "png" | "jpg" | "jpeg" | "gif" | "webp" | "ico" | "svg" | "bmp" | "mp3" | "mp4" | "wav" + | "avi" | "mov" | "mkv" | "webm" | "zip" | "tar" | "gz" | "bz2" | "xz" | "7z" | "rar" + | "exe" | "dll" | "so" | "dylib" | "o" | "a" | "lib" | "woff" | "woff2" | "ttf" | "otf" + | "eot" | "pdf" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx" | "sqlite" | "db" + | "bin" | "dat" | "pyc" | "class" | "wasm" | "node" => true, _ => false, } } /// Resolve a rev string to a commit OID. -fn resolve_commit_oid(domain: &git::GitDomain, rev: &str) -> Result { +fn resolve_commit_oid( + domain: &git::GitDomain, + rev: &str, +) -> Result { domain.resolve_rev(rev).map_err(|e| e.to_string()) } // ── Tool executors ───────────────────────────────────────────────────────────── /// Tool: repo_search — search code content across the repo -async fn repo_search_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 keyword = p.get("keyword").and_then(|v| v.as_str()).ok_or("missing keyword")?; +async fn repo_search_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 keyword = p + .get("keyword") + .and_then(|v| v.as_str()) + .ok_or("missing keyword")?; let context_lines = p.get("context_lines").and_then(|v| v.as_u64()).unwrap_or(2) as usize; let max_results = p.get("max_results").and_then(|v| v.as_u64()).unwrap_or(50) as usize; @@ -153,10 +188,8 @@ async fn repo_search_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result = windows .iter() .map(|(start, end)| { - let snippet: Vec = lines[*start..*end] - .iter() - .map(|l| l.to_string()) - .collect(); + let snippet: Vec = + lines[*start..*end].iter().map(|l| l.to_string()).collect(); serde_json::json!({ "line_start": start + 1, "line_end": end, @@ -187,17 +220,34 @@ async fn repo_search_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result 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")?; +async fn repo_readme_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 domain = ctx.open_repo(project_name, repo_name).await?; let repo = domain.repo(); let tree = head_tree(&domain)?; // Try common README filenames - let candidates = ["README.md", "README.MD", "README.markdown", "README.rst", "README.txt", "README"]; + let candidates = [ + "README.md", + "README.MD", + "README.markdown", + "README.rst", + "README.txt", + "README", + ]; let mut found = None; for candidate in &candidates { @@ -225,10 +275,20 @@ async fn repo_readme_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result 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")?; +async fn repo_commit_log_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 author = p.get("author").and_then(|v| v.as_str()); let keyword = p.get("keyword").and_then(|v| v.as_str()); let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize; @@ -243,7 +303,9 @@ async fn repo_commit_log_exec(ctx: GitToolCtx, args: serde_json::Value) -> Resul limit }; - let commits = domain.commit_log(Some(&head_oid), 0, fetch_limit).map_err(|e| e.to_string())?; + let commits = domain + .commit_log(Some(&head_oid), 0, fetch_limit) + .map_err(|e| e.to_string())?; let keyword_lower = keyword.map(|k| k.to_lowercase()); let author_lower = author.map(|a| a.to_lowercase()); @@ -284,17 +346,29 @@ async fn repo_commit_log_exec(ctx: GitToolCtx, args: serde_json::Value) -> Resul } /// Tool: repo_contributors — contributor statistics -async fn repo_contributors_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")?; +async fn repo_contributors_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 limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(0) as usize; let domain = ctx.open_repo(project_name, repo_name).await?; let head_oid = head_oid(&domain)?; // Walk all commits (up to 10000) - let commits = domain.commit_log(Some(&head_oid), 0, 10000).map_err(|e| e.to_string())?; + let commits = domain + .commit_log(Some(&head_oid), 0, 10000) + .map_err(|e| e.to_string())?; // Aggregate by author email (more reliable than name) let mut authors: HashMap = HashMap::new(); @@ -309,9 +383,7 @@ async fn repo_contributors_exec(ctx: GitToolCtx, args: serde_json::Value) -> Res "last_commit_time": c.author.time_secs, }) }); - entry["commit_count"] = serde_json::json!( - entry["commit_count"].as_u64().unwrap_or(0) + 1 - ); + entry["commit_count"] = serde_json::json!(entry["commit_count"].as_u64().unwrap_or(0) + 1); let t = c.author.time_secs; if t < entry["first_commit_time"].as_i64().unwrap_or(i64::MAX) { entry["first_commit_time"] = serde_json::json!(t); @@ -323,7 +395,9 @@ async fn repo_contributors_exec(ctx: GitToolCtx, args: serde_json::Value) -> Res let mut contributors: Vec = authors.into_values().collect(); contributors.sort_by(|a, b| { - b["commit_count"].as_u64().unwrap_or(0) + b["commit_count"] + .as_u64() + .unwrap_or(0) .cmp(&a["commit_count"].as_u64().unwrap_or(0)) }); @@ -338,11 +412,24 @@ async fn repo_contributors_exec(ctx: GitToolCtx, args: serde_json::Value) -> Res } /// Tool: repo_diff_summary — change summary between two revisions -async fn repo_diff_summary_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 from_rev = p.get("from_rev").and_then(|v| v.as_str()).ok_or("missing from_rev")?; +async fn repo_diff_summary_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 from_rev = p + .get("from_rev") + .and_then(|v| v.as_str()) + .ok_or("missing from_rev")?; let to_rev = p.get("to_rev").and_then(|v| v.as_str()).unwrap_or("HEAD"); let domain = ctx.open_repo(project_name, repo_name).await?; @@ -351,15 +438,18 @@ async fn repo_diff_summary_exec(ctx: GitToolCtx, args: serde_json::Value) -> Res let from_oid = resolve_commit_oid(&domain, from_rev)?; let to_oid = resolve_commit_oid(&domain, to_rev)?; - let from_commit = repo.find_commit(from_oid.to_oid().map_err(|e| e.to_string())?) + let from_commit = repo + .find_commit(from_oid.to_oid().map_err(|e| e.to_string())?) .map_err(|e| format!("from_rev not found: {e}"))?; - let to_commit = repo.find_commit(to_oid.to_oid().map_err(|e| e.to_string())?) + let to_commit = repo + .find_commit(to_oid.to_oid().map_err(|e| e.to_string())?) .map_err(|e| format!("to_rev not found: {e}"))?; let from_tree = from_commit.tree().map_err(|e| e.to_string())?; let to_tree = to_commit.tree().map_err(|e| e.to_string())?; - let diff = repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None) + let diff = repo + .diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None) .map_err(|e| e.to_string())?; let stats = diff.stats().map_err(|e| e.to_string())?; diff --git a/libs/fctool/src/git_tools/tag.rs b/libs/fctool/src/git_tools/tag.rs index a7b239b..8c17222 100644 --- a/libs/fctool/src/git_tools/tag.rs +++ b/libs/fctool/src/git_tools/tag.rs @@ -5,11 +5,24 @@ use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use std::collections::HashMap; -async fn git_tag_list_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 pattern = p.get("pattern").and_then(|v| v.as_str()).map(|s| s.to_string()); +async fn git_tag_list_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 pattern = p + .get("pattern") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); let domain = ctx.open_repo(project_name, repo_name).await?; let all_tags = domain.tag_list().map_err(|e| e.to_string())?; @@ -24,9 +37,9 @@ async fn git_tag_list_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result>() .join(".*"); - let re = regex::Regex::new(&format!("^{}$", regex_pat)) - .ok(); - all_tags.iter() + let re = regex::Regex::new(&format!("^{}$", regex_pat)).ok(); + all_tags + .iter() .filter(|t| { let n = t.name.to_lowercase(); re.as_ref().map(|r| r.is_match(&n)).unwrap_or(false) @@ -52,11 +65,24 @@ fn tag_to_json(t: &git::tags::types::TagInfo) -> serde_json::Value { }) } -async fn git_tag_info_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 name = p.get("name").and_then(|v| v.as_str()).ok_or("missing name")?; +async fn git_tag_info_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 name = p + .get("name") + .and_then(|v| v.as_str()) + .ok_or("missing name")?; let domain = ctx.open_repo(project_name, repo_name).await?; let info = domain.tag_get(name).map_err(|e| e.to_string())?; @@ -64,10 +90,20 @@ async fn git_tag_info_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result 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 query = p.get("query").and_then(|v| v.as_str()).ok_or("missing query")?; +async fn git_tag_search_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 query = p + .get("query") + .and_then(|v| v.as_str()) + .ok_or("missing query")?; let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(5) as usize; // Resolve project_id from project_name for project isolation @@ -81,35 +117,50 @@ async fn git_tag_search_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result let project_id = project.id.to_string(); // Get embed_service from context - let embed = ctx.ctx.embed_service() - .ok_or_else(|| "EmbedService not available — Qdrant vector search is disabled".to_string())?; + let embed = ctx.ctx.embed_service().ok_or_else(|| { + "EmbedService not available — Qdrant vector search is disabled".to_string() + })?; - let results = embed.search_tags(query, &project_id, limit).await + let results = embed + .search_tags(query, &project_id, limit) + .await .map_err(|e| format!("tag search failed: {}", e))?; - let json_results: Vec = results.into_iter().map(|r| { - let repo_name = r.payload.extra.as_ref() - .and_then(|e| e.get("repo_name")) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let tag_name = r.payload.extra.as_ref() - .and_then(|e| e.get("tag_name")) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let description = r.payload.extra.as_ref() - .and_then(|e| e.get("description")) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - serde_json::json!({ - "tag_name": tag_name, - "repo_name": repo_name, - "description": description, - "score": r.score, + let json_results: Vec = results + .into_iter() + .map(|r| { + let repo_name = r + .payload + .extra + .as_ref() + .and_then(|e| e.get("repo_name")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let tag_name = r + .payload + .extra + .as_ref() + .and_then(|e| e.get("tag_name")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let description = r + .payload + .extra + .as_ref() + .and_then(|e| e.get("description")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + serde_json::json!({ + "tag_name": tag_name, + "repo_name": repo_name, + "description": description, + "score": r.score, + }) }) - }).collect(); + .collect(); Ok(serde_json::to_value(json_results).map_err(|e| e.to_string())?) } @@ -117,28 +168,104 @@ async fn git_tag_search_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result pub fn register_git_tools(registry: &mut ToolRegistry) { // git_tag_list let p = HashMap::from([ - ("project_name".into(), ToolParam { name: "project_name".into(), param_type: "string".into(), description: Some("Project name (slug)".into()), required: true, properties: None, items: None }), - ("repo_name".into(), ToolParam { name: "repo_name".into(), param_type: "string".into(), description: Some("Repository name".into()), required: true, properties: None, items: None }), - ("pattern".into(), ToolParam { name: "pattern".into(), param_type: "string".into(), description: Some("Filter tags by name pattern (supports * wildcard)".into()), required: false, properties: None, items: None }), + ( + "project_name".into(), + ToolParam { + name: "project_name".into(), + param_type: "string".into(), + description: Some("Project name (slug)".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "repo_name".into(), + ToolParam { + name: "repo_name".into(), + param_type: "string".into(), + description: Some("Repository name".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "pattern".into(), + ToolParam { + name: "pattern".into(), + param_type: "string".into(), + description: Some("Filter tags by name pattern (supports * wildcard)".into()), + required: false, + properties: None, + items: None, + }, + ), ]); - let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into()]) }; + let schema = ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec!["project_name".into(), "repo_name".into()]), + }; registry.register( - ToolDefinition::new("git_tag_list").description("List all tags in a repository, optionally filtered by a name pattern.").parameters(schema), + ToolDefinition::new("git_tag_list") + .description("List all tags in a repository, optionally filtered by a name pattern.") + .parameters(schema), ToolHandler::new(|ctx, args| { let gctx = super::ctx::GitToolCtx::new(ctx); Box::pin(async move { - git_tag_list_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError) + git_tag_list_exec(gctx, args) + .await + .map_err(agent::ToolError::ExecutionError) }) }), ); // git_tag_info let p = HashMap::from([ - ("project_name".into(), ToolParam { name: "project_name".into(), param_type: "string".into(), description: Some("Project name (slug)".into()), required: true, properties: None, items: None }), - ("repo_name".into(), ToolParam { name: "repo_name".into(), param_type: "string".into(), description: Some("Repository name".into()), required: true, properties: None, items: None }), - ("name".into(), ToolParam { name: "name".into(), param_type: "string".into(), description: Some("Tag name to look up".into()), required: true, properties: None, items: None }), + ( + "project_name".into(), + ToolParam { + name: "project_name".into(), + param_type: "string".into(), + description: Some("Project name (slug)".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "repo_name".into(), + ToolParam { + name: "repo_name".into(), + param_type: "string".into(), + description: Some("Repository name".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "name".into(), + ToolParam { + name: "name".into(), + param_type: "string".into(), + description: Some("Tag name to look up".into()), + required: true, + properties: None, + items: None, + }, + ), ]); - let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into(), "name".into()]) }; + let schema = ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec![ + "project_name".into(), + "repo_name".into(), + "name".into(), + ]), + }; registry.register( ToolDefinition::new("git_tag_info").description("Get detailed information about a specific tag including target commit, annotation, and tagger.").parameters(schema), ToolHandler::new(|ctx, args| { @@ -151,11 +278,45 @@ pub fn register_git_tools(registry: &mut ToolRegistry) { // git_tag_search let p = HashMap::from([ - ("project_name".into(), ToolParam { name: "project_name".into(), param_type: "string".into(), description: Some("Project name (slug)".into()), required: true, properties: None, items: None }), - ("query".into(), ToolParam { name: "query".into(), param_type: "string".into(), description: Some("Semantic search query to find relevant tags".into()), required: true, properties: None, items: None }), - ("limit".into(), ToolParam { name: "limit".into(), param_type: "integer".into(), description: Some("Maximum number of results (default 5)".into()), required: false, properties: None, items: None }), + ( + "project_name".into(), + ToolParam { + name: "project_name".into(), + param_type: "string".into(), + description: Some("Project name (slug)".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "query".into(), + ToolParam { + name: "query".into(), + param_type: "string".into(), + description: Some("Semantic search query to find relevant tags".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "limit".into(), + ToolParam { + name: "limit".into(), + param_type: "integer".into(), + description: Some("Maximum number of results (default 5)".into()), + required: false, + properties: None, + items: None, + }, + ), ]); - let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "query".into()]) }; + let schema = ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec!["project_name".into(), "query".into()]), + }; registry.register( ToolDefinition::new("git_tag_search").description("Semantically search tags across repositories in a project. Uses vector search to find tags relevant to the query, with project-level isolation.").parameters(schema), ToolHandler::new(|ctx, args| { diff --git a/libs/fctool/src/git_tools/tree.rs b/libs/fctool/src/git_tools/tree.rs index 0f94e58..115d899 100644 --- a/libs/fctool/src/git_tools/tree.rs +++ b/libs/fctool/src/git_tools/tree.rs @@ -7,26 +7,51 @@ use std::collections::HashMap; /// Resolve a rev string to a commit OID using the full rev-parse machinery /// (branch names, tags, HEAD, hex prefixes, etc.). -fn resolve_commit_oid(domain: &git::GitDomain, rev: &str) -> Result { +fn resolve_commit_oid( + domain: &git::GitDomain, + rev: &str, +) -> Result { domain.resolve_rev(rev).map_err(|e| e.to_string()) } -async fn git_file_content_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 path = p.get("path").and_then(|v| v.as_str()).ok_or("missing path")?; - let rev = p.get("rev").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| "HEAD".to_string()); +async fn git_file_content_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 path = p + .get("path") + .and_then(|v| v.as_str()) + .ok_or("missing path")?; + let rev = p + .get("rev") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "HEAD".to_string()); let domain = ctx.open_repo(project_name, repo_name).await?; let oid = resolve_commit_oid(&domain, &rev)?; - let entry = domain.tree_entry_by_path_from_commit(&oid, path).map_err(|e| e.to_string())?; + let entry = domain + .tree_entry_by_path_from_commit(&oid, path) + .map_err(|e| e.to_string())?; let blob_info = domain.blob_get(&entry.oid).map_err(|e| e.to_string())?; let content = domain.blob_content(&entry.oid).map_err(|e| e.to_string())?; let (display_content, is_binary) = if blob_info.is_binary { - (base64::engine::general_purpose::STANDARD.encode(&content.content), true) + ( + base64::engine::general_purpose::STANDARD.encode(&content.content), + true, + ) } else { (String::from_utf8_lossy(&content.content).to_string(), false) }; @@ -40,12 +65,29 @@ async fn git_file_content_exec(ctx: GitToolCtx, args: serde_json::Value) -> Resu })) } -async fn git_tree_ls_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 dir_path = p.get("path").and_then(|v| v.as_str()).map(|s| s.to_string()); - let rev = p.get("rev").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| "HEAD".to_string()); +async fn git_tree_ls_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 dir_path = p + .get("path") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let rev = p + .get("rev") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "HEAD".to_string()); let domain = ctx.open_repo(project_name, repo_name).await?; let commit_oid = resolve_commit_oid(&domain, &rev)?; @@ -56,38 +98,63 @@ async fn git_tree_ls_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result { - let entry = domain.tree_entry_by_path(tree_oid, dp).map_err(|e| e.to_string())?; + let entry = domain + .tree_entry_by_path(tree_oid, dp) + .map_err(|e| e.to_string())?; domain.tree_list(&entry.oid).map_err(|e| e.to_string())? } None => domain.tree_list(tree_oid).map_err(|e| e.to_string())?, }; - let result: Vec<_> = entries.iter().map(|e| { - serde_json::json!({ - "name": e.name, - "oid": e.oid.to_string(), - "kind": e.kind, - "is_binary": e.is_binary + let result: Vec<_> = entries + .iter() + .map(|e| { + serde_json::json!({ + "name": e.name, + "oid": e.oid.to_string(), + "kind": e.kind, + "is_binary": e.is_binary + }) }) - }).collect(); + .collect(); Ok(serde_json::to_value(result).map_err(|e| e.to_string())?) } -async fn git_file_history_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 path = p.get("path").and_then(|v| v.as_str()).ok_or("missing path")?; - let rev = p.get("rev").and_then(|v| v.as_str()).map(String::from).unwrap_or_else(|| "HEAD".to_string()); +async fn git_file_history_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 path = p + .get("path") + .and_then(|v| v.as_str()) + .ok_or("missing path")?; + let rev = p + .get("rev") + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| "HEAD".to_string()); let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize; let domain = ctx.open_repo(project_name, repo_name).await?; // Fetch extra commits to have enough candidates after filtering let walk_limit = limit.saturating_mul(2).max(200); - let commits = domain.commit_log(Some(&rev), 0, walk_limit).map_err(|e| e.to_string())?; + let commits = domain + .commit_log(Some(&rev), 0, walk_limit) + .map_err(|e| e.to_string())?; - let result: Vec<_> = commits.iter() + let result: Vec<_> = commits + .iter() .filter(|c| domain.tree_entry_by_path(&c.tree_id, path).is_ok()) .take(limit) .map(|c| flatten_commit(c)) @@ -96,17 +163,36 @@ async fn git_file_history_exec(ctx: GitToolCtx, args: serde_json::Value) -> Resu Ok(serde_json::to_value(result).map_err(|e| e.to_string())?) } -async fn git_blob_get_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 path = p.get("path").and_then(|v| v.as_str()).ok_or("missing path")?; - let rev = p.get("rev").and_then(|v| v.as_str()).map(String::from).unwrap_or_else(|| "HEAD".to_string()); +async fn git_blob_get_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 path = p + .get("path") + .and_then(|v| v.as_str()) + .ok_or("missing path")?; + let rev = p + .get("rev") + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| "HEAD".to_string()); let domain = ctx.open_repo(project_name, repo_name).await?; let oid = resolve_commit_oid(&domain, &rev).map_err(|e| e.to_string())?; - let entry = domain.tree_entry_by_path_from_commit(&oid, path).map_err(|e| e.to_string())?; + let entry = domain + .tree_entry_by_path_from_commit(&oid, path) + .map_err(|e| e.to_string())?; let blob_info = domain.blob_get(&entry.oid).map_err(|e| e.to_string())?; if blob_info.is_binary { @@ -127,8 +213,11 @@ async fn git_blob_get_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result serde_json::Value { use chrono::TimeZone; let ts = c.author.time_secs - (c.author.offset_minutes as i64 * 60); - let author_time = chrono::Utc.timestamp_opt(ts, 0).single() - .map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", c.author.time_secs)); + let author_time = chrono::Utc + .timestamp_opt(ts, 0) + .single() + .map(|dt| dt.to_rfc3339()) + .unwrap_or_else(|| format!("{}", c.author.time_secs)); let oid = c.oid.to_string(); serde_json::json!({ "oid": oid.clone(), @@ -144,12 +233,60 @@ fn flatten_commit(c: &git::commit::types::CommitMeta) -> serde_json::Value { pub fn register_git_tools(registry: &mut ToolRegistry) { // git_file_content let p = HashMap::from([ - ("project_name".into(), ToolParam { name: "project_name".into(), param_type: "string".into(), description: Some("Project name (slug)".into()), required: true, properties: None, items: None }), - ("repo_name".into(), ToolParam { name: "repo_name".into(), param_type: "string".into(), description: Some("Repository name".into()), required: true, properties: None, items: None }), - ("path".into(), ToolParam { name: "path".into(), param_type: "string".into(), description: Some("File path within the repository".into()), required: true, properties: None, items: None }), - ("rev".into(), ToolParam { name: "rev".into(), param_type: "string".into(), description: Some("Revision to read file from (default: HEAD)".into()), required: false, properties: None, items: None }), + ( + "project_name".into(), + ToolParam { + name: "project_name".into(), + param_type: "string".into(), + description: Some("Project name (slug)".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "repo_name".into(), + ToolParam { + name: "repo_name".into(), + param_type: "string".into(), + description: Some("Repository name".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "path".into(), + ToolParam { + name: "path".into(), + param_type: "string".into(), + description: Some("File path within the repository".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "rev".into(), + ToolParam { + name: "rev".into(), + param_type: "string".into(), + description: Some("Revision to read file from (default: HEAD)".into()), + required: false, + properties: None, + items: None, + }, + ), ]); - let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into(), "path".into()]) }; + let schema = ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec![ + "project_name".into(), + "repo_name".into(), + "path".into(), + ]), + }; registry.register( ToolDefinition::new("git_file_content").description("Read the full content of a file at a given revision. Handles both text and binary files.").parameters(schema), ToolHandler::new(|ctx, args| { @@ -162,12 +299,56 @@ pub fn register_git_tools(registry: &mut ToolRegistry) { // git_tree_ls let p = HashMap::from([ - ("project_name".into(), ToolParam { name: "project_name".into(), param_type: "string".into(), description: Some("Project name (slug)".into()), required: true, properties: None, items: None }), - ("repo_name".into(), ToolParam { name: "repo_name".into(), param_type: "string".into(), description: Some("Repository name".into()), required: true, properties: None, items: None }), - ("path".into(), ToolParam { name: "path".into(), param_type: "string".into(), description: Some("Directory path to list (root if omitted)".into()), required: false, properties: None, items: None }), - ("rev".into(), ToolParam { name: "rev".into(), param_type: "string".into(), description: Some("Revision to list tree from (default: HEAD)".into()), required: false, properties: None, items: None }), + ( + "project_name".into(), + ToolParam { + name: "project_name".into(), + param_type: "string".into(), + description: Some("Project name (slug)".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "repo_name".into(), + ToolParam { + name: "repo_name".into(), + param_type: "string".into(), + description: Some("Repository name".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "path".into(), + ToolParam { + name: "path".into(), + param_type: "string".into(), + description: Some("Directory path to list (root if omitted)".into()), + required: false, + properties: None, + items: None, + }, + ), + ( + "rev".into(), + ToolParam { + name: "rev".into(), + param_type: "string".into(), + description: Some("Revision to list tree from (default: HEAD)".into()), + required: false, + properties: None, + items: None, + }, + ), ]); - let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into()]) }; + let schema = ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec!["project_name".into(), "repo_name".into()]), + }; registry.register( ToolDefinition::new("git_tree_ls").description("List the contents of a directory (tree) at a given revision, showing files and subdirectories.").parameters(schema), ToolHandler::new(|ctx, args| { @@ -180,13 +361,71 @@ pub fn register_git_tools(registry: &mut ToolRegistry) { // git_file_history let p = HashMap::from([ - ("project_name".into(), ToolParam { name: "project_name".into(), param_type: "string".into(), description: Some("Project name (slug)".into()), required: true, properties: None, items: None }), - ("repo_name".into(), ToolParam { name: "repo_name".into(), param_type: "string".into(), description: Some("Repository name".into()), required: true, properties: None, items: None }), - ("path".into(), ToolParam { name: "path".into(), param_type: "string".into(), description: Some("File path to trace history for".into()), required: true, properties: None, items: None }), - ("rev".into(), ToolParam { name: "rev".into(), param_type: "string".into(), description: Some("Revision to start history from (default: HEAD)".into()), required: false, properties: None, items: None }), - ("limit".into(), ToolParam { name: "limit".into(), param_type: "integer".into(), description: Some("Maximum number of commits to return (default: 20)".into()), required: false, properties: None, items: None }), + ( + "project_name".into(), + ToolParam { + name: "project_name".into(), + param_type: "string".into(), + description: Some("Project name (slug)".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "repo_name".into(), + ToolParam { + name: "repo_name".into(), + param_type: "string".into(), + description: Some("Repository name".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "path".into(), + ToolParam { + name: "path".into(), + param_type: "string".into(), + description: Some("File path to trace history for".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "rev".into(), + ToolParam { + name: "rev".into(), + param_type: "string".into(), + description: Some("Revision to start history from (default: HEAD)".into()), + required: false, + properties: None, + items: None, + }, + ), + ( + "limit".into(), + ToolParam { + name: "limit".into(), + param_type: "integer".into(), + description: Some("Maximum number of commits to return (default: 20)".into()), + required: false, + properties: None, + items: None, + }, + ), ]); - let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into(), "path".into()]) }; + let schema = ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec![ + "project_name".into(), + "repo_name".into(), + "path".into(), + ]), + }; registry.register( ToolDefinition::new("git_file_history").description("Show the commit history for a specific file, listing all commits that modified it.").parameters(schema), ToolHandler::new(|ctx, args| { @@ -199,12 +438,60 @@ pub fn register_git_tools(registry: &mut ToolRegistry) { // git_blob_get let p = HashMap::from([ - ("project_name".into(), ToolParam { name: "project_name".into(), param_type: "string".into(), description: Some("Project name (slug)".into()), required: true, properties: None, items: None }), - ("repo_name".into(), ToolParam { name: "repo_name".into(), param_type: "string".into(), description: Some("Repository name".into()), required: true, properties: None, items: None }), - ("path".into(), ToolParam { name: "path".into(), param_type: "string".into(), description: Some("File path within the repository".into()), required: true, properties: None, items: None }), - ("rev".into(), ToolParam { name: "rev".into(), param_type: "string".into(), description: Some("Revision to read file from (default: HEAD)".into()), required: false, properties: None, items: None }), + ( + "project_name".into(), + ToolParam { + name: "project_name".into(), + param_type: "string".into(), + description: Some("Project name (slug)".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "repo_name".into(), + ToolParam { + name: "repo_name".into(), + param_type: "string".into(), + description: Some("Repository name".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "path".into(), + ToolParam { + name: "path".into(), + param_type: "string".into(), + description: Some("File path within the repository".into()), + required: true, + properties: None, + items: None, + }, + ), + ( + "rev".into(), + ToolParam { + name: "rev".into(), + param_type: "string".into(), + description: Some("Revision to read file from (default: HEAD)".into()), + required: false, + properties: None, + items: None, + }, + ), ]); - let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into(), "path".into()]) }; + let schema = ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec![ + "project_name".into(), + "repo_name".into(), + "path".into(), + ]), + }; registry.register( ToolDefinition::new("git_blob_get").description("Retrieve the raw content of a single file (blob) at a given revision. Returns error if the file is binary.").parameters(schema), ToolHandler::new(|ctx, args| { @@ -214,4 +501,4 @@ pub fn register_git_tools(registry: &mut ToolRegistry) { }) }), ); -} \ No newline at end of file +} diff --git a/libs/fctool/src/git_tools/types.rs b/libs/fctool/src/git_tools/types.rs index c9805b5..d470086 100644 --- a/libs/fctool/src/git_tools/types.rs +++ b/libs/fctool/src/git_tools/types.rs @@ -11,54 +11,127 @@ use git::tree::types::TreeEntry; use serde::{Deserialize, Serialize}; #[derive(serde::Deserialize)] -pub struct RevQuery { pub rev: String } +pub struct RevQuery { + pub rev: String, +} #[derive(serde::Deserialize)] -pub struct SearchCommits { pub query: String, #[serde(default = "dl")] pub limit: u32 } -fn dl() -> u32 { 20 } +pub struct SearchCommits { + pub query: String, + #[serde(default = "dl")] + pub limit: u32, +} +fn dl() -> u32 { + 20 +} #[derive(serde::Deserialize)] -pub struct GraphParams { #[serde(default)] pub rev: Option, #[serde(default = "dl")] pub limit: u32 } +pub struct GraphParams { + #[serde(default)] + pub rev: Option, + #[serde(default = "dl")] + pub limit: u32, +} #[derive(serde::Deserialize)] -pub struct ReflogParams { #[serde(default)] pub ref_name: Option, #[serde(default = "reflog_def")] pub limit: u32 } -fn reflog_def() -> u32 { 50 } +pub struct ReflogParams { + #[serde(default)] + pub ref_name: Option, + #[serde(default = "reflog_def")] + pub limit: u32, +} +fn reflog_def() -> u32 { + 50 +} #[derive(serde::Deserialize)] -pub struct SingleBranch { pub name: String } +pub struct SingleBranch { + pub name: String, +} #[derive(serde::Deserialize)] -pub struct BranchesMerged { pub branch: String, #[serde(default)] pub into: Option } +pub struct BranchesMerged { + pub branch: String, + #[serde(default)] + pub into: Option, +} #[derive(serde::Deserialize)] -pub struct BranchDiffP { pub local: String, #[serde(default)] pub remote: Option } +pub struct BranchDiffP { + pub local: String, + #[serde(default)] + pub remote: Option, +} #[derive(serde::Deserialize)] -pub struct DiffP { #[serde(default)] pub base: Option, #[serde(default)] pub head: Option, #[serde(default)] pub paths: Option> } +pub struct DiffP { + #[serde(default)] + pub base: Option, + #[serde(default)] + pub head: Option, + #[serde(default)] + pub paths: Option>, +} #[derive(serde::Deserialize)] -pub struct DiffStatsP { pub base: String, pub head: String } +pub struct DiffStatsP { + pub base: String, + pub head: String, +} #[derive(serde::Deserialize)] -pub struct BlameP { pub path: String, #[serde(default)] pub rev: Option, #[serde(default)] pub from_line: Option, #[serde(default)] pub to_line: Option } +pub struct BlameP { + pub path: String, + #[serde(default)] + pub rev: Option, + #[serde(default)] + pub from_line: Option, + #[serde(default)] + pub to_line: Option, +} #[derive(serde::Deserialize)] -pub struct FileContentP { pub path: String, #[serde(default)] pub rev: Option } +pub struct FileContentP { + pub path: String, + #[serde(default)] + pub rev: Option, +} #[derive(serde::Deserialize)] -pub struct TreeLsP { #[serde(default)] pub path: Option, #[serde(default)] pub rev: Option } +pub struct TreeLsP { + #[serde(default)] + pub path: Option, + #[serde(default)] + pub rev: Option, +} #[derive(serde::Deserialize)] -pub struct FileHistoryP { pub path: String, #[serde(default = "dl")] pub limit: u32 } +pub struct FileHistoryP { + pub path: String, + #[serde(default = "dl")] + pub limit: u32, +} #[derive(serde::Deserialize)] -pub struct TagListP { #[serde(default)] pub pattern: Option } +pub struct TagListP { + #[serde(default)] + pub pattern: Option, +} #[derive(serde::Deserialize)] -pub struct SingleTagP { pub name: String } +pub struct SingleTagP { + pub name: String, +} #[derive(serde::Deserialize)] -pub struct GitLogP { #[serde(default)] pub rev: Option, #[serde(default = "dl")] pub limit: u32, #[serde(default)] pub skip: u32 } +pub struct GitLogP { + #[serde(default)] + pub rev: Option, + #[serde(default = "dl")] + pub limit: u32, + #[serde(default)] + pub skip: u32, +} /// Flat commit information for tool responses. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -84,7 +157,12 @@ impl CommitInfo { Self { oid: meta.oid.to_string(), - short_oid: meta.oid.to_string().get(..7).unwrap_or(&meta.oid.to_string()).to_string(), + short_oid: meta + .oid + .to_string() + .get(..7) + .unwrap_or(&meta.oid.to_string()) + .to_string(), message: meta.message.clone(), summary: meta.summary.clone(), author_name: meta.author.name.clone(), diff --git a/libs/fctool/src/lib.rs b/libs/fctool/src/lib.rs index 5d9c3e8..b8adbcc 100644 --- a/libs/fctool/src/lib.rs +++ b/libs/fctool/src/lib.rs @@ -1,6 +1,6 @@ //! AI agent function-call tools: git operations, file parsing/search, project management, and chat tools. -pub mod git_tools; -pub mod file_tools; -pub mod project_tools; pub mod chat_tools; +pub mod file_tools; +pub mod git_tools; +pub mod project_tools; diff --git a/libs/fctool/src/project_tools/arxiv.rs b/libs/fctool/src/project_tools/arxiv.rs index 9500b87..7e83960 100644 --- a/libs/fctool/src/project_tools/arxiv.rs +++ b/libs/fctool/src/project_tools/arxiv.rs @@ -72,10 +72,7 @@ pub async fn arxiv_search_exec( .unwrap_or(DEFAULT_MAX_RESULTS as u64) .min(MAX_MAX_RESULTS as u64) as usize; - let start = args - .get("start") - .and_then(|v| v.as_u64()) - .unwrap_or(0) as usize; + let start = args.get("start").and_then(|v| v.as_u64()).unwrap_or(0) as usize; // Build arXiv API query URL // Encode query for URL @@ -138,7 +135,12 @@ pub async fn arxiv_search_exec( let author_str = if entry.author.is_empty() { "Unknown".to_string() } else { - entry.author.iter().map(|a| a.name.as_str()).collect::>().join(", ") + entry + .author + .iter() + .map(|a| a.name.as_str()) + .collect::>() + .join(", ") }; serde_json::json!({ @@ -204,16 +206,30 @@ pub fn tool_definition() -> ToolDefinition { description: Some("Search query (required). Supports arXiv search syntax, e.g. 'ti:transformer AND au:bengio'.".into()), required: true, properties: None, items: None, }); - p.insert("max_results".into(), ToolParam { - name: "max_results".into(), param_type: "integer".into(), - description: Some("Maximum number of results to return (default 10, max 50). Optional.".into()), - required: false, properties: None, items: None, - }); - p.insert("start".into(), ToolParam { - name: "start".into(), param_type: "integer".into(), - description: Some("Offset for pagination. Optional.".into()), - required: false, properties: None, items: None, - }); + p.insert( + "max_results".into(), + ToolParam { + name: "max_results".into(), + param_type: "integer".into(), + description: Some( + "Maximum number of results to return (default 10, max 50). Optional.".into(), + ), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "start".into(), + ToolParam { + name: "start".into(), + param_type: "integer".into(), + description: Some("Offset for pagination. Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); ToolDefinition::new("project_arxiv_search") .description( "Search arXiv papers by keyword or phrase. \ diff --git a/libs/fctool/src/project_tools/boards.rs b/libs/fctool/src/project_tools/boards.rs index a04a032..6cd7181 100644 --- a/libs/fctool/src/project_tools/boards.rs +++ b/libs/fctool/src/project_tools/boards.rs @@ -3,10 +3,8 @@ use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema}; use chrono::Utc; -use models::projects::{ - project_board, project_board_card, project_board_column, project_members, -}; use models::projects::{MemberRole, ProjectBoard, ProjectBoardCard, ProjectBoardColumn}; +use models::projects::{project_board, project_board_card, project_board_column, project_members}; use models::users::user::Model as UserModel; use sea_orm::*; use std::collections::HashMap; @@ -305,9 +303,7 @@ pub async fn create_board_card_exec( .get("assignee_id") .and_then(|v| Uuid::parse_str(v.as_str()?).ok()); - let issue_id = args - .get("issue_id") - .and_then(|v| v.as_i64()); + let issue_id = args.get("issue_id").and_then(|v| v.as_i64()); // Verify board belongs to project let board = ProjectBoard::find_by_id(board_id) @@ -575,16 +571,28 @@ pub fn list_tool_definition() -> ToolDefinition { pub fn create_board_tool_definition() -> ToolDefinition { let mut p = HashMap::new(); - p.insert("name".into(), ToolParam { - name: "name".into(), param_type: "string".into(), - description: Some("Board name (required).".into()), - required: true, properties: None, items: None, - }); - p.insert("description".into(), ToolParam { - name: "description".into(), param_type: "string".into(), - description: Some("Board description. Optional.".into()), - required: false, properties: None, items: None, - }); + p.insert( + "name".into(), + ToolParam { + name: "name".into(), + param_type: "string".into(), + description: Some("Board name (required).".into()), + required: true, + properties: None, + items: None, + }, + ); + p.insert( + "description".into(), + ToolParam { + name: "description".into(), + param_type: "string".into(), + description: Some("Board description. Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); ToolDefinition::new("project_create_board") .description( "Create a new Kanban board in the current project. Requires admin or owner role.", @@ -598,25 +606,41 @@ pub fn create_board_tool_definition() -> ToolDefinition { pub fn update_board_tool_definition() -> ToolDefinition { let mut p = HashMap::new(); - p.insert("board_id".into(), ToolParam { - name: "board_id".into(), param_type: "string".into(), - description: Some("Board UUID (required).".into()), - required: true, properties: None, items: None, - }); - p.insert("name".into(), ToolParam { - name: "name".into(), param_type: "string".into(), - description: Some("New board name. Optional.".into()), - required: false, properties: None, items: None, - }); - p.insert("description".into(), ToolParam { - name: "description".into(), param_type: "string".into(), - description: Some("New board description. Optional.".into()), - required: false, properties: None, items: None, - }); + p.insert( + "board_id".into(), + ToolParam { + name: "board_id".into(), + param_type: "string".into(), + description: Some("Board UUID (required).".into()), + required: true, + properties: None, + items: None, + }, + ); + p.insert( + "name".into(), + ToolParam { + name: "name".into(), + param_type: "string".into(), + description: Some("New board name. Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "description".into(), + ToolParam { + name: "description".into(), + param_type: "string".into(), + description: Some("New board description. Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); ToolDefinition::new("project_update_board") - .description( - "Update a Kanban board (name or description). Requires admin or owner role.", - ) + .description("Update a Kanban board (name or description). Requires admin or owner role.") .parameters(ToolSchema { schema_type: "object".into(), properties: Some(p), @@ -626,41 +650,83 @@ pub fn update_board_tool_definition() -> ToolDefinition { pub fn create_card_tool_definition() -> ToolDefinition { let mut p = HashMap::new(); - p.insert("board_id".into(), ToolParam { - name: "board_id".into(), param_type: "string".into(), - description: Some("Board UUID (required).".into()), - required: true, properties: None, items: None, - }); - p.insert("column_id".into(), ToolParam { - name: "column_id".into(), param_type: "string".into(), - description: Some("Column UUID. Optional — defaults to first column.".into()), - required: false, properties: None, items: None, - }); - p.insert("title".into(), ToolParam { - name: "title".into(), param_type: "string".into(), - description: Some("Card title (required).".into()), - required: true, properties: None, items: None, - }); - p.insert("description".into(), ToolParam { - name: "description".into(), param_type: "string".into(), - description: Some("Card description. Optional.".into()), - required: false, properties: None, items: None, - }); - p.insert("priority".into(), ToolParam { - name: "priority".into(), param_type: "string".into(), - description: Some("Card priority (e.g. 'low', 'medium', 'high'). Optional.".into()), - required: false, properties: None, items: None, - }); - p.insert("assignee_id".into(), ToolParam { - name: "assignee_id".into(), param_type: "string".into(), - description: Some("Card assignee user UUID. Optional.".into()), - required: false, properties: None, items: None, - }); - p.insert("issue_id".into(), ToolParam { - name: "issue_id".into(), param_type: "integer".into(), - description: Some("Link a project issue NUMBER to this card. Optional.".into()), - required: false, properties: None, items: None, - }); + p.insert( + "board_id".into(), + ToolParam { + name: "board_id".into(), + param_type: "string".into(), + description: Some("Board UUID (required).".into()), + required: true, + properties: None, + items: None, + }, + ); + p.insert( + "column_id".into(), + ToolParam { + name: "column_id".into(), + param_type: "string".into(), + description: Some("Column UUID. Optional — defaults to first column.".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "title".into(), + ToolParam { + name: "title".into(), + param_type: "string".into(), + description: Some("Card title (required).".into()), + required: true, + properties: None, + items: None, + }, + ); + p.insert( + "description".into(), + ToolParam { + name: "description".into(), + param_type: "string".into(), + description: Some("Card description. Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "priority".into(), + ToolParam { + name: "priority".into(), + param_type: "string".into(), + description: Some("Card priority (e.g. 'low', 'medium', 'high'). Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "assignee_id".into(), + ToolParam { + name: "assignee_id".into(), + param_type: "string".into(), + description: Some("Card assignee user UUID. Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "issue_id".into(), + ToolParam { + name: "issue_id".into(), + param_type: "integer".into(), + description: Some("Link a project issue NUMBER to this card. Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); ToolDefinition::new("project_create_board_card") .description( "Create a card on a Kanban board. If column_id is not provided, \ @@ -675,46 +741,96 @@ pub fn create_card_tool_definition() -> ToolDefinition { pub fn update_card_tool_definition() -> ToolDefinition { let mut p = HashMap::new(); - p.insert("card_id".into(), ToolParam { - name: "card_id".into(), param_type: "string".into(), - description: Some("Card UUID (required).".into()), - required: true, properties: None, items: None, - }); - p.insert("title".into(), ToolParam { - name: "title".into(), param_type: "string".into(), - description: Some("New card title. Optional.".into()), - required: false, properties: None, items: None, - }); - p.insert("description".into(), ToolParam { - name: "description".into(), param_type: "string".into(), - description: Some("New card description. Optional.".into()), - required: false, properties: None, items: None, - }); - p.insert("column_id".into(), ToolParam { - name: "column_id".into(), param_type: "string".into(), - description: Some("Move card to a different column. Optional.".into()), - required: false, properties: None, items: None, - }); - p.insert("position".into(), ToolParam { - name: "position".into(), param_type: "integer".into(), - description: Some("New position within column. Optional.".into()), - required: false, properties: None, items: None, - }); - p.insert("priority".into(), ToolParam { - name: "priority".into(), param_type: "string".into(), - description: Some("New priority. Optional.".into()), - required: false, properties: None, items: None, - }); - p.insert("assignee_id".into(), ToolParam { - name: "assignee_id".into(), param_type: "string".into(), - description: Some("New assignee UUID. Optional.".into()), - required: false, properties: None, items: None, - }); - p.insert("issue_id".into(), ToolParam { - name: "issue_id".into(), param_type: "integer".into(), - description: Some("Link to a project issue number. Set to 0 to unlink. Optional.".into()), - required: false, properties: None, items: None, - }); + p.insert( + "card_id".into(), + ToolParam { + name: "card_id".into(), + param_type: "string".into(), + description: Some("Card UUID (required).".into()), + required: true, + properties: None, + items: None, + }, + ); + p.insert( + "title".into(), + ToolParam { + name: "title".into(), + param_type: "string".into(), + description: Some("New card title. Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "description".into(), + ToolParam { + name: "description".into(), + param_type: "string".into(), + description: Some("New card description. Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "column_id".into(), + ToolParam { + name: "column_id".into(), + param_type: "string".into(), + description: Some("Move card to a different column. Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "position".into(), + ToolParam { + name: "position".into(), + param_type: "integer".into(), + description: Some("New position within column. Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "priority".into(), + ToolParam { + name: "priority".into(), + param_type: "string".into(), + description: Some("New priority. Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "assignee_id".into(), + ToolParam { + name: "assignee_id".into(), + param_type: "string".into(), + description: Some("New assignee UUID. Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "issue_id".into(), + ToolParam { + name: "issue_id".into(), + param_type: "integer".into(), + description: Some( + "Link to a project issue number. Set to 0 to unlink. Optional.".into(), + ), + required: false, + properties: None, + items: None, + }, + ); ToolDefinition::new("project_update_board_card") .description( "Update a board card (title, description, column, position, assignee, priority). \ @@ -729,11 +845,17 @@ pub fn update_card_tool_definition() -> ToolDefinition { pub fn delete_card_tool_definition() -> ToolDefinition { let mut p = HashMap::new(); - p.insert("card_id".into(), ToolParam { - name: "card_id".into(), param_type: "string".into(), - description: Some("Card UUID (required).".into()), - required: true, properties: None, items: None, - }); + p.insert( + "card_id".into(), + ToolParam { + name: "card_id".into(), + param_type: "string".into(), + description: Some("Card UUID (required).".into()), + required: true, + properties: None, + items: None, + }, + ); ToolDefinition::new("project_delete_board_card") .description("Delete a board card. Requires admin or owner role.") .parameters(ToolSchema { @@ -750,20 +872,28 @@ pub async fn create_board_column_exec( args: serde_json::Value, ) -> Result { let project_id = ctx.project_id(); - let sender_id = ctx.sender_id().ok_or_else(|| ToolError::ExecutionError("No sender".into()))?; + let sender_id = ctx + .sender_id() + .ok_or_else(|| ToolError::ExecutionError("No sender".into()))?; let db = ctx.db(); require_admin(db, project_id, sender_id).await?; - let board_id = args.get("board_id") + let board_id = args + .get("board_id") .and_then(|v| Uuid::parse_str(v.as_str()?).ok()) .ok_or_else(|| ToolError::ExecutionError("board_id is required".into()))?; - let name = args.get("name").and_then(|v| v.as_str()) + let name = args + .get("name") + .and_then(|v| v.as_str()) .ok_or_else(|| ToolError::ExecutionError("name is required".into()))? .to_string(); - let color = args.get("color").and_then(|v| v.as_str()).map(|s| s.to_string()); + let color = args + .get("color") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); let board = ProjectBoard::find_by_id(board_id) .one(db) @@ -771,7 +901,9 @@ pub async fn create_board_column_exec( .map_err(|e| ToolError::ExecutionError(e.to_string()))? .ok_or_else(|| ToolError::ExecutionError("Board not found".into()))?; if board.project != project_id { - return Err(ToolError::ExecutionError("Board does not belong to this project".into())); + return Err(ToolError::ExecutionError( + "Board does not belong to this project".into(), + )); } let max_pos: Option> = ProjectBoardColumn::find() @@ -794,7 +926,8 @@ pub async fn create_board_column_exec( color: Set(color.clone()), }; - let model = active.insert(db) + let model = active + .insert(db) .await .map_err(|e| ToolError::ExecutionError(e.to_string()))?; @@ -810,21 +943,39 @@ pub async fn create_board_column_exec( pub fn create_column_tool_definition() -> ToolDefinition { let mut p = HashMap::new(); - p.insert("board_id".into(), ToolParam { - name: "board_id".into(), param_type: "string".into(), - description: Some("Board UUID (required).".into()), - required: true, properties: None, items: None, - }); - p.insert("name".into(), ToolParam { - name: "name".into(), param_type: "string".into(), - description: Some("Column name (required).".into()), - required: true, properties: None, items: None, - }); - p.insert("color".into(), ToolParam { - name: "color".into(), param_type: "string".into(), - description: Some("Column color (e.g. '#ff0000'). Optional.".into()), - required: false, properties: None, items: None, - }); + p.insert( + "board_id".into(), + ToolParam { + name: "board_id".into(), + param_type: "string".into(), + description: Some("Board UUID (required).".into()), + required: true, + properties: None, + items: None, + }, + ); + p.insert( + "name".into(), + ToolParam { + name: "name".into(), + param_type: "string".into(), + description: Some("Column name (required).".into()), + required: true, + properties: None, + items: None, + }, + ); + p.insert( + "color".into(), + ToolParam { + name: "color".into(), + param_type: "string".into(), + description: Some("Column color (e.g. '#ff0000'). Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); ToolDefinition::new("project_create_board_column") .description( "Create a new column on a Kanban board. \ diff --git a/libs/fctool/src/project_tools/curl.rs b/libs/fctool/src/project_tools/curl.rs index 98b0e7b..84bef0c 100644 --- a/libs/fctool/src/project_tools/curl.rs +++ b/libs/fctool/src/project_tools/curl.rs @@ -14,8 +14,12 @@ const MAX_BODY_BYTES: usize = 1 << 20; /// Headers that are blocked from user-supplied values to prevent injection attacks. const BLOCKED_HEADERS: &[&str] = &[ - "host", "authorization", "cookie", "proxy-authorization", - "proxy-connection", "proxy-authenticate", + "host", + "authorization", + "cookie", + "proxy-authorization", + "proxy-connection", + "proxy-authenticate", ]; /// Shared reqwest::Client for connection pooling. @@ -111,9 +115,10 @@ pub async fn curl_exec( // Block sensitive headers that could be used for injection attacks for (key, _) in &headers { if BLOCKED_HEADERS.contains(&key.to_lowercase().as_str()) { - return Err(ToolError::ExecutionError( - format!("Header '{}' is not allowed for security reasons", key), - )); + return Err(ToolError::ExecutionError(format!( + "Header '{}' is not allowed for security reasons", + key + ))); } } @@ -145,7 +150,7 @@ pub async fn curl_exec( return Err(ToolError::ExecutionError(format!( "Unsupported HTTP method: {}. Use GET, POST, PUT, DELETE, PATCH, or HEAD.", method - ))) + ))); } }; @@ -156,7 +161,11 @@ pub async fn curl_exec( } // Set default Content-Type for POST/PUT/PATCH if not provided and body exists - if body.is_some() && !headers.iter().any(|(k, _)| k.to_lowercase() == "content-type") { + if body.is_some() + && !headers + .iter() + .any(|(k, _)| k.to_lowercase() == "content-type") + { request = request.header("Content-Type", "application/json"); } @@ -175,22 +184,29 @@ pub async fn curl_exec( if status >= 300 && status < 400 { redirect_count += 1; if redirect_count > MAX_REDIRECTS { - return Err(ToolError::ExecutionError( - format!("Too many redirects (max {})", MAX_REDIRECTS), - )); + return Err(ToolError::ExecutionError(format!( + "Too many redirects (max {})", + MAX_REDIRECTS + ))); } - let location = response.headers() + let location = response + .headers() .get("location") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); let location = match location { Some(l) => l, - None => return Err(ToolError::ExecutionError("Redirect with no Location header".into())), + None => { + return Err(ToolError::ExecutionError( + "Redirect with no Location header".into(), + )); + } }; // Resolve relative redirect against current URL let base = reqwest::Url::parse(¤t_url) .map_err(|e| ToolError::ExecutionError(format!("Invalid current URL: {}", e)))?; - let next_url = base.join(&location) + let next_url = base + .join(&location) .map_err(|e| ToolError::ExecutionError(format!("Invalid redirect URL: {}", e)))?; // Validate redirect target against SSRF rules if let Some(host) = next_url.host_str() { @@ -209,12 +225,7 @@ pub async fn curl_exec( let response_headers: std::collections::HashMap = response .headers() .iter() - .map(|(k, v)| { - ( - k.to_string(), - v.to_str().unwrap_or("").to_string(), - ) - }) + .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) .collect(); let content_type = response @@ -229,10 +240,9 @@ pub async fn curl_exec( || content_type.contains("xml") || content_type.contains("javascript"); - let body_bytes = response - .bytes() - .await - .map_err(|e| ToolError::ExecutionError(format!("Failed to read response body: {}", e)))?; + let body_bytes = response.bytes().await.map_err(|e| { + ToolError::ExecutionError(format!("Failed to read response body: {}", e)) + })?; let body_len = body_bytes.len(); let truncated = body_len > MAX_BODY_BYTES; @@ -264,31 +274,64 @@ pub async fn curl_exec( pub fn tool_definition() -> ToolDefinition { let mut p = HashMap::new(); - p.insert("url".into(), ToolParam { - name: "url".into(), param_type: "string".into(), - description: Some("Full URL to request (required).".into()), - required: true, properties: None, items: None, - }); - p.insert("method".into(), ToolParam { - name: "method".into(), param_type: "string".into(), - description: Some("HTTP method: GET (default), POST, PUT, DELETE, PATCH, HEAD.".into()), - required: false, properties: None, items: None, - }); - p.insert("body".into(), ToolParam { - name: "body".into(), param_type: "string".into(), - description: Some("Request body. Defaults to 'application/json' Content-Type if provided. Optional.".into()), - required: false, properties: None, items: None, - }); - p.insert("headers".into(), ToolParam { - name: "headers".into(), param_type: "object".into(), - description: Some("HTTP headers as key-value pairs. Optional.".into()), - required: false, properties: None, items: None, - }); - p.insert("timeout".into(), ToolParam { - name: "timeout".into(), param_type: "integer".into(), - description: Some("Request timeout in seconds (default 30, max 120). Optional.".into()), - required: false, properties: None, items: None, - }); + p.insert( + "url".into(), + ToolParam { + name: "url".into(), + param_type: "string".into(), + description: Some("Full URL to request (required).".into()), + required: true, + properties: None, + items: None, + }, + ); + p.insert( + "method".into(), + ToolParam { + name: "method".into(), + param_type: "string".into(), + description: Some("HTTP method: GET (default), POST, PUT, DELETE, PATCH, HEAD.".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "body".into(), + ToolParam { + name: "body".into(), + param_type: "string".into(), + description: Some( + "Request body. Defaults to 'application/json' Content-Type if provided. Optional." + .into(), + ), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "headers".into(), + ToolParam { + name: "headers".into(), + param_type: "object".into(), + description: Some("HTTP headers as key-value pairs. Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "timeout".into(), + ToolParam { + name: "timeout".into(), + param_type: "integer".into(), + description: Some("Request timeout in seconds (default 30, max 120). Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); ToolDefinition::new("project_curl") .description( "Perform an HTTP request to any URL. Supports GET, POST, PUT, DELETE, PATCH, HEAD. \ diff --git a/libs/fctool/src/project_tools/issues.rs b/libs/fctool/src/project_tools/issues.rs index c4e6f55..8dddcf2 100644 --- a/libs/fctool/src/project_tools/issues.rs +++ b/libs/fctool/src/project_tools/issues.rs @@ -2,9 +2,11 @@ use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema}; use chrono::Utc; -use models::issues::{issue, issue_assignee, issue_label, Issue, IssueAssignee, IssueLabel, IssueState}; -use models::projects::{MemberRole, ProjectMember}; +use models::issues::{ + Issue, IssueAssignee, IssueLabel, IssueState, issue, issue_assignee, issue_label, +}; use models::projects::project_members; +use models::projects::{MemberRole, ProjectMember}; use models::system::{Label, label}; use models::users::User; use sea_orm::*; @@ -232,7 +234,9 @@ pub async fn create_issue_exec( .await .map_err(|e| ToolError::ExecutionError(e.to_string()))?; if member.is_none() { - return Err(ToolError::ExecutionError("You are not a member of this project".into())); + return Err(ToolError::ExecutionError( + "You are not a member of this project".into(), + )); } let number = next_issue_number(db, project_id).await?; @@ -452,11 +456,17 @@ pub async fn update_issue_exec( pub fn list_tool_definition() -> ToolDefinition { let mut p = HashMap::new(); - p.insert("state".into(), ToolParam { - name: "state".into(), param_type: "string".into(), - description: Some("Filter by issue state: 'open' or 'closed'. Optional.".into()), - required: false, properties: None, items: None, - }); + p.insert( + "state".into(), + ToolParam { + name: "state".into(), + param_type: "string".into(), + description: Some("Filter by issue state: 'open' or 'closed'. Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); ToolDefinition::new("project_list_issues") .description( "List all issues in the current project. \ @@ -471,39 +481,75 @@ pub fn list_tool_definition() -> ToolDefinition { pub fn create_tool_definition() -> ToolDefinition { let mut p = HashMap::new(); - p.insert("title".into(), ToolParam { - name: "title".into(), param_type: "string".into(), - description: Some("Issue title (required).".into()), - required: true, properties: None, items: None, - }); - p.insert("body".into(), ToolParam { - name: "body".into(), param_type: "string".into(), - description: Some("Issue body / description. Optional.".into()), - required: false, properties: None, items: None, - }); - p.insert("milestone".into(), ToolParam { - name: "milestone".into(), param_type: "string".into(), - description: Some("Milestone name. Optional.".into()), - required: false, properties: None, items: None, - }); - p.insert("assignee_ids".into(), ToolParam { - name: "assignee_ids".into(), param_type: "array".into(), - description: Some("Array of user UUIDs to assign. Optional.".into()), - required: false, properties: None, - items: Some(Box::new(ToolParam { - name: "".into(), param_type: "string".into(), - description: None, required: false, properties: None, items: None, - })), - }); - p.insert("label_ids".into(), ToolParam { - name: "label_ids".into(), param_type: "array".into(), - description: Some("Array of label IDs to apply. Optional.".into()), - required: false, properties: None, - items: Some(Box::new(ToolParam { - name: "".into(), param_type: "integer".into(), - description: None, required: false, properties: None, items: None, - })), - }); + p.insert( + "title".into(), + ToolParam { + name: "title".into(), + param_type: "string".into(), + description: Some("Issue title (required).".into()), + required: true, + properties: None, + items: None, + }, + ); + p.insert( + "body".into(), + ToolParam { + name: "body".into(), + param_type: "string".into(), + description: Some("Issue body / description. Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "milestone".into(), + ToolParam { + name: "milestone".into(), + param_type: "string".into(), + description: Some("Milestone name. Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "assignee_ids".into(), + ToolParam { + name: "assignee_ids".into(), + param_type: "array".into(), + description: Some("Array of user UUIDs to assign. Optional.".into()), + required: false, + properties: None, + items: Some(Box::new(ToolParam { + name: "".into(), + param_type: "string".into(), + description: None, + required: false, + properties: None, + items: None, + })), + }, + ); + p.insert( + "label_ids".into(), + ToolParam { + name: "label_ids".into(), + param_type: "array".into(), + description: Some("Array of label IDs to apply. Optional.".into()), + required: false, + properties: None, + items: Some(Box::new(ToolParam { + name: "".into(), + param_type: "integer".into(), + description: None, + required: false, + properties: None, + items: None, + })), + }, + ); ToolDefinition::new("project_create_issue") .description( "Create a new issue in the current project. \ @@ -518,31 +564,61 @@ pub fn create_tool_definition() -> ToolDefinition { pub fn update_tool_definition() -> ToolDefinition { let mut p = HashMap::new(); - p.insert("number".into(), ToolParam { - name: "number".into(), param_type: "integer".into(), - description: Some("Issue number (required).".into()), - required: true, properties: None, items: None, - }); - p.insert("title".into(), ToolParam { - name: "title".into(), param_type: "string".into(), - description: Some("New issue title. Optional.".into()), - required: false, properties: None, items: None, - }); - p.insert("body".into(), ToolParam { - name: "body".into(), param_type: "string".into(), - description: Some("New issue body. Optional.".into()), - required: false, properties: None, items: None, - }); - p.insert("state".into(), ToolParam { - name: "state".into(), param_type: "string".into(), - description: Some("New issue state: 'open' or 'closed'. Optional.".into()), - required: false, properties: None, items: None, - }); - p.insert("milestone".into(), ToolParam { - name: "milestone".into(), param_type: "string".into(), - description: Some("New milestone name. Set to null to remove. Optional.".into()), - required: false, properties: None, items: None, - }); + p.insert( + "number".into(), + ToolParam { + name: "number".into(), + param_type: "integer".into(), + description: Some("Issue number (required).".into()), + required: true, + properties: None, + items: None, + }, + ); + p.insert( + "title".into(), + ToolParam { + name: "title".into(), + param_type: "string".into(), + description: Some("New issue title. Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "body".into(), + ToolParam { + name: "body".into(), + param_type: "string".into(), + description: Some("New issue body. Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "state".into(), + ToolParam { + name: "state".into(), + param_type: "string".into(), + description: Some("New issue state: 'open' or 'closed'. Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "milestone".into(), + ToolParam { + name: "milestone".into(), + param_type: "string".into(), + description: Some("New milestone name. Set to null to remove. Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); ToolDefinition::new("project_update_issue") .description( "Update an existing issue in the current project by its number. \ @@ -564,10 +640,14 @@ pub async fn assign_issue_exec( args: serde_json::Value, ) -> Result { let project_id = ctx.project_id(); - let sender_id = ctx.sender_id().ok_or_else(|| ToolError::ExecutionError("No sender".into()))?; + let sender_id = ctx + .sender_id() + .ok_or_else(|| ToolError::ExecutionError("No sender".into()))?; let db = ctx.db(); - let number = args.get("number").and_then(|v| v.as_i64()) + let number = args + .get("number") + .and_then(|v| v.as_i64()) .ok_or_else(|| ToolError::ExecutionError("number is required".into()))?; let issue = Issue::find() @@ -580,14 +660,24 @@ pub async fn assign_issue_exec( require_issue_modifier(db, project_id, sender_id, issue.author).await?; - let add_ids: Vec = args.get("add_user_ids") + let add_ids: Vec = args + .get("add_user_ids") .and_then(|v| v.as_array()) - .map(|a| a.iter().filter_map(|v| Uuid::parse_str(v.as_str()?).ok()).collect()) + .map(|a| { + a.iter() + .filter_map(|v| Uuid::parse_str(v.as_str()?).ok()) + .collect() + }) .unwrap_or_default(); - let remove_ids: Vec = args.get("remove_user_ids") + let remove_ids: Vec = args + .get("remove_user_ids") .and_then(|v| v.as_array()) - .map(|a| a.iter().filter_map(|v| Uuid::parse_str(v.as_str()?).ok()).collect()) + .map(|a| { + a.iter() + .filter_map(|v| Uuid::parse_str(v.as_str()?).ok()) + .collect() + }) .unwrap_or_default(); let now = Utc::now(); @@ -608,7 +698,9 @@ pub async fn assign_issue_exec( assigned_at: Set(now), ..Default::default() }; - am.insert(db).await.map_err(|e| ToolError::ExecutionError(e.to_string()))?; + am.insert(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; } for uid in &remove_ids { @@ -653,29 +745,53 @@ pub async fn assign_issue_exec( pub fn assign_tool_definition() -> ToolDefinition { let mut p = HashMap::new(); - p.insert("number".into(), ToolParam { - name: "number".into(), param_type: "integer".into(), - description: Some("Issue number (required).".into()), - required: true, properties: None, items: None, - }); - p.insert("add_user_ids".into(), ToolParam { - name: "add_user_ids".into(), param_type: "array".into(), - description: Some("Array of user UUIDs to add as assignees. Optional.".into()), - required: false, properties: None, - items: Some(Box::new(ToolParam { - name: "".into(), param_type: "string".into(), - description: Some("User UUID".into()), required: false, properties: None, items: None, - })), - }); - p.insert("remove_user_ids".into(), ToolParam { - name: "remove_user_ids".into(), param_type: "array".into(), - description: Some("Array of user UUIDs to remove from assignees. Optional.".into()), - required: false, properties: None, - items: Some(Box::new(ToolParam { - name: "".into(), param_type: "string".into(), - description: Some("User UUID".into()), required: false, properties: None, items: None, - })), - }); + p.insert( + "number".into(), + ToolParam { + name: "number".into(), + param_type: "integer".into(), + description: Some("Issue number (required).".into()), + required: true, + properties: None, + items: None, + }, + ); + p.insert( + "add_user_ids".into(), + ToolParam { + name: "add_user_ids".into(), + param_type: "array".into(), + description: Some("Array of user UUIDs to add as assignees. Optional.".into()), + required: false, + properties: None, + items: Some(Box::new(ToolParam { + name: "".into(), + param_type: "string".into(), + description: Some("User UUID".into()), + required: false, + properties: None, + items: None, + })), + }, + ); + p.insert( + "remove_user_ids".into(), + ToolParam { + name: "remove_user_ids".into(), + param_type: "array".into(), + description: Some("Array of user UUIDs to remove from assignees. Optional.".into()), + required: false, + properties: None, + items: Some(Box::new(ToolParam { + name: "".into(), + param_type: "string".into(), + description: Some("User UUID".into()), + required: false, + properties: None, + items: None, + })), + }, + ); ToolDefinition::new("project_assign_issue") .description( "Add or remove assignees on an issue by its number. \ @@ -696,13 +812,19 @@ pub async fn add_comment_exec( args: serde_json::Value, ) -> Result { let project_id = ctx.project_id(); - let sender_id = ctx.sender_id().ok_or_else(|| ToolError::ExecutionError("No sender".into()))?; + let sender_id = ctx + .sender_id() + .ok_or_else(|| ToolError::ExecutionError("No sender".into()))?; let db = ctx.db(); - let number = args.get("number").and_then(|v| v.as_i64()) + let number = args + .get("number") + .and_then(|v| v.as_i64()) .ok_or_else(|| ToolError::ExecutionError("number is required".into()))?; - let body = args.get("body").and_then(|v| v.as_str()) + let body = args + .get("body") + .and_then(|v| v.as_str()) .ok_or_else(|| ToolError::ExecutionError("body is required".into()))? .to_string(); @@ -722,7 +844,9 @@ pub async fn add_comment_exec( .await .map_err(|e| ToolError::ExecutionError(e.to_string()))?; if member.is_none() { - return Err(ToolError::ExecutionError("You are not a member of this project".into())); + return Err(ToolError::ExecutionError( + "You are not a member of this project".into(), + )); } let now = Utc::now(); @@ -734,16 +858,23 @@ pub async fn add_comment_exec( created_at: Set(now), updated_at: Set(now), }; - let model = comment.insert(db).await + let model = comment + .insert(db) + .await .map_err(|e| ToolError::ExecutionError(e.to_string()))?; // Update issue updated_at let mut i_active: issue::ActiveModel = issue.into(); i_active.updated_at = Set(now); - i_active.update(db).await.map_err(|e| ToolError::ExecutionError(e.to_string()))?; + i_active + .update(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; // Look up author name - let author_name = User::find_by_id(sender_id).one(db).await + let author_name = User::find_by_id(sender_id) + .one(db) + .await .map_err(|e| ToolError::ExecutionError(e.to_string()))? .map(|u| u.display_name.unwrap_or(u.username)); @@ -759,16 +890,28 @@ pub async fn add_comment_exec( pub fn add_comment_tool_definition() -> ToolDefinition { let mut p = HashMap::new(); - p.insert("number".into(), ToolParam { - name: "number".into(), param_type: "integer".into(), - description: Some("Issue number (required).".into()), - required: true, properties: None, items: None, - }); - p.insert("body".into(), ToolParam { - name: "body".into(), param_type: "string".into(), - description: Some("Comment body text (required).".into()), - required: true, properties: None, items: None, - }); + p.insert( + "number".into(), + ToolParam { + name: "number".into(), + param_type: "integer".into(), + description: Some("Issue number (required).".into()), + required: true, + properties: None, + items: None, + }, + ); + p.insert( + "body".into(), + ToolParam { + name: "body".into(), + param_type: "string".into(), + description: Some("Comment body text (required).".into()), + required: true, + properties: None, + items: None, + }, + ); ToolDefinition::new("project_add_comment") .description( "Add a comment to an issue in the current project by its number. \ @@ -797,22 +940,24 @@ pub async fn list_labels_exec( .await .map_err(|e| ToolError::ExecutionError(e.to_string()))?; - let result: Vec = labels.into_iter().map(|l| { - serde_json::json!({ - "id": l.id, - "name": l.name, - "color": l.color, + let result: Vec = labels + .into_iter() + .map(|l| { + serde_json::json!({ + "id": l.id, + "name": l.name, + "color": l.color, + }) }) - }).collect(); + .collect(); Ok(serde_json::to_value(result).map_err(|e| ToolError::ExecutionError(e.to_string()))?) } pub fn list_labels_tool_definition() -> ToolDefinition { - ToolDefinition::new("project_list_labels") - .description( - "List all labels available in the current project. \ + ToolDefinition::new("project_list_labels").description( + "List all labels available in the current project. \ Returns label id, name, color, and description. \ Use label IDs when creating or updating issues.", - ) + ) } diff --git a/libs/fctool/src/project_tools/members.rs b/libs/fctool/src/project_tools/members.rs index 1a6cff1..daa65d3 100644 --- a/libs/fctool/src/project_tools/members.rs +++ b/libs/fctool/src/project_tools/members.rs @@ -25,8 +25,7 @@ pub async fn list_members_exec( .await .map_err(|e| ToolError::ExecutionError(e.to_string()))?; - let user_map: std::collections::HashMap<_, _> = - users.into_iter().map(|u| (u.uid, u)).collect(); + let user_map: std::collections::HashMap<_, _> = users.into_iter().map(|u| (u.uid, u)).collect(); let result: Vec<_> = members .into_iter() diff --git a/libs/fctool/src/project_tools/mod.rs b/libs/fctool/src/project_tools/mod.rs index 5cdd1c4..becc460 100644 --- a/libs/fctool/src/project_tools/mod.rs +++ b/libs/fctool/src/project_tools/mod.rs @@ -17,20 +17,18 @@ use agent::{ToolHandler, ToolRegistry}; pub use arxiv::arxiv_search_exec; pub use boards::{ - create_board_card_exec, create_board_exec, create_board_column_exec, - delete_board_card_exec, list_boards_exec, - update_board_card_exec, update_board_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, }; pub use curl::curl_exec; pub use issues::{ - add_comment_exec, assign_issue_exec, create_issue_exec, list_issues_exec, - list_labels_exec, update_issue_exec, + add_comment_exec, assign_issue_exec, create_issue_exec, list_issues_exec, list_labels_exec, + update_issue_exec, }; pub use members::list_members_exec; pub use repos::{create_commit_exec, create_repo_exec, list_repos_exec, update_repo_exec}; pub fn register_all(registry: &mut ToolRegistry) { - registry.register( arxiv::tool_definition(), ToolHandler::new(|ctx, args| Box::pin(arxiv_search_exec(ctx, args))), diff --git a/libs/fctool/src/project_tools/repos.rs b/libs/fctool/src/project_tools/repos.rs index 0f104c8..3b4514c 100644 --- a/libs/fctool/src/project_tools/repos.rs +++ b/libs/fctool/src/project_tools/repos.rs @@ -5,8 +5,8 @@ use chrono::Utc; use git::commit::types::CommitOid; use git::commit::types::CommitSignature; use git2; -use models::projects::{MemberRole, ProjectMember}; use models::projects::project_members; +use models::projects::{MemberRole, ProjectMember}; use models::repos::repo; use models::users::user_email; use sea_orm::*; @@ -96,9 +96,15 @@ pub async fn create_repo_exec( .to_string(); // Validate repo name: no path traversal, no special chars - if repo_name.contains("..") || repo_name.contains('/') || repo_name.contains('\\') - || repo_name.is_empty() || repo_name.len() > 100 - || !repo_name.chars().next().map_or(false, |c| c.is_alphanumeric()) + if repo_name.contains("..") + || repo_name.contains('/') + || repo_name.contains('\\') + || repo_name.is_empty() + || repo_name.len() > 100 + || !repo_name + .chars() + .next() + .map_or(false, |c| c.is_alphanumeric()) { return Err(ToolError::ExecutionError( "Invalid repository name: must start with alphanumeric, contain no path separators or '..', max 100 chars".into(), @@ -176,7 +182,10 @@ pub async fn create_repo_exec( let repo_name = model.repo_name.clone(); let repo_desc = model.description.clone(); tokio::spawn(async move { - if let Err(e) = es.embed_repo(&repo_id, &repo_name, repo_desc.as_deref()).await { + if let Err(e) = es + .embed_repo(&repo_id, &repo_name, repo_desc.as_deref()) + .await + { tracing::warn!(error = %e, repo_id = %repo_id, "failed to embed repo"); } }); @@ -276,7 +285,10 @@ pub async fn update_repo_exec( let repo_name = model.repo_name.clone(); let repo_desc = model.description.clone(); tokio::spawn(async move { - if let Err(e) = es.embed_repo(&repo_id, &repo_name, repo_desc.as_deref()).await { + if let Err(e) = es + .embed_repo(&repo_id, &repo_name, repo_desc.as_deref()) + .await + { tracing::warn!(error = %e, repo_id = %repo_id, "failed to re-embed repo on update"); } }); @@ -345,11 +357,15 @@ pub async fn create_commit_exec( .to_string(); // Validate branch: no path traversal, no backslashes, not empty, no lock files - if branch.contains("..") || branch.contains('\\') || branch.is_empty() - || branch.ends_with(".lock") || branch.starts_with('-') + if branch.contains("..") + || branch.contains('\\') + || branch.is_empty() + || branch.ends_with(".lock") + || branch.starts_with('-') { return Err(ToolError::ExecutionError( - "Invalid branch name: must not contain '..' or backslashes, and must not be empty".into(), + "Invalid branch name: must not contain '..' or backslashes, and must not be empty" + .into(), )); } @@ -415,9 +431,10 @@ pub async fn create_commit_exec( let parent_oid = repo.refname_to_id(&format!("refs/heads/{}", branch)).ok(); if has_head && parent_oid.is_none() { - return Err(ToolError::ExecutionError( - format!("Branch '{}' does not exist in this repository", branch), - )); + return Err(ToolError::ExecutionError(format!( + "Branch '{}' does not exist in this repository", + branch + ))); } let parent_ids: Vec = parent_oid @@ -434,12 +451,15 @@ pub async fn create_commit_exec( // If repo has a parent commit, read its tree into the index so we don't // lose existing files when write_tree() is called. if let Some(oid) = &parent_oid { - let parent_commit = repo.find_commit(*oid) - .map_err(|e| ToolError::ExecutionError(format!("Failed to find parent commit: {}", e)))?; - let parent_tree = parent_commit.tree() - .map_err(|e| ToolError::ExecutionError(format!("Failed to get parent tree: {}", e)))?; - index.read_tree(&parent_tree) - .map_err(|e| ToolError::ExecutionError(format!("Failed to read parent tree into index: {}", e)))?; + let parent_commit = repo.find_commit(*oid).map_err(|e| { + ToolError::ExecutionError(format!("Failed to find parent commit: {}", e)) + })?; + let parent_tree = parent_commit.tree().map_err(|e| { + ToolError::ExecutionError(format!("Failed to get parent tree: {}", e)) + })?; + index.read_tree(&parent_tree).map_err(|e| { + ToolError::ExecutionError(format!("Failed to read parent tree into index: {}", e)) + })?; } for file in files_data { @@ -449,12 +469,17 @@ pub async fn create_commit_exec( .ok_or_else(|| ToolError::ExecutionError("Each file must have a 'path'".into()))?; // Validate path: no traversal, no absolute paths, no .git/ prefix - if path.contains("..") || path.starts_with('/') || path.starts_with('\\') - || path.is_empty() || path.starts_with(".git/") || path == ".git" + if path.contains("..") + || path.starts_with('/') + || path.starts_with('\\') + || path.is_empty() + || path.starts_with(".git/") + || path == ".git" { - return Err(ToolError::ExecutionError( - format!("Invalid file path '{}': must be relative, no '..' or absolute path components", path) - )); + return Err(ToolError::ExecutionError(format!( + "Invalid file path '{}': must be relative, no '..' or absolute path components", + path + ))); } let content = file .get("content") @@ -477,9 +502,11 @@ pub async fn create_commit_exec( flags_extended: 0, path: path.as_bytes().to_vec(), }; - index.add_frombuffer(&mut entry, content.as_bytes()).map_err(|e| { - ToolError::ExecutionError(format!("Failed to add '{}' to index: {}", path, e)) - })?; + index + .add_frombuffer(&mut entry, content.as_bytes()) + .map_err(|e| { + ToolError::ExecutionError(format!("Failed to add '{}' to index: {}", path, e)) + })?; } let tree_oid = index @@ -546,21 +573,41 @@ pub fn list_tool_definition() -> ToolDefinition { pub fn create_tool_definition() -> ToolDefinition { let mut p = HashMap::new(); - p.insert("name".into(), ToolParam { - name: "name".into(), param_type: "string".into(), - description: Some("Repository name (required). Must be unique within the project.".into()), - required: true, properties: None, items: None, - }); - p.insert("description".into(), ToolParam { - name: "description".into(), param_type: "string".into(), - description: Some("Repository description. Optional.".into()), - required: false, properties: None, items: None, - }); - p.insert("is_private".into(), ToolParam { - name: "is_private".into(), param_type: "boolean".into(), - description: Some("Whether the repo is private. Defaults to false. Optional.".into()), - required: false, properties: None, items: None, - }); + p.insert( + "name".into(), + ToolParam { + name: "name".into(), + param_type: "string".into(), + description: Some( + "Repository name (required). Must be unique within the project.".into(), + ), + required: true, + properties: None, + items: None, + }, + ); + p.insert( + "description".into(), + ToolParam { + name: "description".into(), + param_type: "string".into(), + description: Some("Repository description. Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "is_private".into(), + ToolParam { + name: "is_private".into(), + param_type: "boolean".into(), + description: Some("Whether the repo is private. Defaults to false. Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); ToolDefinition::new("project_create_repo") .description( "Create a new repository in the current project. \ @@ -576,26 +623,50 @@ pub fn create_tool_definition() -> ToolDefinition { pub fn update_tool_definition() -> ToolDefinition { let mut p = HashMap::new(); - p.insert("name".into(), ToolParam { - name: "name".into(), param_type: "string".into(), - description: Some("Repository name (required).".into()), - required: true, properties: None, items: None, - }); - p.insert("description".into(), ToolParam { - name: "description".into(), param_type: "string".into(), - description: Some("New repository description. Optional.".into()), - required: false, properties: None, items: None, - }); - p.insert("is_private".into(), ToolParam { - name: "is_private".into(), param_type: "boolean".into(), - description: Some("New privacy setting. Optional.".into()), - required: false, properties: None, items: None, - }); - p.insert("default_branch".into(), ToolParam { - name: "default_branch".into(), param_type: "string".into(), - description: Some("New default branch name. Optional.".into()), - required: false, properties: None, items: None, - }); + p.insert( + "name".into(), + ToolParam { + name: "name".into(), + param_type: "string".into(), + description: Some("Repository name (required).".into()), + required: true, + properties: None, + items: None, + }, + ); + p.insert( + "description".into(), + ToolParam { + name: "description".into(), + param_type: "string".into(), + description: Some("New repository description. Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "is_private".into(), + ToolParam { + name: "is_private".into(), + param_type: "boolean".into(), + description: Some("New privacy setting. Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "default_branch".into(), + ToolParam { + name: "default_branch".into(), + param_type: "string".into(), + description: Some("New default branch name. Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); ToolDefinition::new("project_update_repo") .description( "Update a repository's description, privacy, or default branch. \ @@ -610,47 +681,94 @@ pub fn update_tool_definition() -> ToolDefinition { pub fn create_commit_tool_definition() -> ToolDefinition { let mut p = HashMap::new(); - p.insert("repo_name".into(), ToolParam { - name: "repo_name".into(), param_type: "string".into(), - description: Some("Repository name. Can also use 'name' as alias. Required.".into()), - required: true, properties: None, items: None, - }); - p.insert("name".into(), ToolParam { - name: "name".into(), param_type: "string".into(), - description: Some("Alias for repo_name. Use the same value as returned by project_list_repos.".into()), - required: false, properties: None, items: None, - }); - p.insert("branch".into(), ToolParam { - name: "branch".into(), param_type: "string".into(), - description: Some("Branch to commit to. Defaults to 'main'. Optional.".into()), - required: false, properties: None, items: None, - }); - p.insert("message".into(), ToolParam { - name: "message".into(), param_type: "string".into(), - description: Some("Commit message (required).".into()), - required: true, properties: None, items: None, - }); + p.insert( + "repo_name".into(), + ToolParam { + name: "repo_name".into(), + param_type: "string".into(), + description: Some("Repository name. Can also use 'name' as alias. Required.".into()), + required: true, + properties: None, + items: None, + }, + ); + p.insert( + "name".into(), + ToolParam { + name: "name".into(), + param_type: "string".into(), + description: Some( + "Alias for repo_name. Use the same value as returned by project_list_repos.".into(), + ), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "branch".into(), + ToolParam { + name: "branch".into(), + param_type: "string".into(), + description: Some("Branch to commit to. Defaults to 'main'. Optional.".into()), + required: false, + properties: None, + items: None, + }, + ); + p.insert( + "message".into(), + ToolParam { + name: "message".into(), + param_type: "string".into(), + description: Some("Commit message (required).".into()), + required: true, + properties: None, + items: None, + }, + ); // files items let mut file_item = HashMap::new(); - file_item.insert("path".into(), ToolParam { - name: "path".into(), param_type: "string".into(), - description: Some("File path in the repo (required).".into()), - required: true, properties: None, items: None, - }); - file_item.insert("content".into(), ToolParam { - name: "content".into(), param_type: "string".into(), - description: Some("Full file content as string (required).".into()), - required: true, properties: None, items: None, - }); - p.insert("files".into(), ToolParam { - name: "files".into(), param_type: "array".into(), - description: Some("Array of files to commit (required, non-empty).".into()), - required: true, properties: None, - items: Some(Box::new(ToolParam { - name: "".into(), param_type: "object".into(), - description: None, required: true, properties: Some(file_item), items: None, - })), - }); + file_item.insert( + "path".into(), + ToolParam { + name: "path".into(), + param_type: "string".into(), + description: Some("File path in the repo (required).".into()), + required: true, + properties: None, + items: None, + }, + ); + file_item.insert( + "content".into(), + ToolParam { + name: "content".into(), + param_type: "string".into(), + description: Some("Full file content as string (required).".into()), + required: true, + properties: None, + items: None, + }, + ); + p.insert( + "files".into(), + ToolParam { + name: "files".into(), + param_type: "array".into(), + description: Some("Array of files to commit (required, non-empty).".into()), + required: true, + properties: None, + items: Some(Box::new(ToolParam { + name: "".into(), + param_type: "object".into(), + description: None, + required: true, + properties: Some(file_item), + items: None, + })), + }, + ); ToolDefinition::new("project_create_commit") .description( "Create a new commit in a repository. Commits the given files to the specified branch. \