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