import Foundation // Pure decision logic for bringing an install's on-disk starter-routine library in // line with the current app bundle. No I/O and no `SyncEngine` dependency, so the // safety-critical part is fully unit-testable in isolation: `SyncEngine.reconcileSeeds()` // gathers the live seed-path files, stub set, and live routine names, hands each seed to // `SeedReconcilePlanner.decision(...)`, and executes the returned skip / upgrade / write. // // Why this exists: starter seeds ship as byte-canonical `RoutineDocument` JSON under // FIXED ULIDs (see `SeedLibrary`). `autoSeedIfEmpty` only ever writes them into a // verifiably empty container, so an existing install can never pick up an upgraded // seed revision or a newly-shipped seed. This planner decides, per seed, whether the // live fixed-ULID file is up to date, stale (needs the current bundle bytes), deleted // (vetoed), or missing (write it). // // Safety invariant this relies on: a live file at a seed's fixed ULID can NEVER hold // user-authored content — every user edit forks the seed to a fresh random ULID // (clone-on-edit), and fixed-ULID files only exist on app versions that have // clone-on-edit. So overwriting a seed-path file with current bundle bytes never // destroys user data; at worst it upgrades an older seed revision. // MARK: - Input /// Everything the planner needs to decide one seed, pre-gathered by the engine. struct SeedReconcileInput: Equatable { /// The seed's fixed ULID. let seedID: String /// The bundle seed document (canonical current content). let seedDoc: RoutineDocument /// The decoded live file currently sitting at the seed's fixed path, or `nil` /// when no live file exists there (deleted, or never written). let liveDoc: RoutineDocument? /// Whether a tombstone stub exists for this seed's id. let hasStub: Bool } // MARK: - Decision enum SeedReconcileDecision: Equatable { /// Do nothing — the reason is carried for logging and tests. case skip(SeedSkipReason) /// A live file exists at the seed path but differs from the bundle: overwrite it /// with the verbatim bundle bytes (an older seed revision). case upgrade /// No live file, no veto, no name collision: write the verbatim bundle bytes (a /// newly-shipped seed, or a pre-fixed-ULID install missing the file). case write } enum SeedSkipReason: Equatable { /// The live file already matches the bundle seed (schemaVersion-normalized). case upToDate /// The live file was written by a newer app version — never downgrade-rewrite it. case quarantined /// No live file, but a stub exists: the user deleted this seed. Respect the veto. case vetoed /// No live file and no stub, but a live routine already uses this seed's name /// (e.g. a legacy runtime-built or user-cloned "Upper Body"). Writing the seed /// would duplicate it by name. case nameCollision } // MARK: - Planner enum SeedReconcilePlanner { /// The automatic on-connect decision for one seed (see the decision table in the /// file header). `liveRoutineNames` must be the names of every live routine currently /// in the container/cache after reconcile — the name-collision guard consults it. static func decision( for input: SeedReconcileInput, liveRoutineNames: Set ) -> SeedReconcileDecision { if let live = input.liveDoc { // A live file sits at the seed path. Never touch one a newer app version // wrote (the forward-compat quarantine), otherwise compare content. guard live.isReadable else { return .skip(.quarantined) } return semanticallyEqual(live: live, seed: input.seedDoc) ? .skip(.upToDate) : .upgrade } // No live file at the seed path. if input.hasStub { return .skip(.vetoed) } if liveRoutineNames.contains(input.seedDoc.name) { return .skip(.nameCollision) } return .write } /// Semantic (not byte) equality of a live seed file against the bundle seed. /// /// A byte compare is wrong here: a container file may have been written by an /// older app version, carrying extra now-removed keys and a lower `schemaVersion`. /// Decoding both into the current `RoutineDocument` already drops unknown keys, so /// the only remaining artificial difference is the version stamp — normalize the /// live doc's `schemaVersion` to the seed's before the `==` so an older wrapper of /// identical content reads as up to date (no churn), while a genuine content /// change reads as different (upgrade). static func semanticallyEqual(live: RoutineDocument, seed: RoutineDocument) -> Bool { var normalized = live normalized.schemaVersion = seed.schemaVersion return normalized == seed } /// Whether the deliberate "Restore Starter Routines" action should (re)write a seed: /// only when no live file sits at its path and no live routine already uses its name. /// Unlike `decision(...)` a stub does NOT veto here — restore lifts the veto — but /// the engine still removes the stub as a separate execution step. A seed whose /// live file already exists is left for `decision(...)` to upgrade. static func shouldRestore( hasLiveFile: Bool, seedName: String, liveRoutineNames: Set ) -> Bool { !hasLiveFile && !liveRoutineNames.contains(seedName) } }