1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use pulith_fs::{Transaction, atomic_write};
8use pulith_lock::{LockFile, LockedResource};
9use pulith_resource::{
10 Metadata, ResolvedLocator, ResolvedResource, ResolvedVersion, ResourceId, VersionSelector,
11};
12use pulith_serde_backend::{CodecError, JsonTextCodec, decode_slice, encode_pretty_vec};
13use pulith_store::{StoreKey, StoreMetadataRecord, StoreReady};
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16
17pub type Result<T> = std::result::Result<T, StateError>;
18pub const STATE_SNAPSHOT_SCHEMA_VERSION: u32 = 1;
19
20pub struct ResourceUpsert<'a> {
21 pub resource: &'a ResolvedResource,
22 pub patch: ResourceRecordPatch,
23}
24
25pub trait IntoResourceUpsert<'a> {
26 fn into_resource_upsert(self) -> ResourceUpsert<'a>;
27}
28
29impl<'a> IntoResourceUpsert<'a> for &'a ResolvedResource {
30 fn into_resource_upsert(self) -> ResourceUpsert<'a> {
31 ResourceUpsert {
32 resource: self,
33 patch: ResourceRecordPatch::default(),
34 }
35 }
36}
37
38impl<'a> IntoResourceUpsert<'a> for (&'a ResolvedResource, ResourceRecordPatch) {
39 fn into_resource_upsert(self) -> ResourceUpsert<'a> {
40 ResourceUpsert {
41 resource: self.0,
42 patch: self.1,
43 }
44 }
45}
46
47#[derive(Debug, Error)]
48pub enum StateError {
49 #[error(transparent)]
50 Io(#[from] std::io::Error),
51 #[error(transparent)]
52 Fs(#[from] pulith_fs::Error),
53 #[error(transparent)]
54 Store(#[from] pulith_store::StoreError),
55 #[error(transparent)]
56 Lock(#[from] pulith_lock::LockError),
57 #[error(transparent)]
58 Codec(#[from] CodecError),
59 #[error("unsupported state snapshot schema version: expected {expected}, got {actual}")]
60 UnsupportedSnapshotSchemaVersion { expected: u32, actual: u32 },
61}
62
63#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ResourceRecordPatch {
65 pub selector: Option<VersionSelector>,
66 pub resolved_version: Option<Option<ResolvedVersion>>,
67 pub locator: Option<Option<ResolvedLocator>>,
68 pub artifact_key: Option<Option<StoreKey>>,
69 pub install_path: Option<Option<PathBuf>>,
70 pub lifecycle: Option<ResourceLifecycle>,
71 pub metadata: Option<Metadata>,
72}
73
74impl ResourceRecordPatch {
75 pub fn lifecycle(lifecycle: ResourceLifecycle) -> Self {
76 Self {
77 lifecycle: Some(lifecycle),
78 ..Self::default()
79 }
80 }
81
82 pub fn artifact_key(artifact_key: Option<StoreKey>) -> Self {
83 Self {
84 artifact_key: Some(artifact_key),
85 ..Self::default()
86 }
87 }
88
89 pub fn install_path(install_path: Option<PathBuf>) -> Self {
90 Self {
91 install_path: Some(install_path),
92 ..Self::default()
93 }
94 }
95
96 pub fn metadata(metadata: Metadata) -> Self {
97 Self {
98 metadata: Some(metadata),
99 ..Self::default()
100 }
101 }
102
103 pub fn with_lifecycle(mut self, lifecycle: ResourceLifecycle) -> Self {
104 self.lifecycle = Some(lifecycle);
105 self
106 }
107
108 pub fn with_artifact_key(mut self, artifact_key: Option<StoreKey>) -> Self {
109 self.artifact_key = Some(artifact_key);
110 self
111 }
112
113 pub fn with_install_path(mut self, install_path: Option<PathBuf>) -> Self {
114 self.install_path = Some(install_path);
115 self
116 }
117
118 pub fn with_metadata(mut self, metadata: Metadata) -> Self {
119 self.metadata = Some(metadata);
120 self
121 }
122}
123
124#[derive(Debug, Clone)]
125pub struct StateReady {
126 path: PathBuf,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct StateAnalysisIndex {
131 snapshot: StateSnapshot,
132 activation_owners: HashMap<PathBuf, Vec<ResourceId>>,
133 store_references: Vec<StoreKeyReference>,
134}
135
136impl StateAnalysisIndex {
137 pub fn snapshot(&self) -> &StateSnapshot {
138 &self.snapshot
139 }
140
141 pub fn activation_owners(&self) -> &HashMap<PathBuf, Vec<ResourceId>> {
142 &self.activation_owners
143 }
144
145 pub fn store_references(&self) -> &[StoreKeyReference] {
146 &self.store_references
147 }
148
149 fn from_snapshot(snapshot: StateSnapshot) -> Self {
150 let activation_owners = activation_owner_index(&snapshot.activations);
151 let store_references = store_key_references(&snapshot.resources);
152 Self {
153 snapshot,
154 activation_owners,
155 store_references,
156 }
157 }
158}
159
160impl StateReady {
161 pub fn initialize(path: impl AsRef<Path>) -> Result<Self> {
162 let path = path.as_ref().to_path_buf();
163 if let Some(parent) = path.parent() {
164 std::fs::create_dir_all(parent)?;
165 }
166 if !path.exists() {
167 let initial = encode_pretty_vec(&JsonTextCodec, &StateSnapshot::default())?;
168 atomic_write(&path, &initial, Default::default())?;
169 }
170 Ok(Self { path })
171 }
172
173 pub fn path(&self) -> &Path {
174 &self.path
175 }
176
177 pub fn load(&self) -> Result<StateSnapshot> {
178 let tx = Transaction::open(&self.path)?;
179 let snapshot = load_from_transaction(&tx)?;
180 Ok(snapshot)
181 }
182
183 pub fn save(&self, snapshot: &StateSnapshot) -> Result<()> {
184 let tx = Transaction::open(&self.path)?;
185 save_to_transaction(&tx, snapshot)
186 }
187
188 pub fn export_lock_file(&self) -> Result<LockFile> {
189 let snapshot = self.load()?;
190 let mut lock = LockFile::default();
191
192 for record in snapshot.resources {
193 let (Some(resolved_version), Some(locator)) = (record.resolved_version, record.locator)
194 else {
195 continue;
196 };
197
198 lock.upsert(
199 record.id.as_string(),
200 LockedResource::new(resolved_version.as_str(), resolved_locator_text(&locator))
201 .metadata(record.metadata),
202 );
203 }
204
205 lock.validate()?;
206 Ok(lock)
207 }
208
209 pub fn update<F>(&self, update: F) -> Result<StateSnapshot>
210 where
211 F: FnOnce(StateSnapshot) -> Result<StateSnapshot>,
212 {
213 let tx = Transaction::open(&self.path)?;
214 let current = load_from_transaction(&tx)?;
215 let next = update(current)?;
216 save_to_transaction(&tx, &next)?;
217 Ok(next)
218 }
219
220 pub fn get_resource_record(&self, id: &ResourceId) -> Result<Option<ResourceRecord>> {
221 Ok(self
222 .load()?
223 .resources
224 .into_iter()
225 .find(|record| &record.id == id))
226 }
227
228 pub fn list_activation_records(&self, id: &ResourceId) -> Result<Vec<ActivationRecord>> {
229 Ok(self
230 .load()?
231 .activations
232 .into_iter()
233 .filter(|record| &record.id == id)
234 .collect())
235 }
236
237 pub fn inspect_resource(
238 &self,
239 id: &ResourceId,
240 store: Option<&StoreReady>,
241 ) -> Result<ResourceInspectionReport> {
242 let full_snapshot = self.load()?;
243 let snapshot = capture_resource_state_from_snapshot(&full_snapshot, id);
244 Ok(ResourceInspectionReport::from_snapshot(
245 snapshot,
246 &full_snapshot.activations,
247 store,
248 ))
249 }
250
251 pub fn build_analysis_index(&self) -> Result<StateAnalysisIndex> {
252 Ok(StateAnalysisIndex::from_snapshot(self.load()?))
253 }
254
255 pub fn inspect_resource_with_index(
256 &self,
257 id: &ResourceId,
258 store: Option<&StoreReady>,
259 index: &StateAnalysisIndex,
260 ) -> ResourceInspectionReport {
261 let snapshot = capture_resource_state_from_snapshot(&index.snapshot, id);
262 ResourceInspectionReport::from_snapshot_with_owner_index(
263 snapshot,
264 index.activation_owners(),
265 store,
266 )
267 }
268
269 pub fn list_activation_conflicts(&self) -> Result<Vec<ActivationOwnershipConflict>> {
270 let report = self.activation_ownership_report()?;
271 Ok(report
272 .entries
273 .into_iter()
274 .filter(|entry| entry.owners.len() > 1)
275 .map(|entry| ActivationOwnershipConflict {
276 target: entry.target,
277 owners: entry.owners,
278 })
279 .collect())
280 }
281
282 pub fn activation_ownership_report(&self) -> Result<ActivationOwnershipReport> {
283 let snapshot = self.load()?;
284 Ok(ActivationOwnershipReport::from_activations(
285 &snapshot.activations,
286 ))
287 }
288
289 pub fn activation_ownership_report_with_index(
290 &self,
291 index: &StateAnalysisIndex,
292 ) -> ActivationOwnershipReport {
293 ActivationOwnershipReport::from_owner_index(index.activation_owners())
294 }
295
296 pub fn list_store_references(&self) -> Result<Vec<StoreKeyReference>> {
297 let snapshot = self.load()?;
298 Ok(store_key_references(&snapshot.resources))
299 }
300
301 pub fn list_store_references_with_index(
302 &self,
303 index: &StateAnalysisIndex,
304 ) -> Vec<StoreKeyReference> {
305 index.store_references().to_vec()
306 }
307
308 pub fn protected_store_keys(&self, policy: StoreRetentionPolicy) -> Result<Vec<StoreKey>> {
309 let snapshot = self.load()?;
310 Ok(
311 store_key_references_for_retention(&snapshot.resources, policy)
312 .into_iter()
313 .map(|reference| reference.key)
314 .collect(),
315 )
316 }
317
318 pub fn retained_store_references(
319 &self,
320 policy: StoreRetentionPolicy,
321 ) -> Result<Vec<StoreKeyReference>> {
322 let snapshot = self.load()?;
323 Ok(store_key_references_for_retention(
324 &snapshot.resources,
325 policy,
326 ))
327 }
328
329 pub fn plan_store_metadata_retention(
330 &self,
331 store: &StoreReady,
332 policy: StoreRetentionPolicy,
333 ) -> Result<StoreRetentionPlan> {
334 let reasoned = self.plan_store_metadata_retention_reasoned(store, policy)?;
335
336 let protected_keys = reasoned
337 .protected_keys
338 .iter()
339 .map(|entry| entry.key.clone())
340 .collect();
341 let removable_metadata = reasoned
342 .removable_metadata
343 .iter()
344 .map(|entry| entry.record.clone())
345 .collect();
346 let protected_metadata = reasoned
347 .protected_metadata
348 .iter()
349 .map(|entry| entry.record.clone())
350 .collect();
351
352 Ok(StoreRetentionPlan {
353 policy,
354 protected_keys,
355 removable_metadata,
356 protected_metadata,
357 })
358 }
359
360 pub fn plan_store_metadata_retention_reasoned(
361 &self,
362 store: &StoreReady,
363 policy: StoreRetentionPolicy,
364 ) -> Result<ReasonedStoreRetentionPlan> {
365 let snapshot = self.load()?;
366 let protected_keys = protected_store_keys_with_reasons(&snapshot.resources, policy);
367 let protected_key_values = protected_keys
368 .iter()
369 .map(|entry| entry.key.clone())
370 .collect::<Vec<_>>();
371
372 let mut metadata_plan = store.plan_metadata_prune(&protected_key_values)?;
373 metadata_plan
374 .protected
375 .sort_by_key(|record| record.key.relative_name());
376 metadata_plan
377 .removable
378 .sort_by_key(|record| record.key.relative_name());
379
380 let mut protected_metadata = metadata_plan
381 .protected
382 .into_iter()
383 .map(|record| {
384 let reasons = protected_metadata_reasons(&record, &protected_keys);
385 ProtectedStoreMetadata { record, reasons }
386 })
387 .collect::<Vec<_>>();
388 protected_metadata.sort_by_key(|entry| entry.record.key.relative_name());
389
390 let mut removable_metadata = metadata_plan
391 .removable
392 .into_iter()
393 .map(|record| {
394 let reasons = removable_metadata_reasons(&record, &snapshot.resources, policy);
395 RemovableStoreMetadata { record, reasons }
396 })
397 .collect::<Vec<_>>();
398 removable_metadata.sort_by_key(|entry| entry.record.key.relative_name());
399
400 Ok(ReasonedStoreRetentionPlan {
401 policy,
402 protected_keys,
403 protected_metadata,
404 removable_metadata,
405 })
406 }
407
408 pub fn plan_ownership_and_retention(
409 &self,
410 store: &StoreReady,
411 policy: StoreRetentionPolicy,
412 ) -> Result<OwnershipRetentionPlan> {
413 Ok(OwnershipRetentionPlan {
414 ownership: self.activation_ownership_report()?,
415 retention: self.plan_store_metadata_retention_reasoned(store, policy)?,
416 })
417 }
418
419 pub fn plan_resource_state_repair(
420 &self,
421 id: &ResourceId,
422 store: Option<&StoreReady>,
423 ) -> Result<ResourceRepairPlan> {
424 let inspection = self.inspect_resource(id, store)?;
425 Ok(ResourceRepairPlan::from_inspection(inspection))
426 }
427
428 pub fn apply_resource_state_repair(&self, plan: &ResourceRepairPlan) -> Result<StateSnapshot> {
429 self.update(|mut snapshot| {
430 for action in &plan.actions {
431 match action {
432 ResourceRepairAction::ClearInstallPath { resource } => {
433 if let Some(record) = snapshot
434 .resources
435 .iter_mut()
436 .find(|record| &record.id == resource)
437 {
438 record.install_path = None;
439 }
440 }
441 ResourceRepairAction::ClearArtifactKey { resource } => {
442 if let Some(record) = snapshot
443 .resources
444 .iter_mut()
445 .find(|record| &record.id == resource)
446 {
447 record.artifact_key = None;
448 }
449 }
450 ResourceRepairAction::RemoveActivationRecord { resource, target } => {
451 snapshot
452 .activations
453 .retain(|record| &record.id != resource || &record.target != target);
454 }
455 }
456 }
457
458 Ok(snapshot)
459 })
460 }
461
462 pub fn capture_resource_state(&self, id: &ResourceId) -> Result<ResourceStateSnapshot> {
463 let snapshot = self.load()?;
464 Ok(capture_resource_state_from_snapshot(&snapshot, id))
465 }
466
467 pub fn restore_resource_state(
468 &self,
469 resource_state: &ResourceStateSnapshot,
470 ) -> Result<StateSnapshot> {
471 self.update(|mut snapshot| {
472 snapshot
473 .resources
474 .retain(|record| record.id != resource_state.resource);
475 if let Some(record) = &resource_state.record {
476 snapshot.resources.push(record.clone());
477 }
478
479 snapshot
480 .activations
481 .retain(|record| record.id != resource_state.resource);
482 snapshot
483 .activations
484 .extend(resource_state.activations.iter().cloned());
485
486 Ok(snapshot)
487 })
488 }
489
490 pub fn set_resource_lifecycle(
491 &self,
492 id: &ResourceId,
493 lifecycle: ResourceLifecycle,
494 ) -> Result<StateSnapshot> {
495 self.patch_resource_record(id, ResourceRecordPatch::lifecycle(lifecycle))
496 }
497
498 pub fn patch_resource_record(
499 &self,
500 id: &ResourceId,
501 patch: ResourceRecordPatch,
502 ) -> Result<StateSnapshot> {
503 self.update(|mut snapshot| {
504 if let Some(record) = snapshot
505 .resources
506 .iter_mut()
507 .find(|record| &record.id == id)
508 {
509 record.apply_patch(patch);
510 }
511 Ok(snapshot)
512 })
513 }
514
515 pub fn ensure_resource_record(
516 &self,
517 id: ResourceId,
518 selector: VersionSelector,
519 ) -> Result<StateSnapshot> {
520 self.update(|mut snapshot| {
521 if !snapshot.resources.iter().any(|record| record.id == id) {
522 snapshot.resources.push(ResourceRecord {
523 id,
524 selector,
525 resolved_version: None,
526 locator: None,
527 artifact_key: None,
528 install_path: None,
529 lifecycle: ResourceLifecycle::Declared,
530 metadata: Metadata::new(),
531 });
532 }
533 Ok(snapshot)
534 })
535 }
536
537 pub fn upsert_resource_record(&self, record: ResourceRecord) -> Result<StateSnapshot> {
538 self.update(|mut snapshot| {
539 if let Some(existing) = snapshot
540 .resources
541 .iter_mut()
542 .find(|item| item.id == record.id)
543 {
544 *existing = record;
545 } else {
546 snapshot.resources.push(record);
547 }
548 Ok(snapshot)
549 })
550 }
551
552 pub fn upsert_resolved_resource(
553 &self,
554 resource: &pulith_resource::ResolvedResource,
555 patch: ResourceRecordPatch,
556 ) -> Result<StateSnapshot> {
557 let resource_id = resource.spec().id.clone();
558 let base_record = ResourceRecord::from_resolved_resource(resource);
559
560 self.update(|mut snapshot| {
561 if let Some(existing) = snapshot
562 .resources
563 .iter_mut()
564 .find(|record| record.id == resource_id)
565 {
566 *existing = base_record.clone();
567 existing.apply_patch(patch);
568 } else {
569 let mut record = base_record;
570 record.apply_patch(patch);
571 snapshot.resources.push(record);
572 }
573 Ok(snapshot)
574 })
575 }
576
577 pub fn upsert_resource<'a>(
578 &self,
579 upsert: impl IntoResourceUpsert<'a>,
580 ) -> Result<StateSnapshot> {
581 let upsert = upsert.into_resource_upsert();
582 self.upsert_resolved_resource(upsert.resource, upsert.patch)
583 }
584
585 pub fn append_activation(&self, activation: ActivationRecord) -> Result<StateSnapshot> {
586 self.update(|mut snapshot| {
587 snapshot.activations.push(activation);
588 Ok(snapshot)
589 })
590 }
591
592 pub fn record_activation(&self, id: &ResourceId, target: PathBuf) -> Result<StateSnapshot> {
593 self.append_activation(ActivationRecord {
594 id: id.clone(),
595 target,
596 activated_at_unix: now_unix(),
597 })
598 }
599
600 pub fn remove_resource_record(&self, id: &ResourceId) -> Result<StateSnapshot> {
601 self.update(|mut snapshot| {
602 snapshot.resources.retain(|record| &record.id != id);
603 Ok(snapshot)
604 })
605 }
606
607 pub fn remove_activation_records(&self, id: &ResourceId) -> Result<StateSnapshot> {
608 self.update(|mut snapshot| {
609 snapshot.activations.retain(|record| &record.id != id);
610 Ok(snapshot)
611 })
612 }
613}
614
615#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
616pub struct StateSnapshot {
617 #[serde(default = "default_state_snapshot_schema_version")]
618 pub schema_version: u32,
619 pub resources: Vec<ResourceRecord>,
620 pub activations: Vec<ActivationRecord>,
621}
622
623impl Default for StateSnapshot {
624 fn default() -> Self {
625 Self {
626 schema_version: STATE_SNAPSHOT_SCHEMA_VERSION,
627 resources: Vec::new(),
628 activations: Vec::new(),
629 }
630 }
631}
632
633impl StateSnapshot {
634 pub fn validate(&self) -> Result<()> {
635 if self.schema_version != STATE_SNAPSHOT_SCHEMA_VERSION {
636 return Err(StateError::UnsupportedSnapshotSchemaVersion {
637 expected: STATE_SNAPSHOT_SCHEMA_VERSION,
638 actual: self.schema_version,
639 });
640 }
641 Ok(())
642 }
643}
644
645fn default_state_snapshot_schema_version() -> u32 {
646 STATE_SNAPSHOT_SCHEMA_VERSION
647}
648
649fn resolved_locator_text(locator: &ResolvedLocator) -> String {
650 match locator {
651 ResolvedLocator::Url(url) => url.as_url().as_str().to_string(),
652 ResolvedLocator::LocalPath(path) => path.display().to_string(),
653 }
654}
655
656#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
657pub struct ResourceStateSnapshot {
658 pub resource: ResourceId,
659 pub record: Option<ResourceRecord>,
660 pub activations: Vec<ActivationRecord>,
661}
662
663fn collect_resource_inspection_findings(
664 snapshot: &ResourceStateSnapshot,
665 activation_owners: &HashMap<PathBuf, Vec<ResourceId>>,
666 store: Option<&StoreReady>,
667) -> Vec<ResourceInspectionFinding> {
668 let mut findings = Vec::new();
669
670 if snapshot.record.is_none() {
671 findings.push(ResourceInspectionFinding::MissingResourceRecord {
672 resource: snapshot.resource.clone(),
673 });
674 }
675
676 if let Some(record) = &snapshot.record {
677 if let Some(install_path) = &record.install_path
678 && !install_path.exists()
679 {
680 findings.push(ResourceInspectionFinding::MissingInstallPath {
681 resource: snapshot.resource.clone(),
682 path: install_path.clone(),
683 });
684 }
685
686 if let (Some(store), Some(key)) = (store, &record.artifact_key) {
687 if !store.has_artifact(key) && !store.has_extract(key) {
688 findings.push(ResourceInspectionFinding::MissingStoreEntry {
689 resource: snapshot.resource.clone(),
690 key: key.clone(),
691 });
692 }
693 if !store.has_metadata(key) {
694 findings.push(ResourceInspectionFinding::MissingStoreMetadata {
695 resource: snapshot.resource.clone(),
696 key: key.clone(),
697 });
698 }
699 }
700 }
701
702 for activation in &snapshot.activations {
703 if !activation.target.exists() {
704 findings.push(ResourceInspectionFinding::MissingActivationTarget {
705 resource: snapshot.resource.clone(),
706 target: activation.target.clone(),
707 });
708 }
709
710 let conflicting_owners = activation_owners
711 .get(&activation.target)
712 .map(|owners| {
713 owners
714 .iter()
715 .filter(|owner| *owner != &snapshot.resource)
716 .cloned()
717 .collect::<Vec<_>>()
718 })
719 .unwrap_or_default();
720 if !conflicting_owners.is_empty() {
721 findings.push(ResourceInspectionFinding::ActivationTargetConflict {
722 resource: snapshot.resource.clone(),
723 target: activation.target.clone(),
724 conflicting_owners,
725 });
726 }
727 }
728
729 findings
730}
731
732#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
733pub enum InspectionSeverity {
734 Info,
735 Warning,
736 Error,
737}
738
739#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
740pub enum InspectionCategory {
741 ResourceRecord,
742 InstallPath,
743 ActivationTarget,
744 ActivationOwnership,
745 StoreEntry,
746 StoreMetadata,
747}
748
749#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
750pub enum ResourceInspectionFinding {
751 MissingResourceRecord {
752 resource: ResourceId,
753 },
754 MissingInstallPath {
755 resource: ResourceId,
756 path: PathBuf,
757 },
758 MissingActivationTarget {
759 resource: ResourceId,
760 target: PathBuf,
761 },
762 ActivationTargetConflict {
763 resource: ResourceId,
764 target: PathBuf,
765 conflicting_owners: Vec<ResourceId>,
766 },
767 MissingStoreEntry {
768 resource: ResourceId,
769 key: StoreKey,
770 },
771 MissingStoreMetadata {
772 resource: ResourceId,
773 key: StoreKey,
774 },
775}
776
777impl ResourceInspectionFinding {
778 pub fn severity(&self) -> InspectionSeverity {
779 match self {
780 Self::MissingResourceRecord { .. }
781 | Self::MissingInstallPath { .. }
782 | Self::MissingActivationTarget { .. }
783 | Self::MissingStoreEntry { .. } => InspectionSeverity::Error,
784 Self::ActivationTargetConflict { .. } | Self::MissingStoreMetadata { .. } => {
785 InspectionSeverity::Warning
786 }
787 }
788 }
789
790 pub fn category(&self) -> InspectionCategory {
791 match self {
792 Self::MissingResourceRecord { .. } => InspectionCategory::ResourceRecord,
793 Self::MissingInstallPath { .. } => InspectionCategory::InstallPath,
794 Self::MissingActivationTarget { .. } => InspectionCategory::ActivationTarget,
795 Self::ActivationTargetConflict { .. } => InspectionCategory::ActivationOwnership,
796 Self::MissingStoreEntry { .. } => InspectionCategory::StoreEntry,
797 Self::MissingStoreMetadata { .. } => InspectionCategory::StoreMetadata,
798 }
799 }
800
801 fn sort_key(&self) -> (InspectionSeverity, InspectionCategory, String, String) {
802 let detail = match self {
803 Self::MissingResourceRecord { resource } => (resource.as_string(), String::new()),
804 Self::MissingInstallPath { resource, path } => {
805 (resource.as_string(), path.display().to_string())
806 }
807 Self::MissingActivationTarget { resource, target } => {
808 (resource.as_string(), target.display().to_string())
809 }
810 Self::ActivationTargetConflict {
811 resource,
812 target,
813 conflicting_owners,
814 } => (
815 resource.as_string(),
816 format!(
817 "{}:{}",
818 target.display(),
819 conflicting_owners
820 .iter()
821 .map(ResourceId::as_string)
822 .collect::<Vec<_>>()
823 .join(",")
824 ),
825 ),
826 Self::MissingStoreEntry { resource, key }
827 | Self::MissingStoreMetadata { resource, key } => {
828 (resource.as_string(), key.relative_name())
829 }
830 };
831
832 (self.severity(), self.category(), detail.0, detail.1)
833 }
834
835 pub fn summary_label(&self) -> &'static str {
836 match self {
837 Self::MissingResourceRecord { .. } => "missing-resource-record",
838 Self::MissingInstallPath { .. } => "missing-install-path",
839 Self::MissingActivationTarget { .. } => "missing-activation-target",
840 Self::ActivationTargetConflict { .. } => "activation-target-conflict",
841 Self::MissingStoreEntry { .. } => "missing-store-entry",
842 Self::MissingStoreMetadata { .. } => "missing-store-metadata",
843 }
844 }
845}
846
847#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
848pub struct ResourceInspectionSummary {
849 pub total_findings: usize,
850 pub info_count: usize,
851 pub warning_count: usize,
852 pub error_count: usize,
853}
854
855impl ResourceInspectionSummary {
856 fn from_findings(findings: &[ResourceInspectionFinding]) -> Self {
857 let mut summary = Self {
858 total_findings: findings.len(),
859 ..Self::default()
860 };
861
862 for finding in findings {
863 match finding.severity() {
864 InspectionSeverity::Info => summary.info_count += 1,
865 InspectionSeverity::Warning => summary.warning_count += 1,
866 InspectionSeverity::Error => summary.error_count += 1,
867 }
868 }
869
870 summary
871 }
872}
873
874#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
875pub struct ResourceInspectionReport {
876 pub snapshot: ResourceStateSnapshot,
877 pub findings: Vec<ResourceInspectionFinding>,
878 pub summary: ResourceInspectionSummary,
879}
880
881impl ResourceInspectionReport {
882 pub fn from_snapshot(
883 snapshot: ResourceStateSnapshot,
884 all_activations: &[ActivationRecord],
885 store: Option<&StoreReady>,
886 ) -> Self {
887 let owner_index = activation_owner_index(all_activations);
888 Self::from_snapshot_with_owner_index(snapshot, &owner_index, store)
889 }
890
891 pub fn from_snapshot_with_owner_index(
892 snapshot: ResourceStateSnapshot,
893 activation_owners: &HashMap<PathBuf, Vec<ResourceId>>,
894 store: Option<&StoreReady>,
895 ) -> Self {
896 let mut findings =
897 collect_resource_inspection_findings(&snapshot, activation_owners, store);
898 findings.sort_by_cached_key(ResourceInspectionFinding::sort_key);
899 let summary = ResourceInspectionSummary::from_findings(&findings);
900
901 Self {
902 snapshot,
903 findings,
904 summary,
905 }
906 }
907
908 pub fn is_clean(&self) -> bool {
909 self.findings.is_empty()
910 }
911}
912
913#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
914pub struct ResourceRepairPlan {
915 pub inspection: ResourceInspectionReport,
916 pub actions: Vec<ResourceRepairAction>,
917}
918
919impl ResourceRepairPlan {
920 pub fn from_inspection(inspection: ResourceInspectionReport) -> Self {
921 let mut actions = Vec::new();
922
923 for finding in &inspection.findings {
924 match finding {
925 ResourceInspectionFinding::MissingInstallPath { resource, .. } => {
926 push_unique_action(
927 &mut actions,
928 ResourceRepairAction::ClearInstallPath {
929 resource: resource.clone(),
930 },
931 );
932 }
933 ResourceInspectionFinding::MissingActivationTarget { resource, target } => {
934 push_unique_action(
935 &mut actions,
936 ResourceRepairAction::RemoveActivationRecord {
937 resource: resource.clone(),
938 target: target.clone(),
939 },
940 );
941 }
942 ResourceInspectionFinding::MissingStoreEntry { resource, .. } => {
943 push_unique_action(
944 &mut actions,
945 ResourceRepairAction::ClearArtifactKey {
946 resource: resource.clone(),
947 },
948 );
949 }
950 ResourceInspectionFinding::MissingResourceRecord { .. }
951 | ResourceInspectionFinding::MissingStoreMetadata { .. }
952 | ResourceInspectionFinding::ActivationTargetConflict { .. } => {}
953 }
954 }
955
956 Self {
957 inspection,
958 actions,
959 }
960 }
961}
962
963#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
964pub enum ResourceRepairAction {
965 ClearInstallPath {
966 resource: ResourceId,
967 },
968 ClearArtifactKey {
969 resource: ResourceId,
970 },
971 RemoveActivationRecord {
972 resource: ResourceId,
973 target: PathBuf,
974 },
975}
976
977fn push_unique_action(actions: &mut Vec<ResourceRepairAction>, action: ResourceRepairAction) {
978 if !actions.contains(&action) {
979 actions.push(action);
980 }
981}
982
983#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
984pub struct ActivationOwnershipConflict {
985 pub target: PathBuf,
986 pub owners: Vec<ResourceId>,
987}
988
989#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
990pub enum OwnershipSeverity {
991 Info,
992 Warning,
993 Error,
994}
995
996#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
997pub enum OwnershipReason {
998 SharedActivationTarget {
999 target: PathBuf,
1000 owners: Vec<ResourceId>,
1001 },
1002 StateStoreReference {
1003 key: StoreKey,
1004 owner: ResourceId,
1005 lifecycle: ResourceLifecycle,
1006 },
1007 RetentionPolicyExcludesLifecycle {
1008 policy: StoreRetentionPolicy,
1009 resource: ResourceId,
1010 lifecycle: ResourceLifecycle,
1011 },
1012 UnreferencedStoreMetadata {
1013 key: StoreKey,
1014 },
1015}
1016
1017impl OwnershipReason {
1018 fn sort_key(&self) -> (u8, String, String) {
1019 match self {
1020 Self::SharedActivationTarget { target, owners } => (
1021 0,
1022 target.display().to_string(),
1023 owners
1024 .iter()
1025 .map(ResourceId::as_string)
1026 .collect::<Vec<_>>()
1027 .join(","),
1028 ),
1029 Self::StateStoreReference {
1030 key,
1031 owner,
1032 lifecycle,
1033 } => (
1034 1,
1035 key.relative_name(),
1036 format!("{}:{lifecycle:?}", owner.as_string()),
1037 ),
1038 Self::RetentionPolicyExcludesLifecycle {
1039 policy,
1040 resource,
1041 lifecycle,
1042 } => (2, resource.as_string(), format!("{policy:?}:{lifecycle:?}")),
1043 Self::UnreferencedStoreMetadata { key } => (3, key.relative_name(), String::new()),
1044 }
1045 }
1046}
1047
1048#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1049pub struct ActivationOwnershipEntry {
1050 pub target: PathBuf,
1051 pub owners: Vec<ResourceId>,
1052 pub severity: OwnershipSeverity,
1053 pub reasons: Vec<OwnershipReason>,
1054}
1055
1056#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1057pub struct ActivationOwnershipReport {
1058 pub entries: Vec<ActivationOwnershipEntry>,
1059}
1060
1061impl ActivationOwnershipReport {
1062 pub fn from_activations(activations: &[ActivationRecord]) -> Self {
1063 let owner_index = activation_owner_index(activations);
1064 Self::from_owner_index(&owner_index)
1065 }
1066
1067 pub fn from_owner_index(owner_index: &HashMap<PathBuf, Vec<ResourceId>>) -> Self {
1068 let mut entries = activation_ownership_entries_from_index(owner_index);
1069 entries.sort_by_cached_key(|entry| {
1070 (
1071 entry.severity,
1072 entry.target.display().to_string(),
1073 entry
1074 .owners
1075 .iter()
1076 .map(ResourceId::as_string)
1077 .collect::<Vec<_>>()
1078 .join(","),
1079 )
1080 });
1081 Self { entries }
1082 }
1083
1084 pub fn is_clean(&self) -> bool {
1085 self.entries.is_empty()
1086 }
1087}
1088
1089#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1090pub struct StoreKeyReference {
1091 pub key: StoreKey,
1092 pub owners: Vec<ResourceId>,
1093}
1094
1095#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1096pub struct ProtectedStoreKey {
1097 pub key: StoreKey,
1098 pub reasons: Vec<OwnershipReason>,
1099}
1100
1101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1102pub struct ProtectedStoreMetadata {
1103 pub record: StoreMetadataRecord,
1104 pub reasons: Vec<OwnershipReason>,
1105}
1106
1107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1108pub struct RemovableStoreMetadata {
1109 pub record: StoreMetadataRecord,
1110 pub reasons: Vec<OwnershipReason>,
1111}
1112
1113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1114pub struct ReasonedStoreRetentionPlan {
1115 pub policy: StoreRetentionPolicy,
1116 pub protected_keys: Vec<ProtectedStoreKey>,
1117 pub protected_metadata: Vec<ProtectedStoreMetadata>,
1118 pub removable_metadata: Vec<RemovableStoreMetadata>,
1119}
1120
1121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1122pub struct OwnershipRetentionPlan {
1123 pub ownership: ActivationOwnershipReport,
1124 pub retention: ReasonedStoreRetentionPlan,
1125}
1126
1127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1128pub enum StoreRetentionPolicy {
1129 AllReferenced,
1130 InstalledAndActive,
1131 ActiveOnly,
1132}
1133
1134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1135pub struct StoreRetentionPlan {
1136 pub policy: StoreRetentionPolicy,
1137 pub protected_keys: Vec<StoreKey>,
1138 pub removable_metadata: Vec<StoreMetadataRecord>,
1139 pub protected_metadata: Vec<StoreMetadataRecord>,
1140}
1141
1142fn capture_resource_state_from_snapshot(
1143 snapshot: &StateSnapshot,
1144 id: &ResourceId,
1145) -> ResourceStateSnapshot {
1146 ResourceStateSnapshot {
1147 resource: id.clone(),
1148 record: snapshot
1149 .resources
1150 .iter()
1151 .find(|record| &record.id == id)
1152 .cloned(),
1153 activations: snapshot
1154 .activations
1155 .iter()
1156 .filter(|record| &record.id == id)
1157 .cloned()
1158 .collect(),
1159 }
1160}
1161
1162fn activation_owner_index(activations: &[ActivationRecord]) -> HashMap<PathBuf, Vec<ResourceId>> {
1163 let mut grouped: HashMap<PathBuf, Vec<ResourceId>> = HashMap::new();
1164 for activation in activations {
1165 grouped
1166 .entry(activation.target.clone())
1167 .or_default()
1168 .push(activation.id.clone());
1169 }
1170
1171 for owners in grouped.values_mut() {
1172 owners.sort_by_key(ResourceId::as_string);
1173 owners.dedup();
1174 }
1175
1176 grouped
1177}
1178
1179fn activation_ownership_entries_from_index(
1180 owner_index: &HashMap<PathBuf, Vec<ResourceId>>,
1181) -> Vec<ActivationOwnershipEntry> {
1182 let mut entries = Vec::new();
1183
1184 for (target, owners) in owner_index {
1185 if owners.len() <= 1 {
1186 continue;
1187 }
1188
1189 entries.push(ActivationOwnershipEntry {
1190 target: target.to_path_buf(),
1191 owners: owners.clone(),
1192 severity: OwnershipSeverity::Warning,
1193 reasons: vec![OwnershipReason::SharedActivationTarget {
1194 target: target.to_path_buf(),
1195 owners: owners.clone(),
1196 }],
1197 });
1198 }
1199
1200 entries
1201}
1202
1203fn store_key_references(records: &[ResourceRecord]) -> Vec<StoreKeyReference> {
1204 let mut grouped: HashMap<String, StoreKeyReference> = HashMap::new();
1205
1206 for record in records {
1207 let Some(key) = &record.artifact_key else {
1208 continue;
1209 };
1210
1211 grouped
1212 .entry(key.relative_name())
1213 .or_insert_with(|| StoreKeyReference {
1214 key: key.clone(),
1215 owners: Vec::new(),
1216 })
1217 .owners
1218 .push(record.id.clone());
1219 }
1220
1221 let mut references = grouped.into_values().collect::<Vec<_>>();
1222
1223 for reference in &mut references {
1224 reference.owners.sort_by_key(ResourceId::as_string);
1225 reference.owners.dedup();
1226 }
1227 references.sort_by_key(|reference| reference.key.relative_name());
1228
1229 references
1230}
1231
1232fn store_key_references_for_retention(
1233 records: &[ResourceRecord],
1234 policy: StoreRetentionPolicy,
1235) -> Vec<StoreKeyReference> {
1236 let filtered = records
1237 .iter()
1238 .filter(|record| retention_matches(&record.lifecycle, policy))
1239 .cloned()
1240 .collect::<Vec<_>>();
1241 store_key_references(&filtered)
1242}
1243
1244fn protected_store_keys_with_reasons(
1245 records: &[ResourceRecord],
1246 policy: StoreRetentionPolicy,
1247) -> Vec<ProtectedStoreKey> {
1248 let mut grouped: HashMap<String, ProtectedStoreKey> = HashMap::new();
1249
1250 for record in records {
1251 let Some(key) = &record.artifact_key else {
1252 continue;
1253 };
1254
1255 if !retention_matches(&record.lifecycle, policy) {
1256 continue;
1257 }
1258
1259 let reason = OwnershipReason::StateStoreReference {
1260 key: key.clone(),
1261 owner: record.id.clone(),
1262 lifecycle: record.lifecycle.clone(),
1263 };
1264 grouped
1265 .entry(key.relative_name())
1266 .or_insert_with(|| ProtectedStoreKey {
1267 key: key.clone(),
1268 reasons: Vec::new(),
1269 })
1270 .reasons
1271 .push(reason);
1272 }
1273
1274 let mut entries = grouped.into_values().collect::<Vec<_>>();
1275
1276 for entry in &mut entries {
1277 entry.reasons.sort_by_key(OwnershipReason::sort_key);
1278 entry.reasons.dedup();
1279 }
1280 entries.sort_by_key(|entry| entry.key.relative_name());
1281
1282 entries
1283}
1284
1285fn protected_metadata_reasons(
1286 record: &StoreMetadataRecord,
1287 protected_keys: &[ProtectedStoreKey],
1288) -> Vec<OwnershipReason> {
1289 protected_keys
1290 .iter()
1291 .find(|entry| entry.key == record.key)
1292 .map(|entry| entry.reasons.clone())
1293 .unwrap_or_default()
1294}
1295
1296fn removable_metadata_reasons(
1297 record: &StoreMetadataRecord,
1298 resources: &[ResourceRecord],
1299 policy: StoreRetentionPolicy,
1300) -> Vec<OwnershipReason> {
1301 let mut reasons = Vec::new();
1302
1303 let referencing_records = resources
1304 .iter()
1305 .filter(|resource| resource.artifact_key.as_ref() == Some(&record.key))
1306 .collect::<Vec<_>>();
1307
1308 if referencing_records.is_empty() {
1309 reasons.push(OwnershipReason::UnreferencedStoreMetadata {
1310 key: record.key.clone(),
1311 });
1312 } else {
1313 for resource in referencing_records {
1314 reasons.push(OwnershipReason::RetentionPolicyExcludesLifecycle {
1315 policy,
1316 resource: resource.id.clone(),
1317 lifecycle: resource.lifecycle.clone(),
1318 });
1319 }
1320 }
1321
1322 reasons.sort_by_key(OwnershipReason::sort_key);
1323 reasons.dedup();
1324 reasons
1325}
1326
1327fn retention_matches(lifecycle: &ResourceLifecycle, policy: StoreRetentionPolicy) -> bool {
1328 match policy {
1329 StoreRetentionPolicy::AllReferenced => true,
1330 StoreRetentionPolicy::InstalledAndActive => matches!(
1331 lifecycle,
1332 ResourceLifecycle::Installed
1333 | ResourceLifecycle::Registered
1334 | ResourceLifecycle::Active
1335 ),
1336 StoreRetentionPolicy::ActiveOnly => lifecycle == &ResourceLifecycle::Active,
1337 }
1338}
1339
1340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1341pub struct ResourceRecord {
1342 pub id: ResourceId,
1343 pub selector: VersionSelector,
1344 pub resolved_version: Option<ResolvedVersion>,
1345 pub locator: Option<ResolvedLocator>,
1346 pub artifact_key: Option<StoreKey>,
1347 pub install_path: Option<PathBuf>,
1348 pub lifecycle: ResourceLifecycle,
1349 pub metadata: Metadata,
1350}
1351
1352impl ResourceRecord {
1353 pub fn from_resolved_resource(resource: &pulith_resource::ResolvedResource) -> Self {
1354 Self {
1355 id: resource.spec().id.clone(),
1356 selector: resource.spec().version.clone(),
1357 resolved_version: Some(resource.version().clone()),
1358 locator: Some(resource.locator().clone()),
1359 artifact_key: None,
1360 install_path: None,
1361 lifecycle: ResourceLifecycle::Resolved,
1362 metadata: Metadata::new(),
1363 }
1364 }
1365
1366 pub fn apply_patch(&mut self, patch: ResourceRecordPatch) {
1367 if let Some(selector) = patch.selector {
1368 self.selector = selector;
1369 }
1370 if let Some(resolved_version) = patch.resolved_version {
1371 self.resolved_version = resolved_version;
1372 }
1373 if let Some(locator) = patch.locator {
1374 self.locator = locator;
1375 }
1376 if let Some(artifact_key) = patch.artifact_key {
1377 self.artifact_key = artifact_key;
1378 }
1379 if let Some(install_path) = patch.install_path {
1380 self.install_path = install_path;
1381 }
1382 if let Some(lifecycle) = patch.lifecycle {
1383 self.lifecycle = lifecycle;
1384 }
1385 if let Some(metadata) = patch.metadata {
1386 self.metadata = metadata;
1387 }
1388 }
1389}
1390
1391#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1392pub enum ResourceLifecycle {
1393 Declared,
1394 Resolved,
1395 Fetched,
1396 Materialized,
1397 Installed,
1398 Registered,
1399 Active,
1400 Failed,
1401}
1402
1403#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1404pub struct ActivationRecord {
1405 pub id: ResourceId,
1406 pub target: PathBuf,
1407 pub activated_at_unix: u64,
1408}
1409
1410fn now_unix() -> u64 {
1411 SystemTime::now()
1412 .duration_since(UNIX_EPOCH)
1413 .unwrap_or_default()
1414 .as_secs()
1415}
1416
1417fn load_from_transaction(tx: &Transaction) -> Result<StateSnapshot> {
1418 let bytes = tx.read()?;
1419 if bytes.is_empty() {
1420 return Ok(StateSnapshot::default());
1421 }
1422 let snapshot: StateSnapshot = decode_slice(&JsonTextCodec, &bytes)?;
1423 snapshot.validate()?;
1424 Ok(snapshot)
1425}
1426
1427fn save_to_transaction(tx: &Transaction, snapshot: &StateSnapshot) -> Result<()> {
1428 let encoded = encode_pretty_vec(&JsonTextCodec, snapshot)?;
1429 tx.write(&encoded)?;
1430 Ok(())
1431}
1432
1433#[cfg(test)]
1434mod tests {
1435 use super::*;
1436 use pulith_resource::{RequestedResource, ResourceLocator, ResourceSpec, ValidUrl};
1437 use pulith_serde_backend::CompactJsonTextCodec;
1438 use pulith_store::StoreRoots;
1439
1440 #[test]
1441 fn state_initializes_and_loads_default_snapshot() {
1442 let temp = tempfile::tempdir().unwrap();
1443 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
1444 let snapshot = state.load().unwrap();
1445 assert_eq!(snapshot.schema_version, STATE_SNAPSHOT_SCHEMA_VERSION);
1446 assert!(snapshot.resources.is_empty());
1447 }
1448
1449 #[test]
1450 fn state_rejects_unsupported_snapshot_schema_version() {
1451 let temp = tempfile::tempdir().unwrap();
1452 let state_path = temp.path().join("state.json");
1453 let invalid = StateSnapshot {
1454 schema_version: STATE_SNAPSHOT_SCHEMA_VERSION + 1,
1455 resources: Vec::new(),
1456 activations: Vec::new(),
1457 };
1458 let bytes = encode_pretty_vec(&JsonTextCodec, &invalid).unwrap();
1459 atomic_write(&state_path, &bytes, Default::default()).unwrap();
1460
1461 let state = StateReady::initialize(state_path).unwrap();
1462 assert!(matches!(
1463 state.load(),
1464 Err(StateError::UnsupportedSnapshotSchemaVersion {
1465 expected,
1466 actual
1467 }) if expected == STATE_SNAPSHOT_SCHEMA_VERSION && actual == STATE_SNAPSHOT_SCHEMA_VERSION + 1
1468 ));
1469 }
1470
1471 #[test]
1472 fn state_load_accepts_compact_json_snapshot() {
1473 let temp = tempfile::tempdir().unwrap();
1474 let state_path = temp.path().join("state.json");
1475 let snapshot = StateSnapshot::default();
1476 let bytes = encode_pretty_vec(&CompactJsonTextCodec, &snapshot).unwrap();
1477 atomic_write(&state_path, &bytes, Default::default()).unwrap();
1478
1479 let state = StateReady::initialize(state_path).unwrap();
1480 let loaded = state.load().unwrap();
1481 assert_eq!(loaded.schema_version, STATE_SNAPSHOT_SCHEMA_VERSION);
1482 assert_eq!(loaded.resources.len(), 0);
1483 }
1484
1485 #[test]
1486 fn state_can_export_lock_file_from_resolved_records() {
1487 let temp = tempfile::tempdir().unwrap();
1488 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
1489
1490 let requested = RequestedResource::new(ResourceSpec::new(
1491 ResourceId::parse("example/runtime").unwrap(),
1492 ResourceLocator::Url(ValidUrl::parse("https://example.com/runtime.tgz").unwrap()),
1493 ));
1494 let resolved = requested.resolve(
1495 ResolvedVersion::new("1.2.3").unwrap(),
1496 ResolvedLocator::Url(ValidUrl::parse("https://example.com/runtime.tgz").unwrap()),
1497 None,
1498 );
1499 state.upsert_resource(&resolved).unwrap();
1500
1501 let lock = state.export_lock_file().unwrap();
1502 assert_eq!(lock.resources.len(), 1);
1503
1504 let entry = lock.resources.get("example/runtime").unwrap();
1505 assert_eq!(entry.version, "1.2.3");
1506 assert_eq!(entry.source, "https://example.com/runtime.tgz");
1507 }
1508
1509 #[test]
1510 fn state_export_lock_skips_unresolved_records() {
1511 let temp = tempfile::tempdir().unwrap();
1512 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
1513 let id = ResourceId::parse("example/unresolved").unwrap();
1514
1515 state
1516 .ensure_resource_record(id.clone(), VersionSelector::alias("lts").unwrap())
1517 .unwrap();
1518
1519 let lock = state.export_lock_file().unwrap();
1520 assert!(!lock.resources.contains_key(&id.as_string()));
1521 }
1522
1523 #[test]
1524 fn state_updates_records_transactionally() {
1525 let temp = tempfile::tempdir().unwrap();
1526 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
1527
1528 let id = ResourceId::parse("nodejs.org/node").unwrap();
1529 let updated = state
1530 .update(|mut snapshot| {
1531 snapshot.resources.push(ResourceRecord {
1532 id: id.clone(),
1533 selector: VersionSelector::alias("lts").unwrap(),
1534 resolved_version: None,
1535 locator: None,
1536 artifact_key: None,
1537 install_path: None,
1538 lifecycle: ResourceLifecycle::Declared,
1539 metadata: Metadata::new(),
1540 });
1541 Ok(snapshot)
1542 })
1543 .unwrap();
1544
1545 assert_eq!(updated.resources.len(), 1);
1546 assert_eq!(state.load().unwrap().resources.len(), 1);
1547 }
1548
1549 #[test]
1550 fn state_can_store_resolved_resource_facts() {
1551 let temp = tempfile::tempdir().unwrap();
1552 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
1553
1554 let requested = RequestedResource::new(ResourceSpec::new(
1555 ResourceId::parse("example/runtime").unwrap(),
1556 ResourceLocator::Url(ValidUrl::parse("https://example.com/runtime.zip").unwrap()),
1557 ));
1558 let resolved = requested.resolve(
1559 ResolvedVersion::new("1.0.0").unwrap(),
1560 ResolvedLocator::Url(
1561 ValidUrl::parse("https://mirror.example.com/runtime.zip").unwrap(),
1562 ),
1563 None,
1564 );
1565
1566 state
1567 .upsert_resolved_resource(&resolved, ResourceRecordPatch::default())
1568 .unwrap();
1569
1570 let snapshot = state.load().unwrap();
1571 assert_eq!(snapshot.resources[0].lifecycle, ResourceLifecycle::Resolved);
1572 }
1573
1574 #[test]
1575 fn upsert_resource_absorbs_resolved_resource_without_patch() {
1576 let temp = tempfile::tempdir().unwrap();
1577 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
1578
1579 let resolved = RequestedResource::new(ResourceSpec::new(
1580 ResourceId::parse("example/runtime").unwrap(),
1581 ResourceLocator::Url(ValidUrl::parse("https://example.com/runtime.zip").unwrap()),
1582 ))
1583 .resolve(
1584 ResolvedVersion::new("1.0.0").unwrap(),
1585 ResolvedLocator::Url(
1586 ValidUrl::parse("https://mirror.example.com/runtime.zip").unwrap(),
1587 ),
1588 None,
1589 );
1590
1591 state.upsert_resource(&resolved).unwrap();
1592
1593 let record = state
1594 .get_resource_record(&ResourceId::parse("example/runtime").unwrap())
1595 .unwrap()
1596 .unwrap();
1597 assert_eq!(record.lifecycle, ResourceLifecycle::Resolved);
1598 }
1599
1600 #[test]
1601 fn upsert_resource_absorbs_resolved_resource_with_patch() {
1602 let temp = tempfile::tempdir().unwrap();
1603 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
1604
1605 let resolved = RequestedResource::new(ResourceSpec::new(
1606 ResourceId::parse("example/runtime").unwrap(),
1607 ResourceLocator::Url(ValidUrl::parse("https://example.com/runtime.zip").unwrap()),
1608 ))
1609 .resolve(
1610 ResolvedVersion::new("1.0.0").unwrap(),
1611 ResolvedLocator::Url(
1612 ValidUrl::parse("https://mirror.example.com/runtime.zip").unwrap(),
1613 ),
1614 None,
1615 );
1616
1617 state
1618 .upsert_resource((
1619 &resolved,
1620 ResourceRecordPatch::install_path(Some(PathBuf::from("/opt/runtime")))
1621 .with_lifecycle(ResourceLifecycle::Installed),
1622 ))
1623 .unwrap();
1624
1625 let record = state
1626 .get_resource_record(&ResourceId::parse("example/runtime").unwrap())
1627 .unwrap()
1628 .unwrap();
1629 assert_eq!(record.lifecycle, ResourceLifecycle::Installed);
1630 assert_eq!(record.install_path, Some(PathBuf::from("/opt/runtime")));
1631 }
1632
1633 #[test]
1634 fn upsert_resolved_resource_applies_patch_semantically() {
1635 let temp = tempfile::tempdir().unwrap();
1636 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
1637
1638 let resolved = RequestedResource::new(ResourceSpec::new(
1639 ResourceId::parse("example/runtime").unwrap(),
1640 ResourceLocator::Url(ValidUrl::parse("https://example.com/runtime.zip").unwrap()),
1641 ))
1642 .resolve(
1643 ResolvedVersion::new("1.0.0").unwrap(),
1644 ResolvedLocator::Url(
1645 ValidUrl::parse("https://mirror.example.com/runtime.zip").unwrap(),
1646 ),
1647 None,
1648 );
1649
1650 state
1651 .upsert_resolved_resource(
1652 &resolved,
1653 ResourceRecordPatch::install_path(Some(PathBuf::from("/opt/runtime")))
1654 .with_lifecycle(ResourceLifecycle::Installed)
1655 .with_metadata(Metadata::from([(
1656 "source".to_string(),
1657 "integration".to_string(),
1658 )])),
1659 )
1660 .unwrap();
1661
1662 let record = state
1663 .get_resource_record(&ResourceId::parse("example/runtime").unwrap())
1664 .unwrap()
1665 .unwrap();
1666 assert_eq!(record.lifecycle, ResourceLifecycle::Installed);
1667 assert_eq!(record.install_path, Some(PathBuf::from("/opt/runtime")));
1668 assert_eq!(
1669 record.metadata.get("source").map(String::as_str),
1670 Some("integration")
1671 );
1672 }
1673
1674 #[test]
1675 fn ensure_patch_and_lookup_are_ergonomic() {
1676 let temp = tempfile::tempdir().unwrap();
1677 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
1678 let id = ResourceId::parse("example/runtime").unwrap();
1679
1680 state
1681 .ensure_resource_record(id.clone(), VersionSelector::alias("lts").unwrap())
1682 .unwrap();
1683 state
1684 .patch_resource_record(
1685 &id,
1686 ResourceRecordPatch {
1687 lifecycle: Some(ResourceLifecycle::Resolved),
1688 install_path: Some(Some(PathBuf::from("/opt/runtime"))),
1689 ..ResourceRecordPatch::default()
1690 },
1691 )
1692 .unwrap();
1693
1694 let record = state.get_resource_record(&id).unwrap().unwrap();
1695 assert_eq!(record.lifecycle, ResourceLifecycle::Resolved);
1696 assert_eq!(record.install_path, Some(PathBuf::from("/opt/runtime")));
1697 }
1698
1699 #[test]
1700 fn record_activation_appends_entry() {
1701 let temp = tempfile::tempdir().unwrap();
1702 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
1703 let id = ResourceId::parse("example/runtime").unwrap();
1704
1705 state
1706 .record_activation(&id, PathBuf::from("/active/runtime"))
1707 .unwrap();
1708
1709 let activations = state.list_activation_records(&id).unwrap();
1710 assert_eq!(activations.len(), 1);
1711 assert_eq!(activations[0].target, PathBuf::from("/active/runtime"));
1712 }
1713
1714 #[test]
1715 fn resource_state_can_be_captured_and_restored() {
1716 let temp = tempfile::tempdir().unwrap();
1717 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
1718 let id = ResourceId::parse("example/runtime").unwrap();
1719
1720 state
1721 .ensure_resource_record(id.clone(), VersionSelector::alias("lts").unwrap())
1722 .unwrap();
1723 state
1724 .patch_resource_record(
1725 &id,
1726 ResourceRecordPatch::install_path(Some(PathBuf::from("/opt/runtime")))
1727 .with_lifecycle(ResourceLifecycle::Installed),
1728 )
1729 .unwrap();
1730 state
1731 .record_activation(&id, PathBuf::from("/active/runtime"))
1732 .unwrap();
1733
1734 let captured = state.capture_resource_state(&id).unwrap();
1735
1736 state.remove_resource_record(&id).unwrap();
1737 state.remove_activation_records(&id).unwrap();
1738 assert!(state.get_resource_record(&id).unwrap().is_none());
1739
1740 state.restore_resource_state(&captured).unwrap();
1741
1742 let restored = state.get_resource_record(&id).unwrap().unwrap();
1743 assert_eq!(restored.lifecycle, ResourceLifecycle::Installed);
1744 assert_eq!(restored.install_path, Some(PathBuf::from("/opt/runtime")));
1745 let activations = state.list_activation_records(&id).unwrap();
1746 assert_eq!(activations.len(), 1);
1747 assert_eq!(activations[0].target, PathBuf::from("/active/runtime"));
1748 }
1749
1750 #[test]
1751 fn restore_resource_state_only_affects_target_resource_scope() {
1752 let temp = tempfile::tempdir().unwrap();
1753 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
1754
1755 let runtime_a = ResourceId::parse("example/runtime-a").unwrap();
1756 let runtime_b = ResourceId::parse("example/runtime-b").unwrap();
1757
1758 state
1759 .ensure_resource_record(runtime_a.clone(), VersionSelector::alias("lts").unwrap())
1760 .unwrap();
1761 state
1762 .patch_resource_record(
1763 &runtime_a,
1764 ResourceRecordPatch::install_path(Some(PathBuf::from("/opt/runtime-a")))
1765 .with_lifecycle(ResourceLifecycle::Installed),
1766 )
1767 .unwrap();
1768 state
1769 .record_activation(&runtime_a, PathBuf::from("/active/runtime-a"))
1770 .unwrap();
1771
1772 state
1773 .ensure_resource_record(runtime_b.clone(), VersionSelector::alias("stable").unwrap())
1774 .unwrap();
1775 state
1776 .patch_resource_record(
1777 &runtime_b,
1778 ResourceRecordPatch::install_path(Some(PathBuf::from("/opt/runtime-b")))
1779 .with_lifecycle(ResourceLifecycle::Active),
1780 )
1781 .unwrap();
1782 state
1783 .record_activation(&runtime_b, PathBuf::from("/active/runtime-b"))
1784 .unwrap();
1785
1786 let captured_a = state.capture_resource_state(&runtime_a).unwrap();
1787
1788 state
1789 .patch_resource_record(
1790 &runtime_a,
1791 ResourceRecordPatch::install_path(Some(PathBuf::from("/tmp/stale-runtime-a")))
1792 .with_lifecycle(ResourceLifecycle::Failed),
1793 )
1794 .unwrap();
1795 state.remove_activation_records(&runtime_a).unwrap();
1796
1797 state
1798 .patch_resource_record(
1799 &runtime_b,
1800 ResourceRecordPatch::install_path(Some(PathBuf::from("/opt/runtime-b-updated")))
1801 .with_lifecycle(ResourceLifecycle::Installed),
1802 )
1803 .unwrap();
1804
1805 state.restore_resource_state(&captured_a).unwrap();
1806
1807 let restored_a = state.get_resource_record(&runtime_a).unwrap().unwrap();
1808 assert_eq!(restored_a.lifecycle, ResourceLifecycle::Installed);
1809 assert_eq!(
1810 restored_a.install_path,
1811 Some(PathBuf::from("/opt/runtime-a"))
1812 );
1813 assert_eq!(state.list_activation_records(&runtime_a).unwrap().len(), 1);
1814
1815 let preserved_b = state.get_resource_record(&runtime_b).unwrap().unwrap();
1816 assert_eq!(preserved_b.lifecycle, ResourceLifecycle::Installed);
1817 assert_eq!(
1818 preserved_b.install_path,
1819 Some(PathBuf::from("/opt/runtime-b-updated"))
1820 );
1821 assert_eq!(state.list_activation_records(&runtime_b).unwrap().len(), 1);
1822 }
1823
1824 #[test]
1825 fn resource_inspection_reports_missing_runtime_state() {
1826 let temp = tempfile::tempdir().unwrap();
1827 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
1828 let store = StoreReady::initialize(StoreRoots::new(
1829 temp.path().join("store/artifacts"),
1830 temp.path().join("store/extracts"),
1831 temp.path().join("store/metadata"),
1832 ))
1833 .unwrap();
1834 let id = ResourceId::parse("example/runtime").unwrap();
1835 let key = StoreKey::logical("runtime").unwrap();
1836
1837 state
1838 .ensure_resource_record(id.clone(), VersionSelector::alias("lts").unwrap())
1839 .unwrap();
1840 state
1841 .patch_resource_record(
1842 &id,
1843 ResourceRecordPatch::artifact_key(Some(key.clone()))
1844 .with_install_path(Some(temp.path().join("missing-install")))
1845 .with_lifecycle(ResourceLifecycle::Installed),
1846 )
1847 .unwrap();
1848 state
1849 .record_activation(&id, temp.path().join("active/runtime"))
1850 .unwrap();
1851
1852 let inspection = state.inspect_resource(&id, Some(&store)).unwrap();
1853
1854 assert!(
1855 inspection
1856 .findings
1857 .contains(&ResourceInspectionFinding::MissingInstallPath {
1858 resource: id.clone(),
1859 path: temp.path().join("missing-install"),
1860 })
1861 );
1862 assert!(inspection.findings.contains(
1863 &ResourceInspectionFinding::MissingActivationTarget {
1864 resource: id.clone(),
1865 target: temp.path().join("active/runtime"),
1866 }
1867 ));
1868 assert!(
1869 inspection
1870 .findings
1871 .contains(&ResourceInspectionFinding::MissingStoreEntry {
1872 resource: id.clone(),
1873 key: key.clone(),
1874 })
1875 );
1876 assert!(
1877 inspection
1878 .findings
1879 .contains(&ResourceInspectionFinding::MissingStoreMetadata { resource: id, key })
1880 );
1881 assert_eq!(inspection.summary.error_count, 3);
1882 assert_eq!(inspection.summary.warning_count, 1);
1883 }
1884
1885 #[test]
1886 fn resource_inspection_can_be_clean_when_state_and_store_are_consistent() {
1887 let temp = tempfile::tempdir().unwrap();
1888 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
1889 let store = StoreReady::initialize(StoreRoots::new(
1890 temp.path().join("store/artifacts"),
1891 temp.path().join("store/extracts"),
1892 temp.path().join("store/metadata"),
1893 ))
1894 .unwrap();
1895 let id = ResourceId::parse("example/runtime").unwrap();
1896 let key = StoreKey::logical("runtime").unwrap();
1897 let install_root = temp.path().join("install/runtime");
1898 let activation_target = temp.path().join("active/runtime");
1899
1900 std::fs::create_dir_all(&install_root).unwrap();
1901 std::fs::create_dir_all(activation_target.parent().unwrap()).unwrap();
1902 std::fs::write(&activation_target, b"active").unwrap();
1903 store.put_artifact_bytes(&key, b"payload").unwrap();
1904
1905 state
1906 .ensure_resource_record(id.clone(), VersionSelector::alias("lts").unwrap())
1907 .unwrap();
1908 state
1909 .patch_resource_record(
1910 &id,
1911 ResourceRecordPatch::artifact_key(Some(key))
1912 .with_install_path(Some(install_root))
1913 .with_lifecycle(ResourceLifecycle::Installed),
1914 )
1915 .unwrap();
1916 state.record_activation(&id, activation_target).unwrap();
1917
1918 let inspection = state.inspect_resource(&id, Some(&store)).unwrap();
1919 assert!(inspection.is_clean());
1920 assert_eq!(inspection.summary.total_findings, 0);
1921 }
1922
1923 #[test]
1924 fn resource_inspection_is_read_only() {
1925 let temp = tempfile::tempdir().unwrap();
1926 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
1927 let store = StoreReady::initialize(StoreRoots::new(
1928 temp.path().join("store/artifacts"),
1929 temp.path().join("store/extracts"),
1930 temp.path().join("store/metadata"),
1931 ))
1932 .unwrap();
1933 let id = ResourceId::parse("example/runtime").unwrap();
1934 let key = StoreKey::logical("runtime").unwrap();
1935
1936 state
1937 .ensure_resource_record(id.clone(), VersionSelector::alias("lts").unwrap())
1938 .unwrap();
1939 state
1940 .patch_resource_record(
1941 &id,
1942 ResourceRecordPatch::artifact_key(Some(key))
1943 .with_install_path(Some(temp.path().join("missing-install")))
1944 .with_lifecycle(ResourceLifecycle::Installed),
1945 )
1946 .unwrap();
1947 state
1948 .record_activation(&id, temp.path().join("active/runtime"))
1949 .unwrap();
1950
1951 let before = state.load().unwrap();
1952 let inspection = state.inspect_resource(&id, Some(&store)).unwrap();
1953 let after = state.load().unwrap();
1954
1955 assert!(!inspection.is_clean());
1956 assert_eq!(before, after);
1957 }
1958
1959 #[test]
1960 fn resource_repair_plan_suggests_explicit_state_cleanup_actions() {
1961 let temp = tempfile::tempdir().unwrap();
1962 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
1963 let store = StoreReady::initialize(StoreRoots::new(
1964 temp.path().join("store/artifacts"),
1965 temp.path().join("store/extracts"),
1966 temp.path().join("store/metadata"),
1967 ))
1968 .unwrap();
1969 let id = ResourceId::parse("example/runtime").unwrap();
1970 let key = StoreKey::logical("runtime").unwrap();
1971 let missing_install = temp.path().join("missing-install");
1972 let missing_target = temp.path().join("active/runtime");
1973
1974 state
1975 .ensure_resource_record(id.clone(), VersionSelector::alias("lts").unwrap())
1976 .unwrap();
1977 state
1978 .patch_resource_record(
1979 &id,
1980 ResourceRecordPatch::artifact_key(Some(key.clone()))
1981 .with_install_path(Some(missing_install.clone()))
1982 .with_lifecycle(ResourceLifecycle::Installed),
1983 )
1984 .unwrap();
1985 state
1986 .record_activation(&id, missing_target.clone())
1987 .unwrap();
1988
1989 let plan = state.plan_resource_state_repair(&id, Some(&store)).unwrap();
1990
1991 assert!(
1992 plan.actions
1993 .contains(&ResourceRepairAction::ClearInstallPath {
1994 resource: id.clone(),
1995 })
1996 );
1997 assert!(
1998 plan.actions
1999 .contains(&ResourceRepairAction::ClearArtifactKey {
2000 resource: id.clone(),
2001 })
2002 );
2003 assert!(
2004 plan.actions
2005 .contains(&ResourceRepairAction::RemoveActivationRecord {
2006 resource: id,
2007 target: missing_target,
2008 })
2009 );
2010 }
2011
2012 #[test]
2013 fn resource_repair_plan_can_be_applied_explicitly() {
2014 let temp = tempfile::tempdir().unwrap();
2015 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
2016 let store = StoreReady::initialize(StoreRoots::new(
2017 temp.path().join("store/artifacts"),
2018 temp.path().join("store/extracts"),
2019 temp.path().join("store/metadata"),
2020 ))
2021 .unwrap();
2022 let id = ResourceId::parse("example/runtime").unwrap();
2023 let key = StoreKey::logical("runtime").unwrap();
2024
2025 state
2026 .ensure_resource_record(id.clone(), VersionSelector::alias("lts").unwrap())
2027 .unwrap();
2028 state
2029 .patch_resource_record(
2030 &id,
2031 ResourceRecordPatch::artifact_key(Some(key))
2032 .with_install_path(Some(temp.path().join("missing-install")))
2033 .with_lifecycle(ResourceLifecycle::Installed),
2034 )
2035 .unwrap();
2036 state
2037 .record_activation(&id, temp.path().join("active/runtime"))
2038 .unwrap();
2039
2040 let plan = state.plan_resource_state_repair(&id, Some(&store)).unwrap();
2041 state.apply_resource_state_repair(&plan).unwrap();
2042
2043 let record = state.get_resource_record(&id).unwrap().unwrap();
2044 assert_eq!(record.install_path, None);
2045 assert_eq!(record.artifact_key, None);
2046 assert!(state.list_activation_records(&id).unwrap().is_empty());
2047 }
2048
2049 #[test]
2050 fn resource_repair_plan_actions_are_deterministic() {
2051 let temp = tempfile::tempdir().unwrap();
2052 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
2053 let store = StoreReady::initialize(StoreRoots::new(
2054 temp.path().join("store/artifacts"),
2055 temp.path().join("store/extracts"),
2056 temp.path().join("store/metadata"),
2057 ))
2058 .unwrap();
2059 let id = ResourceId::parse("example/runtime").unwrap();
2060 let key = StoreKey::logical("runtime").unwrap();
2061
2062 state
2063 .ensure_resource_record(id.clone(), VersionSelector::alias("lts").unwrap())
2064 .unwrap();
2065 state
2066 .patch_resource_record(
2067 &id,
2068 ResourceRecordPatch::artifact_key(Some(key))
2069 .with_install_path(Some(temp.path().join("missing-install")))
2070 .with_lifecycle(ResourceLifecycle::Installed),
2071 )
2072 .unwrap();
2073 state
2074 .record_activation(&id, temp.path().join("active/runtime"))
2075 .unwrap();
2076
2077 let first = state.plan_resource_state_repair(&id, Some(&store)).unwrap();
2078 let second = state.plan_resource_state_repair(&id, Some(&store)).unwrap();
2079
2080 assert_eq!(first.actions, second.actions);
2081 assert_eq!(first.inspection.findings, second.inspection.findings);
2082 }
2083
2084 #[test]
2085 fn applying_resource_repair_plan_is_idempotent() {
2086 let temp = tempfile::tempdir().unwrap();
2087 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
2088 let store = StoreReady::initialize(StoreRoots::new(
2089 temp.path().join("store/artifacts"),
2090 temp.path().join("store/extracts"),
2091 temp.path().join("store/metadata"),
2092 ))
2093 .unwrap();
2094 let id = ResourceId::parse("example/runtime").unwrap();
2095 let key = StoreKey::logical("runtime").unwrap();
2096
2097 state
2098 .ensure_resource_record(id.clone(), VersionSelector::alias("lts").unwrap())
2099 .unwrap();
2100 state
2101 .patch_resource_record(
2102 &id,
2103 ResourceRecordPatch::artifact_key(Some(key))
2104 .with_install_path(Some(temp.path().join("missing-install")))
2105 .with_lifecycle(ResourceLifecycle::Installed),
2106 )
2107 .unwrap();
2108 state
2109 .record_activation(&id, temp.path().join("active/runtime"))
2110 .unwrap();
2111
2112 let plan = state.plan_resource_state_repair(&id, Some(&store)).unwrap();
2113 let first_apply = state.apply_resource_state_repair(&plan).unwrap();
2114 let second_apply = state.apply_resource_state_repair(&plan).unwrap();
2115
2116 assert_eq!(first_apply, second_apply);
2117 let record = state.get_resource_record(&id).unwrap().unwrap();
2118 assert_eq!(record.install_path, None);
2119 assert_eq!(record.artifact_key, None);
2120 assert!(state.list_activation_records(&id).unwrap().is_empty());
2121 }
2122
2123 #[test]
2124 fn resource_inspection_reports_activation_target_conflicts() {
2125 let temp = tempfile::tempdir().unwrap();
2126 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
2127 let shared_target = temp.path().join("active/shared-runtime");
2128 std::fs::create_dir_all(shared_target.parent().unwrap()).unwrap();
2129 std::fs::write(&shared_target, b"active").unwrap();
2130
2131 let first = ResourceId::parse("example/runtime-a").unwrap();
2132 let second = ResourceId::parse("example/runtime-b").unwrap();
2133
2134 state
2135 .ensure_resource_record(first.clone(), VersionSelector::alias("lts").unwrap())
2136 .unwrap();
2137 state
2138 .ensure_resource_record(second.clone(), VersionSelector::alias("lts").unwrap())
2139 .unwrap();
2140 state
2141 .record_activation(&first, shared_target.clone())
2142 .unwrap();
2143 state
2144 .record_activation(&second, shared_target.clone())
2145 .unwrap();
2146
2147 let inspection = state.inspect_resource(&first, None).unwrap();
2148 assert!(inspection.findings.contains(
2149 &ResourceInspectionFinding::ActivationTargetConflict {
2150 resource: first.clone(),
2151 target: shared_target.clone(),
2152 conflicting_owners: vec![second.clone()],
2153 }
2154 ));
2155
2156 let conflicts = state.list_activation_conflicts().unwrap();
2157 assert_eq!(conflicts.len(), 1);
2158 assert_eq!(conflicts[0].target, shared_target);
2159 assert!(conflicts[0].owners.contains(&first));
2160 assert!(conflicts[0].owners.contains(&second));
2161 }
2162
2163 #[test]
2164 fn state_can_list_store_key_references() {
2165 let temp = tempfile::tempdir().unwrap();
2166 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
2167 let shared_key = StoreKey::logical("runtime-shared").unwrap();
2168
2169 for resource in ["example/runtime-a", "example/runtime-b"] {
2170 let id = ResourceId::parse(resource).unwrap();
2171 state
2172 .ensure_resource_record(id.clone(), VersionSelector::alias("lts").unwrap())
2173 .unwrap();
2174 state
2175 .patch_resource_record(
2176 &id,
2177 ResourceRecordPatch::artifact_key(Some(shared_key.clone())),
2178 )
2179 .unwrap();
2180 }
2181
2182 let references = state.list_store_references().unwrap();
2183 assert_eq!(references.len(), 1);
2184 assert_eq!(references[0].key, shared_key);
2185 assert_eq!(references[0].owners.len(), 2);
2186 }
2187
2188 #[test]
2189 fn state_can_filter_protected_store_keys_by_retention_policy() {
2190 let temp = tempfile::tempdir().unwrap();
2191 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
2192
2193 let active_id = ResourceId::parse("example/runtime-active").unwrap();
2194 let installed_id = ResourceId::parse("example/runtime-installed").unwrap();
2195 let fetched_id = ResourceId::parse("example/runtime-fetched").unwrap();
2196
2197 let active_key = StoreKey::logical("runtime-active").unwrap();
2198 let installed_key = StoreKey::logical("runtime-installed").unwrap();
2199 let fetched_key = StoreKey::logical("runtime-fetched").unwrap();
2200
2201 for (id, key, lifecycle) in [
2202 (&active_id, &active_key, ResourceLifecycle::Active),
2203 (&installed_id, &installed_key, ResourceLifecycle::Installed),
2204 (&fetched_id, &fetched_key, ResourceLifecycle::Fetched),
2205 ] {
2206 state
2207 .ensure_resource_record(id.clone(), VersionSelector::alias("lts").unwrap())
2208 .unwrap();
2209 state
2210 .patch_resource_record(
2211 id,
2212 ResourceRecordPatch::artifact_key(Some(key.clone())).with_lifecycle(lifecycle),
2213 )
2214 .unwrap();
2215 }
2216
2217 let all = state
2218 .protected_store_keys(StoreRetentionPolicy::AllReferenced)
2219 .unwrap();
2220 assert_eq!(all.len(), 3);
2221
2222 let installed_and_active = state
2223 .protected_store_keys(StoreRetentionPolicy::InstalledAndActive)
2224 .unwrap();
2225 assert!(installed_and_active.contains(&active_key));
2226 assert!(installed_and_active.contains(&installed_key));
2227 assert!(!installed_and_active.contains(&fetched_key));
2228
2229 let active_only = state
2230 .protected_store_keys(StoreRetentionPolicy::ActiveOnly)
2231 .unwrap();
2232 assert_eq!(active_only, vec![active_key]);
2233 }
2234
2235 #[test]
2236 fn retention_policy_can_protect_store_metadata_during_prune() {
2237 let temp = tempfile::tempdir().unwrap();
2238 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
2239 let store = StoreReady::initialize(StoreRoots::new(
2240 temp.path().join("store/artifacts"),
2241 temp.path().join("store/extracts"),
2242 temp.path().join("store/metadata"),
2243 ))
2244 .unwrap();
2245
2246 let active_id = ResourceId::parse("example/runtime-active").unwrap();
2247 let inactive_id = ResourceId::parse("example/runtime-fetched").unwrap();
2248 let active_key = StoreKey::logical("runtime-active").unwrap();
2249 let inactive_key = StoreKey::logical("runtime-fetched").unwrap();
2250
2251 for key in [&active_key, &inactive_key] {
2252 store.put_artifact_bytes(key, b"payload").unwrap();
2253 std::fs::remove_file(store.artifact_path(key)).unwrap();
2254 }
2255
2256 state
2257 .ensure_resource_record(active_id.clone(), VersionSelector::alias("lts").unwrap())
2258 .unwrap();
2259 state
2260 .patch_resource_record(
2261 &active_id,
2262 ResourceRecordPatch::artifact_key(Some(active_key.clone()))
2263 .with_lifecycle(ResourceLifecycle::Active),
2264 )
2265 .unwrap();
2266
2267 state
2268 .ensure_resource_record(inactive_id.clone(), VersionSelector::alias("lts").unwrap())
2269 .unwrap();
2270 state
2271 .patch_resource_record(
2272 &inactive_id,
2273 ResourceRecordPatch::artifact_key(Some(inactive_key.clone()))
2274 .with_lifecycle(ResourceLifecycle::Fetched),
2275 )
2276 .unwrap();
2277
2278 let protected = state
2279 .protected_store_keys(StoreRetentionPolicy::ActiveOnly)
2280 .unwrap();
2281 let report = store.prune_missing_with_protection(&protected).unwrap();
2282
2283 assert_eq!(report.removed_metadata, 1);
2284 assert_eq!(report.protected_metadata, 1);
2285 assert!(store.has_metadata(&active_key));
2286 assert!(!store.has_metadata(&inactive_key));
2287 }
2288
2289 #[test]
2290 fn state_can_plan_store_metadata_retention() {
2291 let temp = tempfile::tempdir().unwrap();
2292 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
2293 let store = StoreReady::initialize(StoreRoots::new(
2294 temp.path().join("store/artifacts"),
2295 temp.path().join("store/extracts"),
2296 temp.path().join("store/metadata"),
2297 ))
2298 .unwrap();
2299
2300 let active_id = ResourceId::parse("example/runtime-active").unwrap();
2301 let fetched_id = ResourceId::parse("example/runtime-fetched").unwrap();
2302 let active_key = StoreKey::logical("runtime-active").unwrap();
2303 let fetched_key = StoreKey::logical("runtime-fetched").unwrap();
2304
2305 for key in [&active_key, &fetched_key] {
2306 store.put_artifact_bytes(key, b"payload").unwrap();
2307 std::fs::remove_file(store.artifact_path(key)).unwrap();
2308 }
2309
2310 state
2311 .ensure_resource_record(active_id.clone(), VersionSelector::alias("lts").unwrap())
2312 .unwrap();
2313 state
2314 .patch_resource_record(
2315 &active_id,
2316 ResourceRecordPatch::artifact_key(Some(active_key.clone()))
2317 .with_lifecycle(ResourceLifecycle::Active),
2318 )
2319 .unwrap();
2320
2321 state
2322 .ensure_resource_record(fetched_id.clone(), VersionSelector::alias("lts").unwrap())
2323 .unwrap();
2324 state
2325 .patch_resource_record(
2326 &fetched_id,
2327 ResourceRecordPatch::artifact_key(Some(fetched_key.clone()))
2328 .with_lifecycle(ResourceLifecycle::Fetched),
2329 )
2330 .unwrap();
2331
2332 let plan = state
2333 .plan_store_metadata_retention(&store, StoreRetentionPolicy::ActiveOnly)
2334 .unwrap();
2335
2336 assert_eq!(plan.policy, StoreRetentionPolicy::ActiveOnly);
2337 assert_eq!(plan.protected_keys, vec![active_key.clone()]);
2338 assert_eq!(plan.protected_metadata.len(), 1);
2339 assert_eq!(plan.protected_metadata[0].key, active_key);
2340 assert_eq!(plan.removable_metadata.len(), 1);
2341 assert_eq!(plan.removable_metadata[0].key, fetched_key);
2342 }
2343
2344 #[test]
2345 fn activation_ownership_report_detects_shared_targets() {
2346 let temp = tempfile::tempdir().unwrap();
2347 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
2348 let shared_target = temp.path().join("active/shared-runtime");
2349 std::fs::create_dir_all(shared_target.parent().unwrap()).unwrap();
2350 std::fs::write(&shared_target, b"active").unwrap();
2351
2352 let first = ResourceId::parse("example/runtime-a").unwrap();
2353 let second = ResourceId::parse("example/runtime-b").unwrap();
2354
2355 state
2356 .record_activation(&first, shared_target.clone())
2357 .unwrap();
2358 state
2359 .record_activation(&second, shared_target.clone())
2360 .unwrap();
2361
2362 let report = state.activation_ownership_report().unwrap();
2363 assert_eq!(report.entries.len(), 1);
2364 let entry = &report.entries[0];
2365 assert_eq!(entry.target, shared_target);
2366 assert_eq!(entry.severity, OwnershipSeverity::Warning);
2367 assert_eq!(entry.owners, vec![first.clone(), second.clone()]);
2368 assert_eq!(
2369 entry.reasons,
2370 vec![OwnershipReason::SharedActivationTarget {
2371 target: entry.target.clone(),
2372 owners: vec![first, second],
2373 }]
2374 );
2375 }
2376
2377 #[test]
2378 fn reasoned_retention_plan_explains_lifecycle_based_protection_and_removal() {
2379 let temp = tempfile::tempdir().unwrap();
2380 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
2381 let store = StoreReady::initialize(StoreRoots::new(
2382 temp.path().join("store/artifacts"),
2383 temp.path().join("store/extracts"),
2384 temp.path().join("store/metadata"),
2385 ))
2386 .unwrap();
2387
2388 let active_id = ResourceId::parse("example/runtime-active").unwrap();
2389 let fetched_id = ResourceId::parse("example/runtime-fetched").unwrap();
2390 let orphaned_key = StoreKey::logical("runtime-orphaned").unwrap();
2391 let active_key = StoreKey::logical("runtime-active").unwrap();
2392 let fetched_key = StoreKey::logical("runtime-fetched").unwrap();
2393
2394 for key in [&active_key, &fetched_key, &orphaned_key] {
2395 store.put_artifact_bytes(key, b"payload").unwrap();
2396 std::fs::remove_file(store.artifact_path(key)).unwrap();
2397 }
2398
2399 state
2400 .ensure_resource_record(active_id.clone(), VersionSelector::alias("lts").unwrap())
2401 .unwrap();
2402 state
2403 .patch_resource_record(
2404 &active_id,
2405 ResourceRecordPatch::artifact_key(Some(active_key.clone()))
2406 .with_lifecycle(ResourceLifecycle::Active),
2407 )
2408 .unwrap();
2409
2410 state
2411 .ensure_resource_record(fetched_id.clone(), VersionSelector::alias("lts").unwrap())
2412 .unwrap();
2413 state
2414 .patch_resource_record(
2415 &fetched_id,
2416 ResourceRecordPatch::artifact_key(Some(fetched_key.clone()))
2417 .with_lifecycle(ResourceLifecycle::Fetched),
2418 )
2419 .unwrap();
2420
2421 let plan = state
2422 .plan_store_metadata_retention_reasoned(&store, StoreRetentionPolicy::ActiveOnly)
2423 .unwrap();
2424
2425 assert_eq!(plan.protected_keys.len(), 1);
2426 assert_eq!(plan.protected_keys[0].key, active_key);
2427 assert_eq!(
2428 plan.protected_keys[0].reasons,
2429 vec![OwnershipReason::StateStoreReference {
2430 key: plan.protected_keys[0].key.clone(),
2431 owner: active_id,
2432 lifecycle: ResourceLifecycle::Active,
2433 }]
2434 );
2435
2436 assert_eq!(plan.removable_metadata.len(), 2);
2437 assert_eq!(plan.removable_metadata[0].record.key, fetched_key);
2438 assert_eq!(
2439 plan.removable_metadata[0].reasons,
2440 vec![OwnershipReason::RetentionPolicyExcludesLifecycle {
2441 policy: StoreRetentionPolicy::ActiveOnly,
2442 resource: fetched_id,
2443 lifecycle: ResourceLifecycle::Fetched,
2444 }]
2445 );
2446 assert_eq!(plan.removable_metadata[1].record.key, orphaned_key);
2447 assert_eq!(
2448 plan.removable_metadata[1].reasons,
2449 vec![OwnershipReason::UnreferencedStoreMetadata {
2450 key: plan.removable_metadata[1].record.key.clone(),
2451 }]
2452 );
2453 }
2454
2455 #[test]
2456 fn ownership_and_retention_plans_are_deterministic() {
2457 let temp = tempfile::tempdir().unwrap();
2458 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
2459 let store = StoreReady::initialize(StoreRoots::new(
2460 temp.path().join("store/artifacts"),
2461 temp.path().join("store/extracts"),
2462 temp.path().join("store/metadata"),
2463 ))
2464 .unwrap();
2465
2466 let id_a = ResourceId::parse("example/runtime-a").unwrap();
2467 let id_b = ResourceId::parse("example/runtime-b").unwrap();
2468 let key_a = StoreKey::logical("runtime-a").unwrap();
2469 let key_b = StoreKey::logical("runtime-b").unwrap();
2470 let shared_target = temp.path().join("active/shared");
2471 std::fs::create_dir_all(shared_target.parent().unwrap()).unwrap();
2472 std::fs::write(&shared_target, b"active").unwrap();
2473
2474 for key in [&key_a, &key_b] {
2475 store.put_artifact_bytes(key, b"payload").unwrap();
2476 std::fs::remove_file(store.artifact_path(key)).unwrap();
2477 }
2478
2479 state
2480 .ensure_resource_record(id_b.clone(), VersionSelector::alias("lts").unwrap())
2481 .unwrap();
2482 state
2483 .patch_resource_record(
2484 &id_b,
2485 ResourceRecordPatch::artifact_key(Some(key_b.clone()))
2486 .with_lifecycle(ResourceLifecycle::Fetched),
2487 )
2488 .unwrap();
2489 state
2490 .ensure_resource_record(id_a.clone(), VersionSelector::alias("lts").unwrap())
2491 .unwrap();
2492 state
2493 .patch_resource_record(
2494 &id_a,
2495 ResourceRecordPatch::artifact_key(Some(key_a.clone()))
2496 .with_lifecycle(ResourceLifecycle::Active),
2497 )
2498 .unwrap();
2499 state
2500 .record_activation(&id_b, shared_target.clone())
2501 .unwrap();
2502 state.record_activation(&id_a, shared_target).unwrap();
2503
2504 let plan_one = state
2505 .plan_ownership_and_retention(&store, StoreRetentionPolicy::ActiveOnly)
2506 .unwrap();
2507 let plan_two = state
2508 .plan_ownership_and_retention(&store, StoreRetentionPolicy::ActiveOnly)
2509 .unwrap();
2510
2511 assert_eq!(plan_one, plan_two);
2512 }
2513
2514 #[test]
2515 fn ownership_and_retention_planning_is_read_only() {
2516 let temp = tempfile::tempdir().unwrap();
2517 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
2518 let store = StoreReady::initialize(StoreRoots::new(
2519 temp.path().join("store/artifacts"),
2520 temp.path().join("store/extracts"),
2521 temp.path().join("store/metadata"),
2522 ))
2523 .unwrap();
2524 let id = ResourceId::parse("example/runtime").unwrap();
2525 let key = StoreKey::logical("runtime").unwrap();
2526
2527 store.put_artifact_bytes(&key, b"payload").unwrap();
2528 std::fs::remove_file(store.artifact_path(&key)).unwrap();
2529
2530 state
2531 .ensure_resource_record(id.clone(), VersionSelector::alias("lts").unwrap())
2532 .unwrap();
2533 state
2534 .patch_resource_record(
2535 &id,
2536 ResourceRecordPatch::artifact_key(Some(key))
2537 .with_lifecycle(ResourceLifecycle::Fetched),
2538 )
2539 .unwrap();
2540 state
2541 .record_activation(&id, temp.path().join("active/runtime"))
2542 .unwrap();
2543
2544 let before = state.load().unwrap();
2545 let _ = state.activation_ownership_report().unwrap();
2546 let _ = state
2547 .plan_store_metadata_retention_reasoned(&store, StoreRetentionPolicy::ActiveOnly)
2548 .unwrap();
2549 let _ = state
2550 .plan_ownership_and_retention(&store, StoreRetentionPolicy::ActiveOnly)
2551 .unwrap();
2552 let after = state.load().unwrap();
2553
2554 assert_eq!(before, after);
2555 }
2556
2557 #[test]
2558 fn analysis_index_matches_direct_reports() {
2559 let temp = tempfile::tempdir().unwrap();
2560 let state = StateReady::initialize(temp.path().join("state.json")).unwrap();
2561 let id_a = ResourceId::parse("example/runtime-a").unwrap();
2562 let id_b = ResourceId::parse("example/runtime-b").unwrap();
2563
2564 state
2565 .ensure_resource_record(id_a.clone(), VersionSelector::alias("lts").unwrap())
2566 .unwrap();
2567 state
2568 .ensure_resource_record(id_b.clone(), VersionSelector::alias("lts").unwrap())
2569 .unwrap();
2570
2571 let shared_target = temp.path().join("active/shared");
2572 state
2573 .record_activation(&id_a, shared_target.clone())
2574 .unwrap();
2575 state.record_activation(&id_b, shared_target).unwrap();
2576
2577 let direct_ownership = state.activation_ownership_report().unwrap();
2578 let direct_refs = state.list_store_references().unwrap();
2579 let index = state.build_analysis_index().unwrap();
2580
2581 let indexed_ownership = state.activation_ownership_report_with_index(&index);
2582 let indexed_refs = state.list_store_references_with_index(&index);
2583 let indexed_inspection = state.inspect_resource_with_index(&id_a, None, &index);
2584 let direct_inspection = state.inspect_resource(&id_a, None).unwrap();
2585
2586 assert_eq!(indexed_ownership, direct_ownership);
2587 assert_eq!(indexed_refs, direct_refs);
2588 assert_eq!(indexed_inspection, direct_inspection);
2589 }
2590}