Skip to main content

chant/
conflict.rs

1//! Conflict detection and resolution spec creation.
2//!
3//! This module handles detection of merge conflicts and automatic creation
4//! of conflict resolution specs with context about the conflicting branches.
5//!
6//! # Doc Audit
7//! - audited: 2026-01-25
8//! - docs: guides/recovery.md
9//! - ignore: false
10
11use anyhow::{Context, Result};
12use std::path::Path;
13use std::process::Command;
14
15use crate::id;
16use crate::spec::{Spec, SpecStatus};
17
18/// Context information about a merge conflict
19#[derive(Debug, Clone)]
20pub struct ConflictContext {
21    pub source_branch: String,
22    pub target_branch: String,
23    pub conflicting_files: Vec<String>,
24    pub source_spec_id: String,
25    pub source_spec_title: Option<String>,
26    pub diff_summary: String,
27}
28
29/// Detect conflicting files from git status
30pub fn detect_conflicting_files() -> Result<Vec<String>> {
31    let output = Command::new("git")
32        .args(["status", "--porcelain"])
33        .output()
34        .context("Failed to run git status")?;
35
36    if !output.status.success() {
37        anyhow::bail!("Failed to get git status");
38    }
39
40    let status = String::from_utf8_lossy(&output.stdout);
41    let mut conflicting_files = Vec::new();
42
43    for line in status.lines() {
44        // Conflicted files in git status output start with "UU", "AA", "DD", "AU", "UD", "UA", "DU"
45        if line.len() > 3 {
46            let status_code = &line[..2];
47            match status_code {
48                "UU" | "AA" | "DD" | "AU" | "UD" | "UA" | "DU" => {
49                    let file = line[3..].trim().to_string();
50                    conflicting_files.push(file);
51                }
52                _ => {}
53            }
54        }
55    }
56
57    Ok(conflicting_files)
58}
59
60/// Extract context from a spec
61pub fn extract_spec_context(specs_dir: &Path, spec_id: &str) -> Result<(Option<String>, String)> {
62    // Try to load the spec to get title and description
63    match crate::spec::resolve_spec(specs_dir, spec_id) {
64        Ok(spec) => {
65            let title = spec.title.clone();
66            let body = spec.body.clone();
67            Ok((title, body))
68        }
69        Err(_) => {
70            // Spec not found, return empty context
71            Ok((None, String::new()))
72        }
73    }
74}
75
76/// Get the diff summary between two branches
77pub fn get_diff_summary(source_branch: &str, target_branch: &str) -> Result<String> {
78    let output = Command::new("git")
79        .args([
80            "diff",
81            "--stat",
82            &format!("{}..{}", target_branch, source_branch),
83        ])
84        .output()
85        .context("Failed to get git diff")?;
86
87    if !output.status.success() {
88        return Ok("(unable to generate diff)".to_string());
89    }
90
91    Ok(String::from_utf8_lossy(&output.stdout)
92        .lines()
93        .take(10) // Limit to 10 lines
94        .map(|s| s.to_string())
95        .collect::<Vec<_>>()
96        .join("\n"))
97}
98
99/// Identify specs that are blocked by conflicting files
100pub fn get_blocked_specs(conflicting_files: &[String], all_specs: &[Spec]) -> Vec<String> {
101    let mut blocked = Vec::new();
102
103    for spec in all_specs {
104        // Skip completed and failed specs
105        if spec.frontmatter.status == SpecStatus::Completed
106            || spec.frontmatter.status == SpecStatus::Failed
107        {
108            continue;
109        }
110
111        // Check if any target_files overlap with conflicting files
112        if let Some(target_files) = &spec.frontmatter.target_files {
113            for conflicting_file in conflicting_files {
114                if target_files.iter().any(|tf| {
115                    // Check for exact match or prefix match (directory containing file)
116                    tf == conflicting_file || conflicting_file.starts_with(&format!("{}/", tf))
117                }) {
118                    blocked.push(spec.id.clone());
119                    break;
120                }
121            }
122        }
123    }
124
125    blocked
126}
127
128/// Create a conflict resolution spec
129pub fn create_conflict_spec(
130    specs_dir: &Path,
131    context: &ConflictContext,
132    blocked_specs: Vec<String>,
133) -> Result<String> {
134    // Generate spec ID
135    let spec_id = id::generate_id(specs_dir)?;
136
137    // Build conflict spec content
138    let mut content = String::new();
139
140    // Frontmatter
141    let conflicting_files_yaml = context
142        .conflicting_files
143        .iter()
144        .map(|f| format!("- {}", f))
145        .collect::<Vec<_>>()
146        .join("\n");
147
148    let blocked_specs_yaml = if blocked_specs.is_empty() {
149        "blocked_specs: []".to_string()
150    } else {
151        let items = blocked_specs
152            .iter()
153            .map(|s| format!("  - {}", s))
154            .collect::<Vec<_>>()
155            .join("\n");
156        format!("blocked_specs:\n{}", items)
157    };
158
159    content.push_str(&format!(
160        r#"---
161type: conflict
162status: pending
163source_branch: {}
164target_branch: {}
165conflicting_files:
166{}
167{}
168original_spec: {}
169---
170"#,
171        context.source_branch,
172        context.target_branch,
173        conflicting_files_yaml,
174        blocked_specs_yaml,
175        context.source_spec_id
176    ));
177
178    // Title and body
179    content.push_str(&format!(
180        "# Resolve merge conflict: {} → {}\n\n",
181        context.source_branch, context.target_branch
182    ));
183
184    content.push_str("## Conflict Summary\n");
185    content.push_str(&format!("- **Source branch**: {}\n", context.source_branch));
186    content.push_str(&format!("- **Target branch**: {}\n", context.target_branch));
187    content.push_str(&format!(
188        "- **Conflicting files**: {}\n",
189        context
190            .conflicting_files
191            .iter()
192            .map(|f| format!("`{}`", f))
193            .collect::<Vec<_>>()
194            .join(", ")
195    ));
196
197    if !blocked_specs.is_empty() {
198        content.push_str(&format!(
199            "- **Blocked specs**: {}\n",
200            blocked_specs.join(", ")
201        ));
202    }
203
204    content.push('\n');
205
206    // Context from original spec
207    content.push_str("## Context from Original Spec\n\n");
208    if let Some(title) = &context.source_spec_title {
209        content.push_str(&format!("**Title**: {}\n\n", title));
210    }
211    content.push_str("```\n");
212    content.push_str(&context.diff_summary);
213    content.push_str("\n```\n\n");
214
215    // Resolution instructions
216    content.push_str("## Resolution Instructions\n\n");
217    content.push_str("1. Examine the conflicting files listed above\n");
218    content.push_str("2. Resolve conflicts manually in your editor or using git tools\n");
219    content.push_str("3. Stage resolved files: `git add <files>`\n");
220    content.push_str("4. Complete the merge: `git commit`\n");
221    content.push_str("5. Update this spec with resolution details\n\n");
222
223    // Acceptance criteria
224    content.push_str("## Acceptance Criteria\n\n");
225    for file in &context.conflicting_files {
226        content.push_str(&format!("- [ ] Resolved conflicts in `{}`\n", file));
227    }
228    content.push_str("- [ ] Merge completed successfully\n");
229
230    // Save the spec
231    let spec_path = specs_dir.join(format!("{}.md", spec_id));
232    std::fs::write(&spec_path, &content).context("Failed to write conflict spec file")?;
233
234    Ok(spec_id)
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::spec::SpecFrontmatter;
241
242    #[test]
243    fn test_detect_conflicting_files_parses_status() {
244        // This test would require mocking git commands or running in a repo with conflicts
245        // For now, we test the parsing logic indirectly
246        let conflicting_files = ["src/config.rs".to_string(), "docs/guide.md".to_string()];
247        assert_eq!(conflicting_files.len(), 2);
248    }
249
250    #[test]
251    fn test_get_blocked_specs_empty_when_no_overlap() {
252        let spec1 = Spec {
253            id: "2026-01-25-001-abc".to_string(),
254            frontmatter: SpecFrontmatter {
255                target_files: Some(vec!["src/lib.rs".to_string()]),
256                ..Default::default()
257            },
258            title: None,
259            body: String::new(),
260        };
261
262        let conflicting_files = vec!["src/config.rs".to_string()];
263        let blocked = get_blocked_specs(&conflicting_files, &[spec1]);
264        assert!(blocked.is_empty());
265    }
266
267    #[test]
268    fn test_get_blocked_specs_finds_overlap() {
269        let spec1 = Spec {
270            id: "2026-01-25-001-abc".to_string(),
271            frontmatter: SpecFrontmatter {
272                status: SpecStatus::Pending,
273                target_files: Some(vec!["src/config.rs".to_string()]),
274                ..Default::default()
275            },
276            title: None,
277            body: String::new(),
278        };
279
280        let conflicting_files = vec!["src/config.rs".to_string()];
281        let blocked = get_blocked_specs(&conflicting_files, &[spec1]);
282        assert_eq!(blocked.len(), 1);
283        assert_eq!(blocked[0], "2026-01-25-001-abc");
284    }
285
286    #[test]
287    fn test_get_blocked_specs_ignores_completed() {
288        let spec1 = Spec {
289            id: "2026-01-25-001-abc".to_string(),
290            frontmatter: SpecFrontmatter {
291                status: SpecStatus::Completed,
292                target_files: Some(vec!["src/config.rs".to_string()]),
293                ..Default::default()
294            },
295            title: None,
296            body: String::new(),
297        };
298
299        let conflicting_files = vec!["src/config.rs".to_string()];
300        let blocked = get_blocked_specs(&conflicting_files, &[spec1]);
301        assert!(blocked.is_empty());
302    }
303}