git_paw/mcp/query/
conflicts.rs1use std::time::{Duration, Instant};
11
12use rmcp::schemars;
13use serde::Serialize;
14
15use crate::broker::conflict::{ConflictTracker, NormalizedFileIntent};
16use crate::broker::messages::{BrokerMessage, Region};
17use crate::broker::publish::fetch_log_entries_over_http;
18use crate::mcp::RepoContext;
19
20use super::now_unix;
21
22#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
24pub struct Conflict {
25 pub shape: String,
27 pub branches: [String; 2],
29 pub files: Vec<String>,
31 pub detected_at: u64,
33}
34
35fn region_label(region: &Region) -> String {
36 match region {
37 Region::Function { name } => format!("fn:{name}"),
38 Region::Class { name } => format!("class:{name}"),
39 Region::Block { anchor } => format!("block:{anchor}"),
40 Region::Range {
41 start_line,
42 end_line,
43 } => format!("lines:{start_line}-{end_line}"),
44 }
45}
46
47#[must_use]
50pub fn conflicts(ctx: &RepoContext) -> Vec<Conflict> {
51 let Some(url) = ctx.broker_url.as_deref() else {
52 return Vec::new();
53 };
54 let Ok(entries) = fetch_log_entries_over_http(url) else {
55 return Vec::new();
56 };
57
58 let now_secs = now_unix();
62 let now_instant = Instant::now();
63 let ttl = Duration::from_hours(1);
64
65 let mut latest: std::collections::HashMap<
66 String,
67 (Vec<NormalizedFileIntent>, String, u64, u64),
68 > = std::collections::HashMap::new();
69 for entry in entries {
70 if let BrokerMessage::Intent { agent_id, payload } = entry.message {
71 let files: Vec<NormalizedFileIntent> = payload
72 .files
73 .iter()
74 .cloned()
75 .map(NormalizedFileIntent::from)
76 .collect();
77 latest.insert(
78 agent_id,
79 (
80 files,
81 payload.summary,
82 entry.timestamp_unix_secs,
83 payload.valid_for_seconds,
84 ),
85 );
86 }
87 }
88
89 let mut tracker = ConflictTracker::new();
90 let mut agents: Vec<String> = Vec::new();
91 for (agent_id, (files, summary, published_at, valid_for)) in latest {
92 if published_at.saturating_add(valid_for) <= now_secs {
93 continue; }
95 agents.push(agent_id.clone());
96 tracker.insert_intent(&agent_id, files, summary, ttl, now_instant);
97 }
98 agents.sort();
99
100 let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
102 let mut out = Vec::new();
103 for agent in &agents {
104 for fc in tracker.forward_overlaps(agent) {
105 let mut pair = [agent.clone(), fc.other_agent.clone()];
106 pair.sort();
107 let key = (pair[0].clone(), pair[1].clone());
108 if !seen.insert(key) {
109 continue;
110 }
111 let mut files: Vec<String> = fc
112 .files
113 .iter()
114 .map(|f| {
115 if f.regions.is_empty() {
116 f.path.clone()
117 } else {
118 let regions: Vec<String> = f.regions.iter().map(region_label).collect();
119 format!("{} [{}]", f.path, regions.join(", "))
120 }
121 })
122 .collect();
123 files.sort();
124 out.push(Conflict {
125 shape: "forward".to_string(),
126 branches: pair,
127 files,
128 detected_at: now_secs,
129 });
130 }
131 }
132 out.sort_by(|a, b| a.branches.cmp(&b.branches));
133 out
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 #[test]
141 fn no_broker_yields_empty() {
142 let ctx = RepoContext {
143 root: std::path::PathBuf::from("/tmp"),
144 git_paw_dir: None,
145 broker_url: None,
146 server_name: "git-paw".to_string(),
147 };
148 assert!(conflicts(&ctx).is_empty());
149 }
150}