1use std::collections::BTreeMap;
9
10use crate::domain::ownership::Sha256;
11use crate::domain::profile::{ProfileId, resolve_destination};
12use crate::domain::projection::Declaration;
13use crate::plan::classify::{Classification, Signals, classify};
14use crate::plan::decision::{self, AnswerSchema, Choice, Decision, Selections};
15use crate::plan::evidence::{Ledger, Producer};
16use crate::plan::finding::{Finding, FindingKind, StyleCandidate, detector};
17use crate::plan::fingerprint::{Value, fingerprint};
18use crate::plan::observe::{Observation, RecordedFile};
19use crate::plan::operation::{Class, Operation, TargetPath, no_duplicate_destination};
20use crate::plan::readiness::{Evaluation, Precondition, Readiness, Requirement, readiness};
21use crate::plan::{
22 Declared, DesiredState, Identity, ObservedState, PLAN_SCHEMA, Plan, Postcondition,
23 ReleaseSource,
24};
25
26#[derive(Debug, Clone)]
28pub struct Inputs<'a> {
29 pub observation: &'a Observation,
31 pub declaration: &'a Declaration,
33 pub candidate: &'a BTreeMap<String, Sha256>,
35 pub baseline: Option<&'a BTreeMap<String, Sha256>>,
38 pub selector: String,
40 pub release: String,
42 pub release_sha256: Sha256,
44 pub provenance: String,
46 pub registry_checksum: Option<Sha256>,
48 pub yanked: bool,
50 pub compatibility: Option<&'a crate::plan::compatibility::Compatibility>,
52 pub interval: Option<&'a crate::plan::compatibility::Interval>,
54 pub briefing: Option<&'a crate::plan::guidance::Briefing>,
56 pub proposed: Option<&'a [Operation]>,
61 pub selections: &'a Selections,
63 pub reserve: &'a [String],
65 pub budget: &'a [crate::domain::debt::Measurement],
71 pub declared: Option<&'a str>,
77 pub declarations_settled: bool,
84 pub now: String,
86}
87
88#[must_use]
96pub fn plan(inputs: &Inputs<'_>) -> Plan {
97 let observation = inputs.observation;
98 let (ledger, refs, release_ref) = ledger_of(inputs);
99
100 let profile = chosen_profile(inputs);
101 let classification = classification_of(inputs, profile);
102 let findings = findings_of(inputs, profile, classification);
103 let style_candidates = style_candidates_of(inputs, classification);
104 let structural = findings
105 .iter()
106 .filter(|found| found.kind == FindingKind::Structural)
107 .count();
108 let mut decisions = decisions_of(inputs, classification, profile, structural, &findings);
109 if let Some(briefing) = inputs.briefing {
110 decisions.extend(briefing.decisions.iter().cloned());
111 }
112 let operations = inputs.proposed.map_or_else(
113 || derived_operations(inputs, profile, classification, &decisions),
114 <[Operation]>::to_vec,
115 );
116
117 let mut preconditions =
118 preconditions_of(inputs, classification, &operations, &decisions, structural);
119 if let (Some(held), Some(interval)) = (inputs.compatibility, inputs.interval) {
120 preconditions.extend(crate::plan::compatibility::preconditions(held, interval));
121 }
122 if let Some(briefing) = inputs.briefing {
123 preconditions.extend(briefing.preconditions.iter().cloned());
124 }
125 let verdict = readiness(&preconditions);
126
127 let desired_state = DesiredState {
128 selector: inputs.selector.clone(),
129 release: inputs.release.clone(),
130 release_sha256: inputs.release_sha256.clone(),
131 profile,
132 reserved: inputs.reserve.to_vec(),
133 declared: Declared {
134 payload_schema: inputs.declaration.payload_schema,
135 managed: inputs.declaration.managed.len(),
136 adopted: inputs.declaration.adopted.len(),
137 sentinels: inputs.declaration.sentinels.len(),
138 },
139 };
140 let observed_state = ObservedState {
141 repository: observation.repository.clone(),
142 installation: observation.installation.clone(),
143 host: observation.host.clone(),
144 corpus: observation.corpus.clone(),
145 evidence_refs: refs,
146 };
147 let release = ReleaseSource {
148 version: inputs.release.clone(),
149 provenance: inputs.provenance.clone(),
150 registry_checksum: inputs.registry_checksum.clone(),
151 yanked: inputs.yanked,
152 minimum_engine: inputs
153 .compatibility
154 .map(|held| held.minimum_engine.to_string()),
155 guidance_coverage: inputs.interval.and_then(|interval| {
156 inputs
157 .briefing
158 .map(|_| crate::plan::guidance::Coverage::Complete)
159 .filter(|_| interval.recorded.is_some())
160 }),
161 guidance_steps: inputs
162 .briefing
163 .map(|briefing| briefing.applicable.clone())
164 .unwrap_or_default(),
165 guidance_excluded: inputs.briefing.map_or(0, |briefing| briefing.excluded),
166 evidence_refs: vec![release_ref],
167 };
168 let digest = fingerprint(&inputs_projection(
169 inputs,
170 classification,
171 &operations,
172 &preconditions,
173 &decisions,
174 ));
175 Plan {
176 identity: Identity {
177 schema: PLAN_SCHEMA.to_string(),
178 plan_id: digest.to_string(),
179 created_at: inputs.now.clone(),
180 engine_version: env!("CARGO_PKG_VERSION").to_string(),
181 },
182 classification,
183 findings,
184 style_candidates,
185 desired_state,
186 observed_state,
187 release,
188 operations,
189 preconditions,
190 decisions,
191 postconditions: postconditions_of(classification),
192 evidence: ledger.items,
193 readiness: verdict,
194 input_fingerprint: digest,
195 }
196}
197
198fn ledger_of(inputs: &Inputs<'_>) -> (Ledger, Vec<String>, String) {
200 let mut ledger = Ledger::new();
201 let mut refs = Vec::new();
202 refs.push(ledger.record(
203 "target",
204 "the target's working tree",
205 Producer::Disk,
206 &inputs.now,
207 None,
208 "walked, skipping version control and build output",
209 ));
210 if let Some(installation) = inputs.observation.installation.as_ref() {
211 refs.push(ledger.record(
212 "record",
213 "the instance record",
214 Producer::Record,
215 &inputs.now,
216 Some(installation.record_sha256.clone()),
217 "read and parsed",
218 ));
219 if let Some(digest) = installation.declaration_sha256.as_ref() {
220 refs.push(ledger.record(
221 "declaration",
222 "the project's own declaration",
223 Producer::Declaration,
224 &inputs.now,
225 Some(digest.clone()),
226 "read from the target",
227 ));
228 }
229 }
230 let release_ref = ledger.record(
231 "release",
232 "the destination release",
233 Producer::Bundle,
234 &inputs.now,
235 Some(inputs.release_sha256.clone()),
236 "read through the release seam",
237 );
238 refs.push(ledger.record(
239 "host",
240 "the resolved user-scope paths",
241 Producer::Host,
242 &inputs.now,
243 None,
244 "read from the environment",
245 ));
246 (ledger, refs, release_ref)
247}
248
249fn chosen_profile(inputs: &Inputs<'_>) -> Option<ProfileId> {
251 if let Some(installation) = inputs.observation.installation.as_ref() {
252 return Some(installation.profile);
253 }
254 match inputs
255 .selections
256 .get(decision::id::PROFILE)
257 .map(String::as_str)
258 {
259 Some("codebase") => Some(ProfileId::Codebase),
260 Some("knowledge-base") => Some(ProfileId::KnowledgeBase),
261 _ => None,
262 }
263}
264
265fn classification_of(inputs: &Inputs<'_>, profile: Option<ProfileId>) -> Classification {
266 let observation = inputs.observation;
267 let drifted = observation
268 .installation
269 .as_ref()
270 .is_some_and(crate::plan::observe::Installation::drifted);
271 let at_destination = observation
272 .installation
273 .as_ref()
274 .is_some_and(|installed| installed.canon_version.to_string() == inputs.release);
275 let _ = profile;
276 classify(Signals {
277 invalid: observation.invalid.is_some(),
278 installed: observation.installation.is_some(),
279 at_destination,
280 drifted,
281 settled: observation.corpus.settled(),
282 })
283}
284
285fn findings_of(
287 inputs: &Inputs<'_>,
288 profile: Option<ProfileId>,
289 classification: Classification,
290) -> Vec<Finding> {
291 let mut findings = Vec::new();
292 if classification != Classification::Migration {
293 return findings;
294 }
295 findings.extend(inputs.budget.iter().filter_map(budget_finding));
298
299 let corpus = &inputs.observation.corpus;
300 if let Some(profile) = profile
303 && let Some(docs_root) = inputs.declaration.docs_root(profile)
304 {
305 for root in &corpus.populated_doc_roots {
306 if root == docs_root.as_str() {
307 continue;
308 }
309 if let Ok(path) = TargetPath::new(root) {
310 findings.push(Finding {
311 kind: FindingKind::Structural,
312 path,
313 rule: detector::FOREIGN_DOCS_ROOT.to_string(),
314 statement: format!(
315 "{root} holds documents and the {profile} profile keeps them under {docs_root}"
316 ),
317 measurement: None,
318 });
319 }
320 }
321 if corpus.settled()
322 && !corpus.has_specs_directory
323 && let Ok(path) = TargetPath::new(&format!("{docs_root}/specs"))
324 {
325 findings.push(Finding {
326 kind: FindingKind::Structural,
327 path,
328 rule: detector::NO_SPECS_DIRECTORY.to_string(),
329 statement: "the corpus is settled and no specifications directory holds its rules"
330 .to_string(),
331 measurement: None,
332 });
333 }
334 }
335 for path in &corpus.spec_without_rule_id {
336 findings.push(Finding {
337 kind: FindingKind::Structural,
338 path: path.clone(),
339 rule: detector::SPEC_WITHOUT_RULE_ID.to_string(),
340 statement: "the document is shaped like a specification and defines no rule ID"
341 .to_string(),
342 measurement: None,
343 });
344 }
345 for path in &corpus.ordinal_named {
346 findings.push(Finding {
347 kind: FindingKind::Structural,
348 path: path.clone(),
349 rule: detector::ORDINAL_FILENAME.to_string(),
350 statement: "the document is named by its position rather than its subject".to_string(),
351 measurement: None,
352 });
353 }
354 for path in &corpus.records_outside_decisions {
355 findings.push(Finding {
356 kind: FindingKind::Structural,
357 path: path.clone(),
358 rule: detector::RECORD_OUTSIDE_DECISIONS.to_string(),
359 statement: "the decision record sits outside a decisions directory".to_string(),
360 measurement: None,
361 });
362 }
363 findings
364}
365
366fn style_candidates_of(inputs: &Inputs<'_>, classification: Classification) -> Vec<StyleCandidate> {
368 if classification != Classification::Migration {
369 return Vec::new();
370 }
371 inputs
372 .observation
373 .corpus
374 .documents
375 .iter()
376 .map(|path| StyleCandidate {
377 path: path.clone(),
378 reason: "the document predates the instance, and no gate judges its prose".to_string(),
379 })
380 .collect()
381}
382
383fn choice(id: &str, consequence: &str) -> Choice {
384 Choice {
385 id: id.to_string(),
386 consequence: consequence.to_string(),
387 }
388}
389
390fn decisions_of(
392 inputs: &Inputs<'_>,
393 classification: Classification,
394 profile: Option<ProfileId>,
395 structural: usize,
396 findings: &[Finding],
397) -> Vec<Decision> {
398 let mut decisions = Vec::new();
399 let selected = |id: &str| inputs.selections.get(id).cloned();
400
401 if inputs.observation.installation.is_none()
402 && matches!(
403 classification,
404 Classification::Setup | Classification::Migration
405 )
406 {
407 decisions.push(Decision {
408 id: decision::id::PROFILE.to_string(),
409 question: "which profile does this repository take?".to_string(),
410 schema: AnswerSchema::Choice {
411 choices: vec![
412 choice("codebase", "records live under docs/"),
413 choice("knowledge-base", "records live under _docs/"),
414 ],
415 },
416 depends_on: Vec::new(),
417 selected: selected(decision::id::PROFILE),
418 });
419 }
420
421 if profile.is_some() {
423 if inputs.observation.installation.is_none() && !inputs.declarations_settled {
424 let depends: Vec<String> = if decisions
425 .iter()
426 .any(|held| held.id == decision::id::PROFILE)
427 {
428 vec![decision::id::PROFILE.to_string()]
429 } else {
430 Vec::new()
431 };
432 decisions.push(Decision {
433 id: decision::id::DOCS_SCRATCH.to_string(),
434 question: "where does material that is not a statement yet stage?".to_string(),
435 schema: AnswerSchema::ChoiceOrValue {
436 choices: vec![choice("none", "the project stages nothing")],
437 prefixes: vec!["project:".to_string(), "external:".to_string()],
440 },
441 depends_on: depends.clone(),
442 selected: selected(decision::id::DOCS_SCRATCH),
443 });
444 decisions.push(Decision {
445 id: decision::id::WRITING_STYLE.to_string(),
446 question: "which writing source does the project select?".to_string(),
447 schema: AnswerSchema::ChoiceOrValue {
448 choices: vec![
449 choice("builtin", "this convention's own chapter, served offline"),
450 choice("none", "no route and no conversion obligation"),
451 ],
452 prefixes: vec!["project:".to_string()],
453 },
454 depends_on: depends,
455 selected: selected(decision::id::WRITING_STYLE),
456 });
457 }
458
459 if classification == Classification::Migration {
460 decisions.extend(migration_decisions(inputs, structural, findings));
461 }
462 }
463
464 if inputs.yanked {
465 decisions.push(Decision {
466 id: decision::id::ACCEPT_YANKED.to_string(),
467 question: format!(
468 "the registry marks {} yanked; land it anyway?",
469 inputs.release
470 ),
471 schema: AnswerSchema::Choice {
472 choices: vec![
473 choice("accept", "the release lands, yanked and named as such"),
474 choice("refuse", "nothing lands; name another release"),
475 ],
476 },
477 depends_on: Vec::new(),
478 selected: selected(decision::id::ACCEPT_YANKED),
479 });
480 }
481 decisions
482}
483
484fn migration_decisions(
486 inputs: &Inputs<'_>,
487 structural: usize,
488 findings: &[Finding],
489) -> Vec<Decision> {
490 let selected = |id: &str| inputs.selections.get(id).cloned();
491 let mut decisions = Vec::new();
492 let mut choices = vec![choice(
493 "sweep",
494 "every durable fact moves into its owner, and the old convention retires",
495 )];
496 if structural == 0 {
499 choices.push(choice(
500 "incremental",
501 "each document converts the next time somebody edits it",
502 ));
503 }
504 decisions.push(Decision {
505 id: decision::id::MIGRATION_SCOPE.to_string(),
506 question: "how much of the corpus moves?".to_string(),
507 schema: AnswerSchema::Choice { choices },
508 depends_on: vec![decision::id::PROFILE.to_string()],
509 selected: selected(decision::id::MIGRATION_SCOPE),
510 });
511 if findings
512 .iter()
513 .any(|found| found.kind == FindingKind::Budget)
514 {
515 decisions.push(Decision {
516 id: decision::id::DEBT_BASELINE.to_string(),
517 question: "are the inherited violations recorded as debt?".to_string(),
518 schema: AnswerSchema::Choice {
519 choices: vec![
520 choice(
521 "record",
522 "each inherited violation becomes a ceiling that only comes down",
523 ),
524 choice(
525 "skip",
526 "nothing is recorded, and each violation fails its gate",
527 ),
528 ],
529 },
530 depends_on: vec![decision::id::MIGRATION_SCOPE.to_string()],
531 selected: selected(decision::id::DEBT_BASELINE),
532 });
533 }
534 decisions
535}
536
537fn derived_operations(
543 inputs: &Inputs<'_>,
544 profile: Option<ProfileId>,
545 classification: Classification,
546 decisions: &[Decision],
547) -> Vec<Operation> {
548 if profile.is_none() {
549 return Vec::new();
550 }
551 if matches!(
557 classification,
558 Classification::Setup | Classification::Migration
559 ) {
560 return Vec::new();
561 }
562 operations_of(inputs, profile, classification, decisions)
563}
564
565fn operations_of(
567 inputs: &Inputs<'_>,
568 profile: Option<ProfileId>,
569 classification: Classification,
570 decisions: &[Decision],
571) -> Vec<Operation> {
572 let mut operations = Vec::new();
573 if matches!(
574 classification,
575 Classification::Invalid | Classification::Current
576 ) {
577 return operations;
578 }
579 let Some(profile) = profile else {
580 return operations;
581 };
582 let Some(docs_root) = inputs.declaration.docs_root(profile) else {
583 return operations;
584 };
585 let held = crate::plan::observe::held_by_path(inputs.observation.installation.as_ref());
586 let recorded: BTreeMap<&str, &RecordedFile> = inputs
587 .observation
588 .installation
589 .iter()
590 .flat_map(|installation| installation.adopted.iter())
591 .map(|file| (file.path.as_str(), file))
592 .collect();
593
594 for projection in &inputs.declaration.managed {
595 let Some(after) = inputs.candidate.get(&projection.source) else {
596 continue;
597 };
598 let Ok(path) = TargetPath::new(&projection.destination) else {
599 continue;
600 };
601 let before = held.get(path.as_str()).cloned();
602 if before.as_ref() == Some(after) {
603 continue;
604 }
605 operations.push(Operation::WriteFile {
606 path,
607 class: Class::Managed,
608 before,
609 after: after.clone(),
610 });
611 }
612
613 for projection in &inputs.declaration.adopted {
614 let Some(seed) = inputs.candidate.get(&projection.source) else {
615 continue;
616 };
617 let destination = resolve_destination(&projection.destination, docs_root);
618 let Ok(path) = TargetPath::new(destination.as_str()) else {
619 continue;
620 };
621 match held.get(path.as_str()) {
622 Some(current) => {
625 let baseline_before = recorded
626 .get(path.as_str())
627 .and_then(|file| file.baseline.clone())
628 .or_else(|| {
629 inputs
630 .baseline
631 .and_then(|held| held.get(&projection.source).cloned())
632 });
633 let Some(baseline_before) = baseline_before else {
634 continue;
635 };
636 if &baseline_before == seed {
637 continue;
638 }
639 operations.push(Operation::KeepFile {
640 path,
641 held: current.clone(),
642 baseline_before,
643 baseline_after: seed.clone(),
644 });
645 }
646 None => operations.push(Operation::WriteFile {
647 path,
648 class: Class::Adopted,
649 before: None,
650 after: seed.clone(),
651 }),
652 }
653 }
654
655 let selected = |id: &str| {
658 decisions
659 .iter()
660 .find(|decision| decision.id == id)
661 .and_then(|decision| decision.selected.as_deref())
662 };
663 let _ = selected(decision::id::DEBT_BASELINE);
667 operations
668}
669
670fn preconditions_of(
672 inputs: &Inputs<'_>,
673 classification: Classification,
674 operations: &[Operation],
675 decisions: &[Decision],
676 structural: usize,
677) -> Vec<Precondition> {
678 let mut preconditions: Vec<Precondition> = Vec::new();
679 macro_rules! require {
680 ($id:expr, $statement:expr, $requirement:expr, $evaluation:expr) => {
681 preconditions.push(Precondition {
682 id: ($id).to_string(),
683 statement: $statement,
684 requirement: $requirement,
685 evaluation: $evaluation,
686 resolved_by: None,
687 evidence_refs: Vec::new(),
688 });
689 };
690 }
691
692 if let Some(reason) = inputs.observation.invalid.as_ref() {
693 require!(
694 "record-is-readable",
695 "the instance record parses".to_string(),
696 Requirement::Required,
697 Evaluation::Unsatisfied {
698 reason: reason.clone(),
699 }
700 );
701 }
702
703 let waiting = decision_preconditions(decisions);
704
705 let edited = edited_managed_files(inputs);
706 if !edited.is_empty() {
707 require!(
708 "managed-files-are-unedited",
709 "every managed file still holds what the record says".to_string(),
710 Requirement::Required,
711 Evaluation::Unsatisfied {
712 reason: edited.join("; "),
713 }
714 );
715 }
716
717 if inputs.baseline.is_none() && inputs.observation.installation.is_some() {
718 require!(
719 "baseline-is-readable",
720 "the recorded release's bundle can be read for baselines".to_string(),
721 Requirement::Advisory,
722 Evaluation::NotObserved {
723 reason:
724 "the recorded release's bundle was not read, so an adopted baseline cannot move"
725 .to_string(),
726 }
727 );
728 }
729
730 let scope = decisions
734 .iter()
735 .find(|decision| decision.id == decision::id::MIGRATION_SCOPE)
736 .and_then(|decision| decision.selected.as_deref());
737 if classification == Classification::Migration && scope == Some("incremental") && structural > 0
738 {
739 require!(
740 "incremental-scope-has-no-structural-finding",
741 "an incremental migration leaves no structural finding behind".to_string(),
742 Requirement::Required,
743 Evaluation::Unsatisfied {
744 reason: format!(
745 "{structural} structural finding(s) would make two conventions coexist"
746 ),
747 }
748 );
749 }
750
751 preconditions.extend(waiting);
752
753 if let Err(clash) = no_duplicate_destination(operations) {
754 require!(
755 "no-destination-is-written-twice",
756 "each destination is written by at most one operation".to_string(),
757 Requirement::Required,
758 Evaluation::Unsatisfied {
759 reason: clash.to_string(),
760 }
761 );
762 }
763 preconditions
764}
765
766fn budget_finding(measured: &crate::domain::debt::Measurement) -> Option<Finding> {
773 let path = TargetPath::new(&measured.path).ok()?;
774 let (statement, found, cap) = match measured.value {
775 crate::domain::debt::Measured::Count { value, budget } if value > budget => (
776 format!(
777 "{} is {value} {} against a budget of {budget}",
778 measured.path, measured.dimension
779 ),
780 value as u64,
781 budget as u64,
782 ),
783 crate::domain::debt::Measured::Flag(true) => (
784 format!("{} carries {}", measured.path, measured.dimension),
785 1,
786 0,
787 ),
788 _ => return None,
789 };
790 Some(Finding {
791 kind: FindingKind::Budget,
792 path,
793 rule: measured.gate.to_string(),
794 statement,
795 measurement: Some(crate::plan::finding::Measurement {
796 dimension: measured.dimension.to_string(),
797 found,
798 cap,
799 }),
800 })
801}
802
803fn edited_managed_files(inputs: &Inputs<'_>) -> Vec<String> {
808 let mut edited = Vec::new();
809 let Some(installation) = inputs.observation.installation.as_ref() else {
810 return edited;
811 };
812 for file in &installation.managed {
813 match file.held.as_ref() {
814 Some(found) if found == &file.recorded => {}
815 Some(_) => edited.push(format!("{} was edited", file.path)),
816 None => edited.push(format!("{} is gone", file.path)),
817 }
818 }
819 for block in &installation.blocks {
823 match block.held.as_ref() {
824 Some(found) if found == &block.recorded => {}
825 Some(_) => edited.push(format!("the managed block in {} was edited", block.path)),
826 None => edited.push(format!("the managed block in {} is gone", block.path)),
827 }
828 }
829 edited
830}
831
832fn decision_preconditions(decisions: &[Decision]) -> Vec<Precondition> {
834 decisions
835 .iter()
836 .map(|decision| Precondition {
837 id: format!("decision:{}", decision.id),
838 statement: decision.question.clone(),
839 requirement: Requirement::DecisionRequired,
840 evaluation: decision.selected.as_ref().map_or_else(
841 || Evaluation::Unsatisfied {
842 reason: "the operator has not answered it".to_string(),
843 },
844 |_| Evaluation::Satisfied,
845 ),
846 resolved_by: Some(decision.id.clone()),
847 evidence_refs: Vec::new(),
848 })
849 .collect()
850}
851
852fn postconditions_of(classification: Classification) -> Vec<Postcondition> {
854 if classification == Classification::Invalid {
855 return Vec::new();
856 }
857 vec![
858 Postcondition {
859 id: "record-matches-the-tree".to_string(),
860 statement: "every operation's destination holds the digest the plan named".to_string(),
861 },
862 Postcondition {
863 id: "verification-passes".to_string(),
864 statement: "sdd verify reports OK against the target".to_string(),
865 },
866 ]
867}
868
869fn inputs_projection(
875 inputs: &Inputs<'_>,
876 classification: Classification,
877 operations: &[Operation],
878 preconditions: &[Precondition],
879 decisions: &[Decision],
880) -> Value {
881 let operations = Value::List(
882 operations
883 .iter()
884 .filter(|operation| !matches!(operation, Operation::WriteRecord { .. }))
890 .map(|operation| {
891 Value::map([
892 ("kind", Value::text(operation.kind())),
893 ("path", Value::text(operation.path().as_str())),
894 (
895 "before",
896 Value::maybe(operation.before().map(std::string::ToString::to_string)),
897 ),
898 (
899 "after",
900 Value::maybe(operation.after().map(std::string::ToString::to_string)),
901 ),
902 ])
903 })
904 .collect(),
905 );
906 let gates = Value::List(
909 preconditions
910 .iter()
911 .filter(|precondition| precondition.requirement != Requirement::Advisory)
912 .map(|precondition| {
913 Value::map([
914 ("id", Value::text(precondition.id.as_str())),
915 (
916 "state",
917 Value::text(match precondition.evaluation {
918 Evaluation::Satisfied => "satisfied",
919 Evaluation::NotObserved { .. } => "not-observed",
920 Evaluation::Unsatisfied { .. } => "unsatisfied",
921 }),
922 ),
923 ])
924 })
925 .collect(),
926 );
927 let selected = Value::Map(
928 decisions
929 .iter()
930 .filter_map(|decision| {
931 decision
932 .selected
933 .as_ref()
934 .map(|answer| (decision.id.clone(), Value::text(answer.as_str())))
935 })
936 .collect(),
937 );
938 Value::map([
939 ("schema", Value::text(PLAN_SCHEMA)),
940 ("classification", Value::text(classification.as_str())),
941 ("release", Value::text(inputs.release.as_str())),
942 (
943 "release_sha256",
944 Value::text(inputs.release_sha256.as_str()),
945 ),
946 (
947 "target",
948 Value::text(inputs.observation.repository.root.as_str()),
949 ),
950 (
951 "record",
952 Value::maybe(
953 inputs
954 .observation
955 .installation
956 .as_ref()
957 .map(|installation| installation.record_sha256.to_string()),
958 ),
959 ),
960 (
961 "declaration",
962 Value::maybe(
963 inputs
964 .observation
965 .installation
966 .as_ref()
967 .and_then(|installation| installation.declaration_sha256.as_ref())
968 .map(std::string::ToString::to_string),
969 ),
970 ),
971 ("operations", operations),
972 ("preconditions", gates),
973 ("decisions", selected),
974 ("declared", declared_projection(inputs)),
975 ])
976}
977
978fn declared_projection(inputs: &Inputs<'_>) -> Value {
985 Value::map([
986 (
987 "profile",
988 Value::maybe(
989 inputs
990 .selections
991 .get(crate::plan::decision::id::PROFILE)
992 .cloned(),
993 ),
994 ),
995 (
996 "reserved",
997 Value::List(
998 inputs
999 .reserve
1000 .iter()
1001 .map(|path| Value::text(path.as_str()))
1002 .collect(),
1003 ),
1004 ),
1005 (
1006 "record",
1007 Value::maybe(inputs.declared.map(std::string::ToString::to_string)),
1008 ),
1009 ])
1010}
1011
1012#[must_use]
1014pub const fn is_ready(verdict: Readiness) -> bool {
1015 matches!(verdict, Readiness::Ready)
1016}