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