Skip to main content

dk_engine/conflict/
payload.rs

1//! Self-contained conflict payloads for the SUBMIT response.
2//!
3//! When two sessions touch the same symbol, the `ConflictPayloadBuilder`
4//! produces a `ConflictBlock` with full source context: base version, their
5//! version, and your version of each conflicting symbol. This gives agents
6//! everything they need to resolve the conflict without additional RPCs.
7
8use std::path::Path;
9
10use serde::Serialize;
11
12use dk_core::{Error, Result, Symbol};
13
14use crate::parser::ParserRegistry;
15
16/// A block of conflicts to include in a SUBMIT response.
17#[derive(Debug, Clone, Serialize)]
18pub struct ConflictBlock {
19    pub conflicting_symbols: Vec<SymbolConflictDetail>,
20    pub message: String,
21}
22
23/// Full detail about a single symbol conflict.
24#[derive(Debug, Clone, Serialize)]
25pub struct SymbolConflictDetail {
26    pub file_path: String,
27    pub qualified_name: String,
28    pub kind: String,
29    pub conflicting_agent: String,
30    pub their_change: SymbolVersion,
31    pub your_change: SymbolVersion,
32    pub base_version: SymbolVersion,
33}
34
35/// A specific version of a symbol's source.
36#[derive(Debug, Clone, Serialize)]
37pub struct SymbolVersion {
38    pub description: String,
39    pub signature: String,
40    pub body: String,
41    /// One of: "modified", "added", "deleted", "base"
42    pub change_type: String,
43}
44
45/// Extract a symbol's source text from content using the parser.
46fn find_symbol_in_content(
47    registry: &ParserRegistry,
48    file_path: &str,
49    content: &str,
50    qualified_name: &str,
51) -> Result<Option<(Symbol, String)>> {
52    let path = Path::new(file_path);
53    if !registry.supports_file(path) {
54        return Err(Error::UnsupportedLanguage(format!(
55            "Unsupported file: {file_path}"
56        )));
57    }
58
59    let analysis = registry.parse_file(path, content.as_bytes())?;
60
61    for sym in &analysis.symbols {
62        if sym.qualified_name == qualified_name {
63            let start = sym.span.start_byte as usize;
64            let end = sym.span.end_byte as usize;
65            let bytes = content.as_bytes();
66            if end <= bytes.len() {
67                let text = String::from_utf8_lossy(&bytes[start..end]).to_string();
68                return Ok(Some((sym.clone(), text)));
69            }
70        }
71    }
72
73    Ok(None)
74}
75
76/// Extract the first line of a symbol's source as its signature.
77fn extract_signature(source: &str) -> String {
78    source
79        .lines()
80        .next()
81        .unwrap_or("")
82        .trim()
83        .to_string()
84}
85
86/// Count lines in source text.
87fn line_count(source: &str) -> usize {
88    if source.is_empty() {
89        0
90    } else {
91        source.lines().count()
92    }
93}
94
95/// Generate a human-readable description comparing two versions of a symbol.
96fn describe_change(
97    base_sig: &str,
98    base_body: &str,
99    changed_sig: &str,
100    changed_body: &str,
101    change_type: &str,
102) -> String {
103    match change_type {
104        "added" => "New symbol added".to_string(),
105        "deleted" => "Symbol deleted".to_string(),
106        _ => {
107            let mut parts = Vec::new();
108
109            if base_sig != changed_sig {
110                parts.push(format!(
111                    "Signature changed from `{base_sig}` to `{changed_sig}`"
112                ));
113            }
114
115            let base_lines = line_count(base_body);
116            let changed_lines = line_count(changed_body);
117            if changed_lines > base_lines {
118                parts.push(format!(
119                    "Added {} lines",
120                    changed_lines - base_lines
121                ));
122            } else if changed_lines < base_lines {
123                parts.push(format!(
124                    "Removed {} lines",
125                    base_lines - changed_lines
126                ));
127            } else if base_body != changed_body {
128                parts.push("Body modified (same line count)".to_string());
129            }
130
131            if parts.is_empty() {
132                "No visible changes".to_string()
133            } else {
134                parts.join("; ")
135            }
136        }
137    }
138}
139
140/// Build a `SymbolVersion` from content, or return an empty version if the
141/// symbol doesn't exist in the given content.
142fn build_symbol_version(
143    registry: &ParserRegistry,
144    file_path: &str,
145    content: &str,
146    qualified_name: &str,
147    base_sig: &str,
148    base_body: &str,
149    label: &str,
150) -> Result<SymbolVersion> {
151    match find_symbol_in_content(registry, file_path, content, qualified_name)? {
152        Some((_sym, text)) => {
153            let sig = extract_signature(&text);
154            let change_type = if label == "base" {
155                "base".to_string()
156            } else if base_body.is_empty() {
157                "added".to_string()
158            } else {
159                "modified".to_string()
160            };
161            let desc = describe_change(base_sig, base_body, &sig, &text, &change_type);
162            Ok(SymbolVersion {
163                description: desc,
164                signature: sig,
165                body: text,
166                change_type,
167            })
168        }
169        None => {
170            let change_type = if label == "base" {
171                "base".to_string()
172            } else {
173                "deleted".to_string()
174            };
175            let desc = describe_change(base_sig, base_body, "", "", &change_type);
176            Ok(SymbolVersion {
177                description: desc,
178                signature: String::new(),
179                body: String::new(),
180                change_type,
181            })
182        }
183    }
184}
185
186/// Build a `SymbolConflictDetail` for a specific symbol conflict.
187///
188/// - `file_path`: the file where the conflict occurs
189/// - `qualified_name`: the symbol's qualified name
190/// - `conflicting_agent`: the name of the other agent
191/// - `base_content`: the common ancestor version of the file
192/// - `their_content`: the conflicting session's version of the file
193/// - `your_content`: your session's version of the file
194pub fn build_conflict_detail(
195    registry: &ParserRegistry,
196    file_path: &str,
197    qualified_name: &str,
198    conflicting_agent: &str,
199    base_content: &str,
200    their_content: &str,
201    your_content: &str,
202) -> Result<SymbolConflictDetail> {
203    // Extract base version first to get reference signature/body
204    let (base_sig, base_body, kind) =
205        match find_symbol_in_content(registry, file_path, base_content, qualified_name)? {
206            Some((sym, text)) => {
207                let sig = extract_signature(&text);
208                let kind = sym.kind.to_string();
209                (sig, text, kind)
210            }
211            None => (String::new(), String::new(), "unknown".to_string()),
212        };
213
214    let base_version = SymbolVersion {
215        description: if base_body.is_empty() {
216            "Symbol does not exist in base".to_string()
217        } else {
218            "Base version".to_string()
219        },
220        signature: base_sig.clone(),
221        body: base_body.clone(),
222        change_type: "base".to_string(),
223    };
224
225    let their_change = build_symbol_version(
226        registry,
227        file_path,
228        their_content,
229        qualified_name,
230        &base_sig,
231        &base_body,
232        "their",
233    )?;
234
235    let your_change = build_symbol_version(
236        registry,
237        file_path,
238        your_content,
239        qualified_name,
240        &base_sig,
241        &base_body,
242        "your",
243    )?;
244
245    Ok(SymbolConflictDetail {
246        file_path: file_path.to_string(),
247        qualified_name: qualified_name.to_string(),
248        kind,
249        conflicting_agent: conflicting_agent.to_string(),
250        their_change,
251        your_change,
252        base_version,
253    })
254}
255
256/// Build a complete `ConflictBlock` from a list of conflicting symbols.
257///
258/// Each entry in `conflicts` is `(file_path, qualified_name, conflicting_agent,
259/// base_content, their_content, your_content)`.
260pub fn build_conflict_block(
261    registry: &ParserRegistry,
262    conflicts: &[(
263        &str, // file_path
264        &str, // qualified_name
265        &str, // conflicting_agent
266        &str, // base_content
267        &str, // their_content
268        &str, // your_content
269    )],
270) -> Result<ConflictBlock> {
271    let mut details = Vec::new();
272
273    for (file_path, qualified_name, agent, base, theirs, yours) in conflicts {
274        details.push(build_conflict_detail(
275            registry,
276            file_path,
277            qualified_name,
278            agent,
279            base,
280            theirs,
281            yours,
282        )?);
283    }
284
285    let count = details.len();
286    let message = if count == 1 {
287        format!(
288            "1 symbol conflict detected in {}",
289            details[0].file_path
290        )
291    } else {
292        let files: std::collections::BTreeSet<&str> =
293            details.iter().map(|d| d.file_path.as_str()).collect();
294        format!(
295            "{count} symbol conflicts detected across {} file(s)",
296            files.len()
297        )
298    };
299
300    Ok(ConflictBlock {
301        conflicting_symbols: details,
302        message,
303    })
304}