1use std::collections::BTreeMap;
7use std::path::Path;
8
9use speccoder_core::snapshot::{ParsedTasks, TaskState};
10use speccoder_core::{
11 CommandData, CommandResult, CommandStatus, ConvergenceRound, StatusReport, StatusTotals,
12 TaskStatus, TraceRow,
13};
14
15use crate::git::Git;
16
17const FS: char = '\u{1f}';
19const RS: char = '\u{1e}';
20
21#[derive(Debug, Clone)]
23struct Evidence {
24 commit: String,
25 verdict: String,
26 run: String,
27 reason: String,
28 task_line: String,
30 intent: String,
32 strength: String,
34}
35
36fn read_log(log: &str, feature: &str) -> (BTreeMap<String, Evidence>, Vec<ConvergenceRound>) {
40 let mut evidence: BTreeMap<String, Evidence> = BTreeMap::new();
41 let mut rounds = Vec::new();
42 for record in log.split(RS) {
43 let fields: Vec<&str> = record.trim_start_matches('\n').split(FS).collect();
44 let [
45 commit,
46 subject,
47 task,
48 verdict,
49 run,
50 reason,
51 task_line,
52 intent,
53 strength,
54 body,
55 ] = fields[..]
56 else {
57 continue;
58 };
59 if let Some(task) = task.trim().strip_prefix(&format!("{feature}/")) {
60 evidence.entry(task.to_owned()).or_insert(Evidence {
61 commit: commit.to_owned(),
62 verdict: verdict.trim().to_owned(),
63 run: run.trim().to_owned(),
64 reason: reason.trim().to_owned(),
65 task_line: task_line.trim().to_owned(),
66 intent: intent.trim().trim_start_matches("sha256:").to_owned(),
67 strength: strength.trim().to_owned(),
68 });
69 } else if let Some(n) = subject
70 .strip_prefix("speccoder: converge round ")
71 .and_then(|r| r.split(' ').next())
72 .and_then(|n| n.parse::<u32>().ok())
73 {
74 rounds.push(ConvergenceRound {
75 round: n,
76 commit: commit.to_owned(),
77 tasks: body
78 .lines()
79 .filter(|l| {
80 l.strip_prefix('T')
81 .is_some_and(|r| r.starts_with(|c: char| c.is_ascii_digit()))
82 })
83 .map(str::to_owned)
84 .collect(),
85 });
86 }
87 }
88 rounds.reverse();
89 (evidence, rounds)
90}
91
92fn statuses(
96 parsed: &ParsedTasks,
97 evidence: &BTreeMap<String, Evidence>,
98 lines: &BTreeMap<String, String>,
99 saved: &[crate::attempts::Saved],
100 skipped: &BTreeMap<String, String>,
101) -> Vec<TaskStatus> {
102 let (schedule, _) = speccoder_core::graph::schedule(parsed);
103 parsed
104 .tasks
105 .iter()
106 .map(|t| {
107 let scheduled = schedule.tasks.iter().find(|s| s.id == t.id);
108 let found = evidence.get(&t.id).filter(|e| {
109 e.task_line.is_empty() || lines.get(&t.id).is_none_or(|h| *h == e.task_line)
110 });
111 let untick = found.filter(|e| !t.done && !e.task_line.is_empty() && !lines.is_empty());
113 let state = if untick.is_some() {
114 "evidence_without_tick".to_owned()
115 } else if t.done {
116 match found.map(|e| e.verdict.as_str()) {
117 Some(v @ ("verified" | "regression_only" | "attested")) => v.to_owned(),
118 _ => "ticked_without_evidence".to_owned(),
119 }
120 } else if skipped.contains_key(&t.id) {
121 "skipped".to_owned()
122 } else if scheduled.is_some_and(|s| s.state == TaskState::Ready) {
123 "ready".to_owned()
124 } else {
125 "blocked".to_owned()
126 };
127 TaskStatus {
128 id: t.id.clone(),
129 title: t.title.clone(),
130 phase: t.phase.clone().unwrap_or_default(),
131 blocked_by: if t.done {
132 Vec::new()
133 } else {
134 scheduled.map(|s| s.blocked_by.clone()).unwrap_or_default()
135 },
136 commit: found
137 .filter(|_| t.done || untick.is_some())
138 .map(|e| e.commit.clone()),
139 attestation: None,
140 strength: found
141 .filter(|e| t.done && !e.strength.is_empty())
142 .map(|e| e.strength.clone()),
143 drift: None,
144 run_id: found
145 .filter(|e| t.done && !e.run.is_empty())
146 .map(|e| e.run.clone()),
147 last_attempt: saved
149 .iter()
150 .find(|a| !t.done && a.task == t.id && !crate::controls::is_set_aside(a))
151 .map(|a| speccoder_core::LastAttempt {
152 outcome: a.outcome.clone(),
153 reason: a.reason.clone(),
154 run_id: a.run_id.clone(),
155 reference: a.reference.clone(),
156 }),
157 set_aside: None,
158 bug: None,
159 note: if t.done {
160 found.map(|e| e.reason.clone()).filter(|r| !r.is_empty())
161 } else {
162 skipped.get(&t.id).cloned()
163 },
164 state,
165 }
166 })
167 .collect()
168}
169
170fn totals(tasks: &[TaskStatus]) -> StatusTotals {
171 let n = |s: &str| tasks.iter().filter(|t| t.state == s).count();
172 StatusTotals {
173 verified: n("verified"),
174 regression_only: n("regression_only"),
175 ticked_without_evidence: n("ticked_without_evidence"),
176 evidence_without_tick: n("evidence_without_tick"),
177 without_attestation: tasks
178 .iter()
179 .filter(|t| t.attestation.as_deref() == Some("missing"))
180 .count(),
181 attested: n("attested"),
182 skipped: n("skipped"),
183 ready: n("ready"),
184 blocked: n("blocked"),
185 stale: tasks
186 .iter()
187 .filter(|t| t.drift.as_ref().is_some_and(speccoder_core::Drift::stale))
188 .count(),
189 }
190}
191
192#[must_use]
194pub fn report(path: &Path, feature: Option<&str>) -> CommandResult {
195 let inspected = crate::inspect(path, feature);
196 let Some(CommandData::Inspect(snapshot)) = inspected.data() else {
197 return CommandResult::carried("report", &inspected);
198 };
199 let format = crate::format::of(snapshot);
200 let Some(f) = snapshot.feature.as_ref() else {
201 if snapshot.features.is_empty()
203 && !snapshot
204 .diagnostics
205 .iter()
206 .any(|d| d.code == "select.not-found")
207 {
208 let unit = format.messages().unit;
209 let create = (format.planning().creates.first())
210 .or((format.planning().toward_tasks)(false).first())
211 .map_or_else(
212 || "speccoder spec".to_owned(),
213 |s| format!("speccoder spec {s} \"…\""),
214 );
215 return CommandResult::new(
216 "report",
217 CommandStatus::Ok,
218 &format!("no {unit}s yet — `{create}` writes the first one"),
219 )
220 .with_next(vec![speccoder_core::next::NextStep {
221 command: create,
222 why: format!("describe the {unit}; its spec is written for you to review"),
223 }]);
224 }
225 let unit = format.messages().unit;
228 let names: Vec<&str> = snapshot.features.iter().map(|f| f.name.as_str()).collect();
229 if let Some(d) = snapshot
230 .diagnostics
231 .iter()
232 .find(|d| d.code == "select.not-found")
233 {
234 return CommandResult::new(
235 "report",
236 CommandStatus::SelectionNeeded,
237 &match &d.hint {
238 Some(h) => format!("{}; {h}", d.message),
239 None => d.message.clone(),
240 },
241 )
242 .with_code(if feature.is_some() {
244 "FEATURE-UNKNOWN"
245 } else {
246 "FEATURE-GONE"
247 });
248 }
249 return CommandResult::new(
250 "report",
251 CommandStatus::SelectionNeeded,
252 &format!(
253 "{} {unit}s and none is current ({})",
254 names.len(),
255 names.join(", ")
256 ),
257 );
258 };
259 let artifacts: Vec<String> = [
260 ("spec", f.artifacts.spec),
261 ("plan", f.artifacts.plan),
262 ("tasks", f.artifacts.tasks),
263 ]
264 .into_iter()
265 .filter(|(_, on)| *on)
266 .map(|(n, _)| n.to_owned())
267 .collect();
268 let uncommitted = crate::hosting::uncommitted(path, feature);
269 let Some(checkout_tasks) = f.tasks.as_ref() else {
272 let planning: Vec<String> = (format.planning().toward_tasks)(f.artifacts.plan)
273 .iter()
274 .map(|s| (*s).to_owned())
275 .collect();
276 return CommandResult::new(
277 "report",
278 CommandStatus::Ok,
279 &format!(
280 "{} is being planned: {} written, no {} yet",
281 f.feature.name,
282 if artifacts.is_empty() {
283 "nothing".to_owned()
284 } else {
285 speccoder_core::words::listed(&artifacts)
286 },
287 format.messages().tasks_file
288 ),
289 )
290 .with_data(CommandData::Report(Box::new(StatusReport {
291 review: crate::review::totals_of(
292 Path::new(&snapshot.root),
293 format,
294 &f.feature.name,
295 &f.feature.dir,
296 ),
297 issues: issues_of(path, Some(&f.feature.name)),
298 feature: f.feature.name.clone(),
299 branch: None,
300 source: "checkout".to_owned(),
301 selected_by: crate::current::reason_text(snapshot.selection.reason, format),
302 journey: journey_of(&artifacts, &uncommitted, 0, 0),
303 artifacts,
304 planning,
305 uncommitted,
306 unmerged_commits: 0,
307 tasks: Vec::new(),
308 convergence: Vec::new(),
309 trace: Vec::new(),
310 totals: speccoder_core::StatusTotals::default(),
311 })));
312 };
313 let name = f.feature.name.clone();
314 let branch = format!("speccoder/{name}");
315 let root = Path::new(&snapshot.root);
316 let git = Git::at(root);
317 let has_branch = git
318 .succeeds(&[
319 "rev-parse",
320 "--verify",
321 "-q",
322 &format!("refs/heads/{branch}"),
323 ])
324 .unwrap_or(false);
325
326 let mut prefix = String::new();
327 let (parsed, source, log, unmerged) = if has_branch {
328 let top = git.toplevel().ok().flatten();
330 let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
331 let rel = top
332 .as_deref()
333 .and_then(|t| {
334 canonical(root)
335 .strip_prefix(canonical(t))
336 .ok()
337 .map(Path::to_path_buf)
338 })
339 .unwrap_or_default();
340 prefix = rel.to_string_lossy().replace('\\', "/");
341 if !prefix.is_empty() {
342 prefix.push('/');
343 }
344 let repo_path = rel
345 .join(&checkout_tasks.path)
346 .to_string_lossy()
347 .replace('\\', "/");
348 let text = git
349 .run(&["show", &format!("{branch}:{repo_path}")])
350 .map(|b| String::from_utf8_lossy(&b).into_owned());
351 let local = text.is_err()
354 && !git
355 .succeeds(&["ls-files", "--error-unmatch", "--", &repo_path])
356 .unwrap_or(false);
357 let parsed = match text {
358 Ok(text) => format.parse_tasks(&checkout_tasks.path, &text).0,
359 Err(_) if local => checkout_tasks.clone(),
360 Err(_) => {
361 return CommandResult::new(
362 "report",
363 CommandStatus::GitPrecondition,
364 &format!("{branch} has no {repo_path}"),
365 );
366 }
367 };
368 let format = format!(
369 "--format=%H{FS}%s{FS}%(trailers:key=Spec-Task,valueonly){FS}%(trailers:key=Verdict,valueonly){FS}%(trailers:key=Run,valueonly){FS}%(trailers:key=Reason,valueonly){FS}%(trailers:key=Task-Line,valueonly){FS}%(trailers:key=Intent-Digest,valueonly){FS}%(trailers:key=Acceptance-Strength,valueonly){FS}%b{RS}"
370 );
371 let log = git
372 .run(&["log", &format, &branch])
373 .map(|b| String::from_utf8_lossy(&b).into_owned())
374 .unwrap_or_default();
375 let unmerged = git
376 .line(&["rev-list", "--count", &format!("HEAD..{branch}")])
377 .ok()
378 .and_then(|n| n.trim().parse().ok())
379 .unwrap_or(0);
380 (
381 parsed,
382 if local { "checkout" } else { "branch" },
383 log,
384 unmerged,
385 )
386 } else {
387 (checkout_tasks.clone(), "checkout", String::new(), 0)
388 };
389 let local_ticks = has_branch && source == "checkout";
390 let lines = if local_ticks {
391 open_lines(&root.join(&checkout_tasks.path), &parsed)
392 } else {
393 BTreeMap::new()
394 };
395 let (evidence, convergence) = read_log(&log, &name);
396 let saved = crate::attempts::list(root, &name);
397 let skipped = crate::controls::skipped(root, &name);
398 let mut tasks = statuses(&parsed, &evidence, &lines, &saved, &skipped);
399 for a in saved.iter().filter(|a| crate::controls::is_set_aside(a)) {
402 if let Some(t) = tasks
403 .iter_mut()
404 .find(|t| t.id == a.task && t.set_aside.is_none())
405 {
406 t.set_aside = Some(speccoder_core::SetAside {
407 reference: a.reference.clone(),
408 files: crate::controls::changed_files(root, &a.commit),
409 });
410 }
411 }
412 for t in tasks.iter_mut() {
414 if let Some(commit) = t.commit.as_deref() {
415 t.attestation = Some(crate::attest::note_state(root, commit));
416 }
417 }
418 if !crate::drift::mark(root, format, &f.feature.dir, &prefix, &mut tasks) && local_ticks {
419 let now = crate::in_place::intent_digest(
422 root,
423 &crate::tools::policy::Policy::new(&[], &[]).rules(format.rules()),
424 format.messages().tasks_file,
425 );
426 for t in &mut tasks {
427 let judged = evidence.get(&t.id).map(|e| e.intent.as_str());
428 if t.commit.is_some() && judged.is_some_and(|i| !i.is_empty() && i != now) {
429 t.drift = Some(speccoder_core::Drift {
430 requirements: Vec::new(),
431 files: Vec::new(),
432 });
433 }
434 }
435 }
436 let trace = trace(&format.requirement_ids(root, &f.feature.dir), &tasks);
437 let totals = totals(&tasks);
438 let finished = totals.verified
439 + totals.regression_only
440 + totals.attested
441 + totals.ticked_without_evidence
442 + totals.skipped;
443 let done =
444 totals.verified + totals.regression_only + totals.attested + totals.ticked_without_evidence;
445 let parts: Vec<String> = [
448 (totals.verified, "verified"),
449 (totals.regression_only, "regression only"),
450 (totals.attested, "attested"),
451 (totals.ticked_without_evidence, "without evidence"),
452 ]
453 .iter()
454 .filter(|(n, _)| *n > 0)
455 .map(|(n, w)| format!("{n} {w}"))
456 .collect();
457 let open: Vec<String> = [
458 (totals.ready, "ready"),
459 (totals.blocked, "blocked"),
460 (totals.skipped, "skipped"),
461 ]
462 .iter()
463 .filter(|(n, _)| *n > 0)
464 .map(|(n, w)| format!("{n} {w}"))
465 .collect();
466 let mut message = format!("{done}/{} done", tasks.len());
467 if !parts.is_empty() {
468 message.push_str(&format!(": {}", parts.join(", ")));
469 }
470 if !open.is_empty() {
471 message.push_str(&format!(
472 "{}{}",
473 if parts.is_empty() { ": " } else { "; " },
474 open.join(", ")
475 ));
476 }
477 if !convergence.is_empty() {
478 message.push_str(&format!("; {} convergence round(s)", convergence.len()));
479 }
480 let uncited = trace.iter().filter(|r| r.task.is_none()).count();
481 if uncited > 0 {
482 message.push_str(&format!("; {uncited} requirement(s) no task cites"));
483 }
484 if totals.without_attestation > 0 {
485 message.push_str(&format!(
486 "; {} tick(s) without an attestation",
487 totals.without_attestation
488 ));
489 }
490 if totals.evidence_without_tick > 0 {
491 message.push_str(&format!(
492 "; {} committed without their local tick (the next `run --in-place` ticks them)",
493 totals.evidence_without_tick
494 ));
495 }
496 if totals.stale > 0 {
497 message.push_str(&format!(
498 "; {} tick(s) proven against an earlier wording of their requirements",
499 totals.stale
500 ));
501 }
502 let drifted = tasks.iter().filter(|t| t.drift.is_some()).count() - totals.stale;
503 if drifted > 0 {
504 message.push_str(&format!(
505 "; the spec changed since {drifted}{} tick(s), not in what they cite",
506 if totals.stale > 0 { " other" } else { "" }
507 ));
508 }
509 if local_ticks {
510 message.push_str(&format!(
511 "; ticks are local to this checkout ({} is not in git)",
512 format.messages().tasks_file
513 ));
514 }
515 if has_branch {
516 if unmerged > 0 {
517 message.push_str(&format!("; {unmerged} commit(s) on {branch} not merged"));
518 }
519 } else {
520 message.push_str("; speccoder has not worked on this feature yet");
521 }
522 #[cfg(feature = "engine")]
524 if let Some(state) = crate::worktree::state_dir() {
525 let plane = crate::worktree::project_state_dir(&state, root);
526 for t in &mut tasks {
527 t.bug = crate::bug::state::load(&plane, &name, &t.id)
528 .map(|b| b.outcome.as_str().to_owned());
529 }
530 }
531 CommandResult::new("report", CommandStatus::Ok, &message).with_data(CommandData::Report(
532 Box::new(StatusReport {
533 review: crate::review::totals_of(root, format, &name, &f.feature.dir),
534 issues: issues_of(root, Some(&name)),
535 feature: name,
536 branch: has_branch.then_some(branch),
537 source: source.to_owned(),
538 selected_by: crate::current::reason_text(snapshot.selection.reason, format),
539 journey: journey_of(&artifacts, &uncommitted, tasks.len(), finished),
540 artifacts,
541 planning: Vec::new(),
542 uncommitted,
543 unmerged_commits: unmerged,
544 tasks,
545 convergence,
546 trace,
547 totals,
548 }),
549 ))
550}
551
552#[must_use]
554pub fn show(path: &Path, feature: Option<&str>, task: &str) -> CommandResult {
555 let full = report(path, feature);
556 let Some(CommandData::Report(r)) = full.data() else {
557 return CommandResult::carried("task show", &full);
558 };
559 let Some(t) = r.tasks.iter().find(|t| t.id == task) else {
560 return CommandResult::new(
561 "task show",
562 CommandStatus::NotFound,
563 &format!("{} has no task {task}", r.feature),
564 )
565 .with_code("NOT-FOUND")
566 .with_fix(vec![format!("speccoder report --feature {}", r.feature)]);
567 };
568 let mut one = (**r).clone();
569 one.tasks = vec![t.clone()];
570 one.trace.retain(|row| row.task.as_deref() == Some(task));
571 one.convergence.clear();
572 one.totals = totals(&one.tasks);
573 let message = format!("{task}: {}", t.state.replace('_', " "));
574 CommandResult::ok("task show", &message).with_data(CommandData::Report(Box::new(one)))
575}
576
577fn trace(requirements: &[(String, String)], tasks: &[TaskStatus]) -> Vec<TraceRow> {
580 requirements
581 .iter()
582 .flat_map(|(id, _)| {
583 let rows: Vec<TraceRow> = tasks
584 .iter()
585 .filter(|t| crate::attest::cites(&t.title, id))
586 .map(|t| TraceRow {
587 requirement: id.clone(),
588 scenario: None,
589 test: None,
590 task: Some(t.id.clone()),
591 verdict: Some(t.state.clone()),
592 changed: t
593 .drift
594 .as_ref()
595 .is_some_and(|d| d.requirements.iter().any(|r| r.id == *id)),
596 })
597 .collect();
598 if rows.is_empty() {
599 vec![TraceRow {
600 requirement: id.clone(),
601 scenario: None,
602 test: None,
603 task: None,
604 verdict: None,
605 changed: false,
606 }]
607 } else {
608 rows
609 }
610 })
611 .collect()
612}
613
614fn open_lines(file: &Path, parsed: &ParsedTasks) -> BTreeMap<String, String> {
617 let text = std::fs::read_to_string(file).unwrap_or_default();
618 let lines: Vec<&str> = text.lines().collect();
619 parsed
620 .tasks
621 .iter()
622 .filter_map(|t| {
623 let line = lines.get(usize::try_from(t.line).ok()?.checked_sub(1)?)?;
624 let open = crate::in_place::untick(line.as_bytes());
625 Some((t.id.clone(), crate::integrity::sha256_hex(&open)))
626 })
627 .collect()
628}
629
630fn journey_of(
631 artifacts: &[String],
632 uncommitted: &[String],
633 total: usize,
634 finished: usize,
635) -> Vec<speccoder_core::actions::JourneyStep> {
636 let has = |a: &str| artifacts.iter().any(|x| x == a);
637 speccoder_core::actions::journey(speccoder_core::actions::JourneyFacts {
638 spec: has("spec"),
639 plan: has("plan"),
640 tasks: has("tasks"),
641 uncommitted: !uncommitted.is_empty(),
642 total,
643 finished,
644 })
645}
646
647fn issues_of(root: &Path, feature: Option<&str>) -> Vec<String> {
649 let mut out: Vec<String> = Vec::new();
650 for (_, _, text) in crate::documents::sources(root, feature) {
651 for url in crate::github::issue_urls(&text) {
652 if !out.contains(&url) {
653 out.push(url);
654 }
655 }
656 }
657 out
658}
659
660#[cfg(test)]
661mod tests {
662 use super::*;
663
664 #[test]
665 fn a_requirement_no_task_cites_is_a_row_of_its_own() {
666 let task = |id: &str, title: &str, state: &str| TaskStatus {
667 id: id.to_owned(),
668 title: title.to_owned(),
669 phase: "Phase 3".to_owned(),
670 state: state.to_owned(),
671 drift: None,
672 blocked_by: Vec::new(),
673 commit: None,
674 run_id: None,
675 last_attempt: None,
676 set_aside: None,
677 bug: None,
678 note: None,
679 attestation: None,
680 strength: None,
681 };
682 let ids = |v: &[&str]| {
683 v.iter()
684 .map(|i| ((*i).to_owned(), String::new()))
685 .collect::<Vec<_>>()
686 };
687 let tasks = [
688 task("T001", "Parse the config (FR-001, FR-002)", "verified"),
689 task("T002", "Render it per FR-002", "ready"),
690 task("T003", "Unrelated FR-0010 work", "ready"),
691 ];
692 let rows = trace(&ids(&["FR-001", "FR-002", "FR-003"]), &tasks);
693 let shown: Vec<_> = rows
694 .iter()
695 .map(|r| {
696 (
697 r.requirement.as_str(),
698 r.task.as_deref(),
699 r.verdict.as_deref(),
700 )
701 })
702 .collect();
703 assert_eq!(
704 shown,
705 [
706 ("FR-001", Some("T001"), Some("verified")),
707 ("FR-002", Some("T001"), Some("verified")),
708 ("FR-002", Some("T002"), Some("ready")),
709 ("FR-003", None, None),
710 ]
711 );
712 }
713
714 #[test]
715 fn trailers_and_rounds_are_read_newest_first() {
716 let rec = |c: &str, s: &str, t: &str, v: &str, r: &str, b: &str| {
717 format!("{c}{FS}{s}{FS}{t}\n{FS}{v}\n{FS}{r}\n{FS}{FS}{FS}{FS}{FS}{b}{RS}\n")
718 };
719 let log = [
720 rec(
721 "c4",
722 "speccoder: T003 again",
723 "f/T003",
724 "verified",
725 "run_b",
726 "",
727 ),
728 rec(
729 "c3",
730 "speccoder: converge round 1 (1 task)",
731 "",
732 "",
733 "",
734 "T003 Fix it per FR-1 (missing)\n\nSpec-Feature: f\nRun: run_c\n",
735 ),
736 rec(
737 "c2",
738 "speccoder: T003 first",
739 "f/T003",
740 "regression_only",
741 "run_a",
742 "",
743 ),
744 rec("c1", "init", "", "", "", ""),
745 rec("c0", "other feature", "g/T001", "verified", "x", ""),
746 ]
747 .concat();
748 let (ev, rounds) = read_log(&log, "f");
749 assert_eq!(ev.len(), 1);
750 assert_eq!(
751 (ev["T003"].commit.as_str(), ev["T003"].verdict.as_str()),
752 ("c4", "verified")
753 );
754 assert_eq!(rounds.len(), 1);
755 assert_eq!(rounds[0].tasks, ["T003 Fix it per FR-1 (missing)"]);
756 }
757}