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            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            // A fixed bug's verdict is the evidence of each task it closed.
74            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/// A regression-only tick's reason from its trailer; a tick without one
109/// (without a recorded reason) reads `unrecorded`, never a guess.
110#[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
116/// Each task's state: evidence for ticked tasks, the schedule for open ones.
117/// `lines` (the open-line hash per task, for local ticks) decides whether a
118/// commit naming a `Task-Line` still matches the task as written.
119fn 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            // In place: a commit for exactly this line, the tick not written.
136            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                // Newest first: the first saved attempt of an open task.
173                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/// The open tasks by lane, for *Before you run*: `None` when every one
224/// is code (nothing worth saying) or there is no task to run.
225#[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/// `speccoder report [--feature]`.
241#[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        // A project with nothing planned yet is a beginning, not an error.
250        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        // Several and none current, or a pointer to one that is gone: say
274        // which exist; the code's fixes say how to pick.
275        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            // Named here, or remembered by the format's pointer.
291            .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    // No task list yet: the feature is being planned — a stage, not an
318    // error. The report says what exists and which planning skills are next.
319    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        // tasks.md as committed at the branch tip, by its repository path.
379        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        // In place, an ignored task list is never committed: its ticks are
402        // the checkout's, checked against the commits' `Task-Line`.
403        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    // Who does each task: derived from its line, its unit and its stop.
450    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        // A planning attempt that changed code made the task code.
466        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    // Changes a run set aside, newest first: the person decides on them,
475    // whatever became of the task.
476    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    // What each committed tick's note says (signatures are `attest verify`'s).
488    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        // The intent is not in git (in place, ignored): the commits'
495        // `Intent-Digest` says whether it changed, not where.
496        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    // Only what there is: a first report says "0/11 done: 3 ready, 8
522    // blocked", not four kinds of evidence nothing has yet.
523    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    // A bug's tasks close together with its fix: none is ready on its own.
534    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    // A task list without a task: nothing is done, the tasks are to write.
549    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    // A bug task's outcome, from what speccoder keeps of the bug.
622    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    // What judges each open task, and each story's scenarios: from the
630    // ticks' `Scenarios:` on the unit's branch (first-parent, as a run reads them).
631    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        // A bug's tasks are judged by the bug's verdict, not one by one.
647        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/// `speccoder task show TASK`: the report narrowed to one task.
675#[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
699/// A promoted unit's rows from where it came from: the lower unit's ticks
700/// of the carried requirement, proven against its earlier wording.
701fn 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            // The lower unit's requirement, never one of this unit's.
729            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
745/// One row per requirement and task citing it (by its identifier as a word
746/// in the title); a requirement no task cites is a row of its own.
747fn 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
784/// Each task's line with its box read as open, hashed: what an in-place
785/// commit's `Task-Line` names.
786fn 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
817/// The GitHub issue addresses the feature's documents name.
818fn 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}