Skip to main content

weft_core/app/
generation.rs

1use crate::app::state::AppBindingResolution;
2use anyhow::{Context, Result};
3use serde::{Deserialize, Serialize};
4use std::collections::{HashMap, HashSet};
5use std::fs;
6use std::io::ErrorKind;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct AppGeneration {
11    pub id: u64,
12    pub app_name: String,
13    pub version: String,
14    pub bindings: Vec<AppBindingResolution>,
15    pub capabilities: Vec<String>,
16    #[serde(default)]
17    pub enabled_features: Vec<String>,
18    #[serde(default)]
19    pub scene: String,
20    pub profile: String,
21    #[serde(default)]
22    pub binding_set_id: String,
23    #[serde(default)]
24    pub closure_id: String,
25    #[serde(default)]
26    pub lock_digest: String,
27    #[serde(default)]
28    pub lock_path: String,
29    #[serde(default)]
30    pub parent_generation: Option<u64>,
31    #[serde(default)]
32    pub created_by: String,
33    pub status: GenerationStatus,
34    #[serde(default)]
35    pub validation_results: Vec<ValidationResult>,
36    pub created_at: u64,
37}
38
39impl Default for AppGeneration {
40    fn default() -> Self {
41        Self {
42            id: 0,
43            app_name: String::new(),
44            version: String::new(),
45            bindings: vec![],
46            capabilities: vec![],
47            enabled_features: vec![],
48            scene: String::new(),
49            profile: String::new(),
50            binding_set_id: String::new(),
51            closure_id: String::new(),
52            lock_digest: String::new(),
53            lock_path: String::new(),
54            parent_generation: None,
55            created_by: String::new(),
56            status: GenerationStatus::Candidate,
57            validation_results: vec![],
58            created_at: 0,
59        }
60    }
61}
62
63#[derive(Debug, Clone, Default)]
64pub struct AppGenerationSummaryMetadata {
65    pub scene: String,
66    pub binding_set_id: String,
67    pub closure_id: String,
68    pub lock_digest: String,
69    pub lock_path: String,
70    pub parent_generation: Option<u64>,
71    pub created_by: String,
72}
73
74#[derive(Debug, Clone, Default)]
75pub struct AppGenerationProposal {
76    pub app_name: String,
77    pub version: String,
78    pub bindings: Vec<AppBindingResolution>,
79    pub capabilities: Vec<String>,
80    pub enabled_features: Vec<String>,
81    pub profile: String,
82    pub metadata: AppGenerationSummaryMetadata,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "lowercase")]
87pub enum GenerationStatus {
88    Candidate,
89    Verified,
90    Active,
91    Rollback,
92    Failed,
93    Archived,
94}
95
96pub const GENERATION_INDEX_SCHEMA_VERSION: u64 = 1;
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct AppGenerationIndex {
100    #[serde(default = "default_generation_index_schema_version")]
101    pub schema_version: u64,
102    #[serde(default)]
103    pub active: Option<u64>,
104    #[serde(default)]
105    pub previous: Option<u64>,
106    #[serde(default)]
107    pub candidate: Option<u64>,
108    pub next_id: u64,
109    #[serde(default)]
110    pub generations: Vec<AppGeneration>,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum GenerationIndexDiagnosticLevel {
116    Warning,
117    RepairNeeded,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct GenerationIndexDiagnostic {
122    pub level: GenerationIndexDiagnosticLevel,
123    pub code: String,
124    pub message: String,
125    #[serde(default)]
126    pub pointer: Option<String>,
127    #[serde(default)]
128    pub generation_id: Option<u64>,
129}
130
131#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
132pub struct GenerationIndexConsistencyReport {
133    pub is_consistent: bool,
134    pub repair_recommended: bool,
135    #[serde(default)]
136    pub diagnostics: Vec<GenerationIndexDiagnostic>,
137}
138
139#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
140pub struct StartupGenerationStoreDiagnostics {
141    pub active_pointer: Option<u64>,
142    pub previous_pointer: Option<u64>,
143    pub generation_index_present: bool,
144    #[serde(default)]
145    pub diagnostics: Vec<GenerationIndexDiagnostic>,
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum ActivationPersistenceStepKind {
151    CheckTargetStatus,
152    CheckTargetLockMetadata,
153    WriteGenerationLock,
154    WritePreviousPointer,
155    ReplaceActivePointer,
156    ReplaceRootLockMirror,
157    UpdateGenerationIndex,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct ActivationPersistenceStep {
162    pub kind: ActivationPersistenceStepKind,
163    pub description: String,
164    #[serde(default)]
165    pub path: Option<String>,
166    #[serde(default)]
167    pub generation_id: Option<u64>,
168    #[serde(default)]
169    pub best_effort: bool,
170}
171
172#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
173pub struct ActivationPersistencePlan {
174    pub target_generation_id: u64,
175    pub target_lock_path: String,
176    #[serde(default)]
177    pub previous_active_generation_id: Option<u64>,
178    #[serde(default)]
179    pub steps: Vec<ActivationPersistenceStep>,
180}
181
182impl Default for AppGenerationIndex {
183    fn default() -> Self {
184        Self {
185            schema_version: GENERATION_INDEX_SCHEMA_VERSION,
186            active: None,
187            previous: None,
188            candidate: None,
189            next_id: 1,
190            generations: Vec::new(),
191        }
192    }
193}
194
195impl AppGenerationIndex {
196    pub fn generation(&self, id: u64) -> Option<&AppGeneration> {
197        self.generations
198            .iter()
199            .find(|generation| generation.id == id)
200    }
201
202    pub fn from_store(store: &AppGenerationStore) -> Self {
203        let mut generations: Vec<AppGeneration> = Vec::new();
204        let mut push_unique = |generation: &AppGeneration| {
205            if generations
206                .iter()
207                .any(|existing| existing.id == generation.id)
208            {
209                return;
210            }
211            generations.push(generation.clone());
212        };
213
214        if let Some(active) = &store.active {
215            push_unique(active);
216        }
217        if let Some(previous) = &store.rollback {
218            push_unique(previous);
219        }
220        if let Some(candidate) = &store.candidate {
221            push_unique(candidate);
222        }
223
224        generations.sort_by_key(|generation| generation.id);
225
226        Self {
227            schema_version: GENERATION_INDEX_SCHEMA_VERSION,
228            active: store.active.as_ref().map(|generation| generation.id),
229            previous: store.rollback.as_ref().map(|generation| generation.id),
230            candidate: store.candidate.as_ref().map(|generation| generation.id),
231            next_id: store.next_generation_id(),
232            generations,
233        }
234    }
235
236    pub fn from_active_summary(active: &AppGeneration) -> Self {
237        Self {
238            schema_version: GENERATION_INDEX_SCHEMA_VERSION,
239            active: Some(active.id),
240            previous: None,
241            candidate: None,
242            next_id: active.id.saturating_add(1).max(1),
243            generations: vec![active.clone()],
244        }
245    }
246
247    pub fn repair_from_sources(
248        store: Option<&AppGenerationStore>,
249        active_summary: Option<&AppGeneration>,
250    ) -> Option<Self> {
251        let store_has_data = store.is_some_and(|store| {
252            store.active.is_some()
253                || store.rollback.is_some()
254                || store.candidate.is_some()
255                || store.next_id != 0
256        });
257        if !store_has_data && active_summary.is_none() {
258            return None;
259        }
260
261        let mut index = store.map(Self::from_store).unwrap_or_default();
262
263        if let Some(active_summary) = active_summary {
264            index.upsert_generation(active_summary.clone());
265            index.active = Some(active_summary.id);
266            index.next_id = index
267                .next_id
268                .max(active_summary.id.saturating_add(1))
269                .max(1);
270        }
271
272        if index.next_id == 0 {
273            index.next_id = 1;
274        }
275        index.generations.sort_by_key(|generation| generation.id);
276        Some(index)
277    }
278
279    pub fn consistency_report(
280        &self,
281        active_pointer: Option<u64>,
282        previous_pointer: Option<u64>,
283    ) -> GenerationIndexConsistencyReport {
284        let mut report = GenerationIndexConsistencyReport {
285            is_consistent: true,
286            repair_recommended: false,
287            diagnostics: Vec::new(),
288        };
289
290        report.compare_pointer("active", active_pointer, self.active);
291        report.compare_pointer("previous", previous_pointer, self.previous);
292
293        for (pointer, generation_id) in [
294            ("active", self.active),
295            ("previous", self.previous),
296            ("candidate", self.candidate),
297        ] {
298            if let Some(generation_id) = generation_id {
299                match self.generation(generation_id) {
300                    Some(generation) => {
301                        if generation.lock_path.trim().is_empty() {
302                            report.warn(
303                                "missing_lock_path",
304                                format!(
305                                    "Generation {} referenced by {} is missing lock_path metadata",
306                                    generation_id, pointer
307                                ),
308                                Some(pointer),
309                                Some(generation_id),
310                            );
311                        }
312
313                        let mut missing_fields = Vec::new();
314                        if generation.scene.trim().is_empty() {
315                            missing_fields.push("scene");
316                        }
317                        if generation.binding_set_id.trim().is_empty() {
318                            missing_fields.push("binding_set_id");
319                        }
320                        if generation.closure_id.trim().is_empty() {
321                            missing_fields.push("closure_id");
322                        }
323                        if generation.lock_digest.trim().is_empty() {
324                            missing_fields.push("lock_digest");
325                        }
326                        if generation.created_by.trim().is_empty() {
327                            missing_fields.push("created_by");
328                        }
329
330                        if !missing_fields.is_empty() {
331                            report.warn(
332                                "incomplete_generation_summary",
333                                format!(
334                                    "Generation {} referenced by {} is missing summary fields: {}",
335                                    generation_id,
336                                    pointer,
337                                    missing_fields.join(", ")
338                                ),
339                                Some(pointer),
340                                Some(generation_id),
341                            );
342                        }
343                    }
344                    None => report.repair_needed(
345                        "pointer_target_missing_from_index",
346                        format!(
347                            "Generation {} referenced by {} is missing from generation index",
348                            generation_id, pointer
349                        ),
350                        Some(pointer),
351                        Some(generation_id),
352                    ),
353                }
354            }
355        }
356
357        report
358    }
359
360    pub fn into_store(self) -> AppGenerationStore {
361        let active = self.active.and_then(|id| self.generation(id).cloned());
362        let rollback = self.previous.and_then(|id| self.generation(id).cloned());
363        let candidate = self.candidate.and_then(|id| self.generation(id).cloned());
364
365        AppGenerationStore {
366            active,
367            candidate,
368            rollback,
369            next_id: if self.next_id == 0 { 1 } else { self.next_id },
370        }
371    }
372
373    fn normalized_for_save(&self) -> Self {
374        let mut normalized = self.clone();
375        if normalized.schema_version == 0 {
376            normalized.schema_version = GENERATION_INDEX_SCHEMA_VERSION;
377        }
378        if normalized.next_id == 0 {
379            normalized.next_id = 1;
380        }
381        normalized
382            .generations
383            .sort_by_key(|generation| generation.id);
384        normalized
385    }
386
387    fn upsert_generation(&mut self, generation: AppGeneration) {
388        if let Some(existing) = self
389            .generations
390            .iter_mut()
391            .find(|existing| existing.id == generation.id)
392        {
393            *existing = generation;
394            return;
395        }
396
397        self.generations.push(generation);
398    }
399
400    fn validate(&self, path: &Path) -> Result<()> {
401        let mut seen = HashSet::new();
402        for generation in &self.generations {
403            if !seen.insert(generation.id) {
404                anyhow::bail!(
405                    "Duplicate generation id {} in {}",
406                    generation.id,
407                    path.display()
408                );
409            }
410        }
411
412        for (label, id) in [
413            ("active", self.active),
414            ("previous", self.previous),
415            ("candidate", self.candidate),
416        ] {
417            if let Some(id) = id {
418                if self.generation(id).is_none() {
419                    anyhow::bail!(
420                        "Generation index {} pointer references missing generation {} in {}",
421                        label,
422                        id,
423                        path.display()
424                    );
425                }
426            }
427        }
428
429        Ok(())
430    }
431}
432
433fn default_generation_index_schema_version() -> u64 {
434    GENERATION_INDEX_SCHEMA_VERSION
435}
436
437#[derive(Debug, Clone, Serialize, Deserialize)]
438pub struct ValidationResult {
439    pub check: String,
440    pub passed: bool,
441    pub message: String,
442}
443
444#[derive(Debug, Clone, Serialize, Deserialize, Default)]
445pub struct AppGenerationStore {
446    pub active: Option<AppGeneration>,
447    pub candidate: Option<AppGeneration>,
448    pub rollback: Option<AppGeneration>,
449    pub next_id: u64,
450}
451
452impl AppGenerationStore {
453    pub fn generation(&self, generation_id: u64) -> Option<&AppGeneration> {
454        self.active
455            .as_ref()
456            .filter(|generation| generation.id == generation_id)
457            .or_else(|| {
458                self.candidate
459                    .as_ref()
460                    .filter(|generation| generation.id == generation_id)
461            })
462            .or_else(|| {
463                self.rollback
464                    .as_ref()
465                    .filter(|generation| generation.id == generation_id)
466            })
467    }
468
469    fn allocate_generation_id(&mut self) -> u64 {
470        if self.next_id == 0 {
471            self.next_id = 1;
472        }
473        let id = self.next_id;
474        self.next_id += 1;
475        id
476    }
477
478    pub fn next_generation_id(&self) -> u64 {
479        if self.next_id == 0 {
480            1
481        } else {
482            self.next_id
483        }
484    }
485
486    pub fn propose(&mut self, proposal: AppGenerationProposal) -> &AppGeneration {
487        let id = self.allocate_generation_id();
488        let AppGenerationProposal {
489            app_name,
490            version,
491            bindings,
492            capabilities,
493            enabled_features,
494            profile,
495            metadata,
496        } = proposal;
497
498        let now = std::time::SystemTime::now()
499            .duration_since(std::time::UNIX_EPOCH)
500            .unwrap_or_default()
501            .as_secs();
502
503        self.candidate = Some(AppGeneration {
504            id,
505            app_name,
506            version,
507            bindings,
508            capabilities,
509            enabled_features,
510            scene: metadata.scene,
511            profile,
512            binding_set_id: metadata.binding_set_id,
513            closure_id: metadata.closure_id,
514            lock_digest: metadata.lock_digest,
515            lock_path: metadata.lock_path,
516            parent_generation: metadata.parent_generation,
517            created_by: metadata.created_by,
518            status: GenerationStatus::Candidate,
519            validation_results: vec![],
520            created_at: now,
521        });
522
523        self.candidate.as_ref().unwrap()
524    }
525
526    pub fn verify_candidate(
527        &mut self,
528        registry: Option<&crate::app::CapabilityRegistry>,
529    ) -> Result<&AppGeneration, String> {
530        let candidate = self
531            .candidate
532            .as_mut()
533            .ok_or_else(|| "No candidate generation to verify".to_string())?;
534
535        let mut results = Vec::new();
536
537        let has_bindings = !candidate.bindings.is_empty();
538        results.push(ValidationResult {
539            check: "boot".into(),
540            passed: has_bindings,
541            message: if has_bindings {
542                "Bindings present".into()
543            } else {
544                "No bindings resolved".into()
545            },
546        });
547
548        let has_capabilities = !candidate.capabilities.is_empty();
549        results.push(ValidationResult {
550            check: "capabilities".into(),
551            passed: has_capabilities,
552            message: if has_capabilities {
553                format!("{} capabilities declared", candidate.capabilities.len())
554            } else {
555                "No capabilities declared".into()
556            },
557        });
558
559        let bound_caps: std::collections::HashSet<&str> = candidate
560            .bindings
561            .iter()
562            .map(|b| b.capability.as_str())
563            .collect();
564        let unbound: Vec<&str> = candidate
565            .capabilities
566            .iter()
567            .filter(|c| !bound_caps.contains(c.as_str()))
568            .map(|c| c.as_str())
569            .collect();
570        let all_bound = unbound.is_empty();
571        results.push(ValidationResult {
572            check: "binding-coverage".into(),
573            passed: all_bound,
574            message: if all_bound {
575                "All capabilities have bindings".into()
576            } else {
577                format!("Unbound capabilities: {:?}", unbound)
578            },
579        });
580
581        if let Some(reg) = registry {
582            let missing: Vec<&str> = candidate
583                .capabilities
584                .iter()
585                .filter(|c| !reg.contains_key(c.as_str()))
586                .map(|c| c.as_str())
587                .collect();
588            let all_in_registry = missing.is_empty();
589            results.push(ValidationResult {
590                check: "registry-coverage".into(),
591                passed: all_in_registry,
592                message: if all_in_registry {
593                    "All capabilities found in registry".into()
594                } else {
595                    format!("Missing from registry: {:?}", missing)
596                },
597            });
598        }
599
600        let all_passed = results.iter().all(|r| r.passed);
601        candidate.validation_results = results;
602        candidate.status = if all_passed {
603            GenerationStatus::Verified
604        } else {
605            GenerationStatus::Failed
606        };
607
608        if all_passed {
609            Ok(candidate)
610        } else {
611            Err("Verification failed".into())
612        }
613    }
614
615    pub fn activate(&mut self) -> Result<&AppGeneration, String> {
616        let candidate = self
617            .candidate
618            .take()
619            .ok_or_else(|| "No candidate generation to activate".to_string())?;
620
621        if candidate.status != GenerationStatus::Verified {
622            self.candidate = Some(candidate);
623            return Err("Candidate must be verified before activation".into());
624        }
625
626        let previous_active = self.active.take();
627
628        let mut activated = candidate;
629        activated.status = GenerationStatus::Active;
630        self.active = Some(activated.clone());
631
632        if let Some(mut prev_active) = previous_active {
633            prev_active.status = GenerationStatus::Rollback;
634            self.rollback = Some(prev_active);
635        }
636
637        Ok(self.active.as_ref().unwrap())
638    }
639
640    pub fn rollback(&mut self) -> Result<&AppGeneration, String> {
641        let rollback_gen = self
642            .rollback
643            .take()
644            .ok_or_else(|| "No rollback generation available".to_string())?;
645
646        if let Some(mut current) = self.active.take() {
647            current.status = GenerationStatus::Failed;
648            self.candidate = Some(current);
649        }
650
651        let mut restored = rollback_gen;
652        restored.status = GenerationStatus::Active;
653        self.active = Some(restored);
654
655        Ok(self.active.as_ref().unwrap())
656    }
657
658    pub fn switch_to_existing(&mut self, generation_id: u64) -> Result<&AppGeneration, String> {
659        if self
660            .active
661            .as_ref()
662            .is_some_and(|generation| generation.id == generation_id)
663        {
664            return Ok(self.active.as_ref().unwrap());
665        }
666
667        let target_status = self
668            .generation(generation_id)
669            .map(|generation| generation.status)
670            .ok_or_else(|| format!("Generation {} not found", generation_id))?;
671
672        match target_status {
673            GenerationStatus::Verified | GenerationStatus::Rollback => {}
674            GenerationStatus::Candidate => {
675                return Err(format!(
676                    "Generation {} must be verified before activation",
677                    generation_id
678                ));
679            }
680            GenerationStatus::Failed => {
681                return Err(format!(
682                    "Generation {} failed verification and cannot be activated",
683                    generation_id
684                ));
685            }
686            GenerationStatus::Archived => {
687                return Err(format!(
688                    "Generation {} is archived and cannot be activated",
689                    generation_id
690                ));
691            }
692            GenerationStatus::Active => {
693                return Err(format!(
694                    "Generation {} is already active but not stored in the active slot",
695                    generation_id
696                ));
697            }
698        }
699
700        let previous_active = self.active.take();
701        let mut target = if self
702            .candidate
703            .as_ref()
704            .is_some_and(|generation| generation.id == generation_id)
705        {
706            self.candidate.take().unwrap()
707        } else if self
708            .rollback
709            .as_ref()
710            .is_some_and(|generation| generation.id == generation_id)
711        {
712            self.rollback.take().unwrap()
713        } else {
714            return Err(format!("Generation {} not found", generation_id));
715        };
716
717        target.status = GenerationStatus::Active;
718        self.active = Some(target);
719
720        if let Some(mut previous_active) = previous_active {
721            previous_active.status = GenerationStatus::Rollback;
722            self.rollback = Some(previous_active);
723        } else {
724            self.rollback = None;
725        }
726
727        Ok(self.active.as_ref().unwrap())
728    }
729}
730
731impl GenerationIndexConsistencyReport {
732    fn compare_pointer(&mut self, pointer: &str, actual: Option<u64>, indexed: Option<u64>) {
733        if actual != indexed {
734            self.repair_needed(
735                "pointer_mismatch",
736                format!(
737                    "{} pointer mismatch: authoritative pointer is {:?}, generation index records {:?}",
738                    pointer, actual, indexed
739                ),
740                Some(pointer),
741                actual.or(indexed),
742            );
743        }
744    }
745
746    fn warn(
747        &mut self,
748        code: &str,
749        message: String,
750        pointer: Option<&str>,
751        generation_id: Option<u64>,
752    ) {
753        self.diagnostics.push(GenerationIndexDiagnostic {
754            level: GenerationIndexDiagnosticLevel::Warning,
755            code: code.into(),
756            message,
757            pointer: pointer.map(str::to_owned),
758            generation_id,
759        });
760    }
761
762    fn repair_needed(
763        &mut self,
764        code: &str,
765        message: String,
766        pointer: Option<&str>,
767        generation_id: Option<u64>,
768    ) {
769        self.is_consistent = false;
770        self.repair_recommended = true;
771        self.diagnostics.push(GenerationIndexDiagnostic {
772            level: GenerationIndexDiagnosticLevel::RepairNeeded,
773            code: code.into(),
774            message,
775            pointer: pointer.map(str::to_owned),
776            generation_id,
777        });
778    }
779}
780
781impl StartupGenerationStoreDiagnostics {
782    pub fn is_clean(&self) -> bool {
783        self.diagnostics.is_empty()
784    }
785}
786
787pub type GenerationStoreMap = HashMap<String, AppGenerationStore>;
788
789pub fn generation_index_path(instance_dir: &Path) -> PathBuf {
790    instance_dir.join("generation-store.toml")
791}
792
793pub fn active_generation_pointer_path(instance_dir: &Path) -> PathBuf {
794    instance_dir.join("active")
795}
796
797pub fn previous_generation_pointer_path(instance_dir: &Path) -> PathBuf {
798    instance_dir.join("previous")
799}
800
801pub fn inspect_startup_generation_store(
802    instance_dir: &Path,
803    store: &AppGenerationStore,
804) -> StartupGenerationStoreDiagnostics {
805    let expected_active = store.active.as_ref().map(|generation| generation.id);
806    let expected_previous = store.rollback.as_ref().map(|generation| generation.id);
807    let mut diagnostics = StartupGenerationStoreDiagnostics::default();
808
809    let active_pointer = match read_active_generation_pointer(instance_dir) {
810        Ok(pointer) => pointer,
811        Err(error) => {
812            diagnostics.diagnostics.push(GenerationIndexDiagnostic {
813                level: GenerationIndexDiagnosticLevel::Warning,
814                code: "startup_active_pointer_unreadable".into(),
815                message: format!(
816                    "Startup ignored unreadable active pointer at '{}': {error:#}",
817                    active_generation_pointer_path(instance_dir).display()
818                ),
819                pointer: Some("active".into()),
820                generation_id: None,
821            });
822            None
823        }
824    };
825    diagnostics.active_pointer = active_pointer;
826
827    let previous_pointer = match read_previous_generation_pointer(instance_dir) {
828        Ok(pointer) => pointer,
829        Err(error) => {
830            diagnostics.diagnostics.push(GenerationIndexDiagnostic {
831                level: GenerationIndexDiagnosticLevel::Warning,
832                code: "startup_previous_pointer_unreadable".into(),
833                message: format!(
834                    "Startup ignored unreadable previous pointer at '{}': {error:#}",
835                    previous_generation_pointer_path(instance_dir).display()
836                ),
837                pointer: Some("previous".into()),
838                generation_id: None,
839            });
840            None
841        }
842    };
843    diagnostics.previous_pointer = previous_pointer;
844
845    if let Some(active_pointer) = active_pointer {
846        if Some(active_pointer) != expected_active {
847            diagnostics.diagnostics.push(GenerationIndexDiagnostic {
848                level: GenerationIndexDiagnosticLevel::Warning,
849                code: "startup_active_pointer_mismatch".into(),
850                message: format!(
851                    "Startup retained lock-derived active generation {:?} while active pointer recorded {:?}",
852                    expected_active, active_pointer
853                ),
854                pointer: Some("active".into()),
855                generation_id: Some(active_pointer),
856            });
857        }
858    }
859
860    if let Some(previous_pointer) = previous_pointer {
861        if Some(previous_pointer) != expected_previous {
862            diagnostics.diagnostics.push(GenerationIndexDiagnostic {
863                level: GenerationIndexDiagnosticLevel::Warning,
864                code: "startup_previous_pointer_mismatch".into(),
865                message: format!(
866                    "Startup retained lock-derived previous generation {:?} while previous pointer recorded {:?}",
867                    expected_previous, previous_pointer
868                ),
869                pointer: Some("previous".into()),
870                generation_id: Some(previous_pointer),
871            });
872        }
873    }
874
875    match load_generation_index(instance_dir) {
876        Ok(Some(index)) => {
877            diagnostics.generation_index_present = true;
878
879            if index.active != expected_active {
880                diagnostics.diagnostics.push(GenerationIndexDiagnostic {
881                    level: GenerationIndexDiagnosticLevel::Warning,
882                    code: "startup_generation_index_active_mismatch".into(),
883                    message: format!(
884                        "Startup retained lock-derived active generation {:?} while generation-store.toml recorded {:?}",
885                        expected_active, index.active
886                    ),
887                    pointer: Some("active".into()),
888                    generation_id: index.active.or(expected_active),
889                });
890            }
891
892            if index.previous != expected_previous {
893                diagnostics.diagnostics.push(GenerationIndexDiagnostic {
894                    level: GenerationIndexDiagnosticLevel::Warning,
895                    code: "startup_generation_index_previous_mismatch".into(),
896                    message: format!(
897                        "Startup retained lock-derived previous generation {:?} while generation-store.toml recorded {:?}",
898                        expected_previous, index.previous
899                    ),
900                    pointer: Some("previous".into()),
901                    generation_id: index.previous.or(expected_previous),
902                });
903            }
904
905            if let (Some(expected_active), Some(indexed_active)) = (
906                store.active.as_ref(),
907                index.active.and_then(|id| index.generation(id)),
908            ) {
909                let mut mismatched_fields = Vec::new();
910                if indexed_active.lock_path != expected_active.lock_path {
911                    mismatched_fields.push("lock_path");
912                }
913                if indexed_active.scene != expected_active.scene {
914                    mismatched_fields.push("scene");
915                }
916                if indexed_active.profile != expected_active.profile {
917                    mismatched_fields.push("profile");
918                }
919                if indexed_active.binding_set_id != expected_active.binding_set_id {
920                    mismatched_fields.push("binding_set_id");
921                }
922                if indexed_active.closure_id != expected_active.closure_id {
923                    mismatched_fields.push("closure_id");
924                }
925
926                if !mismatched_fields.is_empty() {
927                    diagnostics.diagnostics.push(GenerationIndexDiagnostic {
928                        level: GenerationIndexDiagnosticLevel::Warning,
929                        code: "startup_generation_index_summary_mismatch".into(),
930                        message: format!(
931                            "Startup retained lock-derived active generation {} while generation-store.toml differed in: {}",
932                            expected_active.id,
933                            mismatched_fields.join(", ")
934                        ),
935                        pointer: Some("active".into()),
936                        generation_id: Some(expected_active.id),
937                    });
938                }
939            }
940        }
941        Ok(None) => {}
942        Err(error) => {
943            diagnostics.diagnostics.push(GenerationIndexDiagnostic {
944                level: GenerationIndexDiagnosticLevel::Warning,
945                code: "startup_generation_index_unreadable".into(),
946                message: format!(
947                    "Startup ignored unreadable generation-store.toml at '{}': {error:#}",
948                    generation_index_path(instance_dir).display()
949                ),
950                pointer: None,
951                generation_id: None,
952            });
953        }
954    }
955
956    diagnostics
957}
958
959pub fn plan_activation_persistence(
960    instance_dir: &Path,
961    target: &AppGeneration,
962    store: Option<&AppGenerationStore>,
963    index: Option<&AppGenerationIndex>,
964) -> Result<ActivationPersistencePlan, String> {
965    if !matches!(
966        target.status,
967        GenerationStatus::Verified | GenerationStatus::Active
968    ) {
969        return Err(format!(
970            "Generation {} must be verified or active before activation persistence can be planned",
971            target.id
972        ));
973    }
974
975    if target.lock_path.trim().is_empty() {
976        return Err(format!(
977            "Generation {} is missing lock_path metadata required for activation persistence planning",
978            target.id
979        ));
980    }
981
982    if let Some(index) = index {
983        if index.generation(target.id).is_none() {
984            return Err(format!(
985                "Cannot plan activation persistence: target generation {} is missing from generation index",
986                target.id
987            ));
988        }
989    }
990
991    let store_active_id =
992        store.and_then(|store| store.active.as_ref().map(|generation| generation.id));
993    let index_active_id = index.and_then(|index| index.active);
994    let current_active_id =
995        reconcile_planned_generation_id("active", store_active_id, index_active_id)?;
996
997    let store_previous_id =
998        store.and_then(|store| store.rollback.as_ref().map(|generation| generation.id));
999    let index_previous_id = index.and_then(|index| index.previous);
1000    let current_previous_id =
1001        reconcile_planned_generation_id("previous", store_previous_id, index_previous_id)?;
1002
1003    let previous_active_generation_id = match target.status {
1004        GenerationStatus::Active => {
1005            if let Some(current_active_id) = current_active_id {
1006                if current_active_id != target.id {
1007                    return Err(format!(
1008                        "Generation {} is marked active but store/index currently point to generation {}",
1009                        target.id, current_active_id
1010                    ));
1011                }
1012            }
1013            current_previous_id
1014        }
1015        GenerationStatus::Verified => current_active_id,
1016        _ => unreachable!(),
1017    };
1018
1019    let target_lock_path = planned_generation_lock_path(instance_dir, &target.lock_path)
1020        .display()
1021        .to_string();
1022    let previous_pointer_path = previous_generation_pointer_path(instance_dir)
1023        .display()
1024        .to_string();
1025    let active_pointer_path = active_generation_pointer_path(instance_dir)
1026        .display()
1027        .to_string();
1028    let root_lock_mirror_path = instance_dir.join("lock.toml").display().to_string();
1029    let generation_index_path = generation_index_path(instance_dir).display().to_string();
1030
1031    Ok(ActivationPersistencePlan {
1032        target_generation_id: target.id,
1033        target_lock_path: target_lock_path.clone(),
1034        previous_active_generation_id,
1035        steps: vec![
1036            ActivationPersistenceStep {
1037                kind: ActivationPersistenceStepKind::CheckTargetStatus,
1038                description: format!(
1039                    "Confirm generation {} remains verified or active before planning activation persistence",
1040                    target.id
1041                ),
1042                path: None,
1043                generation_id: Some(target.id),
1044                best_effort: false,
1045            },
1046            ActivationPersistenceStep {
1047                kind: ActivationPersistenceStepKind::CheckTargetLockMetadata,
1048                description: format!(
1049                    "Confirm generation {} lock metadata resolves to {}",
1050                    target.id, target_lock_path
1051                ),
1052                path: Some(target_lock_path.clone()),
1053                generation_id: Some(target.id),
1054                best_effort: false,
1055            },
1056            ActivationPersistenceStep {
1057                kind: ActivationPersistenceStepKind::WriteGenerationLock,
1058                description: format!(
1059                    "Write immutable generation lock for generation {} before pointer changes",
1060                    target.id
1061                ),
1062                path: Some(target_lock_path),
1063                generation_id: Some(target.id),
1064                best_effort: false,
1065            },
1066            ActivationPersistenceStep {
1067                kind: ActivationPersistenceStepKind::WritePreviousPointer,
1068                description: match previous_active_generation_id {
1069                    Some(previous_active_generation_id) => format!(
1070                        "Write previous pointer to generation {} before replacing active pointer",
1071                        previous_active_generation_id
1072                    ),
1073                    None => "Clear previous pointer before replacing active pointer".into(),
1074                },
1075                path: Some(previous_pointer_path),
1076                generation_id: previous_active_generation_id,
1077                best_effort: false,
1078            },
1079            ActivationPersistenceStep {
1080                kind: ActivationPersistenceStepKind::ReplaceActivePointer,
1081                description: format!(
1082                    "Atomically replace active pointer with generation {}",
1083                    target.id
1084                ),
1085                path: Some(active_pointer_path),
1086                generation_id: Some(target.id),
1087                best_effort: false,
1088            },
1089            ActivationPersistenceStep {
1090                kind: ActivationPersistenceStepKind::ReplaceRootLockMirror,
1091                description: format!(
1092                    "Atomically replace root lock mirror from generation {} after active pointer update",
1093                    target.id
1094                ),
1095                path: Some(root_lock_mirror_path),
1096                generation_id: Some(target.id),
1097                best_effort: false,
1098            },
1099            ActivationPersistenceStep {
1100                kind: ActivationPersistenceStepKind::UpdateGenerationIndex,
1101                description: "Update generation index as repairable best-effort metadata after pointer changes".into(),
1102                path: Some(generation_index_path),
1103                generation_id: Some(target.id),
1104                best_effort: true,
1105            },
1106        ],
1107    })
1108}
1109
1110fn reconcile_planned_generation_id(
1111    label: &str,
1112    store_id: Option<u64>,
1113    index_id: Option<u64>,
1114) -> Result<Option<u64>, String> {
1115    match (store_id, index_id) {
1116        (Some(store_id), Some(index_id)) if store_id != index_id => Err(format!(
1117            "Cannot plan activation persistence: {} generation differs between store ({}) and index ({})",
1118            label, store_id, index_id
1119        )),
1120        (Some(store_id), _) => Ok(Some(store_id)),
1121        (_, Some(index_id)) => Ok(Some(index_id)),
1122        (None, None) => Ok(None),
1123    }
1124}
1125
1126fn planned_generation_lock_path(instance_dir: &Path, lock_path: &str) -> PathBuf {
1127    let lock_path = Path::new(lock_path);
1128    if lock_path.is_absolute() {
1129        lock_path.to_path_buf()
1130    } else {
1131        instance_dir.join(lock_path)
1132    }
1133}
1134
1135pub fn load_generation_index(instance_dir: &Path) -> Result<Option<AppGenerationIndex>> {
1136    load_generation_index_from_path(&generation_index_path(instance_dir))
1137}
1138
1139pub fn load_generation_index_from_path(path: &Path) -> Result<Option<AppGenerationIndex>> {
1140    let content = match fs::read_to_string(path) {
1141        Ok(content) => content,
1142        Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
1143        Err(error) => {
1144            return Err(error).with_context(|| format!("Failed to read {}", path.display()));
1145        }
1146    };
1147
1148    let mut index: AppGenerationIndex =
1149        toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))?;
1150    if index.schema_version == 0 {
1151        index.schema_version = GENERATION_INDEX_SCHEMA_VERSION;
1152    } else if index.schema_version > GENERATION_INDEX_SCHEMA_VERSION {
1153        anyhow::bail!(
1154            "Unsupported generation index schema version {} in {}",
1155            index.schema_version,
1156            path.display()
1157        );
1158    }
1159    if index.next_id == 0 {
1160        index.next_id = 1;
1161    }
1162    index.validate(path)?;
1163
1164    Ok(Some(index))
1165}
1166
1167pub fn save_generation_index(instance_dir: &Path, index: &AppGenerationIndex) -> Result<()> {
1168    save_generation_index_to_path(&generation_index_path(instance_dir), index)
1169}
1170
1171pub fn save_generation_index_to_path(path: &Path, index: &AppGenerationIndex) -> Result<()> {
1172    let serializable = index.normalized_for_save();
1173    serializable.validate(path)?;
1174
1175    let content = toml::to_string_pretty(&serializable)
1176        .with_context(|| "Failed to serialize generation index")?;
1177    write_string_safely(path, &content)
1178}
1179
1180pub fn read_generation_pointer(path: &Path) -> Result<Option<u64>> {
1181    let content = match fs::read_to_string(path) {
1182        Ok(content) => content,
1183        Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
1184        Err(error) => {
1185            return Err(error).with_context(|| format!("Failed to read {}", path.display()));
1186        }
1187    };
1188
1189    let trimmed = content.trim();
1190    if trimmed.is_empty() {
1191        return Ok(None);
1192    }
1193
1194    let generation_id = trimmed
1195        .parse::<u64>()
1196        .with_context(|| format!("Failed to parse generation pointer {}", path.display()))?;
1197    Ok(Some(generation_id))
1198}
1199
1200pub fn read_active_generation_pointer(instance_dir: &Path) -> Result<Option<u64>> {
1201    read_generation_pointer(&active_generation_pointer_path(instance_dir))
1202}
1203
1204pub fn read_previous_generation_pointer(instance_dir: &Path) -> Result<Option<u64>> {
1205    read_generation_pointer(&previous_generation_pointer_path(instance_dir))
1206}
1207
1208pub fn write_generation_pointer(path: &Path, generation_id: Option<u64>) -> Result<()> {
1209    match generation_id {
1210        Some(generation_id) => write_string_safely(path, &format!("{}\n", generation_id)),
1211        None => {
1212            if let Err(error) = fs::remove_file(path) {
1213                if error.kind() != ErrorKind::NotFound {
1214                    return Err(error)
1215                        .with_context(|| format!("Failed to remove {}", path.display()));
1216                }
1217            }
1218            Ok(())
1219        }
1220    }
1221}
1222
1223pub fn write_active_generation_pointer(
1224    instance_dir: &Path,
1225    generation_id: Option<u64>,
1226) -> Result<()> {
1227    write_generation_pointer(&active_generation_pointer_path(instance_dir), generation_id)
1228}
1229
1230pub fn write_previous_generation_pointer(
1231    instance_dir: &Path,
1232    generation_id: Option<u64>,
1233) -> Result<()> {
1234    write_generation_pointer(
1235        &previous_generation_pointer_path(instance_dir),
1236        generation_id,
1237    )
1238}
1239
1240fn write_string_safely(path: &Path, content: &str) -> Result<()> {
1241    if let Some(parent) = path.parent() {
1242        fs::create_dir_all(parent)
1243            .with_context(|| format!("Failed to create {}", parent.display()))?;
1244    }
1245
1246    let temp_path = temp_write_path(path);
1247    fs::write(&temp_path, content)
1248        .with_context(|| format!("Failed to write {}", temp_path.display()))?;
1249
1250    if path.exists() {
1251        let backup_path = backup_write_path(path);
1252        if backup_path.exists() {
1253            fs::remove_file(&backup_path)
1254                .with_context(|| format!("Failed to clear {}", backup_path.display()))?;
1255        }
1256
1257        fs::rename(path, &backup_path)
1258            .with_context(|| format!("Failed to stage existing {}", path.display()))?;
1259
1260        match fs::rename(&temp_path, path) {
1261            Ok(()) => {
1262                fs::remove_file(&backup_path).with_context(|| {
1263                    format!("Failed to remove backup {}", backup_path.display())
1264                })?;
1265                Ok(())
1266            }
1267            Err(error) => {
1268                let restore_result = fs::rename(&backup_path, path);
1269                if restore_result.is_err() {
1270                    let _ = fs::rename(&temp_path, path);
1271                }
1272                Err(error).with_context(|| format!("Failed to move {} into place", path.display()))
1273            }
1274        }
1275    } else {
1276        fs::rename(&temp_path, path)
1277            .with_context(|| format!("Failed to move {} into place", path.display()))
1278    }?;
1279
1280    Ok(())
1281}
1282
1283fn temp_write_path(path: &Path) -> PathBuf {
1284    let file_name = path
1285        .file_name()
1286        .and_then(|value| value.to_str())
1287        .unwrap_or("generation.tmp");
1288    let unique = std::time::SystemTime::now()
1289        .duration_since(std::time::UNIX_EPOCH)
1290        .unwrap_or_default()
1291        .as_nanos();
1292    path.with_file_name(format!("{}.{}.tmp", file_name, unique))
1293}
1294
1295fn backup_write_path(path: &Path) -> PathBuf {
1296    let file_name = path
1297        .file_name()
1298        .and_then(|value| value.to_str())
1299        .unwrap_or("generation.bak");
1300    path.with_file_name(format!("{}.bak", file_name))
1301}
1302
1303#[cfg(test)]
1304mod tests {
1305    use super::*;
1306    use tempfile::tempdir;
1307
1308    #[test]
1309    fn app_generation_defaults_summary_metadata_safely() {
1310        let generation = AppGeneration::default();
1311
1312        assert_eq!(generation.scene, "");
1313        assert_eq!(generation.binding_set_id, "");
1314        assert_eq!(generation.closure_id, "");
1315        assert_eq!(generation.lock_digest, "");
1316        assert_eq!(generation.lock_path, "");
1317        assert_eq!(generation.parent_generation, None);
1318        assert_eq!(generation.created_by, "");
1319        assert_eq!(generation.status, GenerationStatus::Candidate);
1320    }
1321
1322    #[test]
1323    fn app_generation_deserializes_old_payload_with_defaulted_metadata() {
1324        let payload = serde_json::json!({
1325            "id": 7,
1326            "app_name": "weft-claw",
1327            "version": "0.1.0",
1328            "bindings": [],
1329            "capabilities": ["core.execution"],
1330            "enabled_features": [],
1331            "profile": "developer",
1332            "status": "candidate",
1333            "validation_results": [],
1334            "created_at": 123
1335        });
1336
1337        let generation: AppGeneration =
1338            serde_json::from_value(payload).expect("legacy generation payload should deserialize");
1339
1340        assert_eq!(generation.scene, "");
1341        assert_eq!(generation.binding_set_id, "");
1342        assert_eq!(generation.closure_id, "");
1343        assert_eq!(generation.lock_digest, "");
1344        assert_eq!(generation.lock_path, "");
1345        assert_eq!(generation.parent_generation, None);
1346        assert_eq!(generation.created_by, "");
1347    }
1348
1349    #[test]
1350    fn propose_initializes_summary_metadata_from_supplied_values() {
1351        let mut store = AppGenerationStore::default();
1352        let generation = store.propose(AppGenerationProposal {
1353            app_name: "weft-claw".into(),
1354            version: "0.1.0".into(),
1355            bindings: vec![],
1356            capabilities: vec!["core.execution".into()],
1357            enabled_features: vec![],
1358            profile: "developer".into(),
1359            metadata: AppGenerationSummaryMetadata {
1360                scene: "team".into(),
1361                binding_set_id: "binding-set:sha256:test".into(),
1362                closure_id: "closure:sha256:test".into(),
1363                lock_digest: "sha256:lock".into(),
1364                lock_path: "generations/1.lock.toml".into(),
1365                parent_generation: Some(3),
1366                created_by: "api".into(),
1367            },
1368        });
1369
1370        assert_eq!(generation.scene, "team");
1371        assert_eq!(generation.binding_set_id, "binding-set:sha256:test");
1372        assert_eq!(generation.closure_id, "closure:sha256:test");
1373        assert_eq!(generation.lock_digest, "sha256:lock");
1374        assert_eq!(generation.lock_path, "generations/1.lock.toml");
1375        assert_eq!(generation.parent_generation, Some(3));
1376        assert_eq!(generation.created_by, "api");
1377    }
1378
1379    fn sample_generation(id: u64, status: GenerationStatus) -> AppGeneration {
1380        AppGeneration {
1381            id,
1382            app_name: "weft-claw".into(),
1383            version: "0.1.0".into(),
1384            bindings: vec![],
1385            capabilities: vec!["core.execution".into()],
1386            enabled_features: vec![],
1387            scene: "team".into(),
1388            profile: "developer".into(),
1389            binding_set_id: format!("binding-set:sha256:{}", id),
1390            closure_id: format!("closure:sha256:{}", id),
1391            lock_digest: format!("sha256:lock-{}", id),
1392            lock_path: format!("generations/{}.lock.toml", id),
1393            parent_generation: id.checked_sub(1),
1394            created_by: "cli".into(),
1395            status,
1396            validation_results: vec![],
1397            created_at: 1710000000 + id,
1398        }
1399    }
1400
1401    #[test]
1402    fn generation_index_round_trips_with_generation_summaries() {
1403        let root = tempdir().expect("temp dir");
1404        let instance_dir = root.path().join(".weft").join("weft-claw");
1405
1406        let index = AppGenerationIndex {
1407            schema_version: GENERATION_INDEX_SCHEMA_VERSION,
1408            active: Some(20),
1409            previous: Some(19),
1410            candidate: Some(21),
1411            next_id: 22,
1412            generations: vec![
1413                sample_generation(19, GenerationStatus::Verified),
1414                sample_generation(20, GenerationStatus::Active),
1415                sample_generation(21, GenerationStatus::Candidate),
1416            ],
1417        };
1418
1419        save_generation_index(&instance_dir, &index).expect("index saved");
1420        let loaded = load_generation_index(&instance_dir)
1421            .expect("index loaded")
1422            .expect("index present");
1423
1424        assert_eq!(loaded.schema_version, GENERATION_INDEX_SCHEMA_VERSION);
1425        assert_eq!(loaded.active, Some(20));
1426        assert_eq!(loaded.previous, Some(19));
1427        assert_eq!(loaded.candidate, Some(21));
1428        assert_eq!(loaded.next_id, 22);
1429        assert_eq!(loaded.generations.len(), 3);
1430
1431        let active = loaded.generation(20).expect("active generation stored");
1432        assert_eq!(active.scene, "team");
1433        assert_eq!(active.profile, "developer");
1434        assert_eq!(active.binding_set_id, "binding-set:sha256:20");
1435        assert_eq!(active.closure_id, "closure:sha256:20");
1436        assert_eq!(active.lock_digest, "sha256:lock-20");
1437        assert_eq!(active.lock_path, "generations/20.lock.toml");
1438        assert_eq!(active.parent_generation, Some(19));
1439        assert_eq!(active.created_by, "cli");
1440    }
1441
1442    #[test]
1443    fn missing_generation_index_returns_none() {
1444        let root = tempdir().expect("temp dir");
1445        let instance_dir = root.path().join(".weft").join("weft-claw");
1446
1447        let loaded = load_generation_index(&instance_dir).expect("missing index handled");
1448
1449        assert!(loaded.is_none());
1450    }
1451
1452    #[test]
1453    fn generation_pointer_read_write_round_trip() {
1454        let root = tempdir().expect("temp dir");
1455        let instance_dir = root.path().join(".weft").join("weft-claw");
1456
1457        write_active_generation_pointer(&instance_dir, Some(20)).expect("active pointer written");
1458        write_previous_generation_pointer(&instance_dir, Some(19))
1459            .expect("previous pointer written");
1460
1461        assert_eq!(
1462            read_active_generation_pointer(&instance_dir).expect("active pointer read"),
1463            Some(20)
1464        );
1465        assert_eq!(
1466            read_previous_generation_pointer(&instance_dir).expect("previous pointer read"),
1467            Some(19)
1468        );
1469
1470        write_previous_generation_pointer(&instance_dir, None).expect("previous pointer cleared");
1471
1472        assert_eq!(
1473            read_previous_generation_pointer(&instance_dir).expect("cleared pointer read"),
1474            None
1475        );
1476        assert!(!previous_generation_pointer_path(&instance_dir).exists());
1477    }
1478
1479    #[test]
1480    fn generation_index_store_conversion_preserves_summary_fields() {
1481        let active = sample_generation(30, GenerationStatus::Active);
1482        let previous = sample_generation(29, GenerationStatus::Verified);
1483        let candidate = sample_generation(31, GenerationStatus::Candidate);
1484        let store = AppGenerationStore {
1485            active: Some(active.clone()),
1486            candidate: Some(candidate.clone()),
1487            rollback: Some(previous.clone()),
1488            next_id: 32,
1489        };
1490
1491        let index = AppGenerationIndex::from_store(&store);
1492
1493        assert_eq!(index.active, Some(30));
1494        assert_eq!(index.previous, Some(29));
1495        assert_eq!(index.candidate, Some(31));
1496        assert_eq!(index.next_id, 32);
1497        assert_eq!(
1498            index.generation(30).expect("active summary").lock_path,
1499            active.lock_path
1500        );
1501        assert_eq!(
1502            index.generation(29).expect("previous summary").scene,
1503            previous.scene
1504        );
1505        assert_eq!(
1506            index
1507                .generation(31)
1508                .expect("candidate summary")
1509                .binding_set_id,
1510            candidate.binding_set_id
1511        );
1512
1513        let restored_store = index.into_store();
1514
1515        assert_eq!(restored_store.next_id, 32);
1516        assert_eq!(
1517            restored_store.active.expect("active restored").closure_id,
1518            active.closure_id
1519        );
1520        assert_eq!(
1521            restored_store
1522                .rollback
1523                .expect("previous restored")
1524                .created_by,
1525            previous.created_by
1526        );
1527        assert_eq!(
1528            restored_store
1529                .candidate
1530                .expect("candidate restored")
1531                .lock_digest,
1532            candidate.lock_digest
1533        );
1534    }
1535
1536    #[test]
1537    fn generation_index_repair_rebuilds_missing_index_from_store() {
1538        let active = sample_generation(30, GenerationStatus::Active);
1539        let previous = sample_generation(29, GenerationStatus::Verified);
1540        let candidate = sample_generation(31, GenerationStatus::Candidate);
1541        let store = AppGenerationStore {
1542            active: Some(active.clone()),
1543            candidate: Some(candidate.clone()),
1544            rollback: Some(previous.clone()),
1545            next_id: 32,
1546        };
1547
1548        let repaired = AppGenerationIndex::repair_from_sources(Some(&store), None)
1549            .expect("repair should rebuild index from store");
1550
1551        assert_eq!(repaired.active, Some(30));
1552        assert_eq!(repaired.previous, Some(29));
1553        assert_eq!(repaired.candidate, Some(31));
1554        assert_eq!(repaired.next_id, 32);
1555        assert_eq!(repaired.generations.len(), 3);
1556        assert_eq!(
1557            repaired.generation(30).expect("active exists").scene,
1558            active.scene
1559        );
1560        assert_eq!(
1561            repaired
1562                .generation(31)
1563                .expect("candidate exists")
1564                .binding_set_id,
1565            candidate.binding_set_id
1566        );
1567    }
1568
1569    #[test]
1570    fn generation_index_repair_uses_active_summary_when_store_missing() {
1571        let active = sample_generation(40, GenerationStatus::Active);
1572
1573        let repaired = AppGenerationIndex::repair_from_sources(None, Some(&active))
1574            .expect("repair should build index from active summary");
1575
1576        assert_eq!(repaired.active, Some(40));
1577        assert_eq!(repaired.previous, None);
1578        assert_eq!(repaired.candidate, None);
1579        assert_eq!(repaired.next_id, 41);
1580        assert_eq!(repaired.generations.len(), 1);
1581        assert_eq!(
1582            repaired.generation(40).expect("active exists").lock_path,
1583            active.lock_path
1584        );
1585    }
1586
1587    #[test]
1588    fn generation_index_consistency_report_flags_pointer_mismatch() {
1589        let index = AppGenerationIndex {
1590            schema_version: GENERATION_INDEX_SCHEMA_VERSION,
1591            active: Some(20),
1592            previous: Some(19),
1593            candidate: None,
1594            next_id: 21,
1595            generations: vec![
1596                sample_generation(19, GenerationStatus::Verified),
1597                sample_generation(20, GenerationStatus::Active),
1598            ],
1599        };
1600
1601        let report = index.consistency_report(Some(21), Some(19));
1602
1603        assert!(!report.is_consistent);
1604        assert!(report.repair_recommended);
1605        assert_eq!(report.diagnostics.len(), 1);
1606        assert_eq!(
1607            report.diagnostics[0].level,
1608            GenerationIndexDiagnosticLevel::RepairNeeded
1609        );
1610        assert_eq!(report.diagnostics[0].code, "pointer_mismatch");
1611        assert_eq!(report.diagnostics[0].pointer.as_deref(), Some("active"));
1612        assert_eq!(report.diagnostics[0].generation_id, Some(21));
1613    }
1614
1615    #[test]
1616    fn generation_index_consistency_report_is_clean_for_valid_index() {
1617        let index = AppGenerationIndex {
1618            schema_version: GENERATION_INDEX_SCHEMA_VERSION,
1619            active: Some(20),
1620            previous: Some(19),
1621            candidate: Some(21),
1622            next_id: 22,
1623            generations: vec![
1624                sample_generation(19, GenerationStatus::Verified),
1625                sample_generation(20, GenerationStatus::Active),
1626                sample_generation(21, GenerationStatus::Candidate),
1627            ],
1628        };
1629
1630        let report = index.consistency_report(Some(20), Some(19));
1631
1632        assert!(report.is_consistent);
1633        assert!(!report.repair_recommended);
1634        assert!(report.diagnostics.is_empty());
1635    }
1636
1637    #[test]
1638    fn generation_index_consistency_report_warns_for_missing_lock_path_and_summary_fields() {
1639        let mut active = sample_generation(20, GenerationStatus::Active);
1640        active.lock_path.clear();
1641        active.scene.clear();
1642        active.binding_set_id.clear();
1643        active.closure_id.clear();
1644        active.lock_digest.clear();
1645        active.created_by.clear();
1646
1647        let index = AppGenerationIndex {
1648            schema_version: GENERATION_INDEX_SCHEMA_VERSION,
1649            active: Some(20),
1650            previous: None,
1651            candidate: None,
1652            next_id: 21,
1653            generations: vec![active],
1654        };
1655
1656        let report = index.consistency_report(Some(20), None);
1657
1658        assert!(report.is_consistent);
1659        assert!(!report.repair_recommended);
1660        assert_eq!(report.diagnostics.len(), 2);
1661        assert_eq!(
1662            report.diagnostics[0].level,
1663            GenerationIndexDiagnosticLevel::Warning
1664        );
1665        assert_eq!(report.diagnostics[0].code, "missing_lock_path");
1666        assert_eq!(
1667            report.diagnostics[1].level,
1668            GenerationIndexDiagnosticLevel::Warning
1669        );
1670        assert_eq!(report.diagnostics[1].code, "incomplete_generation_summary");
1671    }
1672
1673    #[test]
1674    fn startup_generation_store_diagnostics_ignore_missing_pointer_and_index_files() {
1675        let root = tempdir().expect("temp dir");
1676        let instance_dir = root.path().join(".weft").join("weft-claw");
1677        fs::create_dir_all(&instance_dir).expect("instance dir created");
1678
1679        let active = sample_generation(20, GenerationStatus::Active);
1680        let store = AppGenerationStore {
1681            active: Some(active),
1682            candidate: None,
1683            rollback: None,
1684            next_id: 21,
1685        };
1686
1687        let diagnostics = inspect_startup_generation_store(&instance_dir, &store);
1688
1689        assert!(diagnostics.is_clean());
1690        assert_eq!(diagnostics.active_pointer, None);
1691        assert_eq!(diagnostics.previous_pointer, None);
1692        assert!(!diagnostics.generation_index_present);
1693        assert!(diagnostics.diagnostics.is_empty());
1694    }
1695
1696    #[test]
1697    fn startup_generation_store_diagnostics_warn_on_pointer_and_index_mismatch() {
1698        let root = tempdir().expect("temp dir");
1699        let instance_dir = root.path().join(".weft").join("weft-claw");
1700        fs::create_dir_all(&instance_dir).expect("instance dir created");
1701
1702        write_active_generation_pointer(&instance_dir, Some(21)).expect("active pointer written");
1703        write_previous_generation_pointer(&instance_dir, Some(18))
1704            .expect("previous pointer written");
1705        save_generation_index(
1706            &instance_dir,
1707            &AppGenerationIndex {
1708                schema_version: GENERATION_INDEX_SCHEMA_VERSION,
1709                active: Some(21),
1710                previous: Some(18),
1711                candidate: None,
1712                next_id: 22,
1713                generations: vec![
1714                    sample_generation(18, GenerationStatus::Verified),
1715                    sample_generation(21, GenerationStatus::Active),
1716                ],
1717            },
1718        )
1719        .expect("index saved");
1720
1721        let store = AppGenerationStore {
1722            active: Some(sample_generation(20, GenerationStatus::Active)),
1723            candidate: None,
1724            rollback: Some(sample_generation(19, GenerationStatus::Verified)),
1725            next_id: 21,
1726        };
1727
1728        let diagnostics = inspect_startup_generation_store(&instance_dir, &store);
1729
1730        assert!(diagnostics.generation_index_present);
1731        assert_eq!(diagnostics.active_pointer, Some(21));
1732        assert_eq!(diagnostics.previous_pointer, Some(18));
1733        assert!(diagnostics
1734            .diagnostics
1735            .iter()
1736            .any(|diagnostic| diagnostic.code == "startup_active_pointer_mismatch"));
1737        assert!(diagnostics
1738            .diagnostics
1739            .iter()
1740            .any(|diagnostic| diagnostic.code == "startup_previous_pointer_mismatch"));
1741        assert!(diagnostics
1742            .diagnostics
1743            .iter()
1744            .any(|diagnostic| diagnostic.code == "startup_generation_index_active_mismatch"));
1745        assert!(diagnostics
1746            .diagnostics
1747            .iter()
1748            .any(|diagnostic| diagnostic.code == "startup_generation_index_previous_mismatch"));
1749    }
1750
1751    #[test]
1752    fn startup_generation_store_diagnostics_are_clean_for_matching_pointer_and_index() {
1753        let root = tempdir().expect("temp dir");
1754        let instance_dir = root.path().join(".weft").join("weft-claw");
1755        fs::create_dir_all(&instance_dir).expect("instance dir created");
1756
1757        let active = sample_generation(20, GenerationStatus::Active);
1758        let previous = sample_generation(19, GenerationStatus::Verified);
1759        let store = AppGenerationStore {
1760            active: Some(active.clone()),
1761            candidate: None,
1762            rollback: Some(previous.clone()),
1763            next_id: 21,
1764        };
1765
1766        write_active_generation_pointer(&instance_dir, Some(20)).expect("active pointer written");
1767        write_previous_generation_pointer(&instance_dir, Some(19))
1768            .expect("previous pointer written");
1769        save_generation_index(&instance_dir, &AppGenerationIndex::from_store(&store))
1770            .expect("index saved");
1771
1772        let diagnostics = inspect_startup_generation_store(&instance_dir, &store);
1773
1774        assert!(diagnostics.is_clean());
1775        assert!(diagnostics.generation_index_present);
1776        assert_eq!(diagnostics.active_pointer, Some(20));
1777        assert_eq!(diagnostics.previous_pointer, Some(19));
1778        assert!(diagnostics.diagnostics.is_empty());
1779    }
1780
1781    #[test]
1782    fn generation_index_rejects_unknown_future_schema_version() {
1783        let root = tempdir().expect("temp dir");
1784        let index_path = generation_index_path(&root.path().join(".weft").join("weft-claw"));
1785
1786        if let Some(parent) = index_path.parent() {
1787            fs::create_dir_all(parent).expect("instance dir created");
1788        }
1789        fs::write(
1790            &index_path,
1791            "schema_version = 99\nnext_id = 2\n[[generations]]\nid = 1\napp_name = 'weft-claw'\nversion = '0.1.0'\nbindings = []\ncapabilities = []\nprofile = 'developer'\nstatus = 'verified'\nlock_path = 'generations/1.lock.toml'\ncreated_at = 1710000001\n",
1792        )
1793        .expect("index fixture written");
1794
1795        let error =
1796            load_generation_index_from_path(&index_path).expect_err("future schema should fail");
1797
1798        assert!(format!("{error:#}").contains("Unsupported generation index schema version 99"));
1799    }
1800
1801    #[test]
1802    fn app_generation_index_default_is_persistable() {
1803        let index = AppGenerationIndex::default();
1804
1805        assert_eq!(index.schema_version, GENERATION_INDEX_SCHEMA_VERSION);
1806        assert_eq!(index.next_id, 1);
1807        assert!(index.generations.is_empty());
1808    }
1809
1810    #[test]
1811    fn generation_index_rejects_pointer_to_missing_generation() {
1812        let root = tempdir().expect("temp dir");
1813        let instance_dir = root.path().join(".weft").join("weft-claw");
1814        let index_path = generation_index_path(&instance_dir);
1815
1816        if let Some(parent) = index_path.parent() {
1817            fs::create_dir_all(parent).expect("instance dir created");
1818        }
1819        fs::write(&index_path, "schema_version = 1\nactive = 3\nnext_id = 4\n")
1820            .expect("index fixture written");
1821
1822        let error = load_generation_index_from_path(&index_path)
1823            .expect_err("missing active generation should fail");
1824
1825        assert!(format!("{error:#}").contains("active pointer references missing generation 3"));
1826    }
1827
1828    #[test]
1829    fn generation_index_rejects_duplicate_generation_ids() {
1830        let root = tempdir().expect("temp dir");
1831        let instance_dir = root.path().join(".weft").join("weft-claw");
1832        let index = AppGenerationIndex {
1833            schema_version: GENERATION_INDEX_SCHEMA_VERSION,
1834            active: Some(1),
1835            previous: None,
1836            candidate: None,
1837            next_id: 2,
1838            generations: vec![
1839                sample_generation(1, GenerationStatus::Active),
1840                sample_generation(1, GenerationStatus::Verified),
1841            ],
1842        };
1843
1844        let error = save_generation_index(&instance_dir, &index)
1845            .expect_err("duplicate generation ids should fail");
1846
1847        assert!(format!("{error:#}").contains("Duplicate generation id 1"));
1848    }
1849
1850    #[test]
1851    fn activation_persistence_plan_orders_checks_and_writes() {
1852        let root = tempdir().expect("temp dir");
1853        let instance_dir = root.path().join(".weft").join("weft-claw");
1854        let target = sample_generation(21, GenerationStatus::Verified);
1855        let store = AppGenerationStore {
1856            active: Some(sample_generation(20, GenerationStatus::Active)),
1857            candidate: Some(target.clone()),
1858            rollback: Some(sample_generation(19, GenerationStatus::Rollback)),
1859            next_id: 22,
1860        };
1861        let index = AppGenerationIndex::from_store(&store);
1862
1863        let plan = plan_activation_persistence(&instance_dir, &target, Some(&store), Some(&index))
1864            .expect("plan should succeed");
1865
1866        assert_eq!(plan.target_generation_id, 21);
1867        assert_eq!(plan.previous_active_generation_id, Some(20));
1868        assert_eq!(
1869            plan.target_lock_path,
1870            instance_dir
1871                .join("generations/21.lock.toml")
1872                .display()
1873                .to_string()
1874        );
1875        assert_eq!(
1876            plan.steps.iter().map(|step| step.kind).collect::<Vec<_>>(),
1877            vec![
1878                ActivationPersistenceStepKind::CheckTargetStatus,
1879                ActivationPersistenceStepKind::CheckTargetLockMetadata,
1880                ActivationPersistenceStepKind::WriteGenerationLock,
1881                ActivationPersistenceStepKind::WritePreviousPointer,
1882                ActivationPersistenceStepKind::ReplaceActivePointer,
1883                ActivationPersistenceStepKind::ReplaceRootLockMirror,
1884                ActivationPersistenceStepKind::UpdateGenerationIndex,
1885            ]
1886        );
1887        assert_eq!(
1888            plan.steps[2].path.as_deref(),
1889            Some(plan.target_lock_path.as_str())
1890        );
1891        assert_eq!(plan.steps[3].generation_id, Some(20));
1892        assert_eq!(
1893            plan.steps[3].path.as_deref(),
1894            Some(
1895                previous_generation_pointer_path(&instance_dir)
1896                    .display()
1897                    .to_string()
1898                    .as_str()
1899            )
1900        );
1901        assert_eq!(
1902            plan.steps[4].path.as_deref(),
1903            Some(
1904                active_generation_pointer_path(&instance_dir)
1905                    .display()
1906                    .to_string()
1907                    .as_str()
1908            )
1909        );
1910        assert!(plan.steps[6].best_effort);
1911    }
1912
1913    #[test]
1914    fn activation_persistence_plan_rejects_unverified_target() {
1915        let root = tempdir().expect("temp dir");
1916        let instance_dir = root.path().join(".weft").join("weft-claw");
1917        let target = sample_generation(21, GenerationStatus::Candidate);
1918
1919        let error = plan_activation_persistence(&instance_dir, &target, None, None)
1920            .expect_err("candidate target should be rejected");
1921
1922        assert!(error.contains("must be verified or active"));
1923    }
1924
1925    #[test]
1926    fn activation_persistence_plan_uses_existing_previous_for_active_target() {
1927        let root = tempdir().expect("temp dir");
1928        let instance_dir = root.path().join(".weft").join("weft-claw");
1929        let target = sample_generation(20, GenerationStatus::Active);
1930        let store = AppGenerationStore {
1931            active: Some(target.clone()),
1932            candidate: None,
1933            rollback: Some(sample_generation(19, GenerationStatus::Rollback)),
1934            next_id: 21,
1935        };
1936
1937        let plan = plan_activation_persistence(&instance_dir, &target, Some(&store), None)
1938            .expect("active target plan should succeed");
1939
1940        assert_eq!(plan.previous_active_generation_id, Some(19));
1941        assert_eq!(plan.steps[3].generation_id, Some(19));
1942        assert!(plan.steps[3]
1943            .description
1944            .contains("Write previous pointer to generation 19"));
1945    }
1946
1947    #[test]
1948    fn activation_persistence_plan_rejects_missing_lock_path_metadata() {
1949        let root = tempdir().expect("temp dir");
1950        let instance_dir = root.path().join(".weft").join("weft-claw");
1951        let mut target = sample_generation(21, GenerationStatus::Verified);
1952        target.lock_path.clear();
1953
1954        let error = plan_activation_persistence(&instance_dir, &target, None, None)
1955            .expect_err("missing lock_path should be rejected");
1956
1957        assert!(error.contains("missing lock_path metadata"));
1958    }
1959
1960    #[test]
1961    fn activation_persistence_plan_derives_previous_active_from_index_when_store_missing() {
1962        let root = tempdir().expect("temp dir");
1963        let instance_dir = root.path().join(".weft").join("weft-claw");
1964        let target = sample_generation(21, GenerationStatus::Verified);
1965        let index = AppGenerationIndex {
1966            schema_version: GENERATION_INDEX_SCHEMA_VERSION,
1967            active: Some(20),
1968            previous: Some(19),
1969            candidate: Some(21),
1970            next_id: 22,
1971            generations: vec![
1972                sample_generation(19, GenerationStatus::Rollback),
1973                sample_generation(20, GenerationStatus::Active),
1974                target.clone(),
1975            ],
1976        };
1977
1978        let plan = plan_activation_persistence(&instance_dir, &target, None, Some(&index))
1979            .expect("index should provide previous active context");
1980
1981        assert_eq!(plan.previous_active_generation_id, Some(20));
1982        assert_eq!(plan.steps[3].generation_id, Some(20));
1983    }
1984
1985    #[test]
1986    fn activation_persistence_plan_rejects_index_missing_target_generation() {
1987        let root = tempdir().expect("temp dir");
1988        let instance_dir = root.path().join(".weft").join("weft-claw");
1989        let target = sample_generation(21, GenerationStatus::Verified);
1990        let index = AppGenerationIndex {
1991            schema_version: GENERATION_INDEX_SCHEMA_VERSION,
1992            active: Some(20),
1993            previous: Some(19),
1994            candidate: Some(21),
1995            next_id: 22,
1996            generations: vec![
1997                sample_generation(19, GenerationStatus::Rollback),
1998                sample_generation(20, GenerationStatus::Active),
1999            ],
2000        };
2001
2002        let error = plan_activation_persistence(&instance_dir, &target, None, Some(&index))
2003            .expect_err("index missing target generation should fail");
2004
2005        assert!(error.contains("target generation 21 is missing from generation index"));
2006    }
2007}