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])
68                    .replace('\0', "");
69                return Ok(Some((sym.clone(), text)));
70            }
71        }
72    }
73
74    Ok(None)
75}
76
77/// Extract the first line of a symbol's source as its signature.
78fn extract_signature(source: &str) -> String {
79    source
80        .lines()
81        .next()
82        .unwrap_or("")
83        .trim()
84        .to_string()
85}
86
87/// Count lines in source text.
88fn line_count(source: &str) -> usize {
89    if source.is_empty() {
90        0
91    } else {
92        source.lines().count()
93    }
94}
95
96/// Generate a human-readable description comparing two versions of a symbol.
97fn describe_change(
98    base_sig: &str,
99    base_body: &str,
100    changed_sig: &str,
101    changed_body: &str,
102    change_type: &str,
103) -> String {
104    match change_type {
105        "added" => "New symbol added".to_string(),
106        "deleted" => "Symbol deleted".to_string(),
107        _ => {
108            let mut parts = Vec::new();
109
110            if base_sig != changed_sig {
111                parts.push(format!(
112                    "Signature changed from `{base_sig}` to `{changed_sig}`"
113                ));
114            }
115
116            let base_lines = line_count(base_body);
117            let changed_lines = line_count(changed_body);
118            if changed_lines > base_lines {
119                parts.push(format!(
120                    "Added {} lines",
121                    changed_lines - base_lines
122                ));
123            } else if changed_lines < base_lines {
124                parts.push(format!(
125                    "Removed {} lines",
126                    base_lines - changed_lines
127                ));
128            } else if base_body != changed_body {
129                parts.push("Body modified (same line count)".to_string());
130            }
131
132            if parts.is_empty() {
133                "No visible changes".to_string()
134            } else {
135                parts.join("; ")
136            }
137        }
138    }
139}
140
141/// Build a `SymbolVersion` from content, or return an empty version if the
142/// symbol doesn't exist in the given content.
143fn build_symbol_version(
144    registry: &ParserRegistry,
145    file_path: &str,
146    content: &str,
147    qualified_name: &str,
148    base_sig: &str,
149    base_body: &str,
150    label: &str,
151) -> Result<SymbolVersion> {
152    match find_symbol_in_content(registry, file_path, content, qualified_name)? {
153        Some((_sym, text)) => {
154            let sig = extract_signature(&text);
155            let change_type = if label == "base" {
156                "base".to_string()
157            } else if base_body.is_empty() {
158                "added".to_string()
159            } else {
160                "modified".to_string()
161            };
162            let desc = describe_change(base_sig, base_body, &sig, &text, &change_type);
163            Ok(SymbolVersion {
164                description: desc,
165                signature: sig,
166                body: text,
167                change_type,
168            })
169        }
170        None => {
171            let change_type = if label == "base" {
172                "base".to_string()
173            } else {
174                "deleted".to_string()
175            };
176            let desc = describe_change(base_sig, base_body, "", "", &change_type);
177            Ok(SymbolVersion {
178                description: desc,
179                signature: String::new(),
180                body: String::new(),
181                change_type,
182            })
183        }
184    }
185}
186
187/// Build a `SymbolConflictDetail` for a specific symbol conflict.
188///
189/// - `file_path`: the file where the conflict occurs
190/// - `qualified_name`: the symbol's qualified name
191/// - `conflicting_agent`: the name of the other agent
192/// - `base_content`: the common ancestor version of the file
193/// - `their_content`: the conflicting session's version of the file
194/// - `your_content`: your session's version of the file
195pub fn build_conflict_detail(
196    registry: &ParserRegistry,
197    file_path: &str,
198    qualified_name: &str,
199    conflicting_agent: &str,
200    base_content: &str,
201    their_content: &str,
202    your_content: &str,
203) -> Result<SymbolConflictDetail> {
204    // Extract base version first to get reference signature/body
205    let (base_sig, base_body, kind) =
206        match find_symbol_in_content(registry, file_path, base_content, qualified_name)? {
207            Some((sym, text)) => {
208                let sig = extract_signature(&text);
209                let kind = sym.kind.to_string();
210                (sig, text, kind)
211            }
212            None => (String::new(), String::new(), "unknown".to_string()),
213        };
214
215    let base_version = SymbolVersion {
216        description: if base_body.is_empty() {
217            "Symbol does not exist in base".to_string()
218        } else {
219            "Base version".to_string()
220        },
221        signature: base_sig.clone(),
222        body: base_body.clone(),
223        change_type: "base".to_string(),
224    };
225
226    let their_change = build_symbol_version(
227        registry,
228        file_path,
229        their_content,
230        qualified_name,
231        &base_sig,
232        &base_body,
233        "their",
234    )?;
235
236    let your_change = build_symbol_version(
237        registry,
238        file_path,
239        your_content,
240        qualified_name,
241        &base_sig,
242        &base_body,
243        "your",
244    )?;
245
246    Ok(SymbolConflictDetail {
247        file_path: file_path.to_string(),
248        qualified_name: qualified_name.to_string(),
249        kind,
250        conflicting_agent: conflicting_agent.to_string(),
251        their_change,
252        your_change,
253        base_version,
254    })
255}
256
257/// Build a complete `ConflictBlock` from a list of conflicting symbols.
258///
259/// Each entry in `conflicts` is `(file_path, qualified_name, conflicting_agent,
260/// base_content, their_content, your_content)`.
261pub fn build_conflict_block(
262    registry: &ParserRegistry,
263    conflicts: &[(
264        &str, // file_path
265        &str, // qualified_name
266        &str, // conflicting_agent
267        &str, // base_content
268        &str, // their_content
269        &str, // your_content
270    )],
271) -> Result<ConflictBlock> {
272    let mut details = Vec::new();
273
274    for (file_path, qualified_name, agent, base, theirs, yours) in conflicts {
275        details.push(build_conflict_detail(
276            registry,
277            file_path,
278            qualified_name,
279            agent,
280            base,
281            theirs,
282            yours,
283        )?);
284    }
285
286    let count = details.len();
287    let message = if count == 1 {
288        format!(
289            "1 symbol conflict detected in {}",
290            details[0].file_path
291        )
292    } else {
293        let files: std::collections::BTreeSet<&str> =
294            details.iter().map(|d| d.file_path.as_str()).collect();
295        format!(
296            "{count} symbol conflicts detected across {} file(s)",
297            files.len()
298        )
299    };
300
301    Ok(ConflictBlock {
302        conflicting_symbols: details,
303        message,
304    })
305}