1use anyhow::{Context, Result};
12use std::path::Path;
13use std::process::Command;
14
15use crate::id;
16use crate::spec::{Spec, SpecStatus};
17
18#[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
29pub 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 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
60pub fn extract_spec_context(specs_dir: &Path, spec_id: &str) -> Result<(Option<String>, String)> {
62 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 Ok((None, String::new()))
72 }
73 }
74}
75
76pub 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) .map(|s| s.to_string())
95 .collect::<Vec<_>>()
96 .join("\n"))
97}
98
99pub 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 if spec.frontmatter.status == SpecStatus::Completed
106 || spec.frontmatter.status == SpecStatus::Failed
107 {
108 continue;
109 }
110
111 if let Some(target_files) = &spec.frontmatter.target_files {
113 for conflicting_file in conflicting_files {
114 if target_files.iter().any(|tf| {
115 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
128pub fn create_conflict_spec(
130 specs_dir: &Path,
131 context: &ConflictContext,
132 blocked_specs: Vec<String>,
133) -> Result<String> {
134 let spec_id = id::generate_id(specs_dir)?;
136
137 let mut content = String::new();
139
140 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 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 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 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 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 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 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 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 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 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}