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_create_conflict_spec_generates_valid_spec() {
244        use std::fs;
245        let temp_dir = tempfile::tempdir().unwrap();
246        let specs_dir = temp_dir.path();
247
248        let context = ConflictContext {
249            source_branch: "chant/2026-01-25-001-abc".to_string(),
250            target_branch: "main".to_string(),
251            conflicting_files: vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
252            source_spec_id: "2026-01-25-001-abc".to_string(),
253            source_spec_title: Some("Original spec title".to_string()),
254            diff_summary: "3 files changed, 10 insertions(+), 5 deletions(-)".to_string(),
255        };
256
257        let blocked_specs = vec!["2026-01-25-002-xyz".to_string()];
258
259        let spec_id = create_conflict_spec(specs_dir, &context, blocked_specs).unwrap();
260
261        // Read the created spec
262        let spec_path = specs_dir.join(format!("{}.md", spec_id));
263        assert!(spec_path.exists(), "Spec file should be created");
264
265        let content = fs::read_to_string(&spec_path).unwrap();
266
267        // Verify frontmatter
268        assert!(content.contains("type: conflict"));
269        assert!(content.contains("status: pending"));
270        assert!(content.contains("source_branch: chant/2026-01-25-001-abc"));
271        assert!(content.contains("target_branch: main"));
272        assert!(content.contains("- src/main.rs"));
273        assert!(content.contains("- src/lib.rs"));
274        assert!(content.contains("blocked_specs:"));
275        assert!(content.contains("- 2026-01-25-002-xyz"));
276        assert!(content.contains("original_spec: 2026-01-25-001-abc"));
277
278        // Verify body content
279        assert!(content.contains("# Resolve merge conflict"));
280        assert!(content.contains("## Conflict Summary"));
281        assert!(content.contains("## Context from Original Spec"));
282        assert!(content.contains("Original spec title"));
283        assert!(content.contains("## Resolution Instructions"));
284        assert!(content.contains("## Acceptance Criteria"));
285        assert!(content.contains("- [ ] Resolved conflicts in `src/main.rs`"));
286        assert!(content.contains("- [ ] Resolved conflicts in `src/lib.rs`"));
287        assert!(content.contains("- [ ] Merge completed successfully"));
288    }
289
290    #[test]
291    fn test_create_conflict_spec_no_blocked_specs() {
292        let temp_dir = tempfile::tempdir().unwrap();
293        let specs_dir = temp_dir.path();
294
295        let context = ConflictContext {
296            source_branch: "chant/spec-001".to_string(),
297            target_branch: "main".to_string(),
298            conflicting_files: vec!["README.md".to_string()],
299            source_spec_id: "spec-001".to_string(),
300            source_spec_title: None,
301            diff_summary: "1 file changed".to_string(),
302        };
303
304        let spec_id = create_conflict_spec(specs_dir, &context, vec![]).unwrap();
305
306        let spec_path = specs_dir.join(format!("{}.md", spec_id));
307        let content = std::fs::read_to_string(&spec_path).unwrap();
308
309        assert!(content.contains("blocked_specs: []"));
310    }
311
312    #[test]
313    fn test_extract_spec_context_existing_spec() {
314        use std::fs;
315        let temp_dir = tempfile::tempdir().unwrap();
316        let specs_dir = temp_dir.path();
317
318        // Create a test spec file
319        let spec_content = r#"---
320type: code
321status: completed
322---
323# Test Spec Title
324
325This is the spec body with some description.
326"#;
327        let spec_path = specs_dir.join("2026-01-25-001-abc.md");
328        fs::write(&spec_path, spec_content).unwrap();
329
330        let (title, body) = extract_spec_context(specs_dir, "2026-01-25-001-abc").unwrap();
331
332        assert_eq!(title, Some("Test Spec Title".to_string()));
333        assert!(body.contains("This is the spec body"));
334    }
335
336    #[test]
337    fn test_extract_spec_context_nonexistent_spec() {
338        let temp_dir = tempfile::tempdir().unwrap();
339        let specs_dir = temp_dir.path();
340
341        let (title, body) = extract_spec_context(specs_dir, "nonexistent").unwrap();
342
343        assert_eq!(title, None);
344        assert_eq!(body, "");
345    }
346
347    #[test]
348    fn test_get_blocked_specs_with_directory_overlap() {
349        let spec1 = Spec {
350            id: "2026-01-25-001-abc".to_string(),
351            frontmatter: SpecFrontmatter {
352                status: SpecStatus::Pending,
353                target_files: Some(vec!["src".to_string()]),
354                ..Default::default()
355            },
356            title: None,
357            body: String::new(),
358        };
359
360        let conflicting_files = vec!["src/config.rs".to_string(), "src/lib.rs".to_string()];
361        let blocked = get_blocked_specs(&conflicting_files, &[spec1]);
362        assert_eq!(blocked.len(), 1);
363        assert_eq!(blocked[0], "2026-01-25-001-abc");
364    }
365
366    #[test]
367    fn test_get_blocked_specs_multiple_specs() {
368        let spec1 = Spec {
369            id: "spec1".to_string(),
370            frontmatter: SpecFrontmatter {
371                status: SpecStatus::Pending,
372                target_files: Some(vec!["src/main.rs".to_string()]),
373                ..Default::default()
374            },
375            title: None,
376            body: String::new(),
377        };
378
379        let spec2 = Spec {
380            id: "spec2".to_string(),
381            frontmatter: SpecFrontmatter {
382                status: SpecStatus::InProgress,
383                target_files: Some(vec!["src/lib.rs".to_string()]),
384                ..Default::default()
385            },
386            title: None,
387            body: String::new(),
388        };
389
390        let spec3 = Spec {
391            id: "spec3".to_string(),
392            frontmatter: SpecFrontmatter {
393                status: SpecStatus::Pending,
394                target_files: Some(vec!["docs/guide.md".to_string()]),
395                ..Default::default()
396            },
397            title: None,
398            body: String::new(),
399        };
400
401        let conflicting_files = vec!["src/main.rs".to_string(), "src/lib.rs".to_string()];
402        let blocked = get_blocked_specs(&conflicting_files, &[spec1, spec2, spec3]);
403        assert_eq!(blocked.len(), 2);
404        assert!(blocked.contains(&"spec1".to_string()));
405        assert!(blocked.contains(&"spec2".to_string()));
406    }
407
408    #[test]
409    fn test_get_blocked_specs_empty_when_no_overlap() {
410        let spec1 = Spec {
411            id: "2026-01-25-001-abc".to_string(),
412            frontmatter: SpecFrontmatter {
413                target_files: Some(vec!["src/lib.rs".to_string()]),
414                ..Default::default()
415            },
416            title: None,
417            body: String::new(),
418        };
419
420        let conflicting_files = vec!["src/config.rs".to_string()];
421        let blocked = get_blocked_specs(&conflicting_files, &[spec1]);
422        assert!(blocked.is_empty());
423    }
424
425    #[test]
426    fn test_get_blocked_specs_finds_overlap() {
427        let spec1 = Spec {
428            id: "2026-01-25-001-abc".to_string(),
429            frontmatter: SpecFrontmatter {
430                status: SpecStatus::Pending,
431                target_files: Some(vec!["src/config.rs".to_string()]),
432                ..Default::default()
433            },
434            title: None,
435            body: String::new(),
436        };
437
438        let conflicting_files = vec!["src/config.rs".to_string()];
439        let blocked = get_blocked_specs(&conflicting_files, &[spec1]);
440        assert_eq!(blocked.len(), 1);
441        assert_eq!(blocked[0], "2026-01-25-001-abc");
442    }
443
444    #[test]
445    fn test_get_blocked_specs_ignores_completed() {
446        let spec1 = Spec {
447            id: "2026-01-25-001-abc".to_string(),
448            frontmatter: SpecFrontmatter {
449                status: SpecStatus::Completed,
450                target_files: Some(vec!["src/config.rs".to_string()]),
451                ..Default::default()
452            },
453            title: None,
454            body: String::new(),
455        };
456
457        let conflicting_files = vec!["src/config.rs".to_string()];
458        let blocked = get_blocked_specs(&conflicting_files, &[spec1]);
459        assert!(blocked.is_empty());
460    }
461}