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::PLAN_ZONE.to_string(),
434 question: "where does the planning tool write its entry documents?".to_string(),
435 schema: AnswerSchema::ChoiceOrValue {
436 choices: vec![
437 choice("env", "wherever the plan-zone variable points"),
438 choice("none", "the project keeps no plan zone"),
439 ],
440 prefixes: vec!["project:".to_string(), "untracked:".to_string()],
441 },
442 depends_on: depends.clone(),
443 selected: selected(decision::id::PLAN_ZONE),
444 });
445 decisions.push(Decision {
446 id: decision::id::DOCS_SCRATCH.to_string(),
447 question: "where does material that is not a statement yet stage?".to_string(),
448 schema: AnswerSchema::ChoiceOrValue {
449 choices: vec![choice("none", "the project stages nothing")],
450 prefixes: vec!["project:".to_string(), "external:".to_string()],
453 },
454 depends_on: depends.clone(),
455 selected: selected(decision::id::DOCS_SCRATCH),
456 });
457 decisions.push(Decision {
458 id: decision::id::WRITING_STYLE.to_string(),
459 question: "which writing source does the project select?".to_string(),
460 schema: AnswerSchema::ChoiceOrValue {
461 choices: vec![
462 choice("builtin", "this convention's own chapter, served offline"),
463 choice("none", "no route and no conversion obligation"),
464 ],
465 prefixes: vec!["project:".to_string()],
466 },
467 depends_on: depends,
468 selected: selected(decision::id::WRITING_STYLE),
469 });
470 }
471
472 if classification == Classification::Migration {
473 decisions.extend(migration_decisions(inputs, structural, findings));
474 }
475 }
476
477 if inputs.yanked {
478 decisions.push(Decision {
479 id: decision::id::ACCEPT_YANKED.to_string(),
480 question: format!(
481 "the registry marks {} yanked; land it anyway?",
482 inputs.release
483 ),
484 schema: AnswerSchema::Choice {
485 choices: vec![
486 choice("accept", "the release lands, yanked and named as such"),
487 choice("refuse", "nothing lands; name another release"),
488 ],
489 },
490 depends_on: Vec::new(),
491 selected: selected(decision::id::ACCEPT_YANKED),
492 });
493 }
494 decisions
495}
496
497fn migration_decisions(
499 inputs: &Inputs<'_>,
500 structural: usize,
501 findings: &[Finding],
502) -> Vec<Decision> {
503 let selected = |id: &str| inputs.selections.get(id).cloned();
504 let mut decisions = Vec::new();
505 let mut choices = vec![choice(
506 "sweep",
507 "every durable fact moves into its owner, and the old convention retires",
508 )];
509 if structural == 0 {
512 choices.push(choice(
513 "incremental",
514 "each document converts the next time somebody edits it",
515 ));
516 }
517 decisions.push(Decision {
518 id: decision::id::MIGRATION_SCOPE.to_string(),
519 question: "how much of the corpus moves?".to_string(),
520 schema: AnswerSchema::Choice { choices },
521 depends_on: vec![decision::id::PROFILE.to_string()],
522 selected: selected(decision::id::MIGRATION_SCOPE),
523 });
524 if findings
525 .iter()
526 .any(|found| found.kind == FindingKind::Budget)
527 {
528 decisions.push(Decision {
529 id: decision::id::DEBT_BASELINE.to_string(),
530 question: "are the inherited violations recorded as debt?".to_string(),
531 schema: AnswerSchema::Choice {
532 choices: vec![
533 choice(
534 "record",
535 "each inherited violation becomes a ceiling that only comes down",
536 ),
537 choice(
538 "skip",
539 "nothing is recorded, and each violation fails its gate",
540 ),
541 ],
542 },
543 depends_on: vec![decision::id::MIGRATION_SCOPE.to_string()],
544 selected: selected(decision::id::DEBT_BASELINE),
545 });
546 }
547 decisions
548}
549
550fn derived_operations(
556 inputs: &Inputs<'_>,
557 profile: Option<ProfileId>,
558 classification: Classification,
559 decisions: &[Decision],
560) -> Vec<Operation> {
561 if profile.is_none() {
562 return Vec::new();
563 }
564 if matches!(
570 classification,
571 Classification::Setup | Classification::Migration
572 ) {
573 return Vec::new();
574 }
575 operations_of(inputs, profile, classification, decisions)
576}
577
578fn operations_of(
580 inputs: &Inputs<'_>,
581 profile: Option<ProfileId>,
582 classification: Classification,
583 decisions: &[Decision],
584) -> Vec<Operation> {
585 let mut operations = Vec::new();
586 if matches!(
587 classification,
588 Classification::Invalid | Classification::Current
589 ) {
590 return operations;
591 }
592 let Some(profile) = profile else {
593 return operations;
594 };
595 let Some(docs_root) = inputs.declaration.docs_root(profile) else {
596 return operations;
597 };
598 let held = crate::plan::observe::held_by_path(inputs.observation.installation.as_ref());
599 let recorded: BTreeMap<&str, &RecordedFile> = inputs
600 .observation
601 .installation
602 .iter()
603 .flat_map(|installation| installation.adopted.iter())
604 .map(|file| (file.path.as_str(), file))
605 .collect();
606
607 for projection in &inputs.declaration.managed {
608 let Some(after) = inputs.candidate.get(&projection.source) else {
609 continue;
610 };
611 let Ok(path) = TargetPath::new(&projection.destination) else {
612 continue;
613 };
614 let before = held.get(path.as_str()).cloned();
615 if before.as_ref() == Some(after) {
616 continue;
617 }
618 operations.push(Operation::WriteFile {
619 path,
620 class: Class::Managed,
621 before,
622 after: after.clone(),
623 });
624 }
625
626 for projection in &inputs.declaration.adopted {
627 let Some(seed) = inputs.candidate.get(&projection.source) else {
628 continue;
629 };
630 let destination = resolve_destination(&projection.destination, docs_root);
631 let Ok(path) = TargetPath::new(destination.as_str()) else {
632 continue;
633 };
634 match held.get(path.as_str()) {
635 Some(current) => {
638 let baseline_before = recorded
639 .get(path.as_str())
640 .and_then(|file| file.baseline.clone())
641 .or_else(|| {
642 inputs
643 .baseline
644 .and_then(|held| held.get(&projection.source).cloned())
645 });
646 let Some(baseline_before) = baseline_before else {
647 continue;
648 };
649 if &baseline_before == seed {
650 continue;
651 }
652 operations.push(Operation::KeepFile {
653 path,
654 held: current.clone(),
655 baseline_before,
656 baseline_after: seed.clone(),
657 });
658 }
659 None => operations.push(Operation::WriteFile {
660 path,
661 class: Class::Adopted,
662 before: None,
663 after: seed.clone(),
664 }),
665 }
666 }
667
668 let selected = |id: &str| {
671 decisions
672 .iter()
673 .find(|decision| decision.id == id)
674 .and_then(|decision| decision.selected.as_deref())
675 };
676 let _ = selected(decision::id::DEBT_BASELINE);
680 operations
681}
682
683fn preconditions_of(
685 inputs: &Inputs<'_>,
686 classification: Classification,
687 operations: &[Operation],
688 decisions: &[Decision],
689 structural: usize,
690) -> Vec<Precondition> {
691 let mut preconditions: Vec<Precondition> = Vec::new();
692 macro_rules! require {
693 ($id:expr, $statement:expr, $requirement:expr, $evaluation:expr) => {
694 preconditions.push(Precondition {
695 id: ($id).to_string(),
696 statement: $statement,
697 requirement: $requirement,
698 evaluation: $evaluation,
699 resolved_by: None,
700 evidence_refs: Vec::new(),
701 });
702 };
703 }
704
705 if let Some(reason) = inputs.observation.invalid.as_ref() {
706 require!(
707 "record-is-readable",
708 "the instance record parses".to_string(),
709 Requirement::Required,
710 Evaluation::Unsatisfied {
711 reason: reason.clone(),
712 }
713 );
714 }
715
716 let waiting = decision_preconditions(decisions);
717
718 let edited = edited_managed_files(inputs);
719 if !edited.is_empty() {
720 require!(
721 "managed-files-are-unedited",
722 "every managed file still holds what the record says".to_string(),
723 Requirement::Required,
724 Evaluation::Unsatisfied {
725 reason: edited.join("; "),
726 }
727 );
728 }
729
730 if inputs.baseline.is_none() && inputs.observation.installation.is_some() {
731 require!(
732 "baseline-is-readable",
733 "the recorded release's bundle can be read for baselines".to_string(),
734 Requirement::Advisory,
735 Evaluation::NotObserved {
736 reason:
737 "the recorded release's bundle was not read, so an adopted baseline cannot move"
738 .to_string(),
739 }
740 );
741 }
742
743 let scope = decisions
747 .iter()
748 .find(|decision| decision.id == decision::id::MIGRATION_SCOPE)
749 .and_then(|decision| decision.selected.as_deref());
750 if classification == Classification::Migration && scope == Some("incremental") && structural > 0
751 {
752 require!(
753 "incremental-scope-has-no-structural-finding",
754 "an incremental migration leaves no structural finding behind".to_string(),
755 Requirement::Required,
756 Evaluation::Unsatisfied {
757 reason: format!(
758 "{structural} structural finding(s) would make two conventions coexist"
759 ),
760 }
761 );
762 }
763
764 preconditions.extend(waiting);
765
766 if let Err(clash) = no_duplicate_destination(operations) {
767 require!(
768 "no-destination-is-written-twice",
769 "each destination is written by at most one operation".to_string(),
770 Requirement::Required,
771 Evaluation::Unsatisfied {
772 reason: clash.to_string(),
773 }
774 );
775 }
776 preconditions
777}
778
779fn budget_finding(measured: &crate::domain::debt::Measurement) -> Option<Finding> {
786 let path = TargetPath::new(&measured.path).ok()?;
787 let (statement, found, cap) = match measured.value {
788 crate::domain::debt::Measured::Count { value, budget } if value > budget => (
789 format!(
790 "{} is {value} {} against a budget of {budget}",
791 measured.path, measured.dimension
792 ),
793 value as u64,
794 budget as u64,
795 ),
796 crate::domain::debt::Measured::Flag(true) => (
797 format!("{} carries {}", measured.path, measured.dimension),
798 1,
799 0,
800 ),
801 _ => return None,
802 };
803 Some(Finding {
804 kind: FindingKind::Budget,
805 path,
806 rule: measured.gate.to_string(),
807 statement,
808 measurement: Some(crate::plan::finding::Measurement {
809 dimension: measured.dimension.to_string(),
810 found,
811 cap,
812 }),
813 })
814}
815
816fn edited_managed_files(inputs: &Inputs<'_>) -> Vec<String> {
821 let mut edited = Vec::new();
822 let Some(installation) = inputs.observation.installation.as_ref() else {
823 return edited;
824 };
825 for file in &installation.managed {
826 match file.held.as_ref() {
827 Some(found) if found == &file.recorded => {}
828 Some(_) => edited.push(format!("{} was edited", file.path)),
829 None => edited.push(format!("{} is gone", file.path)),
830 }
831 }
832 for block in &installation.blocks {
836 match block.held.as_ref() {
837 Some(found) if found == &block.recorded => {}
838 Some(_) => edited.push(format!("the managed block in {} was edited", block.path)),
839 None => edited.push(format!("the managed block in {} is gone", block.path)),
840 }
841 }
842 edited
843}
844
845fn decision_preconditions(decisions: &[Decision]) -> Vec<Precondition> {
847 decisions
848 .iter()
849 .map(|decision| Precondition {
850 id: format!("decision:{}", decision.id),
851 statement: decision.question.clone(),
852 requirement: Requirement::DecisionRequired,
853 evaluation: decision.selected.as_ref().map_or_else(
854 || Evaluation::Unsatisfied {
855 reason: "the operator has not answered it".to_string(),
856 },
857 |_| Evaluation::Satisfied,
858 ),
859 resolved_by: Some(decision.id.clone()),
860 evidence_refs: Vec::new(),
861 })
862 .collect()
863}
864
865fn postconditions_of(classification: Classification) -> Vec<Postcondition> {
867 if classification == Classification::Invalid {
868 return Vec::new();
869 }
870 vec![
871 Postcondition {
872 id: "record-matches-the-tree".to_string(),
873 statement: "every operation's destination holds the digest the plan named".to_string(),
874 },
875 Postcondition {
876 id: "verification-passes".to_string(),
877 statement: "sdd verify reports OK against the target".to_string(),
878 },
879 ]
880}
881
882fn inputs_projection(
888 inputs: &Inputs<'_>,
889 classification: Classification,
890 operations: &[Operation],
891 preconditions: &[Precondition],
892 decisions: &[Decision],
893) -> Value {
894 let operations = Value::List(
895 operations
896 .iter()
897 .filter(|operation| !matches!(operation, Operation::WriteRecord { .. }))
903 .map(|operation| {
904 Value::map([
905 ("kind", Value::text(operation.kind())),
906 ("path", Value::text(operation.path().as_str())),
907 (
908 "before",
909 Value::maybe(operation.before().map(std::string::ToString::to_string)),
910 ),
911 (
912 "after",
913 Value::maybe(operation.after().map(std::string::ToString::to_string)),
914 ),
915 ])
916 })
917 .collect(),
918 );
919 let gates = Value::List(
922 preconditions
923 .iter()
924 .filter(|precondition| precondition.requirement != Requirement::Advisory)
925 .map(|precondition| {
926 Value::map([
927 ("id", Value::text(precondition.id.as_str())),
928 (
929 "state",
930 Value::text(match precondition.evaluation {
931 Evaluation::Satisfied => "satisfied",
932 Evaluation::NotObserved { .. } => "not-observed",
933 Evaluation::Unsatisfied { .. } => "unsatisfied",
934 }),
935 ),
936 ])
937 })
938 .collect(),
939 );
940 let selected = Value::Map(
941 decisions
942 .iter()
943 .filter_map(|decision| {
944 decision
945 .selected
946 .as_ref()
947 .map(|answer| (decision.id.clone(), Value::text(answer.as_str())))
948 })
949 .collect(),
950 );
951 Value::map([
952 ("schema", Value::text(PLAN_SCHEMA)),
953 ("classification", Value::text(classification.as_str())),
954 ("release", Value::text(inputs.release.as_str())),
955 (
956 "release_sha256",
957 Value::text(inputs.release_sha256.as_str()),
958 ),
959 (
960 "target",
961 Value::text(inputs.observation.repository.root.as_str()),
962 ),
963 (
964 "record",
965 Value::maybe(
966 inputs
967 .observation
968 .installation
969 .as_ref()
970 .map(|installation| installation.record_sha256.to_string()),
971 ),
972 ),
973 (
974 "declaration",
975 Value::maybe(
976 inputs
977 .observation
978 .installation
979 .as_ref()
980 .and_then(|installation| installation.declaration_sha256.as_ref())
981 .map(std::string::ToString::to_string),
982 ),
983 ),
984 ("operations", operations),
985 ("preconditions", gates),
986 ("decisions", selected),
987 ("declared", declared_projection(inputs)),
988 ])
989}
990
991fn declared_projection(inputs: &Inputs<'_>) -> Value {
998 Value::map([
999 (
1000 "profile",
1001 Value::maybe(
1002 inputs
1003 .selections
1004 .get(crate::plan::decision::id::PROFILE)
1005 .cloned(),
1006 ),
1007 ),
1008 (
1009 "reserved",
1010 Value::List(
1011 inputs
1012 .reserve
1013 .iter()
1014 .map(|path| Value::text(path.as_str()))
1015 .collect(),
1016 ),
1017 ),
1018 (
1019 "record",
1020 Value::maybe(inputs.declared.map(std::string::ToString::to_string)),
1021 ),
1022 ])
1023}
1024
1025#[must_use]
1027pub const fn is_ready(verdict: Readiness) -> bool {
1028 matches!(verdict, Readiness::Ready)
1029}