use std::collections::BTreeMap;
use crate::domain::ownership::Sha256;
use crate::domain::profile::{ProfileId, resolve_destination};
use crate::domain::projection::Declaration;
use crate::plan::classify::{Classification, Signals, classify};
use crate::plan::decision::{self, AnswerSchema, Choice, Decision, Selections};
use crate::plan::evidence::{Ledger, Producer};
use crate::plan::finding::{Finding, FindingKind, StyleCandidate, detector};
use crate::plan::fingerprint::{Value, fingerprint};
use crate::plan::observe::{Observation, RecordedFile};
use crate::plan::operation::{Class, Operation, TargetPath, no_duplicate_destination};
use crate::plan::readiness::{Evaluation, Precondition, Readiness, Requirement, readiness};
use crate::plan::{
Declared, DesiredState, Identity, ObservedState, PLAN_SCHEMA, Plan, Postcondition,
ReleaseSource,
};
#[derive(Debug, Clone)]
pub struct Inputs<'a> {
pub observation: &'a Observation,
pub declaration: &'a Declaration,
pub candidate: &'a BTreeMap<String, Sha256>,
pub baseline: Option<&'a BTreeMap<String, Sha256>>,
pub selector: String,
pub release: String,
pub release_sha256: Sha256,
pub provenance: String,
pub registry_checksum: Option<Sha256>,
pub yanked: bool,
pub compatibility: Option<&'a crate::plan::compatibility::Compatibility>,
pub interval: Option<&'a crate::plan::compatibility::Interval>,
pub briefing: Option<&'a crate::plan::guidance::Briefing>,
pub proposed: Option<&'a [Operation]>,
pub selections: &'a Selections,
pub reserve: &'a [String],
pub budget: &'a [crate::domain::debt::Measurement],
pub declared: Option<&'a str>,
pub declarations_settled: bool,
pub now: String,
}
#[must_use]
pub fn plan(inputs: &Inputs<'_>) -> Plan {
let observation = inputs.observation;
let (ledger, refs, release_ref) = ledger_of(inputs);
let profile = chosen_profile(inputs);
let classification = classification_of(inputs, profile);
let findings = findings_of(inputs, profile, classification);
let style_candidates = style_candidates_of(inputs, classification);
let structural = findings
.iter()
.filter(|found| found.kind == FindingKind::Structural)
.count();
let mut decisions = decisions_of(inputs, classification, profile, structural, &findings);
if let Some(briefing) = inputs.briefing {
decisions.extend(briefing.decisions.iter().cloned());
}
let operations = inputs.proposed.map_or_else(
|| derived_operations(inputs, profile, classification, &decisions),
<[Operation]>::to_vec,
);
let mut preconditions =
preconditions_of(inputs, classification, &operations, &decisions, structural);
if let (Some(held), Some(interval)) = (inputs.compatibility, inputs.interval) {
preconditions.extend(crate::plan::compatibility::preconditions(held, interval));
}
if let Some(briefing) = inputs.briefing {
preconditions.extend(briefing.preconditions.iter().cloned());
}
let verdict = readiness(&preconditions);
let desired_state = DesiredState {
selector: inputs.selector.clone(),
release: inputs.release.clone(),
release_sha256: inputs.release_sha256.clone(),
profile,
reserved: inputs.reserve.to_vec(),
declared: Declared {
payload_schema: inputs.declaration.payload_schema,
managed: inputs.declaration.managed.len(),
adopted: inputs.declaration.adopted.len(),
sentinels: inputs.declaration.sentinels.len(),
},
};
let observed_state = ObservedState {
repository: observation.repository.clone(),
installation: observation.installation.clone(),
host: observation.host.clone(),
corpus: observation.corpus.clone(),
evidence_refs: refs,
};
let release = ReleaseSource {
version: inputs.release.clone(),
provenance: inputs.provenance.clone(),
registry_checksum: inputs.registry_checksum.clone(),
yanked: inputs.yanked,
minimum_engine: inputs
.compatibility
.map(|held| held.minimum_engine.to_string()),
guidance_coverage: inputs.interval.and_then(|interval| {
inputs
.briefing
.map(|_| crate::plan::guidance::Coverage::Complete)
.filter(|_| interval.recorded.is_some())
}),
guidance_steps: inputs
.briefing
.map(|briefing| briefing.applicable.clone())
.unwrap_or_default(),
guidance_excluded: inputs.briefing.map_or(0, |briefing| briefing.excluded),
evidence_refs: vec![release_ref],
};
let digest = fingerprint(&inputs_projection(
inputs,
classification,
&operations,
&preconditions,
&decisions,
));
Plan {
identity: Identity {
schema: PLAN_SCHEMA.to_string(),
plan_id: digest.to_string(),
created_at: inputs.now.clone(),
engine_version: env!("CARGO_PKG_VERSION").to_string(),
},
classification,
findings,
style_candidates,
desired_state,
observed_state,
release,
operations,
preconditions,
decisions,
postconditions: postconditions_of(classification),
evidence: ledger.items,
readiness: verdict,
input_fingerprint: digest,
}
}
fn ledger_of(inputs: &Inputs<'_>) -> (Ledger, Vec<String>, String) {
let mut ledger = Ledger::new();
let mut refs = Vec::new();
refs.push(ledger.record(
"target",
"the target's working tree",
Producer::Disk,
&inputs.now,
None,
"walked, skipping version control and build output",
));
if let Some(installation) = inputs.observation.installation.as_ref() {
refs.push(ledger.record(
"record",
"the instance record",
Producer::Record,
&inputs.now,
Some(installation.record_sha256.clone()),
"read and parsed",
));
if let Some(digest) = installation.declaration_sha256.as_ref() {
refs.push(ledger.record(
"declaration",
"the project's own declaration",
Producer::Declaration,
&inputs.now,
Some(digest.clone()),
"read from the target",
));
}
}
let release_ref = ledger.record(
"release",
"the destination release",
Producer::Bundle,
&inputs.now,
Some(inputs.release_sha256.clone()),
"read through the release seam",
);
refs.push(ledger.record(
"host",
"the resolved user-scope paths",
Producer::Host,
&inputs.now,
None,
"read from the environment",
));
(ledger, refs, release_ref)
}
fn chosen_profile(inputs: &Inputs<'_>) -> Option<ProfileId> {
if let Some(installation) = inputs.observation.installation.as_ref() {
return Some(installation.profile);
}
match inputs
.selections
.get(decision::id::PROFILE)
.map(String::as_str)
{
Some("codebase") => Some(ProfileId::Codebase),
Some("knowledge-base") => Some(ProfileId::KnowledgeBase),
_ => None,
}
}
fn classification_of(inputs: &Inputs<'_>, profile: Option<ProfileId>) -> Classification {
let observation = inputs.observation;
let drifted = observation
.installation
.as_ref()
.is_some_and(crate::plan::observe::Installation::drifted);
let at_destination = observation
.installation
.as_ref()
.is_some_and(|installed| installed.canon_version.to_string() == inputs.release);
let _ = profile;
classify(Signals {
invalid: observation.invalid.is_some(),
installed: observation.installation.is_some(),
at_destination,
drifted,
settled: observation.corpus.settled(),
})
}
fn findings_of(
inputs: &Inputs<'_>,
profile: Option<ProfileId>,
classification: Classification,
) -> Vec<Finding> {
let mut findings = Vec::new();
if classification != Classification::Migration {
return findings;
}
findings.extend(inputs.budget.iter().filter_map(budget_finding));
let corpus = &inputs.observation.corpus;
if let Some(profile) = profile
&& let Some(docs_root) = inputs.declaration.docs_root(profile)
{
for root in &corpus.populated_doc_roots {
if root == docs_root.as_str() {
continue;
}
if let Ok(path) = TargetPath::new(root) {
findings.push(Finding {
kind: FindingKind::Structural,
path,
rule: detector::FOREIGN_DOCS_ROOT.to_string(),
statement: format!(
"{root} holds documents and the {profile} profile keeps them under {docs_root}"
),
measurement: None,
});
}
}
if corpus.settled()
&& !corpus.has_specs_directory
&& let Ok(path) = TargetPath::new(&format!("{docs_root}/specs"))
{
findings.push(Finding {
kind: FindingKind::Structural,
path,
rule: detector::NO_SPECS_DIRECTORY.to_string(),
statement: "the corpus is settled and no specifications directory holds its rules"
.to_string(),
measurement: None,
});
}
}
for path in &corpus.spec_without_rule_id {
findings.push(Finding {
kind: FindingKind::Structural,
path: path.clone(),
rule: detector::SPEC_WITHOUT_RULE_ID.to_string(),
statement: "the document is shaped like a specification and defines no rule ID"
.to_string(),
measurement: None,
});
}
for path in &corpus.ordinal_named {
findings.push(Finding {
kind: FindingKind::Structural,
path: path.clone(),
rule: detector::ORDINAL_FILENAME.to_string(),
statement: "the document is named by its position rather than its subject".to_string(),
measurement: None,
});
}
for path in &corpus.records_outside_decisions {
findings.push(Finding {
kind: FindingKind::Structural,
path: path.clone(),
rule: detector::RECORD_OUTSIDE_DECISIONS.to_string(),
statement: "the decision record sits outside a decisions directory".to_string(),
measurement: None,
});
}
findings
}
fn style_candidates_of(inputs: &Inputs<'_>, classification: Classification) -> Vec<StyleCandidate> {
if classification != Classification::Migration {
return Vec::new();
}
inputs
.observation
.corpus
.documents
.iter()
.map(|path| StyleCandidate {
path: path.clone(),
reason: "the document predates the instance, and no gate judges its prose".to_string(),
})
.collect()
}
fn choice(id: &str, consequence: &str) -> Choice {
Choice {
id: id.to_string(),
consequence: consequence.to_string(),
}
}
fn decisions_of(
inputs: &Inputs<'_>,
classification: Classification,
profile: Option<ProfileId>,
structural: usize,
findings: &[Finding],
) -> Vec<Decision> {
let mut decisions = Vec::new();
let selected = |id: &str| inputs.selections.get(id).cloned();
if inputs.observation.installation.is_none()
&& matches!(
classification,
Classification::Setup | Classification::Migration
)
{
decisions.push(Decision {
id: decision::id::PROFILE.to_string(),
question: "which profile does this repository take?".to_string(),
schema: AnswerSchema::Choice {
choices: vec![
choice("codebase", "records live under docs/"),
choice("knowledge-base", "records live under _docs/"),
],
},
depends_on: Vec::new(),
selected: selected(decision::id::PROFILE),
});
}
if profile.is_some() {
if inputs.observation.installation.is_none() && !inputs.declarations_settled {
let depends: Vec<String> = if decisions
.iter()
.any(|held| held.id == decision::id::PROFILE)
{
vec![decision::id::PROFILE.to_string()]
} else {
Vec::new()
};
decisions.push(Decision {
id: decision::id::PLAN_ZONE.to_string(),
question: "where does the planning tool write its entry documents?".to_string(),
schema: AnswerSchema::ChoiceOrValue {
choices: vec![
choice("env", "wherever the plan-zone variable points"),
choice("none", "the project keeps no plan zone"),
],
prefixes: vec!["project:".to_string(), "untracked:".to_string()],
},
depends_on: depends.clone(),
selected: selected(decision::id::PLAN_ZONE),
});
decisions.push(Decision {
id: decision::id::DOCS_SCRATCH.to_string(),
question: "where does material that is not a statement yet stage?".to_string(),
schema: AnswerSchema::ChoiceOrValue {
choices: vec![choice("none", "the project stages nothing")],
prefixes: vec!["project:".to_string(), "external:".to_string()],
},
depends_on: depends.clone(),
selected: selected(decision::id::DOCS_SCRATCH),
});
decisions.push(Decision {
id: decision::id::WRITING_STYLE.to_string(),
question: "which writing source does the project select?".to_string(),
schema: AnswerSchema::ChoiceOrValue {
choices: vec![
choice("builtin", "this convention's own chapter, served offline"),
choice("none", "no route and no conversion obligation"),
],
prefixes: vec!["project:".to_string()],
},
depends_on: depends,
selected: selected(decision::id::WRITING_STYLE),
});
}
if classification == Classification::Migration {
decisions.extend(migration_decisions(inputs, structural, findings));
}
}
if inputs.yanked {
decisions.push(Decision {
id: decision::id::ACCEPT_YANKED.to_string(),
question: format!(
"the registry marks {} yanked; land it anyway?",
inputs.release
),
schema: AnswerSchema::Choice {
choices: vec![
choice("accept", "the release lands, yanked and named as such"),
choice("refuse", "nothing lands; name another release"),
],
},
depends_on: Vec::new(),
selected: selected(decision::id::ACCEPT_YANKED),
});
}
decisions
}
fn migration_decisions(
inputs: &Inputs<'_>,
structural: usize,
findings: &[Finding],
) -> Vec<Decision> {
let selected = |id: &str| inputs.selections.get(id).cloned();
let mut decisions = Vec::new();
let mut choices = vec![choice(
"sweep",
"every durable fact moves into its owner, and the old convention retires",
)];
if structural == 0 {
choices.push(choice(
"incremental",
"each document converts the next time somebody edits it",
));
}
decisions.push(Decision {
id: decision::id::MIGRATION_SCOPE.to_string(),
question: "how much of the corpus moves?".to_string(),
schema: AnswerSchema::Choice { choices },
depends_on: vec![decision::id::PROFILE.to_string()],
selected: selected(decision::id::MIGRATION_SCOPE),
});
if findings
.iter()
.any(|found| found.kind == FindingKind::Budget)
{
decisions.push(Decision {
id: decision::id::DEBT_BASELINE.to_string(),
question: "are the inherited violations recorded as debt?".to_string(),
schema: AnswerSchema::Choice {
choices: vec![
choice(
"record",
"each inherited violation becomes a ceiling that only comes down",
),
choice(
"skip",
"nothing is recorded, and each violation fails its gate",
),
],
},
depends_on: vec![decision::id::MIGRATION_SCOPE.to_string()],
selected: selected(decision::id::DEBT_BASELINE),
});
}
decisions
}
fn derived_operations(
inputs: &Inputs<'_>,
profile: Option<ProfileId>,
classification: Classification,
decisions: &[Decision],
) -> Vec<Operation> {
if profile.is_none() {
return Vec::new();
}
if matches!(
classification,
Classification::Setup | Classification::Migration
) {
return Vec::new();
}
operations_of(inputs, profile, classification, decisions)
}
fn operations_of(
inputs: &Inputs<'_>,
profile: Option<ProfileId>,
classification: Classification,
decisions: &[Decision],
) -> Vec<Operation> {
let mut operations = Vec::new();
if matches!(
classification,
Classification::Invalid | Classification::Current
) {
return operations;
}
let Some(profile) = profile else {
return operations;
};
let Some(docs_root) = inputs.declaration.docs_root(profile) else {
return operations;
};
let held = crate::plan::observe::held_by_path(inputs.observation.installation.as_ref());
let recorded: BTreeMap<&str, &RecordedFile> = inputs
.observation
.installation
.iter()
.flat_map(|installation| installation.adopted.iter())
.map(|file| (file.path.as_str(), file))
.collect();
for projection in &inputs.declaration.managed {
let Some(after) = inputs.candidate.get(&projection.source) else {
continue;
};
let Ok(path) = TargetPath::new(&projection.destination) else {
continue;
};
let before = held.get(path.as_str()).cloned();
if before.as_ref() == Some(after) {
continue;
}
operations.push(Operation::WriteFile {
path,
class: Class::Managed,
before,
after: after.clone(),
});
}
for projection in &inputs.declaration.adopted {
let Some(seed) = inputs.candidate.get(&projection.source) else {
continue;
};
let destination = resolve_destination(&projection.destination, docs_root);
let Ok(path) = TargetPath::new(destination.as_str()) else {
continue;
};
match held.get(path.as_str()) {
Some(current) => {
let baseline_before = recorded
.get(path.as_str())
.and_then(|file| file.baseline.clone())
.or_else(|| {
inputs
.baseline
.and_then(|held| held.get(&projection.source).cloned())
});
let Some(baseline_before) = baseline_before else {
continue;
};
if &baseline_before == seed {
continue;
}
operations.push(Operation::KeepFile {
path,
held: current.clone(),
baseline_before,
baseline_after: seed.clone(),
});
}
None => operations.push(Operation::WriteFile {
path,
class: Class::Adopted,
before: None,
after: seed.clone(),
}),
}
}
let selected = |id: &str| {
decisions
.iter()
.find(|decision| decision.id == id)
.and_then(|decision| decision.selected.as_deref())
};
let _ = selected(decision::id::DEBT_BASELINE);
operations
}
fn preconditions_of(
inputs: &Inputs<'_>,
classification: Classification,
operations: &[Operation],
decisions: &[Decision],
structural: usize,
) -> Vec<Precondition> {
let mut preconditions: Vec<Precondition> = Vec::new();
macro_rules! require {
($id:expr, $statement:expr, $requirement:expr, $evaluation:expr) => {
preconditions.push(Precondition {
id: ($id).to_string(),
statement: $statement,
requirement: $requirement,
evaluation: $evaluation,
resolved_by: None,
evidence_refs: Vec::new(),
});
};
}
if let Some(reason) = inputs.observation.invalid.as_ref() {
require!(
"record-is-readable",
"the instance record parses".to_string(),
Requirement::Required,
Evaluation::Unsatisfied {
reason: reason.clone(),
}
);
}
let waiting = decision_preconditions(decisions);
let edited = edited_managed_files(inputs);
if !edited.is_empty() {
require!(
"managed-files-are-unedited",
"every managed file still holds what the record says".to_string(),
Requirement::Required,
Evaluation::Unsatisfied {
reason: edited.join("; "),
}
);
}
if inputs.baseline.is_none() && inputs.observation.installation.is_some() {
require!(
"baseline-is-readable",
"the recorded release's bundle can be read for baselines".to_string(),
Requirement::Advisory,
Evaluation::NotObserved {
reason:
"the recorded release's bundle was not read, so an adopted baseline cannot move"
.to_string(),
}
);
}
let scope = decisions
.iter()
.find(|decision| decision.id == decision::id::MIGRATION_SCOPE)
.and_then(|decision| decision.selected.as_deref());
if classification == Classification::Migration && scope == Some("incremental") && structural > 0
{
require!(
"incremental-scope-has-no-structural-finding",
"an incremental migration leaves no structural finding behind".to_string(),
Requirement::Required,
Evaluation::Unsatisfied {
reason: format!(
"{structural} structural finding(s) would make two conventions coexist"
),
}
);
}
preconditions.extend(waiting);
if let Err(clash) = no_duplicate_destination(operations) {
require!(
"no-destination-is-written-twice",
"each destination is written by at most one operation".to_string(),
Requirement::Required,
Evaluation::Unsatisfied {
reason: clash.to_string(),
}
);
}
preconditions
}
fn budget_finding(measured: &crate::domain::debt::Measurement) -> Option<Finding> {
let path = TargetPath::new(&measured.path).ok()?;
let (statement, found, cap) = match measured.value {
crate::domain::debt::Measured::Count { value, budget } if value > budget => (
format!(
"{} is {value} {} against a budget of {budget}",
measured.path, measured.dimension
),
value as u64,
budget as u64,
),
crate::domain::debt::Measured::Flag(true) => (
format!("{} carries {}", measured.path, measured.dimension),
1,
0,
),
_ => return None,
};
Some(Finding {
kind: FindingKind::Budget,
path,
rule: measured.gate.to_string(),
statement,
measurement: Some(crate::plan::finding::Measurement {
dimension: measured.dimension.to_string(),
found,
cap,
}),
})
}
fn edited_managed_files(inputs: &Inputs<'_>) -> Vec<String> {
let mut edited = Vec::new();
let Some(installation) = inputs.observation.installation.as_ref() else {
return edited;
};
for file in &installation.managed {
match file.held.as_ref() {
Some(found) if found == &file.recorded => {}
Some(_) => edited.push(format!("{} was edited", file.path)),
None => edited.push(format!("{} is gone", file.path)),
}
}
for block in &installation.blocks {
match block.held.as_ref() {
Some(found) if found == &block.recorded => {}
Some(_) => edited.push(format!("the managed block in {} was edited", block.path)),
None => edited.push(format!("the managed block in {} is gone", block.path)),
}
}
edited
}
fn decision_preconditions(decisions: &[Decision]) -> Vec<Precondition> {
decisions
.iter()
.map(|decision| Precondition {
id: format!("decision:{}", decision.id),
statement: decision.question.clone(),
requirement: Requirement::DecisionRequired,
evaluation: decision.selected.as_ref().map_or_else(
|| Evaluation::Unsatisfied {
reason: "the operator has not answered it".to_string(),
},
|_| Evaluation::Satisfied,
),
resolved_by: Some(decision.id.clone()),
evidence_refs: Vec::new(),
})
.collect()
}
fn postconditions_of(classification: Classification) -> Vec<Postcondition> {
if classification == Classification::Invalid {
return Vec::new();
}
vec![
Postcondition {
id: "record-matches-the-tree".to_string(),
statement: "every operation's destination holds the digest the plan named".to_string(),
},
Postcondition {
id: "verification-passes".to_string(),
statement: "sdd verify reports OK against the target".to_string(),
},
]
}
fn inputs_projection(
inputs: &Inputs<'_>,
classification: Classification,
operations: &[Operation],
preconditions: &[Precondition],
decisions: &[Decision],
) -> Value {
let operations = Value::List(
operations
.iter()
.filter(|operation| !matches!(operation, Operation::WriteRecord { .. }))
.map(|operation| {
Value::map([
("kind", Value::text(operation.kind())),
("path", Value::text(operation.path().as_str())),
(
"before",
Value::maybe(operation.before().map(std::string::ToString::to_string)),
),
(
"after",
Value::maybe(operation.after().map(std::string::ToString::to_string)),
),
])
})
.collect(),
);
let gates = Value::List(
preconditions
.iter()
.filter(|precondition| precondition.requirement != Requirement::Advisory)
.map(|precondition| {
Value::map([
("id", Value::text(precondition.id.as_str())),
(
"state",
Value::text(match precondition.evaluation {
Evaluation::Satisfied => "satisfied",
Evaluation::NotObserved { .. } => "not-observed",
Evaluation::Unsatisfied { .. } => "unsatisfied",
}),
),
])
})
.collect(),
);
let selected = Value::Map(
decisions
.iter()
.filter_map(|decision| {
decision
.selected
.as_ref()
.map(|answer| (decision.id.clone(), Value::text(answer.as_str())))
})
.collect(),
);
Value::map([
("schema", Value::text(PLAN_SCHEMA)),
("classification", Value::text(classification.as_str())),
("release", Value::text(inputs.release.as_str())),
(
"release_sha256",
Value::text(inputs.release_sha256.as_str()),
),
(
"target",
Value::text(inputs.observation.repository.root.as_str()),
),
(
"record",
Value::maybe(
inputs
.observation
.installation
.as_ref()
.map(|installation| installation.record_sha256.to_string()),
),
),
(
"declaration",
Value::maybe(
inputs
.observation
.installation
.as_ref()
.and_then(|installation| installation.declaration_sha256.as_ref())
.map(std::string::ToString::to_string),
),
),
("operations", operations),
("preconditions", gates),
("decisions", selected),
("declared", declared_projection(inputs)),
])
}
fn declared_projection(inputs: &Inputs<'_>) -> Value {
Value::map([
(
"profile",
Value::maybe(
inputs
.selections
.get(crate::plan::decision::id::PROFILE)
.cloned(),
),
),
(
"reserved",
Value::List(
inputs
.reserve
.iter()
.map(|path| Value::text(path.as_str()))
.collect(),
),
),
(
"record",
Value::maybe(inputs.declared.map(std::string::ToString::to_string)),
),
])
}
#[must_use]
pub const fn is_ready(verdict: Readiness) -> bool {
matches!(verdict, Readiness::Ready)
}