1use std::collections::BTreeMap;
10
11use camino::{Utf8Path, Utf8PathBuf};
12
13use crate::domain::manifest::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 docs_scratch = selections
236 .get(decision::id::DOCS_SCRATCH)
237 .map(|answer| {
238 let bare = answer
239 .strip_prefix("project:")
240 .or_else(|| answer.strip_prefix("external:"))
241 .unwrap_or(answer);
242 parse_docs_scratch(bare)
243 })
244 .transpose()
245 .map_err(|error| AppError::Usage(format!("--set docs-scratch: {error}")))?;
246 let writing_style = selections
247 .get(decision::id::WRITING_STYLE)
248 .map(|answer| crate::domain::instance_config::WritingStyle::parse_flag(answer))
249 .transpose()
250 .map_err(|error| AppError::Usage(format!("--set writing-style: {error}")))?;
251 Ok(InitOptions {
252 target: target.to_owned(),
253 profile,
254 apply: false,
255 dry_run: true,
256 docs_scratch,
257 reserve: reserve.to_vec(),
258 writing_style,
259 })
260}
261
262fn recorded_managed(
264 observation: &crate::plan::observe::Observation,
265) -> Vec<(String, crate::domain::ownership::Sha256)> {
266 observation
267 .installation
268 .as_ref()
269 .map(|installed| {
270 installed
271 .managed
272 .iter()
273 .map(|file| (file.path.as_str().to_string(), file.recorded.clone()))
274 .collect()
275 })
276 .unwrap_or_default()
277}
278
279fn read_compatibility(
290 release: &ReleaseRef<'_>,
291 payload_schema: u32,
292) -> Result<Option<compatibility::Compatibility>, AppError> {
293 match release.bundle.artifact(compatibility::DECLARATION_PATH) {
294 Ok(bytes) => compatibility::Compatibility::parse(&bytes)
295 .map(Some)
296 .map_err(|error| AppError::Refused(error.to_string())),
297 Err(_) if payload_schema == 0 => Ok(None),
298 Err(_) => Err(AppError::Refused(format!(
299 "release {} declares payload schema {payload_schema} and carries no {}",
300 release.version,
301 compatibility::DECLARATION_PATH
302 ))),
303 }
304}
305
306type Interval = (
312 Option<crate::domain::version::CanonVersion>,
313 crate::domain::version::CanonVersion,
314 Option<compatibility::Interval>,
315);
316
317fn interval_of(
318 observation: &crate::plan::observe::Observation,
319 release: &ReleaseRef<'_>,
320 compatibility: Option<&compatibility::Compatibility>,
321) -> Result<Interval, AppError> {
322 let recorded = observation
323 .installation
324 .as_ref()
325 .map(|installed| installed.canon_version);
326 let destination: crate::domain::version::CanonVersion = release
327 .version
328 .parse()
329 .map_err(|_| AppError::Refused(format!("{} is not a released triple", release.version)))?;
330 let interval = compatibility.map(|_| compatibility::Interval {
331 engine: crate::domain::version::CanonVersion::current(),
332 recorded,
333 destination,
334 });
335 Ok((recorded, destination, interval))
336}
337
338fn payload_digests(manifest: &crate::release::ReleaseManifest) -> BTreeMap<String, Sha256> {
340 manifest
341 .artifacts
342 .iter()
343 .filter(|artifact| artifact.role == Role::Payload)
344 .map(|artifact| (artifact.path.clone(), artifact.sha256.clone()))
345 .collect()
346}
347
348fn declared_summary_of(options: &InitOptions) -> String {
353 format!(
354 "docs-scratch={:?};writing-style={:?}",
355 options.docs_scratch, options.writing_style
356 )
357}
358
359struct Derivation<'a> {
361 target: &'a Utf8Path,
362 profile: ProfileId,
363 declaration: &'a crate::domain::projection::Declaration,
364 observation: &'a crate::plan::observe::Observation,
365 selections: &'a decision::Selections,
366 reserve: &'a [String],
367 declared: Option<&'a InitOptions>,
368 budget: &'a [crate::domain::debt::Measurement],
369}
370
371fn derive_landing(
381 from: &Derivation<'_>,
382 release: &ReleaseRef<'_>,
383) -> Result<crate::plan::derive::Derived, AppError> {
384 let options = match from.declared {
385 Some(held) => held.clone(),
386 None => landing_options(from.target, from.profile, from.selections, from.reserve)?,
387 };
388 let state = compute_target_state(from.target, &options, release.bundle)?;
389 let mut derived = crate::plan::derive::operations_for(
390 from.target,
391 &state.files,
392 from.declaration,
393 from.profile,
394 &recorded_managed(from.observation),
395 )?;
396 if from
399 .selections
400 .get(decision::id::DEBT_BASELINE)
401 .map(String::as_str)
402 == Some("record")
403 && let Some((operation, bytes)) =
404 crate::plan::derive::debt_operation(from.target, from.budget)?
405 {
406 derived.1.insert(Sha256::of(&bytes), bytes);
407 derived.0.push(operation);
408 }
409 Ok(derived)
410}
411
412pub(crate) fn compute_plan(
418 target: &Utf8Path,
419 to: &str,
420 offline: bool,
421 selections: &decision::Selections,
422 reserve: &[String],
423 declared: Option<&InitOptions>,
424 release: &ReleaseRef<'_>,
425) -> Result<(Plan, BTreeMap<Sha256, Vec<u8>>), AppError> {
426 let _ = offline;
427 let observation = observe(target)?;
428 let declaration = release.bundle.declaration()?;
429 let manifest = release.bundle.manifest()?;
430 let candidate = payload_digests(&manifest);
431 let recorded_is_destination = observation
435 .installation
436 .as_ref()
437 .is_some_and(|installed| installed.canon_version.to_string() == release.version);
438 let baseline = recorded_is_destination.then(|| candidate.clone());
439
440 let profile = observation
443 .installation
444 .as_ref()
445 .map(|installed| installed.profile)
446 .or_else(
447 || match selections.get(decision::id::PROFILE).map(String::as_str) {
448 Some("codebase") => Some(ProfileId::Codebase),
449 Some("knowledge-base") => Some(ProfileId::KnowledgeBase),
450 _ => None,
451 },
452 );
453 let answered = declared.is_some()
456 || observation.installation.is_some()
457 || [decision::id::DOCS_SCRATCH, decision::id::WRITING_STYLE]
458 .iter()
459 .all(|id| selections.contains_key(*id));
460 if let (Some(held), Some(asked)) = (
465 observation
466 .installation
467 .as_ref()
468 .map(|installed| installed.profile),
469 declared.map(|options| options.profile),
470 ) && held != asked
471 {
472 return Err(AppError::Refused(format!(
473 "this instance records the {held} profile and the request names {asked}; a profile change is its own migration, not a landing"
474 )));
475 }
476
477 let budget = crate::services::budget::measure_all(target).unwrap_or_default();
481
482 let landing = match profile {
483 Some(profile) if answered && observation.invalid.is_none() => Some(derive_landing(
484 &Derivation {
485 target,
486 profile,
487 declaration: &declaration,
488 observation: &observation,
489 selections,
490 reserve,
491 declared,
492 budget: &budget,
493 },
494 release,
495 )?),
496 _ => None,
497 };
498 let (proposed, blobs) = landing.map_or_else(
499 || (None, BTreeMap::new()),
500 |(operations, bytes)| (Some(operations), bytes),
501 );
502
503 let compatibility = read_compatibility(release, declaration.payload_schema)?;
504 let (recorded, destination, interval) =
505 interval_of(&observation, release, compatibility.as_ref())?;
506 let declared_summary = declared.map(declared_summary_of);
507 let briefing = read_briefing(
508 release.bundle,
509 declaration.payload_schema,
510 recorded,
511 destination,
512 selections,
513 )?;
514
515 let computed = compute(&Inputs {
516 observation: &observation,
517 declaration: &declaration,
518 candidate: &candidate,
519 baseline: baseline.as_ref(),
520 selector: to.to_string(),
521 release: release.version.clone(),
522 release_sha256: manifest.payload_sha256,
523 provenance: release.provenance.to_string(),
524 registry_checksum: release.checksum.clone(),
525 yanked: release.yanked,
526 compatibility: compatibility.as_ref(),
527 interval: interval.as_ref(),
528 briefing: briefing.as_ref(),
529 proposed: proposed.as_deref(),
530 selections,
531 budget: &budget,
532 reserve,
533 declared: declared_summary.as_deref(),
534 declarations_settled: declared.is_some(),
535 now: jiff::Timestamp::now().to_string(),
536 });
537 Ok((computed, blobs))
538}
539
540pub(crate) fn blobs_for(plan: &Plan, bundle: &dyn ReleaseBundle) -> BTreeMap<Sha256, Vec<u8>> {
542 let mut blobs = BTreeMap::new();
543 for operation in &plan.operations {
544 let Some(after) = operation.after() else {
545 continue;
546 };
547 if blobs.contains_key(after) {
548 continue;
549 }
550 if let Ok(bytes) = bundle.blob(after) {
553 blobs.insert(after.clone(), bytes);
554 }
555 }
556 blobs
557}
558
559pub(crate) struct Landing<'a> {
561 pub target: &'a Utf8Path,
563 pub release: ReleaseRef<'a>,
570 pub offline: bool,
572 pub selections: decision::Selections,
579 pub carried: decision::Selections,
581 pub reserve: Vec<String>,
583 pub declared: Option<InitOptions>,
589}
590
591impl Landing<'_> {
592 fn selector(&self) -> String {
598 if self.release.provenance == "native"
599 && self.release.version == crate::domain::version::CanonVersion::current().to_string()
600 {
601 "embedded".to_string()
602 } else {
603 self.release.version.clone()
604 }
605 }
606}
607
608fn plan_lock(store: &Store, fingerprint: &str) -> Result<Lock, AppError> {
618 if let Ok(_walk) = Lock::exclusive(&store.lock_path(), "plan store prune") {
619 let _ = store.prune(jiff::Timestamp::now(), Some(fingerprint));
620 }
621 Lock::exclusive_waiting(&store.plan_lock_path(fingerprint)?, "landing", STORE_WAIT)
622}
623
624fn unwritable_state(cause: AppError) -> AppError {
631 let AppError::Io(ref source) = cause else {
632 return cause;
633 };
634 if source.kind() != std::io::ErrorKind::PermissionDenied {
635 return cause;
636 }
637 let root = state_root().map_or_else(|_| "the state root".to_string(), |path| path.to_string());
638 AppError::Refused(format!(
639 "{root} cannot be written: {source}; every landing keeps its lock, its plan, and its journal there, so set {} to a directory this user owns",
640 crate::domain::paths::XDG_STATE_HOME_VAR
641 ))
642}
643
644pub(crate) fn preview(request: &Landing<'_>) -> Result<Plan, AppError> {
655 let _lock =
656 Lock::shared(&target_lock(request.target)?, "landing preview").map_err(unwritable_state)?;
657 let mut answers = request.carried.clone();
658 answers.extend(request.selections.clone());
659 let (plan, _) = compute_plan(
660 request.target,
661 &request.selector(),
662 request.offline,
663 &answers,
664 &request.reserve,
665 request.declared.as_ref(),
666 &request.release,
667 )?;
668 decision::validate(&plan.decisions, &request.selections)
669 .map_err(|error| AppError::Usage(error.to_string()))?;
670 Ok(plan)
671}
672
673#[must_use]
678pub(crate) fn preview_lines(plan: &Plan) -> Vec<String> {
679 let mut lines = Vec::new();
680 for precondition in &plan.preconditions {
681 if precondition.requirement == crate::plan::readiness::Requirement::Required
682 && !precondition.evaluation.is_satisfied()
683 {
684 lines.push(format!(
685 "BLOCKED {}: {}",
686 precondition.id, precondition.statement
687 ));
688 }
689 }
690 for decision in &plan.decisions {
691 if decision.selected.is_none() {
692 lines.push(format!("DECISION {}: {}", decision.id, decision.question));
693 }
694 }
695 lines
696}
697
698pub(crate) fn land(request: &Landing<'_>) -> Result<ApplyResult, AppError> {
710 let _lock =
711 Lock::exclusive(&target_lock(request.target)?, "landing").map_err(unwritable_state)?;
712 let release = &request.release;
713 let mut answers = request.carried.clone();
714 answers.extend(request.selections.clone());
715 let (plan, blobs) = compute_plan(
716 request.target,
717 &request.selector(),
718 request.offline,
719 &answers,
720 &request.reserve,
721 request.declared.as_ref(),
722 release,
723 )?;
724 decision::validate(&plan.decisions, &request.selections)
729 .map_err(|error| AppError::Usage(error.to_string()))?;
730
731 let store = Store::new(&state_root()?);
732 store.create().map_err(unwritable_state)?;
737 let _store_lock = plan_lock(&store, &plan.identity.plan_id)?;
738 if !store.holds(&plan.identity.plan_id) {
739 let mut blobs = blobs;
740 for (digest, bytes) in blobs_for(&plan, release.bundle) {
741 blobs.entry(digest).or_insert(bytes);
742 }
743 store.put(&plan, &blobs)?;
744 }
745 execute(&Request {
746 store: &store,
747 target: request.target,
748 stored: &plan,
749 recomputed: &plan,
753 bundle: release.bundle,
754 now: jiff::Timestamp::now().to_string(),
755 })
756}