1use std::collections::BTreeMap;
10
11use camino::{Utf8Path, Utf8PathBuf};
12
13use crate::domain::manifest::{PlanZone, parse_docs_scratch};
14use crate::domain::ownership::Sha256;
15use crate::domain::paths::UserEnv;
16use crate::domain::profile::ProfileId;
17use crate::error::AppError;
18use crate::plan::apply::{Request, apply as execute};
19use crate::plan::observe::observe;
20use crate::plan::planner::{Inputs, plan as compute};
21use crate::plan::store::{Result as ApplyResult, Store};
22use crate::plan::{Plan, compatibility, decision, guidance};
23use crate::release::crates_io::CratesIoResolver;
24use crate::release::embedded::EmbeddedReleaseBundle;
25use crate::release::{Provenance, ReleaseBundle, ReleaseResolver, Role, Selector};
26use crate::services::installer::{InitOptions, compute_target_state};
27use crate::transaction::lock::Lock;
28
29pub(crate) const STORE_WAIT: std::time::Duration = std::time::Duration::from_secs(30);
35
36fn selector(value: &str) -> Result<Selector, AppError> {
38 match value {
39 "embedded" => Ok(Selector::Embedded),
40 "latest" => Ok(Selector::Latest),
41 version => version.parse().map(Selector::Exact).map_err(|_| {
42 AppError::Usage(format!(
43 "--to takes embedded, latest, or a semantic version; {version} is none of those"
44 ))
45 }),
46 }
47}
48
49pub(crate) fn state_root() -> Result<Utf8PathBuf, AppError> {
51 Ok(UserEnv::from_process()
52 .state_root()
53 .ok_or_else(|| AppError::Usage("no state root resolves".to_string()))?
54 .path)
55}
56
57pub(crate) fn target_lock(target: &Utf8Path) -> Result<Utf8PathBuf, AppError> {
63 let key = Sha256::of(target.as_str().as_bytes());
64 Ok(state_root()?.join("locks").join(format!("{key}.lock")))
65}
66
67pub(crate) struct Release {
69 pub(crate) bundle: Box<dyn ReleaseBundle>,
70 pub(crate) version: String,
71 pub(crate) provenance: String,
72 pub(crate) checksum: Option<Sha256>,
73 pub(crate) yanked: bool,
74}
75
76pub(crate) fn read_release(to: &str, offline: bool) -> Result<Release, AppError> {
77 match selector(to)? {
78 Selector::Embedded => Ok(Release {
79 bundle: Box::new(EmbeddedReleaseBundle::new()),
80 version: crate::domain::version::CanonVersion::current().to_string(),
81 provenance: "native".to_string(),
82 checksum: None,
83 yanked: false,
84 }),
85 chosen => {
86 let cache = UserEnv::from_process()
87 .user_paths()
88 .ok_or_else(|| AppError::Usage("no cache root resolves".to_string()))?
89 .bundle_cache
90 .path;
91 let resolved = CratesIoResolver::new(&cache)
92 .offline(offline)
93 .resolve(&chosen)?;
94 let manifest = resolved.bundle.manifest()?;
95 let provenance = match manifest.provenance {
96 Provenance::Native => "native",
97 Provenance::LegacyAdapted => "legacy-adapted",
98 };
99 Ok(Release {
100 bundle: resolved.bundle,
101 version: resolved.version.to_string(),
102 provenance: provenance.to_string(),
103 checksum: resolved.registry_checksum,
104 yanked: resolved.yanked,
105 })
106 }
107 }
108}
109
110#[derive(Clone)]
117pub(crate) struct ReleaseRef<'a> {
118 pub bundle: &'a dyn ReleaseBundle,
120 pub version: String,
122 pub provenance: &'static str,
124 pub checksum: Option<Sha256>,
126 pub yanked: bool,
128}
129
130impl<'a> ReleaseRef<'a> {
131 pub(crate) fn of(bundle: &'a dyn ReleaseBundle) -> Result<Self, AppError> {
137 let manifest = bundle.manifest()?;
138 Ok(Self {
139 bundle,
140 version: manifest.version.to_string(),
141 provenance: match manifest.provenance {
142 Provenance::Native => "native",
143 Provenance::LegacyAdapted => "legacy-adapted",
144 },
145 checksum: None,
146 yanked: false,
147 })
148 }
149}
150
151impl Release {
152 pub(crate) fn borrow(&self) -> ReleaseRef<'_> {
154 ReleaseRef {
155 bundle: self.bundle.as_ref(),
156 version: self.version.clone(),
157 provenance: if self.provenance == "native" {
158 "native"
159 } else {
160 "legacy-adapted"
161 },
162 checksum: self.checksum.clone(),
163 yanked: self.yanked,
164 }
165 }
166}
167
168fn read_briefing(
174 bundle: &dyn ReleaseBundle,
175 payload_schema: u32,
176 recorded: Option<crate::domain::version::CanonVersion>,
177 destination: crate::domain::version::CanonVersion,
178 selections: &decision::Selections,
179) -> Result<Option<guidance::Briefing>, AppError> {
180 let bytes = match bundle.artifact(guidance::INDEX_PATH) {
185 Ok(bytes) => bytes,
186 Err(_) if payload_schema == 0 => return Ok(None),
187 Err(source) => {
188 return Err(AppError::Refused(format!(
189 "this release declares payload schema {payload_schema} and its {} could not be read: {source}",
190 guidance::INDEX_PATH
191 )));
192 }
193 };
194 let index =
195 guidance::Index::parse(&bytes).map_err(|error| AppError::Refused(error.to_string()))?;
196 let bodies: Vec<String> = bundle
197 .manifest()?
198 .artifacts
199 .iter()
200 .map(|artifact| artifact.path.clone())
201 .collect();
202 let mut files = Vec::new();
203 for entry in index.interval(recorded, destination) {
204 if entry.guidance == guidance::NONE {
205 continue;
206 }
207 let path = format!("guidance/{}", entry.guidance);
208 let bytes = bundle.artifact(&path)?;
209 let held = guidance::Guidance::parse(&path, &bytes, &bodies)
210 .map_err(|error| AppError::Refused(error.to_string()))?;
211 files.push((entry.version, held));
212 }
213 let held: Vec<String> = if recorded.is_some() {
216 guidance::DESTINATIONS
217 .iter()
218 .map(|destination| (*destination).to_string())
219 .collect()
220 } else {
221 Vec::new()
222 };
223 Ok(Some(guidance::brief(
224 &index, &files, recorded, &held, selections,
225 )))
226}
227
228fn landing_options(
230 target: &Utf8Path,
231 profile: ProfileId,
232 selections: &decision::Selections,
233 reserve: &[String],
234) -> Result<InitOptions, AppError> {
235 let plan_zone = selections
236 .get(decision::id::PLAN_ZONE)
237 .map(|answer| PlanZone::parse(answer.strip_prefix("project:").unwrap_or(answer)))
238 .transpose()
239 .map_err(|error| AppError::Usage(format!("--set plan-zone: {error}")))?;
240 let docs_scratch = selections
241 .get(decision::id::DOCS_SCRATCH)
242 .map(|answer| {
243 let bare = answer
244 .strip_prefix("project:")
245 .or_else(|| answer.strip_prefix("external:"))
246 .unwrap_or(answer);
247 parse_docs_scratch(bare)
248 })
249 .transpose()
250 .map_err(|error| AppError::Usage(format!("--set docs-scratch: {error}")))?;
251 let writing_style = selections
252 .get(decision::id::WRITING_STYLE)
253 .map(|answer| crate::domain::instance_config::WritingStyle::parse_flag(answer))
254 .transpose()
255 .map_err(|error| AppError::Usage(format!("--set writing-style: {error}")))?;
256 Ok(InitOptions {
257 target: target.to_owned(),
258 profile,
259 apply: false,
260 dry_run: true,
261 plan_zone,
262 docs_scratch,
263 reserve: reserve.to_vec(),
264 writing_style,
265 })
266}
267
268fn recorded_managed(
270 observation: &crate::plan::observe::Observation,
271) -> Vec<(String, crate::domain::ownership::Sha256)> {
272 observation
273 .installation
274 .as_ref()
275 .map(|installed| {
276 installed
277 .managed
278 .iter()
279 .map(|file| (file.path.as_str().to_string(), file.recorded.clone()))
280 .collect()
281 })
282 .unwrap_or_default()
283}
284
285fn read_compatibility(
296 release: &ReleaseRef<'_>,
297 payload_schema: u32,
298) -> Result<Option<compatibility::Compatibility>, AppError> {
299 match release.bundle.artifact(compatibility::DECLARATION_PATH) {
300 Ok(bytes) => compatibility::Compatibility::parse(&bytes)
301 .map(Some)
302 .map_err(|error| AppError::Refused(error.to_string())),
303 Err(_) if payload_schema == 0 => Ok(None),
304 Err(_) => Err(AppError::Refused(format!(
305 "release {} declares payload schema {payload_schema} and carries no {}",
306 release.version,
307 compatibility::DECLARATION_PATH
308 ))),
309 }
310}
311
312type Interval = (
318 Option<crate::domain::version::CanonVersion>,
319 crate::domain::version::CanonVersion,
320 Option<compatibility::Interval>,
321);
322
323fn interval_of(
324 observation: &crate::plan::observe::Observation,
325 release: &ReleaseRef<'_>,
326 compatibility: Option<&compatibility::Compatibility>,
327) -> Result<Interval, AppError> {
328 let recorded = observation
329 .installation
330 .as_ref()
331 .map(|installed| installed.canon_version);
332 let destination: crate::domain::version::CanonVersion = release
333 .version
334 .parse()
335 .map_err(|_| AppError::Refused(format!("{} is not a released triple", release.version)))?;
336 let interval = compatibility.map(|_| compatibility::Interval {
337 engine: crate::domain::version::CanonVersion::current(),
338 recorded,
339 destination,
340 });
341 Ok((recorded, destination, interval))
342}
343
344fn payload_digests(manifest: &crate::release::ReleaseManifest) -> BTreeMap<String, Sha256> {
346 manifest
347 .artifacts
348 .iter()
349 .filter(|artifact| artifact.role == Role::Payload)
350 .map(|artifact| (artifact.path.clone(), artifact.sha256.clone()))
351 .collect()
352}
353
354fn declared_summary_of(options: &InitOptions) -> String {
359 format!(
360 "plan-zone={:?};docs-scratch={:?};writing-style={:?}",
361 options.plan_zone, options.docs_scratch, options.writing_style
362 )
363}
364
365struct Derivation<'a> {
367 target: &'a Utf8Path,
368 profile: ProfileId,
369 declaration: &'a crate::domain::projection::Declaration,
370 observation: &'a crate::plan::observe::Observation,
371 selections: &'a decision::Selections,
372 reserve: &'a [String],
373 declared: Option<&'a InitOptions>,
374 budget: &'a [crate::domain::debt::Measurement],
375}
376
377fn derive_landing(
387 from: &Derivation<'_>,
388 release: &ReleaseRef<'_>,
389) -> Result<crate::plan::derive::Derived, AppError> {
390 let options = match from.declared {
391 Some(held) => held.clone(),
392 None => landing_options(from.target, from.profile, from.selections, from.reserve)?,
393 };
394 let state = compute_target_state(from.target, &options, release.bundle)?;
395 let mut derived = crate::plan::derive::operations_for(
396 from.target,
397 &state.files,
398 from.declaration,
399 from.profile,
400 &recorded_managed(from.observation),
401 )?;
402 if from
405 .selections
406 .get(decision::id::DEBT_BASELINE)
407 .map(String::as_str)
408 == Some("record")
409 && let Some((operation, bytes)) =
410 crate::plan::derive::debt_operation(from.target, from.budget)?
411 {
412 derived.1.insert(Sha256::of(&bytes), bytes);
413 derived.0.push(operation);
414 }
415 Ok(derived)
416}
417
418pub(crate) fn compute_plan(
424 target: &Utf8Path,
425 to: &str,
426 offline: bool,
427 selections: &decision::Selections,
428 reserve: &[String],
429 declared: Option<&InitOptions>,
430 release: &ReleaseRef<'_>,
431) -> Result<(Plan, BTreeMap<Sha256, Vec<u8>>), AppError> {
432 let _ = offline;
433 let observation = observe(target)?;
434 let declaration = release.bundle.declaration()?;
435 let manifest = release.bundle.manifest()?;
436 let candidate = payload_digests(&manifest);
437 let recorded_is_destination = observation
441 .installation
442 .as_ref()
443 .is_some_and(|installed| installed.canon_version.to_string() == release.version);
444 let baseline = recorded_is_destination.then(|| candidate.clone());
445
446 let profile = observation
449 .installation
450 .as_ref()
451 .map(|installed| installed.profile)
452 .or_else(
453 || match selections.get(decision::id::PROFILE).map(String::as_str) {
454 Some("codebase") => Some(ProfileId::Codebase),
455 Some("knowledge-base") => Some(ProfileId::KnowledgeBase),
456 _ => None,
457 },
458 );
459 let answered = declared.is_some()
462 || observation.installation.is_some()
463 || [
464 decision::id::PLAN_ZONE,
465 decision::id::DOCS_SCRATCH,
466 decision::id::WRITING_STYLE,
467 ]
468 .iter()
469 .all(|id| selections.contains_key(*id));
470 if let (Some(held), Some(asked)) = (
475 observation
476 .installation
477 .as_ref()
478 .map(|installed| installed.profile),
479 declared.map(|options| options.profile),
480 ) && held != asked
481 {
482 return Err(AppError::Refused(format!(
483 "this instance records the {held} profile and the request names {asked}; a profile change is its own migration, not a landing"
484 )));
485 }
486
487 let budget = crate::services::budget::measure_all(target).unwrap_or_default();
491
492 let landing = match profile {
493 Some(profile) if answered && observation.invalid.is_none() => Some(derive_landing(
494 &Derivation {
495 target,
496 profile,
497 declaration: &declaration,
498 observation: &observation,
499 selections,
500 reserve,
501 declared,
502 budget: &budget,
503 },
504 release,
505 )?),
506 _ => None,
507 };
508 let (proposed, blobs) = landing.map_or_else(
509 || (None, BTreeMap::new()),
510 |(operations, bytes)| (Some(operations), bytes),
511 );
512
513 let compatibility = read_compatibility(release, declaration.payload_schema)?;
514 let (recorded, destination, interval) =
515 interval_of(&observation, release, compatibility.as_ref())?;
516 let declared_summary = declared.map(declared_summary_of);
517 let briefing = read_briefing(
518 release.bundle,
519 declaration.payload_schema,
520 recorded,
521 destination,
522 selections,
523 )?;
524
525 let computed = compute(&Inputs {
526 observation: &observation,
527 declaration: &declaration,
528 candidate: &candidate,
529 baseline: baseline.as_ref(),
530 selector: to.to_string(),
531 release: release.version.clone(),
532 release_sha256: manifest.payload_sha256,
533 provenance: release.provenance.to_string(),
534 registry_checksum: release.checksum.clone(),
535 yanked: release.yanked,
536 compatibility: compatibility.as_ref(),
537 interval: interval.as_ref(),
538 briefing: briefing.as_ref(),
539 proposed: proposed.as_deref(),
540 selections,
541 budget: &budget,
542 reserve,
543 declared: declared_summary.as_deref(),
544 declarations_settled: declared.is_some(),
545 now: jiff::Timestamp::now().to_string(),
546 });
547 Ok((computed, blobs))
548}
549
550pub(crate) fn blobs_for(plan: &Plan, bundle: &dyn ReleaseBundle) -> BTreeMap<Sha256, Vec<u8>> {
552 let mut blobs = BTreeMap::new();
553 for operation in &plan.operations {
554 let Some(after) = operation.after() else {
555 continue;
556 };
557 if blobs.contains_key(after) {
558 continue;
559 }
560 if let Ok(bytes) = bundle.blob(after) {
563 blobs.insert(after.clone(), bytes);
564 }
565 }
566 blobs
567}
568
569pub(crate) struct Landing<'a> {
571 pub target: &'a Utf8Path,
573 pub release: ReleaseRef<'a>,
580 pub offline: bool,
582 pub selections: decision::Selections,
589 pub carried: decision::Selections,
591 pub reserve: Vec<String>,
593 pub declared: Option<InitOptions>,
599}
600
601impl Landing<'_> {
602 fn selector(&self) -> String {
608 if self.release.provenance == "native"
609 && self.release.version == crate::domain::version::CanonVersion::current().to_string()
610 {
611 "embedded".to_string()
612 } else {
613 self.release.version.clone()
614 }
615 }
616}
617
618fn plan_lock(store: &Store, fingerprint: &str) -> Result<Lock, AppError> {
628 if let Ok(_walk) = Lock::exclusive(&store.lock_path(), "plan store prune") {
629 let _ = store.prune(jiff::Timestamp::now(), Some(fingerprint));
630 }
631 Lock::exclusive_waiting(&store.plan_lock_path(fingerprint)?, "landing", STORE_WAIT)
632}
633
634fn unwritable_state(cause: AppError) -> AppError {
641 let AppError::Io(ref source) = cause else {
642 return cause;
643 };
644 if source.kind() != std::io::ErrorKind::PermissionDenied {
645 return cause;
646 }
647 let root = state_root().map_or_else(|_| "the state root".to_string(), |path| path.to_string());
648 AppError::Refused(format!(
649 "{root} cannot be written: {source}; every landing keeps its lock, its plan, and its journal there, so set {} to a directory this user owns",
650 crate::domain::paths::XDG_STATE_HOME_VAR
651 ))
652}
653
654pub(crate) fn preview(request: &Landing<'_>) -> Result<Plan, AppError> {
665 let _lock =
666 Lock::shared(&target_lock(request.target)?, "landing preview").map_err(unwritable_state)?;
667 let mut answers = request.carried.clone();
668 answers.extend(request.selections.clone());
669 let (plan, _) = compute_plan(
670 request.target,
671 &request.selector(),
672 request.offline,
673 &answers,
674 &request.reserve,
675 request.declared.as_ref(),
676 &request.release,
677 )?;
678 decision::validate(&plan.decisions, &request.selections)
679 .map_err(|error| AppError::Usage(error.to_string()))?;
680 Ok(plan)
681}
682
683#[must_use]
688pub(crate) fn preview_lines(plan: &Plan) -> Vec<String> {
689 let mut lines = Vec::new();
690 for precondition in &plan.preconditions {
691 if precondition.requirement == crate::plan::readiness::Requirement::Required
692 && !precondition.evaluation.is_satisfied()
693 {
694 lines.push(format!(
695 "BLOCKED {}: {}",
696 precondition.id, precondition.statement
697 ));
698 }
699 }
700 for decision in &plan.decisions {
701 if decision.selected.is_none() {
702 lines.push(format!("DECISION {}: {}", decision.id, decision.question));
703 }
704 }
705 lines
706}
707
708pub(crate) fn land(request: &Landing<'_>) -> Result<ApplyResult, AppError> {
720 let _lock =
721 Lock::exclusive(&target_lock(request.target)?, "landing").map_err(unwritable_state)?;
722 let release = &request.release;
723 let mut answers = request.carried.clone();
724 answers.extend(request.selections.clone());
725 let (plan, blobs) = compute_plan(
726 request.target,
727 &request.selector(),
728 request.offline,
729 &answers,
730 &request.reserve,
731 request.declared.as_ref(),
732 release,
733 )?;
734 decision::validate(&plan.decisions, &request.selections)
739 .map_err(|error| AppError::Usage(error.to_string()))?;
740
741 let store = Store::new(&state_root()?);
742 store.create().map_err(unwritable_state)?;
747 let _store_lock = plan_lock(&store, &plan.identity.plan_id)?;
748 if !store.holds(&plan.identity.plan_id) {
749 let mut blobs = blobs;
750 for (digest, bytes) in blobs_for(&plan, release.bundle) {
751 blobs.entry(digest).or_insert(bytes);
752 }
753 store.put(&plan, &blobs)?;
754 }
755 execute(&Request {
756 store: &store,
757 target: request.target,
758 stored: &plan,
759 recomputed: &plan,
763 bundle: release.bundle,
764 now: jiff::Timestamp::now().to_string(),
765 })
766}