1use std::future::Future;
18use std::path::Path;
19
20use std::collections::HashSet;
21
22use car_ast::{
23 analyze, expand_footprint, FootprintSubtask, ProjectIndex, SymbolFootprint, SymbolRef,
24};
25use serde::Deserialize;
26
27use super::harness::{Subtask, FOOTPRINT_BLAST_DEPTH};
28
29#[derive(Debug)]
31pub struct DecomposeResult {
32 pub subtasks: Vec<Subtask>,
34 pub levels: Vec<Vec<String>>,
36 pub prefer_single_session: bool,
41 pub attempts: u32,
42 pub issues: Vec<String>,
44}
45
46impl DecomposeResult {
47 pub fn is_valid(&self) -> bool {
48 !self.subtasks.is_empty() && self.issues.is_empty()
49 }
50}
51
52#[derive(Deserialize)]
55struct WireRef {
56 file: String,
57 symbol: String,
58}
59
60#[derive(Deserialize)]
61struct WireSubtask {
62 id: String,
63 #[serde(default)]
64 prompt: String,
65 #[serde(default)]
66 files: Vec<String>,
67 #[serde(default)]
68 writes: Vec<WireRef>,
69 #[serde(default)]
70 reads: Vec<WireRef>,
71}
72
73#[derive(Deserialize)]
74struct WirePlan {
75 subtasks: Vec<WireSubtask>,
76}
77
78pub fn parse_plan(text: &str) -> Result<Vec<Subtask>, String> {
82 let wire: WirePlan = serde_json::from_str(text)
83 .or_else(|_| {
84 let start = text.find('{').ok_or("no JSON object found")?;
85 let end = text.rfind('}').ok_or("no closing brace")?;
86 if end <= start {
87 return Err("malformed JSON span".to_string());
88 }
89 serde_json::from_str(&text[start..=end]).map_err(|e| e.to_string())
90 })
91 .map_err(|e: String| format!("parse failed: {e}"))?;
92
93 if wire.subtasks.is_empty() {
94 return Err("plan has no subtasks".to_string());
95 }
96 let mut seen = HashSet::new();
97 for w in &wire.subtasks {
98 if !seen.insert(w.id.as_str()) {
99 return Err(format!("duplicate subtask id '{}'", w.id));
100 }
101 }
102
103 let subtasks = wire
104 .subtasks
105 .into_iter()
106 .map(|w| {
107 let footprint = if w.writes.is_empty() && w.reads.is_empty() {
108 None
109 } else {
110 Some(SymbolFootprint {
111 writes: w
112 .writes
113 .iter()
114 .map(|r| SymbolRef::new(r.file.clone(), r.symbol.clone()))
115 .collect(),
116 reads: w
117 .reads
118 .iter()
119 .map(|r| SymbolRef::new(r.file.clone(), r.symbol.clone()))
120 .collect(),
121 uncertain: false,
122 })
123 };
124 let files = if w.files.is_empty() {
127 let mut fs: Vec<String> = w
128 .writes
129 .iter()
130 .chain(&w.reads)
131 .map(|r| r.file.clone())
132 .collect();
133 fs.sort();
134 fs.dedup();
135 fs
136 } else {
137 w.files
138 };
139 Subtask {
140 id: w.id,
141 prompt: w.prompt,
142 files,
143 footprint,
144 }
145 })
146 .collect();
147 Ok(annotate_stub_dependencies(subtasks))
148}
149
150fn annotate_stub_dependencies(mut subtasks: Vec<Subtask>) -> Vec<Subtask> {
169 let mut planned_writes: HashSet<SymbolRef> = HashSet::new();
173 for st in &subtasks {
174 if let Some(fp) = &st.footprint {
175 planned_writes.extend(fp.writes.iter().cloned());
176 }
177 }
178
179 for st in &mut subtasks {
180 let Some(fp) = &st.footprint else { continue };
181 let mut pending: Vec<String> = fp
182 .reads
183 .iter()
184 .filter(|r| planned_writes.contains(r))
185 .map(|r| format!("`{}` in {}", r.symbol, r.file))
186 .collect();
187 if pending.is_empty() {
188 continue;
189 }
190 pending.sort();
191 pending.dedup();
192 st.prompt = format!(
193 "{}\n\nNote: {} {} written by a different subtask in this plan and {} still an \
194 unimplemented stub in your working copy. Do NOT implement {} yourself — that is \
195 outside your declared footprint and will be rejected. Write against the documented \
196 contract (the signature and doc comment) instead.",
197 st.prompt,
198 pending.join(", "),
199 if pending.len() == 1 { "is" } else { "are" },
200 if pending.len() == 1 { "is" } else { "are" },
201 if pending.len() == 1 { "it" } else { "them" },
202 );
203 }
204 subtasks
205}
206
207fn evaluate(index: &ProjectIndex, subtasks: &[Subtask]) -> (Vec<Vec<String>>, Vec<String>) {
211 let fsubs: Vec<FootprintSubtask> = subtasks
212 .iter()
213 .filter_map(|s| {
214 s.footprint.as_ref().map(|fp| FootprintSubtask {
215 id: s.id.clone(),
216 footprint: expand_footprint(index, fp, FOOTPRINT_BLAST_DEPTH),
217 })
218 })
219 .collect();
220 if fsubs.len() != subtasks.len() {
221 return (Vec::new(), Vec::new());
224 }
225 let plan = analyze(&fsubs);
226 let conflicts = plan
227 .conflicts
228 .iter()
229 .map(|(a, b)| format!("subtasks '{a}' and '{b}' write overlapping symbols — split, merge, or hoist a shared contract"))
230 .collect();
231 (plan.levels, conflicts)
232}
233
234pub async fn decompose<F, Fut>(
238 repo_root: &Path,
239 goal: &str,
240 max_attempts: u32,
241 generate: F,
242) -> DecomposeResult
243where
244 F: Fn(String) -> Fut,
245 Fut: Future<Output = Result<String, String>>,
246{
247 let max_attempts = max_attempts.max(1);
248 let mut issues: Vec<String> = Vec::new();
249 let mut attempts = 0;
250 let index = ProjectIndex::build(repo_root);
254
255 while attempts < max_attempts {
256 attempts += 1;
257 let prompt = build_prompt(goal, &issues);
258 let raw = match generate(prompt).await {
259 Ok(r) => r,
260 Err(e) => {
261 issues = vec![format!("generation failed: {e}")];
262 continue;
263 }
264 };
265 let subtasks = match parse_plan(&raw) {
266 Ok(s) => s,
267 Err(e) => {
268 issues = vec![e];
269 continue;
270 }
271 };
272 let missing: Vec<&str> = subtasks
276 .iter()
277 .filter(|s| s.footprint.is_none())
278 .map(|s| s.id.as_str())
279 .collect();
280 if !missing.is_empty() {
281 issues = vec![format!(
282 "subtasks {missing:?} declared no writes/reads; every subtask must declare its symbol footprint"
283 )];
284 continue;
285 }
286 let (levels, conflicts) = evaluate(&index, &subtasks);
287 if !conflicts.is_empty() {
288 issues = conflicts;
289 continue;
290 }
291
292 let prefer_single_session =
294 subtasks.len() <= 1 || (!levels.is_empty() && levels.iter().all(|l| l.len() <= 1));
295 return DecomposeResult {
296 subtasks,
297 levels,
298 prefer_single_session,
299 attempts,
300 issues: Vec::new(),
301 };
302 }
303
304 DecomposeResult {
305 subtasks: Vec::new(),
306 levels: Vec::new(),
307 prefer_single_session: true,
308 attempts,
309 issues,
310 }
311}
312
313fn build_prompt(goal: &str, prior_issues: &[String]) -> String {
314 let mut p = String::new();
315 p.push_str("Decompose this coding goal into independent subtasks. Emit JSON:\n");
316 p.push_str(
317 "{\"subtasks\":[{\"id\":\"...\",\"prompt\":\"...\",\"writes\":[{\"file\":\"path\",\"symbol\":\"name\"}],\"reads\":[...]}]}\n",
318 );
319 p.push_str("Each subtask declares the symbols it WRITES (defines/modifies) and READS.\n");
320 p.push_str("Rules:\n");
321 p.push_str(
325 "- Use the FEWEST subtasks that cover the goal. Do NOT add scaffolding/setup/registry subtasks — module declarations, imports, and wiring already exist or belong to the subtask that needs them.\n",
326 );
327 p.push_str(
331 "- Exactly ONE subtask WRITES each symbol. If subtask B uses a symbol that subtask A defines, put that symbol in B's `reads` (NOT B's `writes`).\n",
332 );
333 p.push_str(
334 "- If two subtasks would have to modify the SAME symbol, they are not independent — merge them into one subtask.\n",
335 );
336 p.push_str("- One file per subtask is a good default.\n");
337 p.push_str(
341 "Example — goal \"add `parse()` in util.rs, and `total()` in lib.rs that calls `parse()`\":\n",
342 );
343 p.push_str(
344 " {\"subtasks\":[{\"id\":\"parse\",\"writes\":[{\"file\":\"util.rs\",\"symbol\":\"parse\"}]},{\"id\":\"total\",\"writes\":[{\"file\":\"lib.rs\",\"symbol\":\"total\"}],\"reads\":[{\"file\":\"util.rs\",\"symbol\":\"parse\"}]}]}\n",
345 );
346 p.push_str(
347 " `total` calls `parse`, so `parse` is in `total`'s READS — NOT its writes. Only `parse`'s own subtask writes it.\n\nGOAL: ",
348 );
349 p.push_str(goal);
350 if !prior_issues.is_empty() {
351 p.push_str("\n\nFix these problems from the previous attempt:\n");
352 for issue in prior_issues {
353 p.push_str("- ");
354 p.push_str(issue);
355 p.push('\n');
356 }
357 }
358 p
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364
365 fn repo() -> tempfile::TempDir {
366 let dir = tempfile::tempdir().unwrap();
367 let root = dir.path();
368 for args in [
369 vec!["init", "-q", "-b", "main"],
370 vec!["config", "user.email", "t@t.t"],
371 vec!["config", "user.name", "t"],
372 ] {
373 std::process::Command::new("git")
374 .args(&args)
375 .current_dir(root)
376 .output()
377 .unwrap();
378 }
379 std::fs::create_dir_all(root.join("src")).unwrap();
380 std::fs::write(root.join("src/a.rs"), "pub fn a() {}\n").unwrap();
381 std::fs::write(root.join("src/b.rs"), "pub fn b() {}\n").unwrap();
382 std::process::Command::new("git")
383 .args(["add", "-A"])
384 .current_dir(root)
385 .output()
386 .unwrap();
387 std::process::Command::new("git")
388 .args(["commit", "-qm", "base"])
389 .current_dir(root)
390 .output()
391 .unwrap();
392 dir
393 }
394
395 #[test]
396 fn parse_plan_extracts_footprints() {
397 let text = r#"prose... {"subtasks":[
398 {"id":"x","prompt":"do x","writes":[{"file":"src/a.rs","symbol":"a"}]},
399 {"id":"y","prompt":"do y","reads":[{"file":"src/a.rs","symbol":"a"}]}
400 ]} trailing"#;
401 let subs = parse_plan(text).unwrap();
402 assert_eq!(subs.len(), 2);
403 assert!(subs[0]
404 .footprint
405 .as_ref()
406 .unwrap()
407 .writes
408 .iter()
409 .any(|r| r.symbol == "a"));
410 assert_eq!(subs[0].files, vec!["src/a.rs".to_string()]);
411 }
412
413 #[tokio::test]
414 async fn decompose_accepts_disjoint_plan() {
415 let dir = repo();
416 let json = r#"{"subtasks":[
417 {"id":"x","prompt":"edit a","writes":[{"file":"src/a.rs","symbol":"a"}]},
418 {"id":"y","prompt":"edit b","writes":[{"file":"src/b.rs","symbol":"b"}]}
419 ]}"#;
420 let result = decompose(dir.path(), "do both", 3, |_p| {
421 let j = json.to_string();
422 async move { Ok(j) }
423 })
424 .await;
425 assert!(result.is_valid(), "{result:?}");
426 assert_eq!(result.subtasks.len(), 2);
427 assert!(!result.prefer_single_session, "{:?}", result.levels);
429 }
430
431 #[tokio::test]
432 async fn decompose_repairs_conflicting_plan_then_gives_up() {
433 let dir = repo();
434 let bad = r#"{"subtasks":[
437 {"id":"x","prompt":"p","writes":[{"file":"src/a.rs","symbol":"a"}]},
438 {"id":"y","prompt":"q","writes":[{"file":"src/a.rs","symbol":"a"}]}
439 ]}"#;
440 let result = decompose(dir.path(), "g", 3, |_p| {
441 let j = bad.to_string();
442 async move { Ok(j) }
443 })
444 .await;
445 assert_eq!(result.attempts, 3, "retried on conflict");
446 assert!(!result.is_valid());
447 assert!(result.prefer_single_session);
448 assert!(result.issues.iter().any(|i| i.contains("overlapping")));
449 }
450
451 #[test]
456 fn dependent_subtask_is_warned_its_dependency_is_a_stub() {
457 let wire = r#"{"subtasks":[
458 {"id":"x","prompt":"implement a","writes":[{"file":"src/a.rs","symbol":"a"}]},
459 {"id":"y","prompt":"implement b using a","writes":[{"file":"src/b.rs","symbol":"b"}],"reads":[{"file":"src/a.rs","symbol":"a"}]}
460 ]}"#;
461 let subs = parse_plan(wire).expect("plan parses");
462 let y = subs.iter().find(|s| s.id == "y").unwrap();
463 assert!(
464 y.prompt.contains("unimplemented stub"),
465 "dependent subtask must be told: {:?}",
466 y.prompt
467 );
468 assert!(
469 y.prompt.contains("`a` in src/a.rs"),
470 "the warning must name the symbol: {:?}",
471 y.prompt
472 );
473 let x = subs.iter().find(|s| s.id == "x").unwrap();
476 assert!(
477 !x.prompt.contains("unimplemented stub"),
478 "the writer must not be warned about its own symbol: {:?}",
479 x.prompt
480 );
481 }
482
483 #[test]
486 fn reads_not_written_by_the_plan_are_not_called_stubs() {
487 let wire = r#"{"subtasks":[
488 {"id":"y","prompt":"implement b using existing helper","writes":[{"file":"src/b.rs","symbol":"b"}],"reads":[{"file":"src/existing.rs","symbol":"helper"}]}
489 ]}"#;
490 let subs = parse_plan(wire).expect("plan parses");
491 assert!(
492 !subs[0].prompt.contains("unimplemented stub"),
493 "a pre-existing dependency is real code, not a stub: {:?}",
494 subs[0].prompt
495 );
496 }
497
498 #[test]
499 fn parse_plan_rejects_duplicate_ids() {
500 let text = r#"{"subtasks":[
501 {"id":"x","writes":[{"file":"a.rs","symbol":"a"}]},
502 {"id":"x","writes":[{"file":"b.rs","symbol":"b"}]}
503 ]}"#;
504 let err = parse_plan(text).unwrap_err();
505 assert!(err.contains("duplicate subtask id"), "{err}");
506 }
507
508 #[tokio::test]
509 async fn decompose_treats_missing_footprint_as_repairworthy() {
510 let dir = repo();
511 let no_fp = r#"{"subtasks":[{"id":"x","prompt":"p"}]}"#;
514 let result = decompose(dir.path(), "g", 2, |_p| {
515 let j = no_fp.to_string();
516 async move { Ok(j) }
517 })
518 .await;
519 assert!(!result.is_valid());
520 assert!(result
521 .issues
522 .iter()
523 .any(|i| i.contains("declared no writes/reads")));
524 }
525
526 #[tokio::test]
527 async fn decompose_repairs_bad_json_then_succeeds() {
528 let dir = repo();
529 let calls = std::sync::atomic::AtomicU32::new(0);
530 let good =
531 r#"{"subtasks":[{"id":"x","prompt":"p","writes":[{"file":"src/a.rs","symbol":"a"}]}]}"#;
532 let result = decompose(dir.path(), "g", 3, |_p| {
533 let n = calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
534 let good = good.to_string();
535 async move {
536 if n == 0 {
537 Ok("not json".to_string())
538 } else {
539 Ok(good)
540 }
541 }
542 })
543 .await;
544 assert!(result.is_valid(), "{result:?}");
545 assert_eq!(result.attempts, 2, "first attempt bad json, second good");
546 assert!(result.prefer_single_session);
548 }
549}