Skip to main content

speccoder_runtime/
status.rs

1//! `speccoder report`: a feature's tasks and the evidence behind
2//! each tick, read from what is durable and reviewable — `tasks.md` at the
3//! tip of `speccoder/<feature>` and the trailers of its commits. Read-only:
4//! no model, no worktree, no state directory.
5
6use 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
17/// Field and record separators for `git log` output.
18const FS: char = '\u{1f}';
19const RS: char = '\u{1e}';
20
21/// What a speccoder commit says about a task.
22#[derive(Debug, Clone)]
23struct Evidence {
24    commit: String,
25    verdict: String,
26    run: String,
27    reason: String,
28    /// The task's line, unticked, when the commit was made in place.
29    task_line: String,
30    /// The intent it was judged against, when made in place.
31    intent: String,
32    /// `Acceptance-Strength:`.
33    strength: String,
34}
35
36/// Reads the trailers speccoder writes (`Spec-Task`, `Verdict`, `Run`) and
37/// its convergence commits from `log` (newest first): the latest commit per
38/// task wins; rounds come back oldest first.
39fn 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
92/// Each task's state: evidence for ticked tasks, the schedule for open ones.
93/// `lines` (the open-line hash per task, for local ticks) decides whether a
94/// commit naming a `Task-Line` still matches the task as written.
95fn 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            // In place: a commit for exactly this line, the tick not written.
112            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                // Newest first: the first saved attempt of an open task.
148                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/// `speccoder report [--feature]`.
193#[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        // A project with nothing planned yet is a beginning, not an error.
202        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        // Several and none current, or a pointer to one that is gone: say
226        // which exist; the code's fixes say how to pick.
227        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            // Named here, or remembered by the format's pointer.
243            .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    // No task list yet: the feature is being planned — a stage, not an
270    // error. The report says what exists and which planning skills are next.
271    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        // tasks.md as committed at the branch tip, by its repository path.
329        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        // In place, an ignored task list is never committed: its ticks are
352        // the checkout's, checked against the commits' `Task-Line`.
353        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    // Changes a run set aside, newest first: the person decides on them,
400    // whatever became of the task.
401    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    // What each committed tick's note says (signatures are `attest verify`'s).
413    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        // The intent is not in git (in place, ignored): the commits'
420        // `Intent-Digest` says whether it changed, not where.
421        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    // Only what there is: a first report says "0/11 done: 3 ready, 8
446    // blocked", not four kinds of evidence nothing has yet.
447    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    // A bug task's outcome, from what speccoder keeps of the bug.
523    #[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/// `speccoder task show TASK`: the report narrowed to one task.
553#[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
577/// One row per requirement and task citing it (by its identifier as a word
578/// in the title); a requirement no task cites is a row of its own.
579fn 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
614/// Each task's line with its box read as open, hashed: what an in-place
615/// commit's `Task-Line` names.
616fn 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
647/// The GitHub issue addresses the feature's documents name.
648fn 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}