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 regression: String,
36}
37
38fn read_log(log: &str, feature: &str) -> (BTreeMap<String, Evidence>, Vec<ConvergenceRound>) {
42 let mut evidence: BTreeMap<String, Evidence> = BTreeMap::new();
43 let mut rounds = Vec::new();
44 for record in log.split(RS) {
45 let fields: Vec<&str> = record.trim_start_matches('\n').split(FS).collect();
46 let [
47 commit,
48 subject,
49 task,
50 verdict,
51 run,
52 reason,
53 task_line,
54 intent,
55 strength,
56 regression,
57 body,
58 ] = fields[..]
59 else {
60 continue;
61 };
62 if let Some(task) = task.trim().strip_prefix(&format!("{feature}/")) {
63 let e = Evidence {
64 commit: commit.to_owned(),
65 verdict: verdict.trim().to_owned(),
66 run: run.trim().to_owned(),
67 reason: reason.trim().to_owned(),
68 task_line: task_line.trim().to_owned(),
69 intent: intent.trim().trim_start_matches("sha256:").to_owned(),
70 strength: strength.trim().to_owned(),
71 regression: regression.trim().to_owned(),
72 };
73 for closed in body
75 .lines()
76 .filter_map(|l| l.strip_prefix("Closes-Task:"))
77 .filter_map(|v| v.trim().strip_prefix(&format!("{feature}/")))
78 {
79 evidence.entry(closed.to_owned()).or_insert(Evidence {
80 task_line: String::new(),
81 ..e.clone()
82 });
83 }
84 evidence.entry(task.to_owned()).or_insert(e);
85 } else if let Some(n) = subject
86 .strip_prefix("speccoder: converge round ")
87 .and_then(|r| r.split(' ').next())
88 .and_then(|n| n.parse::<u32>().ok())
89 {
90 rounds.push(ConvergenceRound {
91 round: n,
92 commit: commit.to_owned(),
93 tasks: body
94 .lines()
95 .filter(|l| {
96 l.strip_prefix('T')
97 .is_some_and(|r| r.starts_with(|c: char| c.is_ascii_digit()))
98 })
99 .map(str::to_owned)
100 .collect(),
101 });
102 }
103 }
104 rounds.reverse();
105 (evidence, rounds)
106}
107
108#[must_use]
111pub fn regression_of(trailer: &str) -> speccoder_core::regression::Regression {
112 use speccoder_core::regression::{Regression, RegressionReason, parse_trailer};
113 Regression::of(parse_trailer(trailer.trim()).unwrap_or(RegressionReason::Unrecorded))
114}
115
116fn statuses(
120 parsed: &ParsedTasks,
121 evidence: &BTreeMap<String, Evidence>,
122 lines: &BTreeMap<String, String>,
123 saved: &[crate::attempts::Saved],
124 skipped: &BTreeMap<String, String>,
125) -> Vec<TaskStatus> {
126 let (schedule, _) = speccoder_core::graph::schedule(parsed);
127 parsed
128 .tasks
129 .iter()
130 .map(|t| {
131 let scheduled = schedule.tasks.iter().find(|s| s.id == t.id);
132 let found = evidence.get(&t.id).filter(|e| {
133 e.task_line.is_empty() || lines.get(&t.id).is_none_or(|h| *h == e.task_line)
134 });
135 let untick = found.filter(|e| !t.done && !e.task_line.is_empty() && !lines.is_empty());
137 let state = if untick.is_some() {
138 "evidence_without_tick".to_owned()
139 } else if t.done {
140 match found.map(|e| e.verdict.as_str()) {
141 Some(v @ ("verified" | "regression_only" | "attested")) => v.to_owned(),
142 _ => "ticked_without_evidence".to_owned(),
143 }
144 } else if skipped.contains_key(&t.id) {
145 "skipped".to_owned()
146 } else if scheduled.is_some_and(|s| s.state == TaskState::Ready) {
147 "ready".to_owned()
148 } else {
149 "blocked".to_owned()
150 };
151 TaskStatus {
152 lane: speccoder_core::lane::Lane::default(),
153 id: t.id.clone(),
154 title: t.title.clone(),
155 phase: t.phase.clone().unwrap_or_default(),
156 blocked_by: if t.done {
157 Vec::new()
158 } else {
159 scheduled.map(|s| s.blocked_by.clone()).unwrap_or_default()
160 },
161 commit: found
162 .filter(|_| t.done || untick.is_some())
163 .map(|e| e.commit.clone()),
164 attestation: None,
165 strength: found
166 .filter(|e| t.done && !e.strength.is_empty())
167 .map(|e| e.strength.clone()),
168 drift: None,
169 run_id: found
170 .filter(|e| t.done && !e.run.is_empty())
171 .map(|e| e.run.clone()),
172 last_attempt: saved
174 .iter()
175 .find(|a| !t.done && a.task == t.id && !crate::controls::is_set_aside(a))
176 .map(|a| speccoder_core::LastAttempt {
177 outcome: a.outcome.clone(),
178 reason: a.reason.clone(),
179 run_id: a.run_id.clone(),
180 reference: a.reference.clone(),
181 story_open: a.story_open.clone(),
182 own_test: a.own_test.clone(),
183 }),
184 judged: None,
185 set_aside: None,
186 bug: None,
187 regression: found
188 .filter(|e| t.done && e.verdict == "regression_only")
189 .map(|e| regression_of(&e.regression)),
190 note: if t.done {
191 found.map(|e| e.reason.clone()).filter(|r| !r.is_empty())
192 } else {
193 skipped.get(&t.id).cloned()
194 },
195 state,
196 }
197 })
198 .collect()
199}
200
201fn totals(tasks: &[TaskStatus]) -> StatusTotals {
202 let n = |s: &str| tasks.iter().filter(|t| t.state == s).count();
203 StatusTotals {
204 verified: n("verified"),
205 regression_only: n("regression_only"),
206 ticked_without_evidence: n("ticked_without_evidence"),
207 evidence_without_tick: n("evidence_without_tick"),
208 without_attestation: tasks
209 .iter()
210 .filter(|t| t.attestation.as_deref() == Some("missing"))
211 .count(),
212 attested: n("attested"),
213 skipped: n("skipped"),
214 ready: n("ready"),
215 blocked: n("blocked"),
216 stale: tasks
217 .iter()
218 .filter(|t| t.drift.as_ref().is_some_and(speccoder_core::Drift::stale))
219 .count(),
220 }
221}
222
223#[must_use]
226pub fn lanes(path: &Path, feature: Option<&str>) -> Option<speccoder_core::lane::Lanes> {
227 let r = report(path, feature);
228 let Some(CommandData::Report(r)) = r.data() else {
229 return None;
230 };
231 let lanes = speccoder_core::lane::Lanes::of(
232 r.tasks
233 .iter()
234 .filter(|t| matches!(t.state.as_str(), "ready" | "blocked"))
235 .map(|t| (t.id.as_str(), t.lane)),
236 );
237 lanes.notable().then_some(lanes)
238}
239
240#[must_use]
242pub fn report(path: &Path, feature: Option<&str>) -> CommandResult {
243 let inspected = crate::inspect(path, feature);
244 let Some(CommandData::Inspect(snapshot)) = inspected.data() else {
245 return CommandResult::carried("report", &inspected);
246 };
247 let format = crate::format::of(snapshot);
248 let Some(f) = snapshot.feature.as_ref() else {
249 if snapshot.features.is_empty()
251 && !snapshot
252 .diagnostics
253 .iter()
254 .any(|d| d.code == "select.not-found")
255 {
256 let unit = format.messages().unit;
257 let create = (format.planning().creates.first())
258 .or((format.planning().toward_tasks)(false).first())
259 .map_or_else(
260 || "speccoder spec".to_owned(),
261 |s| format!("speccoder spec {s} \"…\""),
262 );
263 return CommandResult::new(
264 "report",
265 CommandStatus::Ok,
266 &format!("no {unit}s yet — `{create}` writes the first one"),
267 )
268 .with_next(vec![speccoder_core::next::NextStep {
269 command: create,
270 why: format!("describe the {unit}; its spec is written for you to review"),
271 }]);
272 }
273 let unit = format.messages().unit;
276 let names: Vec<&str> = snapshot.features.iter().map(|f| f.name.as_str()).collect();
277 if let Some(d) = snapshot
278 .diagnostics
279 .iter()
280 .find(|d| d.code == "select.not-found")
281 {
282 return CommandResult::new(
283 "report",
284 CommandStatus::SelectionNeeded,
285 &match &d.hint {
286 Some(h) => format!("{}; {h}", d.message),
287 None => d.message.clone(),
288 },
289 )
290 .with_code(if feature.is_some() {
292 "FEATURE-UNKNOWN"
293 } else {
294 "FEATURE-GONE"
295 });
296 }
297 return CommandResult::new(
298 "report",
299 CommandStatus::SelectionNeeded,
300 &format!(
301 "{} {unit}s and none is current ({})",
302 names.len(),
303 names.join(", ")
304 ),
305 );
306 };
307 let artifacts: Vec<String> = [
308 ("spec", f.artifacts.spec),
309 ("plan", f.artifacts.plan),
310 ("tasks", f.artifacts.tasks),
311 ]
312 .into_iter()
313 .filter(|(_, on)| *on)
314 .map(|(n, _)| n.to_owned())
315 .collect();
316 let uncommitted = crate::hosting::uncommitted(path, feature);
317 let Some(checkout_tasks) = f.tasks.as_ref() else {
320 let planning: Vec<String> = (format.planning().toward_tasks)(f.artifacts.plan)
321 .iter()
322 .map(|s| (*s).to_owned())
323 .collect();
324 return CommandResult::new(
325 "report",
326 CommandStatus::Ok,
327 &format!(
328 "{} is being planned: {} written, no {} yet",
329 f.feature.name,
330 if artifacts.is_empty() {
331 "nothing".to_owned()
332 } else {
333 speccoder_core::words::listed(&artifacts)
334 },
335 format.messages().tasks_file
336 ),
337 )
338 .with_data(CommandData::Report(Box::new(StatusReport {
339 bug: crate::bug::of_unit(snapshot),
340 stories: Vec::new(),
341 review: crate::review::totals_of(
342 Path::new(&snapshot.root),
343 format,
344 &f.feature.name,
345 &f.feature.dir,
346 ),
347 issues: issues_of(path, Some(&f.feature.name)),
348 feature: f.feature.name.clone(),
349 branch: None,
350 source: "checkout".to_owned(),
351 selected_by: crate::current::reason_text(snapshot.selection.reason, format),
352 journey: journey_of(&artifacts, &uncommitted, 0, 0),
353 artifacts,
354 planning,
355 uncommitted,
356 unmerged_commits: 0,
357 tasks: Vec::new(),
358 convergence: Vec::new(),
359 trace: Vec::new(),
360 totals: speccoder_core::StatusTotals::default(),
361 })));
362 };
363 let name = f.feature.name.clone();
364 let branch = format!("speccoder/{name}");
365 let root = Path::new(&snapshot.root);
366 let git = Git::at(root);
367 let has_branch = git
368 .succeeds(&[
369 "rev-parse",
370 "--verify",
371 "-q",
372 &format!("refs/heads/{branch}"),
373 ])
374 .unwrap_or(false);
375
376 let mut prefix = String::new();
377 let (parsed, source, log, unmerged) = if has_branch {
378 let top = git.toplevel().ok().flatten();
380 let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
381 let rel = top
382 .as_deref()
383 .and_then(|t| {
384 canonical(root)
385 .strip_prefix(canonical(t))
386 .ok()
387 .map(Path::to_path_buf)
388 })
389 .unwrap_or_default();
390 prefix = rel.to_string_lossy().replace('\\', "/");
391 if !prefix.is_empty() {
392 prefix.push('/');
393 }
394 let repo_path = rel
395 .join(&checkout_tasks.path)
396 .to_string_lossy()
397 .replace('\\', "/");
398 let text = git
399 .run(&["show", &format!("{branch}:{repo_path}")])
400 .map(|b| String::from_utf8_lossy(&b).into_owned());
401 let local = text.is_err()
404 && !git
405 .succeeds(&["ls-files", "--error-unmatch", "--", &repo_path])
406 .unwrap_or(false);
407 let parsed = match text {
408 Ok(text) => format.parse_tasks(&checkout_tasks.path, &text).0,
409 Err(_) if local => checkout_tasks.clone(),
410 Err(_) => {
411 return CommandResult::new(
412 "report",
413 CommandStatus::GitPrecondition,
414 &format!("{branch} has no {repo_path}"),
415 );
416 }
417 };
418 let format = format!(
419 "--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}%(trailers:key=Regression-Only,valueonly){FS}%b{RS}"
420 );
421 let log = git
422 .run(&["log", &format, &branch])
423 .map(|b| String::from_utf8_lossy(&b).into_owned())
424 .unwrap_or_default();
425 let unmerged = git
426 .line(&["rev-list", "--count", &format!("HEAD..{branch}")])
427 .ok()
428 .and_then(|n| n.trim().parse().ok())
429 .unwrap_or(0);
430 (
431 parsed,
432 if local { "checkout" } else { "branch" },
433 log,
434 unmerged,
435 )
436 } else {
437 (checkout_tasks.clone(), "checkout", String::new(), 0)
438 };
439 let local_ticks = has_branch && source == "checkout";
440 let lines = if local_ticks {
441 open_lines(&root.join(&checkout_tasks.path), &parsed)
442 } else {
443 BTreeMap::new()
444 };
445 let (evidence, convergence) = read_log(&log, &name);
446 let saved = crate::attempts::list(root, &name);
447 let skipped = crate::controls::skipped(root, &name);
448 let mut tasks = statuses(&parsed, &evidence, &lines, &saved, &skipped);
449 for (t, p) in tasks.iter_mut().zip(&parsed.tasks) {
451 let open = !matches!(
452 t.state.as_str(),
453 "verified" | "regression_only" | "attested" | "ticked_without_evidence" | "skipped"
454 );
455 t.lane = speccoder_core::lane::classify(
456 &p.title,
457 open && p.yours,
458 open && t
459 .last_attempt
460 .as_ref()
461 .is_some_and(|a| a.outcome == "blocked"),
462 &f.feature.dir,
463 &format.rules().judged,
464 );
465 if t.lane == speccoder_core::lane::Lane::Planning
467 && saved
468 .iter()
469 .any(|a| a.task == t.id && a.outcome == crate::pipeline::CHANGED_CODE)
470 {
471 t.lane = speccoder_core::lane::Lane::Code;
472 }
473 }
474 for a in saved.iter().filter(|a| crate::controls::is_set_aside(a)) {
477 if let Some(t) = tasks
478 .iter_mut()
479 .find(|t| t.id == a.task && t.set_aside.is_none())
480 {
481 t.set_aside = Some(speccoder_core::SetAside {
482 reference: a.reference.clone(),
483 files: crate::controls::changed_files(root, &a.commit),
484 });
485 }
486 }
487 for t in tasks.iter_mut() {
489 if let Some(commit) = t.commit.as_deref() {
490 t.attestation = Some(crate::attest::note_state(root, commit));
491 }
492 }
493 if !crate::drift::mark(root, format, &f.feature.dir, &prefix, &mut tasks) && local_ticks {
494 let now = crate::in_place::intent_digest(
497 root,
498 &crate::tools::policy::Policy::new(&[], &[]).rules(format.rules()),
499 format.messages().tasks_file,
500 );
501 for t in &mut tasks {
502 let judged = evidence.get(&t.id).map(|e| e.intent.as_str());
503 if t.commit.is_some() && judged.is_some_and(|i| !i.is_empty() && i != now) {
504 t.drift = Some(speccoder_core::Drift {
505 requirements: Vec::new(),
506 files: Vec::new(),
507 });
508 }
509 }
510 }
511 let mut trace = trace(&format.requirement_ids(root, &f.feature.dir), &tasks);
512 trace.extend(inherited(root, &name));
513 let totals = totals(&tasks);
514 let finished = totals.verified
515 + totals.regression_only
516 + totals.attested
517 + totals.ticked_without_evidence
518 + totals.skipped;
519 let done =
520 totals.verified + totals.regression_only + totals.attested + totals.ticked_without_evidence;
521 let parts: Vec<String> = [
524 (totals.verified, "verified"),
525 (totals.regression_only, "regression only"),
526 (totals.attested, "attested"),
527 (totals.ticked_without_evidence, "without evidence"),
528 ]
529 .iter()
530 .filter(|(n, _)| *n > 0)
531 .map(|(n, w)| format!("{n} {w}"))
532 .collect();
533 let bug = crate::bug::of_unit(snapshot);
535 let open: Vec<String> = if bug.is_some() {
536 Vec::new()
537 } else {
538 [
539 (totals.ready, "ready"),
540 (totals.blocked, "blocked"),
541 (totals.skipped, "skipped"),
542 ]
543 .iter()
544 .filter(|(n, _)| *n > 0)
545 .map(|(n, w)| format!("{n} {w}"))
546 .collect()
547 };
548 let mut message = if tasks.is_empty() {
550 format!(
551 "no tasks yet: {} lists none; {}",
552 format.messages().tasks_file,
553 format.messages().make_tasks
554 )
555 } else {
556 format!("{done}/{} done", tasks.len())
557 };
558 let planning: Vec<String> = if tasks.is_empty() {
559 (format.planning().toward_tasks)(f.artifacts.plan)
560 .iter()
561 .map(|s| (*s).to_owned())
562 .collect()
563 } else {
564 Vec::new()
565 };
566 if !parts.is_empty() {
567 message.push_str(&format!(": {}", parts.join(", ")));
568 }
569 if !open.is_empty() {
570 message.push_str(&format!(
571 "{}{}",
572 if parts.is_empty() { ": " } else { "; " },
573 open.join(", ")
574 ));
575 }
576 if !convergence.is_empty() {
577 message.push_str(&format!("; {} convergence round(s)", convergence.len()));
578 }
579 let uncited = trace.iter().filter(|r| r.task.is_none()).count();
580 if uncited > 0 {
581 message.push_str(&format!("; {uncited} requirement(s) no task cites"));
582 }
583 if totals.without_attestation > 0 {
584 message.push_str(&format!(
585 "; {} tick(s) without an attestation",
586 totals.without_attestation
587 ));
588 }
589 if totals.evidence_without_tick > 0 {
590 message.push_str(&format!(
591 "; {} committed without their local tick (the next `run --in-place` ticks them)",
592 totals.evidence_without_tick
593 ));
594 }
595 if totals.stale > 0 {
596 message.push_str(&format!(
597 "; {} tick(s) proven against an earlier wording of their requirements",
598 totals.stale
599 ));
600 }
601 let drifted = tasks.iter().filter(|t| t.drift.is_some()).count() - totals.stale;
602 if drifted > 0 {
603 message.push_str(&format!(
604 "; the spec changed since {drifted}{} tick(s), not in what they cite",
605 if totals.stale > 0 { " other" } else { "" }
606 ));
607 }
608 if local_ticks {
609 message.push_str(&format!(
610 "; ticks are local to this checkout ({} is not in git)",
611 format.messages().tasks_file
612 ));
613 }
614 if has_branch {
615 if unmerged > 0 {
616 message.push_str(&format!("; {unmerged} commit(s) on {branch} not merged"));
617 }
618 } else {
619 message.push_str("; speccoder has not worked on this feature yet");
620 }
621 if let Some(state) = crate::worktree::state_dir() {
623 let plane = crate::worktree::project_state_dir(&state, root);
624 for t in &mut tasks {
625 t.bug = crate::bug::state::load(&plane, &name, &t.id)
626 .map(|b| b.outcome.as_str().to_owned());
627 }
628 }
629 let (shares, stories) =
632 crate::pipeline::story::story_view(&crate::pipeline::story::StoryView {
633 format,
634 snapshot,
635 feature: &name,
636 unit_dir: &f.feature.dir,
637 tasks: &parsed,
638 repo: root,
639 rev: if has_branch { &branch } else { "HEAD" },
640 });
641 for t in &mut tasks {
642 let open = !matches!(
643 t.state.as_str(),
644 "verified" | "regression_only" | "attested" | "ticked_without_evidence" | "skipped"
645 );
646 if open && bug.is_none() {
648 t.judged = shares.get(&t.id).cloned();
649 }
650 }
651 CommandResult::new("report", CommandStatus::Ok, &message).with_data(CommandData::Report(
652 Box::new(StatusReport {
653 stories: if bug.is_some() { Vec::new() } else { stories },
654 bug,
655 review: crate::review::totals_of(root, format, &name, &f.feature.dir),
656 issues: issues_of(root, Some(&name)),
657 feature: name,
658 branch: has_branch.then_some(branch),
659 source: source.to_owned(),
660 selected_by: crate::current::reason_text(snapshot.selection.reason, format),
661 journey: journey_of(&artifacts, &uncommitted, tasks.len(), finished),
662 artifacts,
663 planning,
664 uncommitted,
665 unmerged_commits: unmerged,
666 tasks,
667 convergence,
668 trace,
669 totals,
670 }),
671 ))
672}
673
674#[must_use]
676pub fn show(path: &Path, feature: Option<&str>, task: &str) -> CommandResult {
677 let full = report(path, feature);
678 let Some(CommandData::Report(r)) = full.data() else {
679 return CommandResult::carried("task show", &full);
680 };
681 let Some(t) = r.tasks.iter().find(|t| t.id == task) else {
682 return CommandResult::new(
683 "task show",
684 CommandStatus::NotFound,
685 &format!("{} has no task {task}", r.feature),
686 )
687 .with_code("NOT-FOUND")
688 .with_fix(vec![format!("speccoder report --feature {}", r.feature)]);
689 };
690 let mut one = (**r).clone();
691 one.tasks = vec![t.clone()];
692 one.trace.retain(|row| row.task.as_deref() == Some(task));
693 one.convergence.clear();
694 one.totals = totals(&one.tasks);
695 let message = format!("{task}: {}", t.state.replace('_', " "));
696 CommandResult::ok("task show", &message).with_data(CommandData::Report(Box::new(one)))
697}
698
699fn inherited(root: &Path, unit: &str) -> Vec<TraceRow> {
702 let Some(state) = crate::worktree::state_dir() else {
703 return Vec::new();
704 };
705 let plane = crate::worktree::project_state_dir(&state, root);
706 let Some((from_unit, requirement, task)) = crate::ladder::of(&plane, unit)
707 .into_iter()
708 .find_map(|o| match o {
709 speccoder_core::ladder::Origin::PromotedFrom {
710 from_unit,
711 requirement,
712 task,
713 ..
714 } => Some((from_unit, requirement, task)),
715 _ => None,
716 })
717 else {
718 return Vec::new();
719 };
720 let lower = report(root, Some(&from_unit));
721 let Some(CommandData::Report(r)) = lower.data() else {
722 return Vec::new();
723 };
724 r.tasks
725 .iter()
726 .filter(|t| t.id == task && t.commit.is_some())
727 .map(|t| TraceRow {
728 requirement: format!("{from_unit}/{requirement}"),
730 scenario: None,
731 test: None,
732 task: Some(format!("{from_unit}/{}", t.id)),
733 verdict: Some(t.state.clone()),
734 changed: true,
735 inherited: Some(speccoder_core::Inherited {
736 unit: from_unit.clone(),
737 requirement: requirement.clone(),
738 task: t.id.clone(),
739 commit: t.commit.clone(),
740 }),
741 })
742 .collect()
743}
744
745fn trace(requirements: &[(String, String)], tasks: &[TaskStatus]) -> Vec<TraceRow> {
748 requirements
749 .iter()
750 .flat_map(|(id, _)| {
751 let rows: Vec<TraceRow> = tasks
752 .iter()
753 .filter(|t| crate::attest::cites(&t.title, id))
754 .map(|t| TraceRow {
755 requirement: id.clone(),
756 scenario: None,
757 test: None,
758 task: Some(t.id.clone()),
759 verdict: Some(t.state.clone()),
760 changed: t
761 .drift
762 .as_ref()
763 .is_some_and(|d| d.requirements.iter().any(|r| r.id == *id)),
764 inherited: None,
765 })
766 .collect();
767 if rows.is_empty() {
768 vec![TraceRow {
769 requirement: id.clone(),
770 scenario: None,
771 test: None,
772 task: None,
773 verdict: None,
774 changed: false,
775 inherited: None,
776 }]
777 } else {
778 rows
779 }
780 })
781 .collect()
782}
783
784fn open_lines(file: &Path, parsed: &ParsedTasks) -> BTreeMap<String, String> {
787 let text = std::fs::read_to_string(file).unwrap_or_default();
788 let lines: Vec<&str> = text.lines().collect();
789 parsed
790 .tasks
791 .iter()
792 .filter_map(|t| {
793 let line = lines.get(usize::try_from(t.line).ok()?.checked_sub(1)?)?;
794 let open = crate::in_place::untick(line.as_bytes());
795 Some((t.id.clone(), crate::integrity::sha256_hex(&open)))
796 })
797 .collect()
798}
799
800fn journey_of(
801 artifacts: &[String],
802 uncommitted: &[String],
803 total: usize,
804 finished: usize,
805) -> Vec<speccoder_core::actions::JourneyStep> {
806 let has = |a: &str| artifacts.iter().any(|x| x == a);
807 speccoder_core::actions::journey(speccoder_core::actions::JourneyFacts {
808 spec: has("spec"),
809 plan: has("plan"),
810 tasks: has("tasks"),
811 uncommitted: !uncommitted.is_empty(),
812 total,
813 finished,
814 })
815}
816
817fn issues_of(root: &Path, feature: Option<&str>) -> Vec<String> {
819 let mut out: Vec<String> = Vec::new();
820 for (_, _, text) in crate::documents::sources(root, feature) {
821 for url in crate::github::issue_urls(&text) {
822 if !out.contains(&url) {
823 out.push(url);
824 }
825 }
826 }
827 out
828}
829
830#[cfg(test)]
831mod tests {
832 use super::*;
833
834 #[test]
835 fn a_requirement_no_task_cites_is_a_row_of_its_own() {
836 let task = |id: &str, title: &str, state: &str| TaskStatus {
837 lane: speccoder_core::lane::Lane::default(),
838 id: id.to_owned(),
839 title: title.to_owned(),
840 phase: "Phase 3".to_owned(),
841 state: state.to_owned(),
842 drift: None,
843 blocked_by: Vec::new(),
844 commit: None,
845 run_id: None,
846 last_attempt: None,
847 judged: None,
848 set_aside: None,
849 bug: None,
850 regression: None,
851 note: None,
852 attestation: None,
853 strength: None,
854 };
855 let ids = |v: &[&str]| {
856 v.iter()
857 .map(|i| ((*i).to_owned(), String::new()))
858 .collect::<Vec<_>>()
859 };
860 let tasks = [
861 task("T001", "Parse the config (FR-001, FR-002)", "verified"),
862 task("T002", "Render it per FR-002", "ready"),
863 task("T003", "Unrelated FR-0010 work", "ready"),
864 ];
865 let rows = trace(&ids(&["FR-001", "FR-002", "FR-003"]), &tasks);
866 let shown: Vec<_> = rows
867 .iter()
868 .map(|r| {
869 (
870 r.requirement.as_str(),
871 r.task.as_deref(),
872 r.verdict.as_deref(),
873 )
874 })
875 .collect();
876 assert_eq!(
877 shown,
878 [
879 ("FR-001", Some("T001"), Some("verified")),
880 ("FR-002", Some("T001"), Some("verified")),
881 ("FR-002", Some("T002"), Some("ready")),
882 ("FR-003", None, None),
883 ]
884 );
885 }
886
887 #[test]
888 fn trailers_and_rounds_are_read_newest_first() {
889 let rec = |c: &str, s: &str, t: &str, v: &str, r: &str, b: &str| {
890 format!("{c}{FS}{s}{FS}{t}\n{FS}{v}\n{FS}{r}\n{FS}{FS}{FS}{FS}{FS}{FS}{b}{RS}\n")
891 };
892 let log = [
893 rec(
894 "c4",
895 "speccoder: T003 again",
896 "f/T003",
897 "verified",
898 "run_b",
899 "",
900 ),
901 rec(
902 "c3",
903 "speccoder: converge round 1 (1 task)",
904 "",
905 "",
906 "",
907 "T003 Fix it per FR-1 (missing)\n\nSpec-Feature: f\nRun: run_c\n",
908 ),
909 rec(
910 "c2",
911 "speccoder: T003 first",
912 "f/T003",
913 "regression_only",
914 "run_a",
915 "",
916 ),
917 rec("c1", "init", "", "", "", ""),
918 rec("c0", "other feature", "g/T001", "verified", "x", ""),
919 ]
920 .concat();
921 let (ev, rounds) = read_log(&log, "f");
922 assert_eq!(ev.len(), 1);
923 assert_eq!(
924 (ev["T003"].commit.as_str(), ev["T003"].verdict.as_str()),
925 ("c4", "verified")
926 );
927 assert_eq!(rounds.len(), 1);
928 assert_eq!(rounds[0].tasks, ["T003 Fix it per FR-1 (missing)"]);
929 }
930}