use std::collections::BTreeMap;
use camino::Utf8Path;
use crate::cli::reconcile::{Observe, PlanArgs, ReconcileAction, ReconcileArgs};
use crate::error::RkError;
use crate::landing::manifest;
use crate::output::Output;
use crate::plan::gather::{self, Flags, RecordRead, Request};
use crate::plan::planner::{self, Baseline, Candidate};
use crate::plan::{Classification, Operation, Plan, Planned, Readiness, Verification};
use crate::release::{CrateReleaseSource, EmbeddedReleaseSource, ReleaseSource};
pub fn run(args: &ReconcileArgs) -> Result<(), RkError> {
match &args.action {
ReconcileAction::Plan(plan_args) => plan(plan_args),
}
}
fn plan(args: &PlanArgs) -> Result<(), RkError> {
let out = Output::new(args.json);
let decisions = parse_decisions(&args.decide)?;
let nix = match args.nix.as_deref() {
None => None,
Some("on") => Some(true),
Some("off") => Some(false),
Some(other) => {
return Err(RkError::Usage(format!(
"unknown --nix value '{other}'; the values are: on, off"
)));
}
};
let flags = Flags {
tech: args.tech.clone(),
forge: args.forge.clone(),
repo: args.repo.clone(),
workflow: args.workflow.clone(),
style: args.style.clone(),
nix,
};
let clock = manifest::now();
let planned = compute(
&args.target,
&args.to,
args.fetch,
args.observe.contains(&Observe::Forge),
&flags,
&decisions,
&clock,
)?;
render(out, &planned.plan);
out.emit(&planned.plan)
}
pub fn compute(
target: &Utf8Path,
selector: &str,
fetch: bool,
observe_forge: bool,
flags: &Flags,
decisions: &BTreeMap<String, String>,
clock: &str,
) -> Result<Planned, RkError> {
let embedded = EmbeddedReleaseSource;
let crate_source = if selector == "embedded" {
None
} else {
Some(CrateReleaseSource::new(selector)?)
};
let (candidate_source, venue, verification): (&dyn ReleaseSource, &str, Verification) =
match &crate_source {
None => (&embedded, "embedded", Verification::Embedded),
Some(source) => {
let resolved = source.resolve()?;
(
source,
"crates",
Verification::RegistryChecksum {
cksum: resolved.cksum.clone(),
},
)
}
};
let candidate_manifest = candidate_source.manifest()?;
let request = Request {
target,
flags,
decisions,
observe_forge,
clock,
source: candidate_source,
};
let observation = gather::observe(&request)?;
let resolution = gather::resolve(&request, &observation)?;
let recorded = match &observation.record {
RecordRead::Present { manifest, .. } => {
Some((manifest.rk_version.clone(), manifest.payload_sha256.clone()))
}
RecordRead::Absent | RecordRead::Invalid { .. } => None,
};
let baseline_crate = match &recorded {
Some((version, digest))
if *digest != EmbeddedReleaseSource::manifest_ref().payload_sha256
&& *digest != candidate_manifest.payload_sha256 =>
{
let source = CrateReleaseSource::new(version)?;
if fetch || source.is_cached() {
Some(source)
} else {
None
}
}
_ => None,
};
let baseline = match &recorded {
None => Baseline::NotNeeded,
Some((_, digest)) if *digest == EmbeddedReleaseSource::manifest_ref().payload_sha256 => {
Baseline::Embedded(&embedded)
}
Some((version, digest)) if *digest == candidate_manifest.payload_sha256 => {
Baseline::Cached {
version: version.clone(),
source: candidate_source,
}
}
Some((version, _)) => match &baseline_crate {
Some(source) => {
source.resolve()?;
Baseline::Cached {
version: version.clone(),
source,
}
}
None => Baseline::NotObserved {
reason: format!(
"the recorded release {version} is not in the release cache; --fetch reads it through the crates venue"
),
},
},
};
planner::plan(planner::Inputs {
clock,
engine_version: env!("CARGO_PKG_VERSION"),
selector,
candidate: Candidate {
source: candidate_source,
manifest: candidate_manifest,
venue,
verification,
},
baseline,
observation,
resolution,
selected: decisions,
})
}
fn parse_decisions(raw: &[String]) -> Result<BTreeMap<String, String>, RkError> {
let mut decisions = BTreeMap::new();
for item in raw {
let Some((id, answer)) = item.split_once('=') else {
return Err(RkError::Usage(format!(
"--decide takes <id>=<answer>; '{item}' has no '='"
)));
};
if id.is_empty() || answer.is_empty() {
return Err(RkError::Usage(format!(
"--decide takes <id>=<answer>; '{item}' leaves one side empty"
)));
}
decisions.insert(id.to_owned(), answer.to_owned());
}
Ok(decisions)
}
fn render(out: Output, plan: &Plan) {
out.result_line(format!(
"plan {} for {} toward release-kit {} ({})",
plan.identity.plan_id,
plan.observed_state.repository.target,
plan.desired_state.release.version,
plan.desired_state.release.venue
));
out.result_line(format!("classification: {}", plan.classification.as_str()));
for finding in &plan.findings {
out.result_line(format!(" {}: {}", finding.code, finding.detail));
}
out.result_line(format!("readiness: {}", plan.readiness.as_str()));
let mut by_kind: BTreeMap<&str, usize> = BTreeMap::new();
for operation in &plan.operations {
*by_kind.entry(operation.kind()).or_default() += 1;
}
if by_kind.is_empty() {
out.result_line("operations: none");
} else {
out.result_line(format!(
"operations: {}",
by_kind
.iter()
.map(|(kind, count)| format!("{count} {kind}"))
.collect::<Vec<_>>()
.join(", ")
));
for operation in &plan.operations {
out.result_line(format!(" {}", describe(operation)));
}
}
for precondition in plan.preconditions.iter().filter(|p| !p.evaluation.holds()) {
let reason = match &precondition.evaluation {
crate::plan::Evaluation::Satisfied => String::new(),
crate::plan::Evaluation::NotObserved { reason }
| crate::plan::Evaluation::Unsatisfied { reason } => reason.clone(),
};
out.result_line(format!(
"precondition {} ({}): {}: {reason}",
precondition.id,
precondition.requirement.as_str(),
precondition.evaluation.word()
));
}
for decision in plan.decisions.iter().filter(|d| d.selected.is_none()) {
out.result_line(format!("decision {}: {}", decision.id, decision.question));
for choice in &decision.choices {
out.result_line(format!(" {}: {}", choice.answer, choice.consequence));
}
}
out.result_line(format!("fingerprint: {}", plan.input_fingerprint));
out.next(&next_lines(plan));
}
fn describe(operation: &Operation) -> String {
match operation {
Operation::WriteFile { path, kind, .. } => format!("write-file {path} ({})", kind.as_str()),
Operation::SpliceBlock { path, .. } => format!("splice-block {path}"),
Operation::RemoveOwnedFile { path, .. } => format!("remove-owned-file {path}"),
Operation::WriteRecord { .. } => format!("write-record {}", manifest::MANIFEST_PATH),
Operation::UpdatePin {
manager,
before,
after,
} => format!("update-pin {manager} {before} -> {after}"),
}
}
fn next_lines(plan: &Plan) -> Vec<String> {
let target = &plan.observed_state.repository.target;
match plan.readiness {
Readiness::Blocked => {
vec!["resolve each unsatisfied required precondition above, then plan again".to_owned()]
}
Readiness::NeedsDecision => plan
.decisions
.iter()
.filter(|d| d.selected.is_none())
.map(|d| {
format!(
"rk reconcile plan --target {target} --decide {}=<{}> selects an answer",
d.id,
d.choices
.iter()
.map(|c| c.answer.as_str())
.collect::<Vec<_>>()
.join("|")
)
})
.collect(),
Readiness::Ready => match plan.classification {
Classification::Setup | Classification::Migration => vec![format!(
"rk init --target {target} --apply lands what this plan names"
)],
Classification::Upgrade if plan.operations.is_empty() => {
vec!["nothing to take: the target is at this release".to_owned()]
}
Classification::Upgrade => vec![format!(
"rk upgrade --target {target} --apply takes what this plan names"
)],
Classification::Drift | Classification::Invalid => {
vec!["resolve the findings above, then plan again".to_owned()]
}
},
}
}