1pub mod apply;
13pub mod classify;
14pub mod compatibility;
15pub mod evidence;
16pub mod fingerprint;
17pub mod gather;
18pub mod guidance;
19pub mod lock;
20pub mod operation;
21pub mod planner;
22pub mod readiness;
23pub mod store;
24
25use std::collections::BTreeMap;
26
27use serde::{Deserialize, Serialize};
28
29pub use classify::{Classification, Finding, Verdict};
30pub use evidence::{EvidenceItem, EvidenceKind};
31pub use operation::Operation;
32pub use readiness::{Evaluation, Precondition, Readiness, Requirement};
33
34use crate::digest::Digest;
35use crate::landing::Kind;
36
37pub const PLAN_SCHEMA: &str = "rk.plan/3";
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "kebab-case")]
44pub enum Intent {
45 Reconcile,
47 Setup,
49 Upgrade,
52 Adopt,
55}
56
57impl Intent {
58 #[must_use]
60 pub const fn as_str(self) -> &'static str {
61 match self {
62 Self::Reconcile => "reconcile",
63 Self::Setup => "setup",
64 Self::Upgrade => "upgrade",
65 Self::Adopt => "adopt",
66 }
67 }
68}
69
70#[derive(Debug, Serialize, Deserialize)]
72pub struct Plan {
73 pub schema: std::borrow::Cow<'static, str>,
75 pub identity: Identity,
77 pub classification: Classification,
79 pub findings: Vec<Finding>,
81 pub desired_state: DesiredState,
83 pub observed_state: ObservedState,
85 pub release: Release,
87 pub operations: Vec<Operation>,
89 pub preconditions: Vec<Precondition>,
91 pub decisions: Vec<Decision>,
93 pub postconditions: Vec<Postcondition>,
95 pub evidence: Vec<EvidenceItem>,
97 pub readiness: Readiness,
99 pub input_fingerprint: Digest,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct Identity {
106 pub plan_id: String,
110 pub created_at: String,
112 pub engine_version: String,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct DesiredState {
119 pub intent: Intent,
121 pub selector: String,
124 pub release: ResolvedRelease,
126 #[serde(skip_serializing_if = "Option::is_none")]
129 pub configuration: Option<Configuration>,
130 #[serde(skip_serializing_if = "Option::is_none")]
132 pub unresolved: Option<String>,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct ResolvedRelease {
138 pub version: String,
140 pub venue: String,
142 pub payload_sha256: Digest,
144 pub payload_schema: u32,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct Configuration {
151 pub tech: String,
153 pub forge: String,
155 pub repo: String,
157 pub workflow: String,
159 #[serde(skip_serializing_if = "Option::is_none")]
161 pub style: Option<String>,
162 pub nix: bool,
164 pub trunk: String,
166 pub line_prefix: String,
168 pub security_contact: String,
170 pub security_response: String,
172 pub sources: BTreeMap<String, String>,
175 pub evidence_refs: Vec<String>,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct ObservedState {
182 pub repository: Repository,
184 pub installation: Installation,
186 pub host: Host,
188 pub forge: ForgeState,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct Repository {
195 pub target: String,
197 pub git: bool,
199 pub tags: usize,
201 pub long_lived_branches: Vec<String>,
203 pub release_markers: Vec<String>,
205 pub collisions: Vec<String>,
207 #[serde(skip_serializing_if = "Option::is_none")]
209 pub tech: Option<String>,
210 #[serde(skip_serializing_if = "Option::is_none")]
212 pub forge: Option<String>,
213 #[serde(skip_serializing_if = "Option::is_none")]
215 pub repo: Option<String>,
216 pub verdict: Verdict,
218 pub evidence_refs: Vec<String>,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct Installation {
225 pub record: RecordState,
227 pub configuration: ConfigurationState,
229 pub destinations: Vec<Destination>,
231 pub evidence_refs: Vec<String>,
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
237#[serde(tag = "state", rename_all = "kebab-case")]
238pub enum RecordState {
239 Absent,
241 Present {
243 rk_version: String,
245 payload_sha256: Digest,
247 schema_version: u64,
249 origin: String,
251 sha256: Digest,
253 },
254 Invalid {
256 reason: String,
258 },
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct ConfigurationState {
264 pub present: bool,
266 #[serde(skip_serializing_if = "Option::is_none")]
268 pub sha256: Option<Digest>,
269 #[serde(skip_serializing_if = "Option::is_none")]
271 pub invalid: Option<String>,
272 pub pending: Vec<String>,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct Destination {
279 pub path: String,
281 pub present: bool,
283 #[serde(skip_serializing_if = "Option::is_none")]
285 pub sha256: Option<Digest>,
286 #[serde(skip_serializing_if = "Option::is_none")]
288 pub recorded_kind: Option<Kind>,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct Host {
294 pub engine_version: String,
296 #[serde(skip_serializing_if = "Option::is_none")]
298 pub pin: Option<PinState>,
299 pub evidence_refs: Vec<String>,
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct PinState {
306 pub manager: String,
308 pub file: String,
310 pub version: String,
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
316#[serde(tag = "state", rename_all = "kebab-case")]
317pub enum ForgeState {
318 NotObserved {
320 reason: String,
322 },
323 Observed {
325 trunk: String,
327 #[serde(skip_serializing_if = "Option::is_none")]
329 remote_tip: Option<String>,
330 evidence_refs: Vec<String>,
332 },
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct Release {
338 pub candidate: BundleIdentity,
340 pub verification: Verification,
342 pub baseline: BaselineState,
344 pub compatibility: Compatibility,
346 pub guidance: Guidance,
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct BundleIdentity {
353 pub version: String,
355 pub payload_sha256: Digest,
357 pub payload_schema: u32,
359 pub artifacts: usize,
361 pub evidence_refs: Vec<String>,
363}
364
365#[derive(Debug, Clone, Serialize, Deserialize)]
367#[serde(tag = "method", rename_all = "kebab-case")]
368pub enum Verification {
369 Embedded,
371 RegistryChecksum {
373 cksum: Digest,
375 },
376 Directory,
378}
379
380#[derive(Debug, Clone, Serialize, Deserialize)]
382#[serde(tag = "state", rename_all = "kebab-case")]
383pub enum BaselineState {
384 NotNeeded,
386 Embedded,
388 Cached {
390 version: String,
392 },
393 NotObserved {
396 reason: String,
398 },
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct Compatibility {
408 pub engine_schema: u32,
410 pub bundle_schema: u32,
412 pub readable: bool,
414 #[serde(skip_serializing_if = "Option::is_none")]
416 pub engine_minimum: Option<String>,
417 #[serde(skip_serializing_if = "Option::is_none")]
420 pub generator: Option<GeneratorFact>,
421 #[serde(skip_serializing_if = "Option::is_none")]
424 pub forge_floor: Option<ForgeFloor>,
425 pub intermediate: Vec<IntermediateFact>,
428 pub evidence_refs: Vec<String>,
430}
431
432#[derive(Debug, Clone, Serialize, Deserialize)]
434pub struct GeneratorFact {
435 pub name: String,
437 pub pin: String,
439 pub artifact: String,
441 #[serde(skip_serializing_if = "Option::is_none")]
443 pub host: Option<String>,
444}
445
446#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct ForgeFloor {
449 pub forge: String,
451 pub minimum: String,
453 #[serde(skip_serializing_if = "Option::is_none")]
455 pub observed: Option<String>,
456}
457
458#[derive(Debug, Clone, Serialize, Deserialize)]
460pub struct IntermediateFact {
461 pub version: String,
463 pub reason: String,
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize)]
469pub struct Guidance {
470 pub coverage: Coverage,
472 #[serde(skip_serializing_if = "Option::is_none")]
474 pub interval: Option<Interval>,
475 pub steps: Vec<GuidanceStep>,
478 pub excluded: usize,
480 pub evidence_refs: Vec<String>,
482}
483
484#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
487#[serde(tag = "state", rename_all = "kebab-case")]
488pub enum Coverage {
489 NotNeeded,
491 Covered,
493 Partial {
495 since: String,
497 },
498 Unavailable,
500}
501
502#[derive(Debug, Clone, Serialize, Deserialize)]
505pub struct Interval {
506 pub from: String,
508 pub to: String,
510}
511
512#[derive(Debug, Clone, Serialize, Deserialize)]
514pub struct GuidanceStep {
515 pub version: String,
517 pub title: String,
519 pub destinations: Vec<String>,
521 pub action: String,
523 pub body: String,
525}
526
527#[derive(Debug, Clone, Serialize, Deserialize)]
529pub struct Decision {
530 pub id: String,
532 pub question: String,
534 pub choices: Vec<Choice>,
536 #[serde(skip_serializing_if = "Option::is_none")]
538 pub selected: Option<String>,
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize)]
543pub struct Choice {
544 pub answer: String,
546 pub consequence: String,
548}
549
550pub const DECISION_CHOICES: [(&str, &[&str]); 6] = [
560 ("workflow-mode", &["worktree", "branches"]),
561 ("release-style", &["trunk", "lines"]),
562 ("release-activity", &["history", "migrate"]),
563 ("partial-baseline", &["accept", "fetch"]),
564 (guidance::PARTIAL_GUIDANCE_DECISION, &["accept"]),
565 (compatibility::PIN_MANAGER_DECISION, &["wire", "host"]),
566];
567
568#[must_use]
570pub fn decision_choices(id: &str) -> Option<&'static [&'static str]> {
571 DECISION_CHOICES
572 .iter()
573 .find(|(known, _)| *known == id)
574 .map(|(_, choices)| *choices)
575}
576
577#[must_use]
582pub fn decision_answered(id: &str, answer: Option<&str>) -> bool {
583 match (decision_choices(id), answer) {
584 (Some(choices), Some(answer)) => choices.contains(&answer),
585 _ => false,
586 }
587}
588
589#[derive(Debug, Clone, Serialize, Deserialize)]
591#[serde(tag = "check", rename_all = "kebab-case")]
592pub enum Postcondition {
593 RecordReadsBack {
595 sha256: Digest,
597 },
598 DestinationHolds {
600 path: String,
602 sha256: Digest,
604 },
605 StatusCheckClean,
607 PinReads {
609 manager: String,
611 version: String,
613 },
614}
615
616#[derive(Debug, Clone, Serialize, Deserialize)]
619pub struct PlanRequest {
620 pub target: camino::Utf8PathBuf,
623 pub intent: Intent,
625 pub selector: String,
627 pub fetch: bool,
629 pub observe_forge: bool,
631 pub flags: gather::Flags,
633 pub decisions: BTreeMap<String, String>,
635}
636
637impl PlanRequest {
638 pub fn canonicalized(mut self) -> Result<Self, crate::error::RkError> {
653 self.target = canonical_target(&self.target)?;
654 Ok(self)
655 }
656}
657
658pub fn canonical_target(
665 target: &camino::Utf8Path,
666) -> Result<camino::Utf8PathBuf, crate::error::RkError> {
667 let resolved = std::fs::canonicalize(target).map_err(|error| {
668 crate::error::RkError::Usage(format!(
669 "the target {target} does not resolve to a directory: {error}"
670 ))
671 })?;
672 camino::Utf8PathBuf::from_path_buf(resolved).map_err(|path| {
673 crate::error::RkError::Other(anyhow::anyhow!(
674 "the target resolves to {}, which is not valid UTF-8",
675 path.display()
676 ))
677 })
678}
679
680#[derive(Debug)]
684pub struct Planned {
685 pub plan: Plan,
687 pub blobs: BTreeMap<Digest, Vec<u8>>,
690 pub outcomes: Vec<DestinationOutcome>,
693 pub config: Option<crate::config::Plan>,
695 pub withheld: Vec<crate::landing::Withheld>,
697}
698
699#[derive(Debug, Clone, PartialEq, Eq)]
701pub struct DestinationOutcome {
702 pub path: String,
704 pub kind: Kind,
706 pub recorded: bool,
708 pub disposition: Disposition,
710}
711
712#[derive(Debug, Clone, Copy, PartialEq, Eq)]
714pub enum Disposition {
715 Write,
717 Unchanged,
720 Kept,
722 Drift,
724 State,
726 Conflict,
728 Missing,
730}
731
732impl Disposition {
733 #[must_use]
735 pub const fn as_str(self) -> &'static str {
736 match self {
737 Self::Write => "write",
738 Self::Unchanged => "unchanged",
739 Self::Kept => "kept",
740 Self::Drift => "drift",
741 Self::State => "state",
742 Self::Conflict => "conflict",
743 Self::Missing => "missing",
744 }
745 }
746}
747
748#[cfg(test)]
749mod tests {
750 use std::collections::BTreeMap;
751
752 use super::{
753 BaselineState, BundleIdentity, Choice, Classification, Compatibility, Configuration,
754 ConfigurationState, Coverage, DECISION_CHOICES, Decision, DesiredState, Destination,
755 Evaluation, ForgeFloor, ForgeState, GeneratorFact, Guidance, GuidanceStep, Host, Identity,
756 Installation, Intent, IntermediateFact, Interval, Operation, PLAN_SCHEMA, PinState, Plan,
757 Postcondition, Precondition, Readiness, RecordState, Release, Repository, Requirement,
758 ResolvedRelease, Verdict, Verification, decision_answered, decision_choices,
759 };
760 use crate::digest::Digest;
761 use crate::landing::Kind;
762 use crate::plan::classify::Finding;
763 use crate::plan::evidence::{EvidenceItem, EvidenceKind};
764
765 #[test]
769 #[allow(
770 clippy::too_many_lines,
771 reason = "the snapshot builds every section of the plan once, and cutting it would hide a section from the one test that holds the shape"
772 )]
773 fn the_plan_schema_is_versioned_and_snapshot_tested() {
774 let a = Digest::of(b"a");
775 let b = Digest::of(b"b");
776 let plan = Plan {
777 schema: PLAN_SCHEMA.into(),
778 identity: Identity {
779 plan_id: "0123456789abcdef".into(),
780 created_at: "2026-01-01T00:00:00Z".into(),
781 engine_version: "0.0.0".into(),
782 },
783 classification: Classification::Upgrade,
784 findings: vec![Finding {
785 code: "payload-collision".into(),
786 detail: "SECURITY.md".into(),
787 }],
788 desired_state: DesiredState {
789 intent: Intent::Reconcile,
790 selector: "embedded".into(),
791 release: ResolvedRelease {
792 version: "0.0.0".into(),
793 venue: "embedded".into(),
794 payload_sha256: a.clone(),
795 payload_schema: 1,
796 },
797 configuration: Some(Configuration {
798 tech: "rust".into(),
799 forge: "github".into(),
800 repo: "acme/widget".into(),
801 workflow: "worktree".into(),
802 style: Some("trunk".into()),
803 nix: false,
804 trunk: "master".into(),
805 line_prefix: "release/".into(),
806 security_contact: String::new(),
807 security_response: "best-effort".into(),
808 sources: BTreeMap::from([("tech".to_owned(), "record".to_owned())]),
809 evidence_refs: vec!["record".into()],
810 }),
811 unresolved: None,
812 },
813 observed_state: super::ObservedState {
814 repository: Repository {
815 target: "/tmp/t".into(),
816 git: true,
817 tags: 0,
818 long_lived_branches: vec![],
819 release_markers: vec![],
820 collisions: vec!["SECURITY.md".into()],
821 tech: Some("rust".into()),
822 forge: Some("github".into()),
823 repo: Some("acme/widget".into()),
824 verdict: Verdict::Brownfield,
825 evidence_refs: vec!["repository".into()],
826 },
827 installation: Installation {
828 record: RecordState::Present {
829 rk_version: "0.0.0".into(),
830 payload_sha256: a.clone(),
831 schema_version: 6,
832 origin: "init".into(),
833 sha256: b.clone(),
834 },
835 configuration: ConfigurationState {
836 present: true,
837 sha256: Some(b.clone()),
838 invalid: None,
839 pending: vec![],
840 },
841 destinations: vec![Destination {
842 path: "SECURITY.md".into(),
843 present: true,
844 sha256: Some(a.clone()),
845 recorded_kind: Some(Kind::Rendered),
846 }],
847 evidence_refs: vec!["record".into(), "configuration".into()],
848 },
849 host: Host {
850 engine_version: "0.0.0".into(),
851 pin: Some(PinState {
852 manager: "mise".into(),
853 file: "mise.toml".into(),
854 version: "0.0.0".into(),
855 }),
856 evidence_refs: vec!["host".into()],
857 },
858 forge: ForgeState::NotObserved {
859 reason: "not requested".into(),
860 },
861 },
862 release: Release {
863 candidate: BundleIdentity {
864 version: "0.0.0".into(),
865 payload_sha256: a.clone(),
866 payload_schema: 1,
867 artifacts: 1,
868 evidence_refs: vec!["candidate-bundle".into()],
869 },
870 verification: Verification::Embedded,
871 baseline: BaselineState::Embedded,
872 compatibility: Compatibility {
873 engine_schema: 1,
874 bundle_schema: 1,
875 readable: true,
876 engine_minimum: Some("0.0.0".into()),
877 generator: Some(GeneratorFact {
878 name: "cargo-dist".into(),
879 pin: "0.32.0".into(),
880 artifact: "dist-workspace.toml".into(),
881 host: Some("0.32.0".into()),
882 }),
883 forge_floor: Some(ForgeFloor {
884 forge: "gitlab".into(),
885 minimum: "18.2".into(),
886 observed: Some("18.2.0".into()),
887 }),
888 intermediate: vec![IntermediateFact {
889 version: "0.0.0".into(),
890 reason: "the record changed shape".into(),
891 }],
892 evidence_refs: vec!["candidate-bundle".into()],
893 },
894 guidance: Guidance {
895 coverage: Coverage::Partial {
896 since: "0.0.0".into(),
897 },
898 interval: Some(Interval {
899 from: "0.0.0".into(),
900 to: "0.0.0".into(),
901 }),
902 steps: vec![GuidanceStep {
903 version: "0.0.0".into(),
904 title: "release-kit 0.0.0".into(),
905 destinations: vec![".envrc".into()],
906 action: "operator-step".into(),
907 body: "## What to do".into(),
908 }],
909 excluded: 1,
910 evidence_refs: vec!["candidate-bundle".into()],
911 },
912 },
913 operations: vec![Operation::WriteRecord {
914 before: Some(b.clone()),
915 after: a.clone(),
916 }],
917 preconditions: vec![Precondition {
918 id: "record-readable".into(),
919 requirement: Requirement::Required,
920 evaluation: Evaluation::Satisfied,
921 decision: None,
922 evidence_refs: vec!["record".into()],
923 }],
924 decisions: vec![Decision {
925 id: "workflow-mode".into(),
926 question: "which working-copy mode".into(),
927 choices: vec![Choice {
928 answer: "worktree".into(),
929 consequence: "every branch in a linked worktree".into(),
930 }],
931 selected: Some("worktree".into()),
932 }],
933 postconditions: vec![Postcondition::RecordReadsBack { sha256: a.clone() }],
934 evidence: vec![EvidenceItem {
935 id: "record".into(),
936 kind: EvidenceKind::Record,
937 producer: "rk".into(),
938 observed_at: "2026-01-01T00:00:00Z".into(),
939 sha256: Some(b.clone()),
940 method: "read".into(),
941 }],
942 readiness: Readiness::Ready,
943 input_fingerprint: a.clone(),
944 };
945 let json = serde_json::to_string(&plan).expect("a plan serializes");
946 let expected = format!(
947 r###"{{"schema":"rk.plan/3","identity":{{"plan_id":"0123456789abcdef","created_at":"2026-01-01T00:00:00Z","engine_version":"0.0.0"}},"classification":"upgrade","findings":[{{"code":"payload-collision","detail":"SECURITY.md"}}],"desired_state":{{"intent":"reconcile","selector":"embedded","release":{{"version":"0.0.0","venue":"embedded","payload_sha256":"{a}","payload_schema":1}},"configuration":{{"tech":"rust","forge":"github","repo":"acme/widget","workflow":"worktree","style":"trunk","nix":false,"trunk":"master","line_prefix":"release/","security_contact":"","security_response":"best-effort","sources":{{"tech":"record"}},"evidence_refs":["record"]}}}},"observed_state":{{"repository":{{"target":"/tmp/t","git":true,"tags":0,"long_lived_branches":[],"release_markers":[],"collisions":["SECURITY.md"],"tech":"rust","forge":"github","repo":"acme/widget","verdict":"brownfield","evidence_refs":["repository"]}},"installation":{{"record":{{"state":"present","rk_version":"0.0.0","payload_sha256":"{a}","schema_version":6,"origin":"init","sha256":"{b}"}},"configuration":{{"present":true,"sha256":"{b}","pending":[]}},"destinations":[{{"path":"SECURITY.md","present":true,"sha256":"{a}","recorded_kind":"rendered"}}],"evidence_refs":["record","configuration"]}},"host":{{"engine_version":"0.0.0","pin":{{"manager":"mise","file":"mise.toml","version":"0.0.0"}},"evidence_refs":["host"]}},"forge":{{"state":"not-observed","reason":"not requested"}}}},"release":{{"candidate":{{"version":"0.0.0","payload_sha256":"{a}","payload_schema":1,"artifacts":1,"evidence_refs":["candidate-bundle"]}},"verification":{{"method":"embedded"}},"baseline":{{"state":"embedded"}},"compatibility":{{"engine_schema":1,"bundle_schema":1,"readable":true,"engine_minimum":"0.0.0","generator":{{"name":"cargo-dist","pin":"0.32.0","artifact":"dist-workspace.toml","host":"0.32.0"}},"forge_floor":{{"forge":"gitlab","minimum":"18.2","observed":"18.2.0"}},"intermediate":[{{"version":"0.0.0","reason":"the record changed shape"}}],"evidence_refs":["candidate-bundle"]}},"guidance":{{"coverage":{{"state":"partial","since":"0.0.0"}},"interval":{{"from":"0.0.0","to":"0.0.0"}},"steps":[{{"version":"0.0.0","title":"release-kit 0.0.0","destinations":[".envrc"],"action":"operator-step","body":"## What to do"}}],"excluded":1,"evidence_refs":["candidate-bundle"]}}}},"operations":[{{"op":"write-record","before":"{b}","after":"{a}"}}],"preconditions":[{{"id":"record-readable","requirement":"required","evaluation":{{"state":"satisfied"}},"evidence_refs":["record"]}}],"decisions":[{{"id":"workflow-mode","question":"which working-copy mode","choices":[{{"answer":"worktree","consequence":"every branch in a linked worktree"}}],"selected":"worktree"}}],"postconditions":[{{"check":"record-reads-back","sha256":"{a}"}}],"evidence":[{{"id":"record","kind":"record","producer":"rk","observed_at":"2026-01-01T00:00:00Z","sha256":"{b}","method":"read"}}],"readiness":"ready","input_fingerprint":"{a}"}}"###
948 );
949 assert_eq!(json, expected);
950 }
951
952 #[test]
959 fn every_decision_the_planner_asks_is_in_the_catalogue() {
960 let sources = [
961 include_str!("planner.rs"),
962 include_str!("compatibility.rs"),
963 include_str!("guidance.rs"),
964 ];
965 for (id, choices) in DECISION_CHOICES {
968 assert!(
969 sources.iter().any(|source| source.contains(id)),
970 "the catalogue names {id} and no module asks it"
971 );
972 assert!(!choices.is_empty(), "{id} declares no answer");
973 for answer in choices {
974 assert!(
975 decision_answered(id, Some(answer)),
976 "{id} does not take its own declared answer {answer}"
977 );
978 }
979 assert!(
980 !decision_answered(id, Some("not-a-declared-answer")),
981 "{id} takes an answer it does not declare"
982 );
983 }
984 assert!(decision_choices("not-a-decision").is_none());
985 assert!(!decision_answered("not-a-decision", Some("anything")));
986 }
987}