1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
10#[serde(tag = "kind", rename_all = "snake_case")]
11pub enum Runnable {
12 Skill {
14 name: String,
16 arguments: String,
18 },
19 Run {
21 task: Option<String>,
23 },
24 Converge,
27 Commit {
29 paths: Vec<String>,
31 },
32 Approve {
35 task: String,
37 call: String,
39 digest: String,
41 },
42 Reject {
44 task: String,
46 call: String,
48 digest: String,
50 },
51 Accept {
53 task: String,
55 },
56 Skip {
58 task: String,
60 },
61 Nudge {
63 task: String,
65 },
66 Rule {
68 task: String,
70 },
71 Review {
74 ids: Vec<String>,
76 verdict: String,
80 },
81 IssueComment {
83 issue: u64,
85 assign: bool,
87 },
88 QuickChange {
90 request: String,
92 checks: Vec<String>,
94 run: bool,
96 },
97 KeepSetAside {
100 task: String,
102 },
103 DropSetAside {
105 task: String,
107 },
108 FixSetup,
111 Feature,
113 PullRequest,
115 BugFix {
118 symptom: String,
120 expected: Vec<String>,
122 unchanged: Vec<String>,
124 issue: Option<u64>,
126 tests: Vec<String>,
129 candidate: Option<String>,
131 slug: Option<String>,
133 run: bool,
135 },
136 BugConfirm {
139 task: String,
141 digest: String,
143 reject: bool,
145 },
146 BugKeep {
149 task: String,
151 },
152 BugRun {
154 slug: Option<String>,
156 task: Option<String>,
158 },
159 AllowAlways {
162 task: String,
164 call: String,
166 },
167 WidenPolicy {
170 key: PolicyList,
172 prefix: Vec<String>,
174 digest: String,
176 },
177 DecideBatch {
179 run: String,
181 verdict: BatchVerdict,
183 calls: Vec<String>,
185 digest: String,
187 resume_asking: bool,
189 },
190 OracleTests {
193 req: String,
195 op: crate::oracle::OracleOp,
197 path: Option<String>,
199 scenario: Option<String>,
201 reading: Option<crate::oracle::Reading>,
203 },
204 ApproveAndRun {
207 mode: crate::oracle::RunMode,
209 grants: Vec<String>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
214 sheet: Option<String>,
215 },
216 Comment {
218 op: CommentOp,
220 anchor: Option<crate::oracle::CommentAnchor>,
222 id: Option<String>,
224 },
225 CommentsSend {
227 ids: Vec<String>,
230 },
231 RevisionUndo {
233 turn: Option<String>,
235 },
236 CodeNudge {
238 task: String,
240 comments: Vec<crate::oracle::CodeComment>,
242 },
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
247#[serde(rename_all = "snake_case")]
248pub enum CommentOp {
249 Add,
251 Delete,
253 Keep,
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
259#[serde(rename_all = "snake_case")]
260pub enum PolicyList {
261 CommandsPrefix,
263 NetworkPrefix,
265}
266
267impl PolicyList {
268 #[must_use]
270 pub const fn as_str(self) -> &'static str {
271 match self {
272 Self::CommandsPrefix => "commands_prefix",
273 Self::NetworkPrefix => "network_prefix",
274 }
275 }
276
277 #[must_use]
279 pub const fn key(self) -> &'static str {
280 match self {
281 Self::CommandsPrefix => "commands.allow_prefix",
282 Self::NetworkPrefix => "sandbox.network_allow_prefix",
283 }
284 }
285
286 #[must_use]
288 pub fn of_key(key: &str) -> Option<Self> {
289 match key {
290 "commands.allow_prefix" | "commands_prefix" => Some(Self::CommandsPrefix),
291 "sandbox.network_allow_prefix" | "network_prefix" => Some(Self::NetworkPrefix),
292 _ => None,
293 }
294 }
295}
296
297#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
299#[serde(rename_all = "snake_case")]
300pub enum BatchVerdict {
301 Approve,
303 Reject,
305}
306
307#[derive(
313 Debug,
314 Clone,
315 Copy,
316 PartialEq,
317 Eq,
318 Hash,
319 PartialOrd,
320 Ord,
321 Serialize,
322 Deserialize,
323 schemars::JsonSchema,
324)]
325#[serde(rename_all = "snake_case")]
326pub enum ActionKind {
327 Skill,
329 RunTask,
331 RunFeature,
333 Converge,
335 Commit,
337 Approve,
339 Reject,
341 Accept,
343 Skip,
345 Nudge,
347 Rule,
349 Review,
351 IssueComment,
353 QuickChange,
355 KeepSetAside,
357 DropSetAside,
359 FixSetup,
361 Feature,
363 PullRequest,
365 BugFix,
367 BugConfirm,
369 BugRun,
371 BugKeep,
373 AllowAlways,
375 WidenPolicy,
377 DecideBatch,
379 OracleTests,
381 ApproveAndRun,
383 Comment,
385 CommentsSend,
387 RevisionUndo,
389 CodeNudge,
391 Command,
393}
394
395impl ActionKind {
396 pub const ALL: &'static [Self] = &[
398 Self::Skill,
399 Self::RunTask,
400 Self::RunFeature,
401 Self::Converge,
402 Self::Commit,
403 Self::Approve,
404 Self::Reject,
405 Self::Accept,
406 Self::Skip,
407 Self::Nudge,
408 Self::Rule,
409 Self::Review,
410 Self::IssueComment,
411 Self::QuickChange,
412 Self::KeepSetAside,
413 Self::DropSetAside,
414 Self::FixSetup,
415 Self::Feature,
416 Self::PullRequest,
417 Self::BugFix,
418 Self::BugConfirm,
419 Self::BugRun,
420 Self::BugKeep,
421 Self::AllowAlways,
422 Self::WidenPolicy,
423 Self::DecideBatch,
424 Self::OracleTests,
425 Self::ApproveAndRun,
426 Self::Comment,
427 Self::CommentsSend,
428 Self::RevisionUndo,
429 Self::CodeNudge,
430 Self::Command,
431 ];
432
433 #[must_use]
435 pub const fn as_str(self) -> &'static str {
436 match self {
437 Self::Skill => "skill",
438 Self::RunTask => "run_task",
439 Self::RunFeature => "run_feature",
440 Self::Converge => "converge",
441 Self::Commit => "commit",
442 Self::Approve => "approve",
443 Self::Reject => "reject",
444 Self::Accept => "accept",
445 Self::Skip => "skip",
446 Self::Nudge => "nudge",
447 Self::Rule => "rule",
448 Self::Review => "review",
449 Self::IssueComment => "issue_comment",
450 Self::QuickChange => "quick_change",
451 Self::KeepSetAside => "keep_set_aside",
452 Self::DropSetAside => "drop_set_aside",
453 Self::FixSetup => "fix_setup",
454 Self::Feature => "feature",
455 Self::PullRequest => "pull_request",
456 Self::BugFix => "bug_fix",
457 Self::BugConfirm => "bug_confirm",
458 Self::BugRun => "bug_run",
459 Self::BugKeep => "bug_keep",
460 Self::AllowAlways => "allow_always",
461 Self::WidenPolicy => "widen_policy",
462 Self::DecideBatch => "decide_batch",
463 Self::OracleTests => "oracle_tests",
464 Self::ApproveAndRun => "approve_and_run",
465 Self::Comment => "comment",
466 Self::CommentsSend => "comments_send",
467 Self::RevisionUndo => "revision_undo",
468 Self::CodeNudge => "code_nudge",
469 Self::Command => "command",
470 }
471 }
472
473 #[must_use]
476 pub fn example(self) -> Option<Runnable> {
477 let task = || "T001".to_owned();
478 Some(match self {
479 Self::Skill => Runnable::Skill {
480 name: "plan".to_owned(),
481 arguments: String::new(),
482 },
483 Self::RunTask => Runnable::Run { task: Some(task()) },
484 Self::RunFeature => Runnable::Run { task: None },
485 Self::Converge => Runnable::Converge,
486 Self::Commit => Runnable::Commit {
487 paths: vec!["plan.md".to_owned()],
488 },
489 Self::Approve => Runnable::Approve {
490 task: task(),
491 call: "0f3c".to_owned(),
492 digest: "d1".to_owned(),
493 },
494 Self::Reject => Runnable::Reject {
495 task: task(),
496 call: "0f3c".to_owned(),
497 digest: "d1".to_owned(),
498 },
499 Self::Accept => Runnable::Accept { task: task() },
500 Self::Skip => Runnable::Skip { task: task() },
501 Self::Nudge => Runnable::Nudge { task: task() },
502 Self::Rule => Runnable::Rule { task: task() },
503 Self::Review => Runnable::Review {
504 ids: vec!["FR-001".to_owned()],
505 verdict: "approve".to_owned(),
506 },
507 Self::IssueComment => Runnable::IssueComment {
508 issue: 42,
509 assign: false,
510 },
511 Self::QuickChange => Runnable::QuickChange {
512 request: "a change".to_owned(),
513 checks: Vec::new(),
514 run: false,
515 },
516 Self::KeepSetAside => Runnable::KeepSetAside { task: task() },
517 Self::DropSetAside => Runnable::DropSetAside { task: task() },
518 Self::FixSetup => Runnable::FixSetup,
519 Self::Feature => Runnable::Feature,
520 Self::PullRequest => Runnable::PullRequest,
521 Self::BugFix => Runnable::BugFix {
522 symptom: "saving an empty title crashes".to_owned(),
523 expected: vec!["an empty title is refused".to_owned()],
524 unchanged: Vec::new(),
525 issue: None,
526 tests: Vec::new(),
527 candidate: None,
528 slug: None,
529 run: false,
530 },
531 Self::BugConfirm => Runnable::BugConfirm {
532 task: "007-bugfixes/T001".to_owned(),
533 digest: "sha256:0".to_owned(),
534 reject: false,
535 },
536 Self::BugRun => Runnable::BugRun {
537 slug: None,
538 task: Some("007-bugfixes/T001".to_owned()),
539 },
540 Self::BugKeep => Runnable::BugKeep {
541 task: "007-bugfixes/T001".to_owned(),
542 },
543 Self::AllowAlways => Runnable::AllowAlways {
544 task: task(),
545 call: "0f3c".to_owned(),
546 },
547 Self::WidenPolicy => Runnable::WidenPolicy {
548 key: PolicyList::CommandsPrefix,
549 prefix: vec!["npm".to_owned(), "run".to_owned()],
550 digest: "d1".to_owned(),
551 },
552 Self::DecideBatch => Runnable::DecideBatch {
553 run: "run-1-0a0b0c0d".to_owned(),
554 verdict: BatchVerdict::Approve,
555 calls: vec!["0f3c".to_owned()],
556 digest: "d1".to_owned(),
557 resume_asking: false,
558 },
559 Self::OracleTests => Runnable::OracleTests {
560 req: "US1".to_owned(),
561 op: crate::oracle::OracleOp::Write,
562 path: None,
563 scenario: None,
564 reading: None,
565 },
566 Self::ApproveAndRun => Runnable::ApproveAndRun {
567 mode: crate::oracle::RunMode::Ask,
568 grants: Vec::new(),
569 sheet: None,
570 },
571 Self::Comment => Runnable::Comment {
572 op: CommentOp::Delete,
573 anchor: None,
574 id: Some("c1".to_owned()),
575 },
576 Self::CommentsSend => Runnable::CommentsSend { ids: Vec::new() },
577 Self::RevisionUndo => Runnable::RevisionUndo { turn: None },
578 Self::CodeNudge => Runnable::CodeNudge {
579 task: task(),
580 comments: vec![crate::oracle::CodeComment {
581 path: "src/lib.rs".to_owned(),
582 line: 1,
583 quote: "pub fn ok() {}".to_owned(),
584 text: "name it".to_owned(),
585 }],
586 },
587 Self::Command => return None,
588 })
589 }
590}
591
592impl Runnable {
593 #[must_use]
595 pub fn examples() -> Vec<Self> {
596 ActionKind::ALL.iter().filter_map(|k| k.example()).collect()
597 }
598
599 #[must_use]
601 pub const fn kind(&self) -> ActionKind {
602 match self {
603 Self::Skill { .. } => ActionKind::Skill,
604 Self::Run { task: Some(_) } => ActionKind::RunTask,
605 Self::Run { task: None } => ActionKind::RunFeature,
606 Self::Converge => ActionKind::Converge,
607 Self::Commit { .. } => ActionKind::Commit,
608 Self::Approve { .. } => ActionKind::Approve,
609 Self::Reject { .. } => ActionKind::Reject,
610 Self::Accept { .. } => ActionKind::Accept,
611 Self::Skip { .. } => ActionKind::Skip,
612 Self::Nudge { .. } => ActionKind::Nudge,
613 Self::Rule { .. } => ActionKind::Rule,
614 Self::Review { .. } => ActionKind::Review,
615 Self::IssueComment { .. } => ActionKind::IssueComment,
616 Self::QuickChange { .. } => ActionKind::QuickChange,
617 Self::KeepSetAside { .. } => ActionKind::KeepSetAside,
618 Self::DropSetAside { .. } => ActionKind::DropSetAside,
619 Self::FixSetup => ActionKind::FixSetup,
620 Self::Feature => ActionKind::Feature,
621 Self::PullRequest => ActionKind::PullRequest,
622 Self::BugFix { .. } => ActionKind::BugFix,
623 Self::BugConfirm { .. } => ActionKind::BugConfirm,
624 Self::BugRun { .. } => ActionKind::BugRun,
625 Self::BugKeep { .. } => ActionKind::BugKeep,
626 Self::AllowAlways { .. } => ActionKind::AllowAlways,
627 Self::WidenPolicy { .. } => ActionKind::WidenPolicy,
628 Self::DecideBatch { .. } => ActionKind::DecideBatch,
629 Self::OracleTests { .. } => ActionKind::OracleTests,
630 Self::ApproveAndRun { .. } => ActionKind::ApproveAndRun,
631 Self::Comment { .. } => ActionKind::Comment,
632 Self::CommentsSend { .. } => ActionKind::CommentsSend,
633 Self::RevisionUndo { .. } => ActionKind::RevisionUndo,
634 Self::CodeNudge { .. } => ActionKind::CodeNudge,
635 }
636 }
637}
638
639#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
643pub struct ActionId {
644 pub kind: ActionKind,
646 pub subject: Option<String>,
649}
650
651#[must_use]
654pub fn identity(run: Option<&Runnable>, command: &str) -> ActionId {
655 let Some(run) = run else {
656 let words: Vec<&str> = command
658 .split_whitespace()
659 .skip_while(|w| *w == "speccoder")
660 .take_while(|w| {
661 w.chars().next().is_some_and(|c| c.is_ascii_lowercase())
662 && w.chars()
663 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
664 })
665 .collect();
666 return ActionId {
667 kind: ActionKind::Command,
668 subject: (!words.is_empty()).then(|| words.join(" ")),
669 };
670 };
671 let subject = match run {
672 Runnable::Skill { name, .. } => Some(name.clone()),
673 Runnable::Run { task } => task.clone(),
674 Runnable::Approve { task, .. }
675 | Runnable::Reject { task, .. }
676 | Runnable::Accept { task }
677 | Runnable::Skip { task }
678 | Runnable::Nudge { task }
679 | Runnable::Rule { task }
680 | Runnable::KeepSetAside { task }
681 | Runnable::DropSetAside { task }
682 | Runnable::AllowAlways { task, .. }
683 | Runnable::CodeNudge { task, .. } => Some(task.clone()),
684 Runnable::OracleTests { req, .. } => Some(req.clone()),
685 Runnable::ApproveAndRun { mode, .. } => Some(mode.as_str().to_owned()),
686 Runnable::Comment { id, .. } => id.clone(),
687 Runnable::RevisionUndo { turn } => turn.clone(),
688 Runnable::WidenPolicy { key, .. } => Some(key.as_str().to_owned()),
689 Runnable::DecideBatch { run, .. } => Some(run.clone()),
690 Runnable::IssueComment { issue, .. } => Some(issue.to_string()),
691 Runnable::BugConfirm { task, .. } | Runnable::BugKeep { task } => Some(task.clone()),
692 Runnable::BugRun { slug, task } => task.clone().or_else(|| slug.clone()),
693 Runnable::BugFix { slug, issue, .. } => {
694 slug.clone().or_else(|| issue.map(|n| format!("#{n}")))
695 }
696 Runnable::Review { ids, verdict } => Some(match ids.first() {
697 Some(id) => format!("{verdict}:{id}"),
698 None => verdict.clone(),
699 }),
700 Runnable::Converge
701 | Runnable::Commit { .. }
702 | Runnable::QuickChange { .. }
703 | Runnable::FixSetup
704 | Runnable::Feature
705 | Runnable::PullRequest
706 | Runnable::CommentsSend { .. } => None,
707 };
708 ActionId {
709 kind: run.kind(),
710 subject,
711 }
712}
713
714#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
718#[serde(rename_all = "snake_case")]
719pub enum SkillRole {
720 Create,
722 TowardTasks,
724 Revise,
726 Check,
728 Other,
730}
731
732#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
737#[serde(rename_all = "snake_case")]
738pub enum Via {
739 #[serde(alias = "cli", alias = "tui", alias = "dashboard")]
741 Terminal,
742 Web,
744 Window,
746 Acp,
748 Phone,
750 Ci,
752 Mcp,
754}
755
756impl Via {
757 #[must_use]
759 pub const fn as_str(self) -> &'static str {
760 match self {
761 Self::Terminal => "terminal",
762 Self::Web => "web",
763 Self::Window => "window",
764 Self::Acp => "acp",
765 Self::Phone => "phone",
766 Self::Ci => "ci",
767 Self::Mcp => "mcp",
768 }
769 }
770
771 #[must_use]
774 pub fn read(recorded: &str) -> Option<(Self, Option<String>)> {
775 if let Some(client) = recorded.strip_prefix("editor ") {
776 return Some((Self::Acp, Some(client.to_owned())));
777 }
778 let via = match recorded {
779 "terminal" | "cli" | "tui" | "dashboard" => Self::Terminal,
780 "web" => Self::Web,
781 "window" => Self::Window,
782 "acp" | "editor" => Self::Acp,
783 "phone" => Self::Phone,
784 "ci" => Self::Ci,
785 "mcp" => Self::Mcp,
786 _ => return None,
787 };
788 Some((via, None))
789 }
790}
791
792impl std::fmt::Display for Via {
793 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
794 f.write_str(self.as_str())
795 }
796}
797
798#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
800pub struct Action {
801 pub group: String,
803 pub label: String,
805 pub why: String,
807 pub command: String,
809 pub run: Option<Runnable>,
812 pub blocked: Option<String>,
814 pub input: Option<String>,
816 pub confirm: Option<String>,
819 pub id: ActionId,
821 pub role: Option<SkillRole>,
824}
825
826impl Action {
827 #[must_use]
829 pub fn new(
830 group: &str,
831 label: impl Into<String>,
832 why: impl Into<String>,
833 command: impl Into<String>,
834 ) -> Self {
835 let command = command.into();
836 Self {
837 group: group.to_owned(),
838 label: label.into(),
839 why: why.into(),
840 id: identity(None, &command),
841 command,
842 run: None,
843 blocked: None,
844 input: None,
845 confirm: None,
846 role: None,
847 }
848 }
849
850 #[must_use]
852 pub fn with_run(mut self, run: Runnable) -> Self {
853 self.id = identity(Some(&run), &self.command);
854 self.run = Some(run);
855 self
856 }
857
858 #[must_use]
860 pub const fn with_role(mut self, role: SkillRole) -> Self {
861 self.role = Some(role);
862 self
863 }
864
865 #[must_use]
867 pub const fn runnable(&self) -> bool {
868 self.run.is_some() && self.blocked.is_none()
869 }
870}
871
872#[must_use]
874pub fn primary(actions: &[Action]) -> Option<&Action> {
875 actions.iter().find(|a| a.group == "next" && a.runnable())
876}
877
878#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
880#[serde(rename_all = "snake_case")]
881pub enum StepState {
882 Done,
884 Now,
886 Todo,
888 Skipped,
891}
892
893#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
896pub struct JourneyStep {
897 pub name: String,
899 pub state: StepState,
901}
902
903#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
905pub struct JourneyFacts {
906 pub spec: bool,
908 pub plan: bool,
910 pub tasks: bool,
912 pub uncommitted: bool,
914 pub total: usize,
916 pub finished: usize,
918}
919
920#[must_use]
924pub fn journey(f: JourneyFacts) -> Vec<JourneyStep> {
925 let skipped = [!f.spec && (f.plan || f.tasks), !f.plan && f.tasks];
926 let done = [
927 ("spec", f.spec),
928 ("plan", f.plan),
929 ("tasks", f.tasks),
930 ("commit", f.tasks && !f.uncommitted),
931 ("run", f.total > 0 && f.finished == f.total),
932 ("review", false),
933 ];
934 let passed = |i: usize| skipped.get(i).copied().unwrap_or(false);
935 let now = done
936 .iter()
937 .enumerate()
938 .position(|(i, (_, d))| !d && !passed(i))
939 .unwrap_or(done.len());
940 done.iter()
941 .enumerate()
942 .map(|(i, (name, d))| JourneyStep {
943 name: (*name).to_owned(),
944 state: if *d && i < now {
945 StepState::Done
946 } else if passed(i) && i < now {
947 StepState::Skipped
948 } else if i == now {
949 StepState::Now
950 } else {
951 StepState::Todo
952 },
953 })
954 .collect()
955}
956
957#[cfg(test)]
958mod tests {
959 use super::*;
960
961 #[test]
962 fn every_kind_is_listed_once_and_every_runnable_has_its_own() {
963 let schema = serde_json::to_value(schemars::schema_for!(ActionKind))
965 .unwrap_or_else(|e| panic!("{e}"));
966 let mut named: Vec<String> = schema["oneOf"]
967 .as_array()
968 .into_iter()
969 .flatten()
970 .filter_map(|v| v["const"].as_str().map(str::to_owned))
971 .collect();
972 named.sort();
973 let mut listed: Vec<String> = ActionKind::ALL
974 .iter()
975 .map(|k| k.as_str().to_owned())
976 .collect();
977 listed.sort();
978 assert_eq!(named, listed, "ActionKind::ALL is not the enum");
979 for k in ActionKind::ALL {
980 assert_eq!(
981 serde_json::to_value(k).unwrap_or_default(),
982 k.as_str(),
983 "{k:?}"
984 );
985 if let Some(run) = k.example() {
986 assert_eq!(run.kind(), *k);
987 assert_eq!(identity(Some(&run), "").kind, *k);
988 }
989 }
990 assert_eq!(
991 Runnable::examples().len(),
992 ActionKind::ALL.len() - 1,
993 "every kind but `command` has a runnable"
994 );
995 }
996
997 #[test]
998 fn identity_is_what_runs_never_how_it_is_worded() {
999 let a = Action::new(
1000 "task",
1001 "accept T004",
1002 "tick it",
1003 "speccoder task accept T004",
1004 )
1005 .with_run(Runnable::Accept {
1006 task: "T004".to_owned(),
1007 });
1008 let mut b = a.clone();
1009 b.label = "x tick it anyway".to_owned();
1010 b.why = "x".to_owned();
1011 assert_eq!(a.id, b.id);
1012 assert_eq!(
1013 a.id,
1014 ActionId {
1015 kind: ActionKind::Accept,
1016 subject: Some("T004".to_owned())
1017 }
1018 );
1019 let id = |c: &str| identity(None, c).subject;
1020 assert_eq!(
1021 id("speccoder attest verify").as_deref(),
1022 Some("attest verify")
1023 );
1024 assert_eq!(id("speccoder doctor").as_deref(), Some("doctor"));
1025 assert_eq!(id("speccoder pr --yes").as_deref(), Some("pr"));
1026 assert_eq!(
1027 id("speccoder spec specify \"…\"").as_deref(),
1028 Some("spec specify")
1029 );
1030 let review = |ids: &[&str], v: &str| {
1031 identity(
1032 Some(&Runnable::Review {
1033 ids: ids.iter().map(|s| (*s).to_owned()).collect(),
1034 verdict: v.to_owned(),
1035 }),
1036 "",
1037 )
1038 .subject
1039 };
1040 assert_eq!(review(&[], "approve").as_deref(), Some("approve"));
1041 assert_eq!(review(&["Q1"], "answer").as_deref(), Some("answer:Q1"));
1042 }
1043
1044 #[test]
1045 fn origins_read_back_from_every_value_ever_recorded() {
1046 for (old, via, client) in [
1047 ("cli", Via::Terminal, None),
1048 ("tui", Via::Terminal, None),
1049 ("dashboard", Via::Terminal, None),
1050 ("terminal", Via::Terminal, None),
1051 ("web", Via::Web, None),
1052 ("editor Zed", Via::Acp, Some("Zed")),
1053 ] {
1054 assert_eq!(
1055 Via::read(old),
1056 Some((via, client.map(str::to_owned))),
1057 "{old}"
1058 );
1059 }
1060 let parsed: Via = serde_json::from_str("\"dashboard\"").unwrap_or(Via::Web);
1061 assert_eq!(parsed, Via::Terminal);
1062 assert_eq!(Via::Acp.as_str(), "acp");
1063 assert_eq!(Via::read("nobody"), None);
1064 }
1065
1066 fn states(f: JourneyFacts) -> Vec<(String, StepState)> {
1067 journey(f).into_iter().map(|s| (s.name, s.state)).collect()
1068 }
1069
1070 #[test]
1071 fn the_journey_marks_what_is_done_and_what_is_now() {
1072 use StepState::{Done, Now, Todo};
1073 let names = ["spec", "plan", "tasks", "commit", "run", "review"].map(String::from);
1074 let at = |v: [StepState; 6]| names.iter().cloned().zip(v).collect::<Vec<_>>();
1075 assert_eq!(
1076 states(JourneyFacts::default()),
1077 at([Now, Todo, Todo, Todo, Todo, Todo])
1078 );
1079 let planned = JourneyFacts {
1080 spec: true,
1081 plan: true,
1082 ..JourneyFacts::default()
1083 };
1084 assert_eq!(states(planned), at([Done, Done, Now, Todo, Todo, Todo]));
1085 let written = JourneyFacts {
1086 tasks: true,
1087 uncommitted: true,
1088 total: 3,
1089 ..planned
1090 };
1091 assert_eq!(states(written), at([Done, Done, Done, Now, Todo, Todo]));
1092 let running = JourneyFacts {
1093 uncommitted: false,
1094 finished: 1,
1095 ..written
1096 };
1097 assert_eq!(states(running), at([Done, Done, Done, Done, Now, Todo]));
1098 let finished = JourneyFacts {
1099 finished: 3,
1100 ..running
1101 };
1102 assert_eq!(states(finished), at([Done, Done, Done, Done, Done, Now]));
1103 }
1104
1105 #[test]
1106 fn a_task_list_without_a_spec_skips_to_the_run() {
1107 use StepState::{Now, Skipped, Todo};
1108 let names = ["spec", "plan", "tasks", "commit", "run", "review"].map(String::from);
1109 let at = |v: [StepState; 6]| names.iter().cloned().zip(v).collect::<Vec<_>>();
1110 let only_tasks = JourneyFacts {
1111 tasks: true,
1112 total: 4,
1113 finished: 1,
1114 ..JourneyFacts::default()
1115 };
1116 assert_eq!(
1117 states(only_tasks),
1118 at([
1119 Skipped,
1120 Skipped,
1121 StepState::Done,
1122 StepState::Done,
1123 Now,
1124 Todo
1125 ])
1126 );
1127 let planned = JourneyFacts {
1129 plan: true,
1130 ..JourneyFacts::default()
1131 };
1132 assert_eq!(
1133 states(planned),
1134 at([Skipped, StepState::Done, Now, Todo, Todo, Todo])
1135 );
1136 }
1137}