Skip to main content

pulith_state/
lib.rs

1//! Transaction-backed persistent state for Pulith resources.
2
3use 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}