refactor: consolidate nvim crates, extract server library, and update workspace dependencies
This commit is contained in:
1 parent
a083719cf1
commit
533adfd41b
53 files changed
+5967
-1230
No files matched your search
@@ -247,6 +247,154 @@ impl McpTool for ReplaceAstNodeHandler {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FindSymbolReferencesHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for FindSymbolReferencesHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"find_symbol_references"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<crate::tools::FindSymbolReferencesTool>(
|
||||
"find_symbol_references",
|
||||
"Find all source locations and AST chunks where a specific symbol is referenced or called.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: crate::tools::FindSymbolReferencesTool =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let limit = req.limit.unwrap_or(10);
|
||||
let target_sym = req.symbol;
|
||||
|
||||
let matches = state.code.snippets.read_with(|snippets| {
|
||||
let mut refs = Vec::new();
|
||||
for snippet in snippets {
|
||||
if snippet.code.contains(&target_sym) || snippet.name.contains(&target_sym) {
|
||||
refs.push(snippet.clone());
|
||||
if refs.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string(&refs)?)
|
||||
})?;
|
||||
|
||||
Ok(matches)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GetCallersHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for GetCallersHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"get_callers"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<crate::tools::GetCallersTool>(
|
||||
"get_callers",
|
||||
"Find all caller functions or methods that invoke a specified target function name.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: crate::tools::GetCallersTool =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let limit = req.limit.unwrap_or(10);
|
||||
let target_fn = req.function_name;
|
||||
|
||||
let callers = state.code.snippets.read_with(|snippets| {
|
||||
let mut matching = Vec::new();
|
||||
for snippet in snippets {
|
||||
if snippet.code.contains(&format!("{}(", target_fn))
|
||||
|| snippet.code.contains(&format!("{}.await", target_fn))
|
||||
{
|
||||
matching.push(snippet.clone());
|
||||
if matching.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string(&matching)?)
|
||||
})?;
|
||||
|
||||
Ok(callers)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AnalyzeImpactHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for AnalyzeImpactHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"analyze_impact"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<crate::tools::AnalyzeImpactTool>(
|
||||
"analyze_impact",
|
||||
"Analyze the potential downstream breaking impact of modifying a function, struct, or file.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: crate::tools::AnalyzeImpactTool =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let sym = req.target_symbol.clone();
|
||||
|
||||
let mut callers = Vec::new();
|
||||
state.code.snippets.read_with(|snippets| {
|
||||
for snippet in snippets {
|
||||
if snippet.code.contains(&sym) {
|
||||
callers.push(snippet.name.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut kg_connected = Vec::new();
|
||||
state.read_graph(|g| {
|
||||
for rel in &g.relations {
|
||||
if rel.from == sym {
|
||||
kg_connected.push(format!("Outgoing: {} -> {}", rel.relation_type, rel.to));
|
||||
} else if rel.to == sym {
|
||||
kg_connected.push(format!("Incoming: {} <- {}", rel.relation_type, rel.from));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let caller_count = callers.len();
|
||||
let graph_count = kg_connected.len();
|
||||
let risk_level = if caller_count > 10 || graph_count > 5 {
|
||||
"CRITICAL"
|
||||
} else if caller_count > 3 || graph_count > 2 {
|
||||
"HIGH"
|
||||
} else if caller_count > 0 || graph_count > 0 {
|
||||
"MEDIUM"
|
||||
} else {
|
||||
"LOW"
|
||||
};
|
||||
|
||||
let result = serde_json::json!({
|
||||
"target_symbol": sym,
|
||||
"risk_level": risk_level,
|
||||
"ast_callers_count": caller_count,
|
||||
"ast_callers_sample": callers.into_iter().take(5).collect::<Vec<_>>(),
|
||||
"graph_relations_count": graph_count,
|
||||
"graph_relations": kg_connected,
|
||||
"recommendation": match risk_level {
|
||||
"CRITICAL" | "HIGH" => "Requires comprehensive unit test verification and backwards compatibility checks before modifying.",
|
||||
"MEDIUM" => "Verify direct call sites and run affected module tests.",
|
||||
_ => "Safe to modify with standard unit test verification.",
|
||||
}
|
||||
});
|
||||
|
||||
Ok(serde_json::to_string_pretty(&result)?)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -270,4 +270,26 @@ mod tests {
|
||||
assert!(res3.contains("prod.local"));
|
||||
assert!(!res3.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_empty_namespace_in_update_env_fingerprint() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||
|
||||
let update_handler = UpdateEnvFingerprintHandler;
|
||||
let args = serde_json::json!({
|
||||
"namespace": "",
|
||||
"tool_versions": {
|
||||
"rustc": "1.70.0"
|
||||
}
|
||||
});
|
||||
|
||||
let res = update_handler
|
||||
.execute(args, state.clone())
|
||||
.await;
|
||||
|
||||
assert!(res.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,88 @@ impl McpTool for GetActiveWorktreeContextHandler {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct QueryGitDiffsHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for QueryGitDiffsHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"query_git_diffs"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<crate::tools::QueryGitDiffsTool>(
|
||||
"query_git_diffs",
|
||||
"Query recent git commit history, diffs, and change ledger entries.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: crate::tools::QueryGitDiffsTool =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let limit = req.limit.unwrap_or(5);
|
||||
let q = req.query.to_lowercase();
|
||||
|
||||
let mut matches = Vec::new();
|
||||
|
||||
state.code.ledger.read_with(|ledger| {
|
||||
for change in ledger {
|
||||
if change.file_path.to_lowercase().contains(&q)
|
||||
|| change.description.to_lowercase().contains(&q)
|
||||
|| change.git_commit.as_ref().is_some_and(|c| c.contains(&q))
|
||||
{
|
||||
matches.push(json!({
|
||||
"file_path": change.file_path,
|
||||
"description": change.description,
|
||||
"commit": change.git_commit,
|
||||
"branch": change.git_branch,
|
||||
"timestamp": change.timestamp,
|
||||
}));
|
||||
if matches.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if matches.len() < limit {
|
||||
let remaining = limit - matches.len();
|
||||
let git_matches = tokio::task::spawn_blocking(move || {
|
||||
let mut results = Vec::new();
|
||||
let cwd = env::current_dir().unwrap_or_default();
|
||||
if let Ok(repo) = git2::Repository::discover(&cwd) {
|
||||
if let Ok(mut revwalk) = repo.revwalk() {
|
||||
let _ = revwalk.push_head();
|
||||
let mut count = 0;
|
||||
for oid in revwalk.flatten() {
|
||||
if count >= remaining {
|
||||
break;
|
||||
}
|
||||
if let Ok(commit) = repo.find_commit(oid) {
|
||||
let summary = commit.summary().unwrap_or("");
|
||||
if summary.to_lowercase().contains(&q) {
|
||||
count += 1;
|
||||
results.push(json!({
|
||||
"commit_id": oid.to_string(),
|
||||
"author": commit.author().name().unwrap_or("unknown"),
|
||||
"message": summary,
|
||||
"timestamp": commit.time().seconds(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
results
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
matches.extend(git_matches);
|
||||
}
|
||||
|
||||
Ok(serde_json::to_string_pretty(&matches)?)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -110,4 +192,22 @@ mod tests {
|
||||
assert!(parsed.get("modified_files").is_some());
|
||||
assert!(parsed.get("diff").is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_active_worktree_context_empty_git_repo() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||
|
||||
let handler = GetActiveWorktreeContextHandler;
|
||||
|
||||
let result = handler
|
||||
.execute(serde_json::json!({}), state)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get worktree context: {}", e))
|
||||
.unwrap();
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
assert!(parsed.get("branch").is_some() || parsed.is_object());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,8 +119,9 @@ impl McpTool for CreateEntitiesHandler {
|
||||
let req: CreateEntitiesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let mut inserted = Vec::new();
|
||||
state.modify_graph(|g| {
|
||||
for entity in req.entities {
|
||||
for mut entity in req.entities {
|
||||
if !entity.name.is_empty() {
|
||||
entity.entity_type = crate::models::normalize_entity_type(&entity.entity_type);
|
||||
inserted.push(entity.clone());
|
||||
g.entities.insert(entity.name.clone(), entity);
|
||||
}
|
||||
@@ -165,8 +166,9 @@ impl McpTool for CreateRelationsHandler {
|
||||
};
|
||||
let mut missing_nodes = std::collections::HashSet::new();
|
||||
state.modify_graph(|g| {
|
||||
for relation in req.relations {
|
||||
for mut relation in req.relations {
|
||||
if !relation.from.is_empty() && !relation.to.is_empty() {
|
||||
relation.relation_type = crate::models::normalize_relation_type(&relation.relation_type);
|
||||
let from_exists = g.entities.contains_key(&relation.from);
|
||||
let to_exists = g.entities.contains_key(&relation.to);
|
||||
if from_exists && to_exists {
|
||||
@@ -353,8 +355,9 @@ impl McpTool for ReadGraphHandler {
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: ReadGraphTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let max_tokens = req.max_tokens;
|
||||
let data = state.read_graph(|full| -> crate::error::Result<String> {
|
||||
if let Some(ns) = req.namespace {
|
||||
let mut result_json = if let Some(ns) = req.namespace {
|
||||
let mut filtered = BorrowedGraph::default();
|
||||
for (k, v) in &full.entities {
|
||||
if v.namespace == ns {
|
||||
@@ -366,10 +369,19 @@ impl McpTool for ReadGraphHandler {
|
||||
filtered.relations.push(r);
|
||||
}
|
||||
}
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string(&filtered)?)
|
||||
serde_json::to_string(&filtered)?
|
||||
} else {
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string(full)?)
|
||||
serde_json::to_string(full)?
|
||||
};
|
||||
|
||||
if let Some(max_tok) = max_tokens {
|
||||
let max_chars = max_tok * 4;
|
||||
if result_json.len() > max_chars {
|
||||
result_json.truncate(max_chars);
|
||||
result_json.push_str("... [TRUNCATED_TO_MAX_TOKENS]");
|
||||
}
|
||||
}
|
||||
Ok(result_json)
|
||||
})?;
|
||||
Ok(data)
|
||||
}
|
||||
@@ -553,6 +565,54 @@ impl McpTool for CondenseEntityHandler {
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: CondenseEntityTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let mut missing = false;
|
||||
|
||||
if req.summarized_observations.is_empty() {
|
||||
let mut obs_to_condense = Vec::new();
|
||||
state.read_graph(|g| {
|
||||
if let Some(e) = g.entities.get(&req.entity_name) {
|
||||
obs_to_condense = e.observations.clone();
|
||||
} else {
|
||||
missing = true;
|
||||
}
|
||||
});
|
||||
if missing {
|
||||
return Err(crate::error::AppError::Internal(format!(
|
||||
"Error: Entity '{}' not found. Please verify the exact entity name using search_nodes.",
|
||||
req.entity_name
|
||||
)));
|
||||
}
|
||||
let mut unique_obs: Vec<String> = Vec::new();
|
||||
for obs in obs_to_condense {
|
||||
let clean = obs.trim().to_string();
|
||||
if !clean.is_empty() && !unique_obs.contains(&clean) {
|
||||
unique_obs.push(clean);
|
||||
}
|
||||
}
|
||||
if unique_obs.len() > 3 && state.ollama.is_available().await {
|
||||
let prompt = format!(
|
||||
"Condense the following observations for entity '{}' into 2-3 concise architectural facts:\n- {}",
|
||||
req.entity_name,
|
||||
unique_obs.join("\n- ")
|
||||
);
|
||||
if let Ok(summary) = state.ollama.generate(&prompt, None, None).await {
|
||||
let lines: Vec<String> = summary
|
||||
.lines()
|
||||
.map(|l| l.trim().trim_start_matches('-').trim().to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect();
|
||||
if !lines.is_empty() {
|
||||
unique_obs = lines;
|
||||
}
|
||||
}
|
||||
}
|
||||
state.modify_graph(|master| {
|
||||
if let Some(e) = master.entities.get_mut(&req.entity_name) {
|
||||
e.observations = unique_obs;
|
||||
}
|
||||
});
|
||||
return Ok(format!("Entity '{}' observations condensed with deduplication/AI summary.", req.entity_name));
|
||||
}
|
||||
|
||||
state.modify_graph(|master| {
|
||||
if let Some(e) = master.entities.get_mut(&req.entity_name) {
|
||||
e.observations = req.summarized_observations;
|
||||
@@ -655,14 +715,316 @@ impl McpTool for FindOrphansHandler {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GetSubgraphHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for GetSubgraphHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"get_subgraph"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<GetSubgraphTool>("get_subgraph", "Execute get_subgraph")
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: GetSubgraphTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let root = req
|
||||
.root_entity
|
||||
.or(req.root_node)
|
||||
.ok_or_else(|| crate::error::AppError::Internal("root_entity or root_node is required".to_string()))?;
|
||||
let depth = req.depth.unwrap_or(2);
|
||||
let format = req.format.unwrap_or(SubgraphFormat::Json);
|
||||
|
||||
if format == SubgraphFormat::MarkdownTree {
|
||||
let (sub_entities, sub_relations) = state.read_graph(|g| {
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
let mut queue = std::collections::VecDeque::new();
|
||||
queue.push_back((root.clone(), 0));
|
||||
visited.insert(root.clone());
|
||||
|
||||
while let Some((curr, d)) = queue.pop_front() {
|
||||
if d >= depth {
|
||||
continue;
|
||||
}
|
||||
for r in &g.relations {
|
||||
if r.from == curr && !visited.contains(&r.to) {
|
||||
visited.insert(r.to.clone());
|
||||
queue.push_back((r.to.clone(), d + 1));
|
||||
} else if r.to == curr && !visited.contains(&r.from) {
|
||||
visited.insert(r.from.clone());
|
||||
queue.push_back((r.from.clone(), d + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ent: Vec<_> = g.entities.values().filter(|e| visited.contains(&e.name)).cloned().collect();
|
||||
let rel: Vec<_> = g.relations.iter().filter(|r| visited.contains(&r.from) && visited.contains(&r.to)).cloned().collect();
|
||||
(ent, rel)
|
||||
});
|
||||
|
||||
let mut markdown = format!("# Subgraph Topology for `{}`\n\n", root);
|
||||
markdown.push_str("## Entities\n");
|
||||
for e in &sub_entities {
|
||||
markdown.push_str(&format!("- **{}** (`{}`)\n", e.name, e.entity_type));
|
||||
for obs in &e.observations {
|
||||
markdown.push_str(&format!(" - {}\n", obs));
|
||||
}
|
||||
}
|
||||
|
||||
markdown.push_str("\n## Relations\n");
|
||||
for r in &sub_relations {
|
||||
markdown.push_str(&format!("- `{}` --[{}]--> `{}`\n", r.from, r.relation_type, r.to));
|
||||
}
|
||||
return Ok(markdown);
|
||||
}
|
||||
|
||||
let data = state.read_graph(|full| {
|
||||
let mut visited_nodes: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut matched_entities = std::collections::HashMap::new();
|
||||
let mut matched_relations = Vec::new();
|
||||
|
||||
let mut queue = std::collections::VecDeque::new();
|
||||
queue.push_back((root.clone(), 0));
|
||||
visited_nodes.insert(root.clone());
|
||||
|
||||
while let Some((curr, current_depth)) = queue.pop_front() {
|
||||
if let Some(entity) = full.entities.get(&curr) {
|
||||
matched_entities.insert(curr.clone(), entity.clone());
|
||||
}
|
||||
|
||||
if current_depth < depth {
|
||||
for rel in &full.relations {
|
||||
if rel.from == curr {
|
||||
matched_relations.push(rel.clone());
|
||||
if !visited_nodes.contains(&rel.to) {
|
||||
visited_nodes.insert(rel.to.clone());
|
||||
queue.push_back((rel.to.clone(), current_depth + 1));
|
||||
}
|
||||
} else if rel.to == curr {
|
||||
matched_relations.push(rel.clone());
|
||||
if !visited_nodes.contains(&rel.from) {
|
||||
visited_nodes.insert(rel.from.clone());
|
||||
queue.push_back((rel.from.clone(), current_depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = serde_json::json!({
|
||||
"root_node": root,
|
||||
"depth": depth,
|
||||
"entities": matched_entities,
|
||||
"relations": matched_relations,
|
||||
});
|
||||
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&result)?)
|
||||
})?;
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
}
|
||||
|
||||
use crate::handlers::utils::*;
|
||||
|
||||
pub struct SweepGraphHealthHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for SweepGraphHealthHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"sweep_graph_health"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<SweepGraphHealthTool>("sweep_graph_health", "Audit and sweep graph health")
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: SweepGraphHealthTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let auto_prune = req.auto_prune_orphans.unwrap_or(false);
|
||||
|
||||
let mut orphans = Vec::new();
|
||||
let mut duplicates = Vec::new();
|
||||
|
||||
state.modify_graph(|g| {
|
||||
// 1. Identify Orphans
|
||||
let mut connected = std::collections::HashSet::new();
|
||||
for r in &g.relations {
|
||||
connected.insert(r.from.clone());
|
||||
connected.insert(r.to.clone());
|
||||
}
|
||||
|
||||
for name in g.entities.keys() {
|
||||
if !connected.contains(name) {
|
||||
orphans.push(name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if auto_prune {
|
||||
for orphan in &orphans {
|
||||
g.entities.remove(orphan);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Compute similarity pairs for duplicate detection
|
||||
let names: Vec<_> = g.entities.keys().cloned().collect();
|
||||
for i in 0..names.len() {
|
||||
for j in (i + 1)..names.len() {
|
||||
let n1 = &names[i];
|
||||
let n2 = &names[j];
|
||||
|
||||
let l1 = n1.to_lowercase();
|
||||
let l2 = n2.to_lowercase();
|
||||
if l1 == l2 || ((l1.contains(&l2) || l2.contains(&l1)) && l1.len().min(l2.len()) > 3) {
|
||||
duplicates.push(serde_json::json!({
|
||||
"entity_a": n1,
|
||||
"entity_b": n2,
|
||||
"suggested_action": format!("merge_entities(source: '{}', target: '{}')", n1, n2)
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let report = serde_json::json!({
|
||||
"orphaned_entities": orphans,
|
||||
"orphans_pruned": auto_prune,
|
||||
"potential_duplicates": duplicates,
|
||||
"health_score": if orphans.is_empty() && duplicates.is_empty() { "100%" } else { "Needs Maintenance" }
|
||||
});
|
||||
|
||||
Ok(serde_json::to_string_pretty(&report)?)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ResolveStaleSymbolsHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for ResolveStaleSymbolsHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"resolve_stale_symbols"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<ResolveStaleSymbolsTool>("resolve_stale_symbols", "Inspect Knowledge Graph entities and tech debt symbol/line references against files on disk, healing stale pointers.")
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: ResolveStaleSymbolsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let auto_heal = req.auto_heal.unwrap_or(true);
|
||||
|
||||
let mut checked = 0;
|
||||
let mut stale_entities = Vec::new();
|
||||
|
||||
state.modify_graph(|g| {
|
||||
for (name, _entity) in g.entities.iter_mut() {
|
||||
if let Some(target) = &req.target {
|
||||
if name != target && !name.contains(target) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
checked += 1;
|
||||
let path = std::path::Path::new(name);
|
||||
if (name.contains('/') || name.contains('\\') || name.contains('.')) && !path.exists() {
|
||||
stale_entities.push(name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if auto_heal {
|
||||
for stale in &stale_entities {
|
||||
g.entities.remove(stale);
|
||||
g.relations.retain(|r| &r.from != stale && &r.to != stale);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let report = serde_json::json!({
|
||||
"checked_count": checked,
|
||||
"stale_entities_found": stale_entities,
|
||||
"auto_healed": auto_heal,
|
||||
"status": "success"
|
||||
});
|
||||
|
||||
Ok(serde_json::to_string_pretty(&report)?)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SummarizeSubgraphHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for SummarizeSubgraphHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"summarize_subgraph"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<SummarizeSubgraphTool>("summarize_subgraph", "Generate compact, LLM-optimized Markdown topology of a graph component capped within a token budget.")
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: SummarizeSubgraphTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let depth = req.depth.unwrap_or(2);
|
||||
|
||||
let (sub_entities, sub_relations) = state.read_graph(|g| {
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
let mut queue = std::collections::VecDeque::new();
|
||||
queue.push_back((req.root_entity.clone(), 0));
|
||||
visited.insert(req.root_entity.clone());
|
||||
|
||||
while let Some((curr, d)) = queue.pop_front() {
|
||||
if d >= depth {
|
||||
continue;
|
||||
}
|
||||
for r in &g.relations {
|
||||
if r.from == curr && !visited.contains(&r.to) {
|
||||
visited.insert(r.to.clone());
|
||||
queue.push_back((r.to.clone(), d + 1));
|
||||
} else if r.to == curr && !visited.contains(&r.from) {
|
||||
visited.insert(r.from.clone());
|
||||
queue.push_back((r.from.clone(), d + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ent: Vec<_> = g.entities.values().filter(|e| visited.contains(&e.name)).cloned().collect();
|
||||
let rel: Vec<_> = g.relations.iter().filter(|r| visited.contains(&r.from) && visited.contains(&r.to)).cloned().collect();
|
||||
(ent, rel)
|
||||
});
|
||||
|
||||
let mut markdown = format!("# Subgraph Topology for `{}`\n\n", req.root_entity);
|
||||
markdown.push_str("## Entities\n");
|
||||
for e in &sub_entities {
|
||||
markdown.push_str(&format!("- **{}** (`{}`)\n", e.name, e.entity_type));
|
||||
for obs in &e.observations {
|
||||
markdown.push_str(&format!(" - {}\n", obs));
|
||||
}
|
||||
}
|
||||
|
||||
markdown.push_str("\n## Relations\n");
|
||||
for r in &sub_relations {
|
||||
markdown.push_str(&format!("- `{}` --[{}]--> `{}`\n", r.from, r.relation_type, r.to));
|
||||
}
|
||||
|
||||
let max_tokens = req.max_tokens.unwrap_or(1000);
|
||||
let max_chars = max_tokens * 4;
|
||||
if markdown.len() > max_chars {
|
||||
markdown.truncate(max_chars);
|
||||
markdown.push_str("\n... [Truncated to fit token budget]");
|
||||
}
|
||||
|
||||
Ok(markdown)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::handlers::meta::{BroadcastAgentSignalHandler, QueryAgentSignalsHandler};
|
||||
use serde_json::json;
|
||||
use tempfile::tempdir;
|
||||
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_and_read_entities() {
|
||||
let dir = tempdir().unwrap();
|
||||
@@ -888,4 +1250,71 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(!res_orphans.contains("Y"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_more_graph_handlers() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||
|
||||
let create_handler = CreateEntitiesHandler;
|
||||
let args_ent = json!({
|
||||
"entities": [
|
||||
{"name": "Alpha", "entity_type": "Node", "observations": ["Obs1", "Obs2"], "namespace": "global"},
|
||||
{"name": "Beta", "entity_type": "Node", "observations": ["Obs3"], "namespace": "global"}
|
||||
]
|
||||
});
|
||||
create_handler.execute(args_ent, state.clone()).await.unwrap();
|
||||
|
||||
let rel_handler = CreateRelationsHandler;
|
||||
let args_rel = json!({
|
||||
"relations": [
|
||||
{"from": "Alpha", "to": "Beta", "relation_type": "links_to", "namespace": "global"}
|
||||
]
|
||||
});
|
||||
rel_handler.execute(args_rel, state.clone()).await.unwrap();
|
||||
|
||||
let path_handler = QueryGraphPathHandler;
|
||||
let path_res = path_handler.execute(json!({"start_node": "Alpha", "end_node": "Beta"}), state.clone()).await.unwrap();
|
||||
assert!(path_res.contains("Alpha"));
|
||||
|
||||
let sub_handler = GetSubgraphHandler;
|
||||
let sub_res = sub_handler.execute(json!({"root_entity": "Alpha"}), state.clone()).await.unwrap();
|
||||
assert!(sub_res.contains("Alpha"));
|
||||
|
||||
let sum_handler = SummarizeSubgraphHandler;
|
||||
let sum_res = sum_handler.execute(json!({"root_entity": "Alpha"}), state.clone()).await.unwrap();
|
||||
assert!(!sum_res.is_empty());
|
||||
|
||||
|
||||
let sweep_handler = SweepGraphHealthHandler;
|
||||
let sweep_res = sweep_handler.execute(json!({}), state.clone()).await.unwrap();
|
||||
assert!(!sweep_res.is_empty());
|
||||
|
||||
let del_obs_handler = DeleteObservationsHandler;
|
||||
let del_obs_res = del_obs_handler.execute(json!({
|
||||
"deletions": [{"entity_name": "Alpha", "observations": ["Obs1"]}]
|
||||
}), state.clone()).await.unwrap();
|
||||
assert_eq!(del_obs_res, "Observations deleted");
|
||||
|
||||
let del_rel_handler = DeleteRelationsHandler;
|
||||
let del_rel_res = del_rel_handler.execute(json!({
|
||||
"relations": [{"from": "Alpha", "to": "Beta", "relation_type": "links_to"}]
|
||||
}), state.clone()).await.unwrap();
|
||||
assert_eq!(del_rel_res, "Relations deleted");
|
||||
|
||||
let bcast_handler = BroadcastAgentSignalHandler;
|
||||
let bcast_res = bcast_handler.execute(json!({
|
||||
"sender": "agent1",
|
||||
"signal_type": "task_completed",
|
||||
"payload": "fix_bug"
|
||||
}), state.clone()).await.unwrap();
|
||||
assert!(bcast_res.contains("Broadcasted signal"));
|
||||
|
||||
|
||||
let qsignal_handler = QueryAgentSignalsHandler;
|
||||
let qsignal_res = qsignal_handler.execute(json!({"sender": "agent1"}), state.clone()).await.unwrap();
|
||||
assert!(qsignal_res.contains("task_completed"));
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,4 +133,30 @@ mod tests {
|
||||
assert!(result.contains("line1"));
|
||||
assert!(result.contains("line3"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_recent_logs_with_large_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||
let handler = GetRecentLogsHandler;
|
||||
|
||||
let log_file = dir.path().join("large_test.log");
|
||||
let mut buffer = String::new();
|
||||
for _ in 0..1000 {
|
||||
buffer.push_str("line\n");
|
||||
}
|
||||
std::fs::write(&log_file, buffer).unwrap();
|
||||
|
||||
let args = serde_json::json!({
|
||||
"file_path": log_file.to_str().unwrap()
|
||||
});
|
||||
|
||||
let result = handler
|
||||
.execute(args, state)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get recent logs: {}", e))
|
||||
.unwrap();
|
||||
assert!(result.contains("line"));
|
||||
}
|
||||
}
|
||||
|
||||
+1650
-15
File diff suppressed because it is too large.
Load diff
@@ -7,6 +7,90 @@ use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct ManageStickyNotesHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for ManageStickyNotesHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"manage_sticky_notes"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<ManageStickyNotesTool>(
|
||||
"manage_sticky_notes",
|
||||
"Manage ephemeral sticky notes with TTL (add, read, delete, clear)",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: ManageStickyNotesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
match req.action {
|
||||
StickyNoteAction::Add => {
|
||||
let content = req.content.ok_or_else(|| {
|
||||
crate::error::AppError::Internal("content is required for 'add' action".to_string())
|
||||
})?;
|
||||
let now = crate::handlers::utils::now_secs();
|
||||
let expires_at = if let Some(ttl) = req.ttl_seconds {
|
||||
Some(now + ttl)
|
||||
} else if req.session_only.unwrap_or(false) {
|
||||
Some(now + 14400)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
state.code.sticky.modify(|notes| {
|
||||
notes.push(StickyNote {
|
||||
timestamp: now,
|
||||
content,
|
||||
expires_at,
|
||||
});
|
||||
});
|
||||
Ok("Sticky note added.".to_string())
|
||||
}
|
||||
StickyNoteAction::Read => {
|
||||
let now = crate::handlers::utils::now_secs();
|
||||
let mut active_notes = Vec::new();
|
||||
state.code.sticky.modify(|notes| {
|
||||
notes.retain(|n| {
|
||||
if let Some(exp) = n.expires_at {
|
||||
exp > now
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
active_notes = notes.clone();
|
||||
});
|
||||
Ok(serde_json::to_string(&active_notes)?)
|
||||
}
|
||||
StickyNoteAction::Delete => {
|
||||
let idx = req.index.ok_or_else(|| {
|
||||
crate::error::AppError::Internal("index is required for 'delete' action".to_string())
|
||||
})?;
|
||||
let mut success = false;
|
||||
state.code.sticky.modify(|notes| {
|
||||
if idx > 0 && idx <= notes.len() {
|
||||
notes.remove(idx - 1);
|
||||
success = true;
|
||||
}
|
||||
});
|
||||
if success {
|
||||
Ok("Sticky note deleted.".to_string())
|
||||
} else {
|
||||
Err(crate::error::AppError::Internal(
|
||||
"Invalid sticky note index.".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
StickyNoteAction::Clear => {
|
||||
state.code.sticky.modify(|notes| {
|
||||
notes.clear();
|
||||
});
|
||||
Ok("All sticky notes cleared.".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AddStickyNoteHandler;
|
||||
|
||||
#[async_trait]
|
||||
@@ -21,11 +105,20 @@ impl McpTool for AddStickyNoteHandler {
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: AddStickyNoteTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let now = crate::handlers::utils::now_secs();
|
||||
let expires_at = if let Some(ttl) = req.ttl_seconds {
|
||||
Some(now + ttl)
|
||||
} else if req.session_only.unwrap_or(false) {
|
||||
Some(now + 14400) // Default 4-hour session TTL
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
state.code.sticky.modify(|notes| {
|
||||
notes.push(StickyNote {
|
||||
timestamp: crate::handlers::utils::now_secs(),
|
||||
timestamp: now,
|
||||
content: req.content,
|
||||
expires_at: None,
|
||||
expires_at,
|
||||
});
|
||||
});
|
||||
Ok("Sticky note added.".to_string())
|
||||
@@ -48,11 +141,21 @@ impl McpTool for ReadStickyNotesHandler {
|
||||
}
|
||||
|
||||
async fn execute(&self, _args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let data = state
|
||||
.code
|
||||
.sticky
|
||||
.read_with(|s| Ok::<String, crate::error::AppError>(serde_json::to_string(s)?))?;
|
||||
Ok(data)
|
||||
let now = crate::handlers::utils::now_secs();
|
||||
let mut active_notes = Vec::new();
|
||||
|
||||
state.code.sticky.modify(|notes| {
|
||||
notes.retain(|n| {
|
||||
if let Some(exp) = n.expires_at {
|
||||
exp > now
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
active_notes = notes.clone();
|
||||
});
|
||||
|
||||
Ok(serde_json::to_string(&active_notes)?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +241,10 @@ impl McpTool for LeaveHandoffMemoHandler {
|
||||
namespace: req.namespace,
|
||||
timestamp: crate::handlers::utils::now_secs(),
|
||||
expires_at: None,
|
||||
})
|
||||
});
|
||||
if memos.len() > 200 {
|
||||
memos.remove(0);
|
||||
}
|
||||
});
|
||||
Ok("Handoff memo left".to_string())
|
||||
}
|
||||
@@ -227,7 +333,10 @@ impl McpTool for AddSessionSummaryHandler {
|
||||
namespace: req.namespace,
|
||||
timestamp: crate::handlers::utils::now_secs(),
|
||||
expires_at: None,
|
||||
})
|
||||
});
|
||||
if summaries.len() > 200 {
|
||||
summaries.remove(0);
|
||||
}
|
||||
});
|
||||
Ok("Session summary added".to_string())
|
||||
}
|
||||
@@ -268,6 +377,57 @@ impl McpTool for GenerateStandupReportHandler {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PromoteToEntityHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for PromoteToEntityHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"promote_to_entity"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<PromoteToEntityTool>(
|
||||
"promote_to_entity",
|
||||
"Promote a transient sticky note or active task observation into a permanent Knowledge Graph entity.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: PromoteToEntityTool =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
|
||||
let entity_name = req.entity_name.clone();
|
||||
let obs = req.content.clone();
|
||||
let entity_type = req.entity_type.clone();
|
||||
let namespace = req.namespace.clone();
|
||||
|
||||
state.modify_graph(|g| {
|
||||
let entity = g
|
||||
.entities
|
||||
.entry(entity_name.clone())
|
||||
.or_insert_with(|| Entity {
|
||||
name: entity_name.clone(),
|
||||
entity_type,
|
||||
observations: Vec::new(),
|
||||
namespace,
|
||||
git_branch: None,
|
||||
});
|
||||
if !entity.observations.contains(&obs) {
|
||||
entity.observations.push(obs.clone());
|
||||
}
|
||||
});
|
||||
|
||||
state.code.sticky.modify(|notes| {
|
||||
notes.retain(|n| !n.content.contains(&obs));
|
||||
});
|
||||
|
||||
Ok(format!(
|
||||
"Promoted transient note into permanent entity '{}'.",
|
||||
req.entity_name
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -366,4 +526,22 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(!res4.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_invalid_sticky_note_action() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||
|
||||
let handler = ManageStickyNotesHandler;
|
||||
let invalid_args = serde_json::json!({
|
||||
"action": "invalid_action"
|
||||
});
|
||||
|
||||
let res = handler
|
||||
.execute(invalid_args, state.clone())
|
||||
.await;
|
||||
assert!(res.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -281,11 +281,12 @@ impl McpTool for ListActiveTasksHandler {
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: ListActiveTasksTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let level = req.summary_level.as_deref().unwrap_or("detailed");
|
||||
let data = state.project.tasks.read_with(|tasks| {
|
||||
let filtered: Vec<_> = tasks
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
let status_match = t.status != "done";
|
||||
let status_match = t.status != "done" && t.status != "completed";
|
||||
let branch_match = match &req.git_branch {
|
||||
Some(branch) => {
|
||||
t.git_branch.is_none()
|
||||
@@ -295,8 +296,31 @@ impl McpTool for ListActiveTasksHandler {
|
||||
};
|
||||
status_match && branch_match
|
||||
})
|
||||
.map(|t| match level {
|
||||
"compact" => serde_json::json!({
|
||||
"id": t.id,
|
||||
"title": t.title,
|
||||
"status": t.status,
|
||||
}),
|
||||
"full" => serde_json::to_value(t).unwrap_or_default(),
|
||||
_ => serde_json::json!({
|
||||
"id": t.id,
|
||||
"title": t.title,
|
||||
"status": t.status,
|
||||
"description": t.description,
|
||||
"git_branch": t.git_branch,
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string(&filtered)?)
|
||||
let mut json_str = serde_json::to_string(&filtered)?;
|
||||
if let Some(max_t) = req.max_tokens {
|
||||
let char_limit = max_t * 4;
|
||||
if json_str.len() > char_limit {
|
||||
json_str.truncate(char_limit);
|
||||
json_str.push_str(" ...[truncated due to max_tokens]");
|
||||
}
|
||||
}
|
||||
Ok::<String, crate::error::AppError>(json_str)
|
||||
})?;
|
||||
Ok(data)
|
||||
}
|
||||
@@ -684,4 +708,36 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(res_del.contains("Deleted task and its children (2 total)."));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
async fn test_list_milestones_with_namespace() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||
|
||||
let add_milestone = AddMilestoneHandler;
|
||||
let args_ms = serde_json::json!({
|
||||
"name": "v1.0",
|
||||
"title": "Release 1.0",
|
||||
"description": "First release",
|
||||
"target_date": 1700000000,
|
||||
"end_date": 1700000000,
|
||||
"namespace": "global"
|
||||
});
|
||||
let res1 = add_milestone
|
||||
.execute(args_ms, state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(res1.contains("Milestone added"));
|
||||
|
||||
let list_ms = ListMilestonesHandler;
|
||||
let res2 = list_ms
|
||||
.execute(serde_json::json!({"namespace": "global"}), state.clone())
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
|
||||
.unwrap();
|
||||
assert!(res2.contains("Release 1.0"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,11 +23,11 @@ impl McpTool for WriteClipboardHandler {
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let tool_args: WriteClipboardTool = serde_json::from_value(args)
|
||||
.map_err(|e| crate::error::AppError::Internal(format!("Invalid args: {}", e)))?;
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
let mut msgs = Vec::new();
|
||||
|
||||
if let Ok(mut clipboard) = Clipboard::new()
|
||||
@@ -77,7 +77,10 @@ impl McpTool for WriteClipboardHandler {
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))?
|
||||
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??;
|
||||
|
||||
state.record_activity("clipboard", &res, None);
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,51 +104,51 @@ impl McpTool for ReadClipboardHandler {
|
||||
tokio::task::spawn_blocking(move || -> crate::error::Result<serde_json::Value> {
|
||||
let mut out = serde_json::Map::new();
|
||||
|
||||
if let Ok(mut clipboard) = arboard::Clipboard::new()
|
||||
&& let Ok(text) = clipboard.get_text()
|
||||
&& !text.trim().is_empty()
|
||||
{
|
||||
out.insert("text".into(), json!(text));
|
||||
}
|
||||
|
||||
if let Ok(mut clipboard) = arboard::Clipboard::new()
|
||||
&& let Ok(image_data) = clipboard.get_image()
|
||||
&& let Some(img) = ImageBuffer::<image::Rgba<u8>, _>::from_raw(
|
||||
image_data.width as u32,
|
||||
image_data.height as u32,
|
||||
image_data.bytes.into_owned(),
|
||||
)
|
||||
{
|
||||
let mut dynamic_img = image::DynamicImage::ImageRgba8(img);
|
||||
let max_dim = 1024;
|
||||
if dynamic_img.width() > max_dim || dynamic_img.height() > max_dim {
|
||||
dynamic_img = dynamic_img.resize(max_dim, max_dim, FilterType::Lanczos3);
|
||||
if let Ok(mut clipboard) = arboard::Clipboard::new() {
|
||||
if let Ok(text) = clipboard.get_text() {
|
||||
if !text.trim().is_empty() {
|
||||
out.insert("text".into(), json!(text));
|
||||
}
|
||||
}
|
||||
let rgb_img = dynamic_img.into_rgb8();
|
||||
|
||||
let cache_dir = dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.join(".gemini/mcp_memory/clipboard");
|
||||
let _ = std::fs::create_dir_all(&cache_dir);
|
||||
if let Ok(image_data) = clipboard.get_image() {
|
||||
if let Some(img) = ImageBuffer::<image::Rgba<u8>, _>::from_raw(
|
||||
image_data.width as u32,
|
||||
image_data.height as u32,
|
||||
image_data.bytes.into_owned(),
|
||||
) {
|
||||
let mut dynamic_img = image::DynamicImage::ImageRgba8(img);
|
||||
let max_dim = 1024;
|
||||
if dynamic_img.width() > max_dim || dynamic_img.height() > max_dim {
|
||||
dynamic_img = dynamic_img.resize(max_dim, max_dim, FilterType::Lanczos3);
|
||||
}
|
||||
let rgb_img = dynamic_img.into_rgb8();
|
||||
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
let file_path = cache_dir.join(format!("clipboard_{}.jpg", timestamp));
|
||||
let cache_dir = dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.join(".gemini/mcp_memory/clipboard");
|
||||
let _ = std::fs::create_dir_all(&cache_dir);
|
||||
|
||||
if rgb_img
|
||||
.save_with_format(&file_path, image::ImageFormat::Jpeg)
|
||||
.is_ok()
|
||||
{
|
||||
let path_str = file_path.to_string_lossy().to_string();
|
||||
out.insert("image_path".into(), json!(path_str));
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
let file_path = cache_dir.join(format!("clipboard_{}.jpg", timestamp));
|
||||
|
||||
// Read image bytes for base64 encoding if needed by vision
|
||||
if let Ok(bytes) = std::fs::read(&file_path) {
|
||||
use base64::Engine;
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
out.insert("image_base64".into(), json!(b64));
|
||||
if rgb_img
|
||||
.save_with_format(&file_path, image::ImageFormat::Jpeg)
|
||||
.is_ok()
|
||||
{
|
||||
let path_str = file_path.to_string_lossy().to_string();
|
||||
out.insert("image_path".into(), json!(path_str));
|
||||
|
||||
// Read image bytes for base64 encoding if needed by vision
|
||||
if let Ok(bytes) = std::fs::read(&file_path) {
|
||||
use base64::Engine;
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
out.insert("image_base64".into(), json!(b64));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,6 +179,7 @@ impl McpTool for ReadClipboardHandler {
|
||||
}
|
||||
}
|
||||
|
||||
state.record_activity("clipboard", "Read contents from OS clipboard", None);
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&final_obj)?)
|
||||
}
|
||||
}
|
||||
@@ -203,14 +207,14 @@ impl McpTool for ToggleClipboardWatchModeHandler {
|
||||
let mut watch_mode = state.clipboard_watch_mode.write().await;
|
||||
*watch_mode = tool_args.enable;
|
||||
|
||||
if tool_args.enable {
|
||||
Ok(
|
||||
"Clipboard watch mode enabled. Changes will be ingested as StickyNotes."
|
||||
.to_string(),
|
||||
)
|
||||
let status_msg = if tool_args.enable {
|
||||
"Clipboard watch mode enabled. Changes will be ingested as StickyNotes."
|
||||
} else {
|
||||
Ok("Clipboard watch mode disabled.".to_string())
|
||||
}
|
||||
"Clipboard watch mode disabled."
|
||||
};
|
||||
|
||||
state.record_activity("clipboard", status_msg, None);
|
||||
Ok(status_msg.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,4 +284,21 @@ mod tests {
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
assert!(parsed.is_object());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_read_clipboard_empty() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||
let handler = ReadClipboardHandler;
|
||||
|
||||
let result = handler
|
||||
.execute(serde_json::json!({}), state)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read clipboard: {}", e))
|
||||
.unwrap();
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
assert!(parsed.is_object());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ impl McpTool for StoreSnippetHandler {
|
||||
code: req.code,
|
||||
description: req.description,
|
||||
updated_at: crate::handlers::utils::now_secs(),
|
||||
tags: req.tags.unwrap_or_default(),
|
||||
embedding,
|
||||
};
|
||||
|
||||
@@ -138,6 +139,43 @@ impl McpTool for StoreSnippetHandler {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TagSnippetHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for TagSnippetHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"tag_snippet"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<TagSnippetTool>("tag_snippet", "Execute tag_snippet")
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: TagSnippetTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let mut found = false;
|
||||
state.code.snippets.modify(|snippets| {
|
||||
for s in snippets.iter_mut() {
|
||||
if s.name == req.name {
|
||||
for tag in &req.tags {
|
||||
if !s.tags.contains(tag) {
|
||||
s.tags.push(tag.clone());
|
||||
}
|
||||
}
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if found {
|
||||
Ok(format!("Snippet '{}' tagged with: {:?}", req.name, req.tags))
|
||||
} else {
|
||||
Err(crate::error::AppError::Internal(format!("Snippet '{}' not found.", req.name)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SearchSnippetsHandler;
|
||||
|
||||
#[async_trait]
|
||||
@@ -147,22 +185,49 @@ impl McpTool for SearchSnippetsHandler {
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<SearchSnippetsTool>("search_snippets", "Execute search_snippets")
|
||||
crate::mcp::tool_def::<SearchSnippetsTool>(
|
||||
"search_snippets",
|
||||
"Search stored code snippets using Hybrid BM25 + Vector ranking or text search",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: SearchSnippetsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let query = req.query;
|
||||
let limit = req.limit.unwrap_or(10);
|
||||
let include_body = req.include_body.unwrap_or(false);
|
||||
let tags_filter = req.tags.unwrap_or_default();
|
||||
|
||||
let data = state.code.snippets.read_with(|snippets| {
|
||||
let results: Vec<_> = snippets
|
||||
.iter()
|
||||
.filter(|s| {
|
||||
contains_ignore_ascii_case(&s.name, &query)
|
||||
let matches_text = query.is_empty()
|
||||
|| contains_ignore_ascii_case(&s.name, &query)
|
||||
|| contains_ignore_ascii_case(&s.description, &query)
|
||||
|| contains_ignore_ascii_case(&s.language, &query)
|
||||
|| s.tags.iter().any(|t| contains_ignore_ascii_case(t, &query));
|
||||
|
||||
let matches_tags = tags_filter.is_empty()
|
||||
|| tags_filter.iter().any(|tf| s.tags.iter().any(|st| contains_ignore_ascii_case(st, tf)));
|
||||
|
||||
matches_text && matches_tags
|
||||
})
|
||||
.take(limit)
|
||||
.map(|s| {
|
||||
if include_body {
|
||||
serde_json::json!(s)
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"name": s.name,
|
||||
"description": s.description,
|
||||
"language": s.language,
|
||||
"tags": s.tags
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string(&results)?)
|
||||
Ok::<String, crate::error::AppError>(serde_json::to_string_pretty(&results)?)
|
||||
})?;
|
||||
Ok(data)
|
||||
}
|
||||
@@ -523,7 +588,60 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(res8, "PR checklist cleared");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_workspace_and_subagent_handlers() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let state = Arc::new(MemoryState::new(temp_dir.path().to_str().unwrap()));
|
||||
|
||||
// Snapshots
|
||||
let create_snap = CreateSnapshotHandler;
|
||||
let snap_res = create_snap.execute(json!({"description": "Test snap", "namespace": "global"}), state.clone()).await.unwrap();
|
||||
assert!(snap_res.contains("SNAP-"));
|
||||
let snap_id = snap_res.split_whitespace().nth(1).unwrap();
|
||||
|
||||
let restore_snap = RestoreSnapshotHandler;
|
||||
let restore_res = restore_snap.execute(json!({"snapshot_id": snap_id}), state.clone()).await.unwrap();
|
||||
assert!(restore_res.contains("Successfully restored"));
|
||||
|
||||
// Subagent Namespace
|
||||
let create_sub_ns = CreateSubagentNamespaceHandler;
|
||||
let sub_ns_res = create_sub_ns.execute(json!({"subagent_id": "test_agent"}), state.clone()).await.unwrap();
|
||||
assert!(sub_ns_res.contains("subagent-test_agent"));
|
||||
|
||||
let manage_sub_ns = ManageSubagentNamespaceHandler;
|
||||
let manage_res = manage_sub_ns.execute(json!({
|
||||
"subagent_id": "test_agent",
|
||||
"action": "create"
|
||||
}), state.clone()).await.unwrap();
|
||||
assert!(manage_res.contains("initialized"));
|
||||
|
||||
// Context Workspace Diff
|
||||
let save_ws = SaveContextWorkspaceHandler;
|
||||
save_ws.execute(json!({"name": "ws_a", "description": "Workspace A", "pinned_files": ["src/lib.rs"], "active_task_ids": []}), state.clone()).await.unwrap();
|
||||
save_ws.execute(json!({"name": "ws_b", "description": "Workspace B", "pinned_files": ["src/main.rs"], "active_task_ids": []}), state.clone()).await.unwrap();
|
||||
|
||||
|
||||
let diff_ws = DiffContextWorkspacesHandler;
|
||||
let diff_res = diff_ws.execute(json!({"workspace_a": "ws_a", "workspace_b": "ws_b"}), state.clone()).await.unwrap();
|
||||
assert!(diff_res.contains("workspace_a"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_diff_context_workspaces_handler() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||
|
||||
let save_ws = SaveContextWorkspaceHandler;
|
||||
save_ws.execute(json!({"name": "ws_1", "description": "Workspace 1", "pinned_files": ["src/lib.rs"], "active_task_ids": []}), state.clone()).await.unwrap();
|
||||
save_ws.execute(json!({"name": "ws_2", "description": "Workspace 2", "pinned_files": ["src/main.rs"], "active_task_ids": []}), state.clone()).await.unwrap();
|
||||
|
||||
let diff_ws = DiffContextWorkspacesHandler;
|
||||
let diff_res = diff_ws.execute(json!({"workspace_a": "ws_1", "workspace_b": "ws_2"}), state.clone()).await.unwrap();
|
||||
assert!(diff_res.contains("ws_1"));
|
||||
}
|
||||
}
|
||||
|
||||
use crate::tools::ReadDirectoryArchitectureTool;
|
||||
use std::fs;
|
||||
|
||||
@@ -726,6 +844,95 @@ impl McpTool for RestoreSnapshotHandler {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ManageSubagentNamespaceHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for ManageSubagentNamespaceHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"manage_subagent_namespace"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<ManageSubagentNamespaceTool>(
|
||||
"manage_subagent_namespace",
|
||||
"Manage isolated memory namespaces for subagent sessions (create, condense/promote, or purge)",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: ManageSubagentNamespaceTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let subagent_ns = format!("subagent-{}", req.subagent_id);
|
||||
|
||||
match req.action {
|
||||
SubagentNamespaceAction::Create => {
|
||||
state.record_activity("namespace", &format!("Created subagent namespace {}", subagent_ns), None);
|
||||
Ok(format!("Subagent memory namespace '{}' initialized.", subagent_ns))
|
||||
}
|
||||
SubagentNamespaceAction::Purge => {
|
||||
state.project.pinned_files.modify(|files| {
|
||||
files.retain(|f| f.namespace != subagent_ns);
|
||||
});
|
||||
state.record_activity("namespace", &format!("Purged subagent namespace {}", subagent_ns), None);
|
||||
Ok(format!("Subagent memory namespace '{}' purged.", subagent_ns))
|
||||
}
|
||||
SubagentNamespaceAction::Condense => {
|
||||
let purge = req.purge_after_promotion.unwrap_or(true);
|
||||
let mut promoted_entities = 0;
|
||||
let mut promoted_relations = 0;
|
||||
|
||||
state.modify_graph(|g| {
|
||||
let sub_entities: Vec<Entity> = g
|
||||
.entities
|
||||
.values()
|
||||
.filter(|e| e.namespace == subagent_ns)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
for mut e in sub_entities {
|
||||
promoted_entities += 1;
|
||||
e.namespace = "global".to_string();
|
||||
let global_entry = g.entities.entry(e.name.clone()).or_insert_with(|| Entity {
|
||||
name: e.name.clone(),
|
||||
entity_type: e.entity_type.clone(),
|
||||
observations: Vec::new(),
|
||||
namespace: "global".to_string(),
|
||||
git_branch: e.git_branch.clone(),
|
||||
});
|
||||
for obs in e.observations {
|
||||
if !global_entry.observations.contains(&obs) {
|
||||
global_entry.observations.push(obs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for r in &mut g.relations {
|
||||
if r.namespace == subagent_ns {
|
||||
promoted_relations += 1;
|
||||
r.namespace = "global".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if purge {
|
||||
g.entities.retain(|_, e| e.namespace != subagent_ns);
|
||||
g.relations.retain(|r| r.namespace != subagent_ns);
|
||||
}
|
||||
});
|
||||
|
||||
if purge {
|
||||
state.project.pinned_files.modify(|files| {
|
||||
files.retain(|f| f.namespace != subagent_ns);
|
||||
});
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"Subagent namespace '{}' condensed: promoted {} entities and {} relations to global graph.",
|
||||
subagent_ns, promoted_entities, promoted_relations
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CreateSubagentNamespaceHandler;
|
||||
|
||||
#[async_trait]
|
||||
@@ -770,3 +977,141 @@ impl McpTool for PurgeSubagentNamespaceHandler {
|
||||
Ok(format!("Subagent memory namespace '{}' purged.", ns))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CondenseSubagentNamespaceHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for CondenseSubagentNamespaceHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"condense_subagent_namespace"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<CondenseSubagentNamespaceTool>(
|
||||
"condense_subagent_namespace",
|
||||
"Auto-condense a subagent's memory namespace and promote its entities/relations to the global Knowledge Graph.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: CondenseSubagentNamespaceTool =
|
||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let subagent_ns = format!("subagent-{}", req.subagent_id);
|
||||
let purge = req.purge_after_promotion.unwrap_or(true);
|
||||
|
||||
let mut promoted_entities = 0;
|
||||
let mut promoted_relations = 0;
|
||||
|
||||
state.modify_graph(|g| {
|
||||
let sub_entities: Vec<Entity> = g
|
||||
.entities
|
||||
.values()
|
||||
.filter(|e| e.namespace == subagent_ns)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
for mut e in sub_entities {
|
||||
promoted_entities += 1;
|
||||
e.namespace = "global".to_string();
|
||||
let global_entry = g.entities.entry(e.name.clone()).or_insert_with(|| Entity {
|
||||
name: e.name.clone(),
|
||||
entity_type: e.entity_type.clone(),
|
||||
observations: Vec::new(),
|
||||
namespace: "global".to_string(),
|
||||
git_branch: e.git_branch.clone(),
|
||||
});
|
||||
for obs in e.observations {
|
||||
if !global_entry.observations.contains(&obs) {
|
||||
global_entry.observations.push(obs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for r in &mut g.relations {
|
||||
if r.namespace == subagent_ns {
|
||||
r.namespace = "global".to_string();
|
||||
promoted_relations += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if purge {
|
||||
g.entities.retain(|_, e| e.namespace != subagent_ns);
|
||||
g.relations.retain(|r| r.namespace != subagent_ns);
|
||||
}
|
||||
});
|
||||
|
||||
if purge {
|
||||
state.project.pinned_files.modify(|files| {
|
||||
files.retain(|f| f.namespace != subagent_ns);
|
||||
});
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"Consolidated subagent namespace '{}': Promoted {} entities, {} relations to global. Purged: {}",
|
||||
subagent_ns, promoted_entities, promoted_relations, purge
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DiffContextWorkspacesHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTool for DiffContextWorkspacesHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"diff_context_workspaces"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
crate::mcp::tool_def::<DiffContextWorkspacesTool>("diff_context_workspaces", "Execute diff_context_workspaces")
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
|
||||
let req: DiffContextWorkspacesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
|
||||
let workspaces = state.project.context_workspaces.read_with(|ws| ws.clone());
|
||||
let ws_a = workspaces.iter().find(|w| w.name == req.workspace_a);
|
||||
let ws_b = workspaces.iter().find(|w| w.name == req.workspace_b);
|
||||
|
||||
if ws_a.is_none() || ws_b.is_none() {
|
||||
return Err(crate::error::AppError::Internal(format!(
|
||||
"One or both context workspaces not found: '{}' found={}, '{}' found={}",
|
||||
req.workspace_a, ws_a.is_some(), req.workspace_b, ws_b.is_some()
|
||||
)));
|
||||
}
|
||||
|
||||
let a = ws_a.unwrap();
|
||||
let b = ws_b.unwrap();
|
||||
|
||||
let set_files_a: std::collections::HashSet<_> = a.pinned_files.iter().cloned().collect();
|
||||
let set_files_b: std::collections::HashSet<_> = b.pinned_files.iter().cloned().collect();
|
||||
|
||||
let added_files: Vec<_> = set_files_b.difference(&set_files_a).cloned().collect();
|
||||
let removed_files: Vec<_> = set_files_a.difference(&set_files_b).cloned().collect();
|
||||
let shared_files: Vec<_> = set_files_a.intersection(&set_files_b).cloned().collect();
|
||||
|
||||
let set_tasks_a: std::collections::HashSet<_> = a.active_task_ids.iter().cloned().collect();
|
||||
let set_tasks_b: std::collections::HashSet<_> = b.active_task_ids.iter().cloned().collect();
|
||||
|
||||
let added_tasks: Vec<_> = set_tasks_b.difference(&set_tasks_a).cloned().collect();
|
||||
let removed_tasks: Vec<_> = set_tasks_a.difference(&set_tasks_b).cloned().collect();
|
||||
let shared_tasks: Vec<_> = set_tasks_a.intersection(&set_tasks_b).cloned().collect();
|
||||
|
||||
let diff = serde_json::json!({
|
||||
"workspace_a": a.name,
|
||||
"workspace_b": b.name,
|
||||
"pinned_files": {
|
||||
"added_in_b": added_files,
|
||||
"removed_in_b": removed_files,
|
||||
"shared": shared_files
|
||||
},
|
||||
"active_tasks": {
|
||||
"added_in_b": added_tasks,
|
||||
"removed_in_b": removed_tasks,
|
||||
"shared": shared_tasks
|
||||
}
|
||||
});
|
||||
|
||||
Ok(serde_json::to_string_pretty(&diff)?)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user