Skip to main content

agentstategraph_migrate/
lib.rs

1//! Schema migration registry for AgentStateGraph databases.
2//!
3//! See `spec/UPGRADE-PATH.md` for the design.
4//!
5//! ```text
6//! flow:
7//!   consumer startup → check(repo, target) → CheckResult
8//!     UpToDate       → continue
9//!     Unversioned /  → run migrations
10//!     UpgradeAvailable
11//!     Downgrade      → refuse (exit 64)
12//!     Corrupt        → refuse (exit 65)
13//! ```
14//!
15//! Builtin migrations live in `migrations/`. External siblings may
16//! register their own via `Registry::register`.
17
18pub mod exit;
19pub mod migrations;
20
21use std::fmt;
22
23use agentstategraph::{
24    CommitOptions, META_SCHEMA_VERSION_PATH, RepoError, Repository, SCHEMA_VERSION,
25};
26use agentstategraph_core::{Atom, IntentCategory, Object};
27use semver::Version;
28use thiserror::Error;
29
30/// Version stamped into a `.db` when the meta sentinel is absent. This
31/// is the pre-`/_meta` era — everything written before 0.4.0.
32pub const IMPLICIT_VERSION: &str = "0.3.0";
33
34#[derive(Debug, Error)]
35pub enum MigrateError {
36    #[error("repository error: {0}")]
37    Repo(#[from] RepoError),
38
39    #[error("failed to parse stored schema version {0:?}: {1}")]
40    BadStoredVersion(String, semver::Error),
41
42    #[error("failed to parse target version {0:?}: {1}")]
43    BadTargetVersion(String, semver::Error),
44
45    #[error("migration {name} failed: {source}")]
46    MigrationFailed {
47        name: String,
48        #[source]
49        source: Box<dyn std::error::Error + Send + Sync>,
50    },
51
52    #[error("storage is corrupt: {0}")]
53    Corrupt(String),
54}
55
56/// Summary of what a migration accomplished on a single ref.
57#[derive(Debug, Clone)]
58pub struct MigrationOutcome {
59    pub name: String,
60    pub commit_id: Option<agentstategraph_core::ObjectId>,
61    pub from: Version,
62    pub to: Version,
63    pub notes: Vec<String>,
64}
65
66/// A single named, idempotent migration step.
67pub trait Migration: Send + Sync {
68    /// Version requirement that the *current* stored schema must satisfy.
69    #[allow(clippy::wrong_self_convention)]
70    fn from_version(&self) -> &semver::VersionReq;
71
72    /// Version that this migration produces.
73    fn to_version(&self) -> &Version;
74
75    /// Short stable identifier, e.g. `"plan_assignments_sidecar_to_native"`.
76    fn name(&self) -> &str;
77
78    /// Human-readable one-liner for dry-run output.
79    fn describe(&self) -> &str;
80
81    /// Cheap probe: is there actually work to do on `ref_name`?
82    /// Returning `Ok(false)` makes the migration a no-op (still advances
83    /// the schema_version sentinel, but without changing user data).
84    fn applies_to(&self, repo: &Repository, ref_name: &str) -> Result<bool, MigrateError>;
85
86    /// Perform the migration, producing (or skipping) one commit tagged
87    /// `IntentCategory::Migrate` that also bumps `/_meta/schema_version`
88    /// to `self.to_version()`.
89    fn migrate(&self, repo: &Repository, ref_name: &str) -> Result<MigrationOutcome, MigrateError>;
90}
91
92/// Registry of migrations, ordered by `to_version` then registration order.
93pub struct Registry {
94    items: Vec<Box<dyn Migration>>,
95}
96
97impl Default for Registry {
98    fn default() -> Self {
99        Self::builtin()
100    }
101}
102
103impl Registry {
104    pub fn empty() -> Self {
105        Self { items: Vec::new() }
106    }
107
108    /// Registry pre-loaded with every migration shipped by this crate.
109    pub fn builtin() -> Self {
110        let mut r = Self::empty();
111        r.register(Box::new(migrations::v0_4_0_plan_assignments::Migration));
112        r
113    }
114
115    pub fn register(&mut self, m: Box<dyn Migration>) {
116        self.items.push(m);
117        self.items.sort_by_key(|m| m.to_version().clone());
118    }
119
120    /// Migrations whose `to_version` is `> current` and `<= target` and
121    /// whose `from_version` requirement matches `current`.
122    pub fn plan<'a>(&'a self, current: &Version, target: &Version) -> Vec<&'a dyn Migration> {
123        // Walk transitively: each step's `to_version` becomes the next
124        // step's "current." Stops at the first gap where no registered
125        // migration advances the version further.
126        let mut out: Vec<&'a dyn Migration> = Vec::new();
127        let mut cursor = current.clone();
128
129        loop {
130            if &cursor >= target {
131                break;
132            }
133            let next = self.items.iter().find(|m| {
134                m.to_version() > &cursor
135                    && m.to_version() <= target
136                    && m.from_version().matches(&cursor)
137            });
138            match next {
139                Some(m) => {
140                    cursor = m.to_version().clone();
141                    out.push(m.as_ref());
142                }
143                None => break,
144            }
145        }
146
147        out
148    }
149
150    /// Execute the plan.
151    ///
152    /// Each migration runs as its own commit. On failure, earlier commits
153    /// remain durable — operators re-run or branch from the last good
154    /// commit.
155    pub fn run(
156        &self,
157        repo: &Repository,
158        ref_name: &str,
159        target: &Version,
160        mode: RunMode,
161    ) -> Result<Report, MigrateError> {
162        let current = read_stored_version(repo, ref_name)?;
163        let plan = self.plan(&current, target);
164
165        let mut report = Report {
166            from: current.clone(),
167            target: target.clone(),
168            final_version: current,
169            steps: Vec::new(),
170            mode,
171        };
172
173        for m in plan {
174            let step_from = report.final_version.clone();
175            let step_to = m.to_version().clone();
176
177            if mode == RunMode::DryRun {
178                let applies = m.applies_to(repo, ref_name).unwrap_or(true); // pessimistic in dry-run
179                report.steps.push(StepReport {
180                    name: m.name().to_string(),
181                    describe: m.describe().to_string(),
182                    from: step_from,
183                    to: step_to.clone(),
184                    status: if applies {
185                        StepStatus::WouldApply
186                    } else {
187                        StepStatus::WouldSkip
188                    },
189                    commit_id: None,
190                    notes: Vec::new(),
191                });
192                report.final_version = step_to;
193                continue;
194            }
195
196            match m.migrate(repo, ref_name) {
197                Ok(outcome) => {
198                    let status = if outcome.commit_id.is_some() {
199                        StepStatus::Applied
200                    } else {
201                        StepStatus::Skipped
202                    };
203                    report.steps.push(StepReport {
204                        name: outcome.name,
205                        describe: m.describe().to_string(),
206                        from: step_from,
207                        to: step_to.clone(),
208                        status,
209                        commit_id: outcome.commit_id,
210                        notes: outcome.notes,
211                    });
212                    report.final_version = step_to;
213                }
214                Err(e) => {
215                    report.steps.push(StepReport {
216                        name: m.name().to_string(),
217                        describe: m.describe().to_string(),
218                        from: step_from,
219                        to: step_to,
220                        status: StepStatus::Failed,
221                        commit_id: None,
222                        notes: vec![format!("{e}")],
223                    });
224                    return Err(e);
225                }
226            }
227        }
228
229        Ok(report)
230    }
231
232    pub fn iter(&self) -> impl Iterator<Item = &dyn Migration> {
233        self.items.iter().map(|b| b.as_ref())
234    }
235}
236
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238pub enum RunMode {
239    DryRun,
240    Apply,
241}
242
243#[derive(Debug, Clone)]
244pub struct Report {
245    pub from: Version,
246    pub target: Version,
247    pub final_version: Version,
248    pub steps: Vec<StepReport>,
249    pub mode: RunMode,
250}
251
252#[derive(Debug, Clone)]
253pub struct StepReport {
254    pub name: String,
255    pub describe: String,
256    pub from: Version,
257    pub to: Version,
258    pub status: StepStatus,
259    pub commit_id: Option<agentstategraph_core::ObjectId>,
260    pub notes: Vec<String>,
261}
262
263#[derive(Debug, Clone, Copy, PartialEq, Eq)]
264pub enum StepStatus {
265    WouldApply,
266    WouldSkip,
267    Applied,
268    Skipped,
269    Failed,
270}
271
272impl fmt::Display for StepStatus {
273    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274        let s = match self {
275            StepStatus::WouldApply => "would-apply",
276            StepStatus::WouldSkip => "would-skip",
277            StepStatus::Applied => "ok",
278            StepStatus::Skipped => "skip",
279            StepStatus::Failed => "fail",
280        };
281        f.write_str(s)
282    }
283}
284
285/// Result of `check()` — what a consumer should do at startup.
286#[derive(Debug, Clone)]
287pub enum CheckResult {
288    /// Stored version equals target, or target reachable with zero migrations.
289    UpToDate { version: Version },
290    /// Stored version predates target and migrations exist.
291    UpgradeAvailable {
292        from: Version,
293        to: Version,
294        migrations: Vec<String>,
295    },
296    /// The `.db` schema is newer than this binary knows about.
297    Downgrade { db: Version, binary: Version },
298    /// No `/_meta/schema_version` sentinel — pre-0.4 database.
299    Unversioned { implicit: Version },
300    /// Corrupt/unparseable meta sentinel.
301    Corrupt(String),
302}
303
304/// Inspect a repository and tell a consumer what to do.
305///
306/// `target` is typically `Version::parse(SCHEMA_VERSION).unwrap()` — the
307/// consumer's idea of "current." `ref_name` is usually `"main"`.
308pub fn check(
309    repo: &Repository,
310    ref_name: &str,
311    target: &Version,
312    registry: &Registry,
313) -> Result<CheckResult, MigrateError> {
314    match read_stored_version_raw(repo, ref_name)? {
315        StoredVersion::Present(v) => {
316            if &v > target {
317                Ok(CheckResult::Downgrade {
318                    db: v,
319                    binary: target.clone(),
320                })
321            } else if &v == target || registry.plan(&v, target).is_empty() {
322                Ok(CheckResult::UpToDate { version: v })
323            } else {
324                let migrations = registry
325                    .plan(&v, target)
326                    .iter()
327                    .map(|m| m.name().to_string())
328                    .collect();
329                Ok(CheckResult::UpgradeAvailable {
330                    from: v,
331                    to: target.clone(),
332                    migrations,
333                })
334            }
335        }
336        StoredVersion::Absent => {
337            let implicit = Version::parse(IMPLICIT_VERSION).expect("IMPLICIT_VERSION is valid");
338            Ok(CheckResult::Unversioned { implicit })
339        }
340        StoredVersion::Corrupt(s) => Ok(CheckResult::Corrupt(s)),
341    }
342}
343
344/// Current schema version of this binary, as a parsed `semver::Version`.
345pub fn binary_version() -> Version {
346    Version::parse(SCHEMA_VERSION).expect("workspace version parses as semver")
347}
348
349// ---------------------------------------------------------------------------
350// Internal helpers
351// ---------------------------------------------------------------------------
352
353enum StoredVersion {
354    Present(Version),
355    Absent,
356    Corrupt(String),
357}
358
359fn read_stored_version_raw(
360    repo: &Repository,
361    ref_name: &str,
362) -> Result<StoredVersion, MigrateError> {
363    match repo.get(ref_name, META_SCHEMA_VERSION_PATH) {
364        Ok(Object::Atom(Atom::String(s))) => match Version::parse(&s) {
365            Ok(v) => Ok(StoredVersion::Present(v)),
366            Err(e) => Ok(StoredVersion::Corrupt(format!(
367                "schema_version {s:?} not valid semver: {e}"
368            ))),
369        },
370        Ok(other) => Ok(StoredVersion::Corrupt(format!(
371            "schema_version has non-string shape: {other:?}"
372        ))),
373        Err(RepoError::Tree(_)) => Ok(StoredVersion::Absent),
374        Err(e) => Err(MigrateError::Repo(e)),
375    }
376}
377
378fn read_stored_version(repo: &Repository, ref_name: &str) -> Result<Version, MigrateError> {
379    match read_stored_version_raw(repo, ref_name)? {
380        StoredVersion::Present(v) => Ok(v),
381        StoredVersion::Absent => Version::parse(IMPLICIT_VERSION)
382            .map_err(|e| MigrateError::BadStoredVersion(IMPLICIT_VERSION.into(), e)),
383        StoredVersion::Corrupt(s) => Err(MigrateError::Corrupt(s)),
384    }
385}
386
387/// Helper for migration authors: build a `CommitOptions` tagged
388/// `IntentCategory::Migrate` with the conventional agent_id.
389pub fn migrate_commit_options(
390    name: &str,
391    description: impl Into<String>,
392    reasoning: impl Into<String>,
393) -> CommitOptions {
394    CommitOptions::new(
395        format!("agentstategraph-migrate/{name}"),
396        IntentCategory::Migrate,
397        description,
398    )
399    .with_reasoning(reasoning)
400}
401
402/// Helper: produce an Object holding a schema_version string.
403pub fn version_object(v: &Version) -> Object {
404    Object::Atom(Atom::String(v.to_string()))
405}
406
407/// Public re-export — migration authors need this.
408pub use agentstategraph::META_PATH_PREFIX;
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use agentstategraph_storage::SqliteStorage;
414
415    fn fresh_repo() -> Repository {
416        let repo = Repository::new(Box::new(
417            SqliteStorage::in_memory().expect("in-memory sqlite"),
418        ));
419        repo.init().unwrap();
420        repo
421    }
422
423    fn set_version(repo: &Repository, v: &str) {
424        repo.set(
425            "main",
426            META_SCHEMA_VERSION_PATH,
427            &version_object(&Version::parse(v).unwrap()),
428            CommitOptions::new("test/setup", IntentCategory::Migrate, "set version"),
429        )
430        .unwrap();
431    }
432
433    /// Minimal Migration stub: writes a new schema_version sentinel.
434    struct StubMigration {
435        name: &'static str,
436        from: semver::VersionReq,
437        to: Version,
438        /// Whether applies_to() reports work to do.
439        applies: bool,
440    }
441
442    impl Migration for StubMigration {
443        fn from_version(&self) -> &semver::VersionReq {
444            &self.from
445        }
446        fn to_version(&self) -> &Version {
447            &self.to
448        }
449        fn name(&self) -> &str {
450            self.name
451        }
452        fn describe(&self) -> &str {
453            "stub migration"
454        }
455        fn applies_to(&self, _: &Repository, _: &str) -> Result<bool, MigrateError> {
456            Ok(self.applies)
457        }
458        fn migrate(
459            &self,
460            repo: &Repository,
461            ref_name: &str,
462        ) -> Result<MigrationOutcome, MigrateError> {
463            let commit_id = repo
464                .set(
465                    ref_name,
466                    META_SCHEMA_VERSION_PATH,
467                    &version_object(&self.to),
468                    CommitOptions::new(
469                        format!("agentstategraph-migrate/{}", self.name),
470                        IntentCategory::Migrate,
471                        format!("stub → {}", self.to),
472                    ),
473                )
474                .map_err(MigrateError::Repo)?;
475            Ok(MigrationOutcome {
476                name: self.name.to_string(),
477                commit_id: Some(commit_id),
478                from: Version::parse(IMPLICIT_VERSION).unwrap(),
479                to: self.to.clone(),
480                notes: Vec::new(),
481            })
482        }
483    }
484
485    /// A stub that always returns an error from migrate().
486    struct FailingMigration {
487        name: &'static str,
488        from: semver::VersionReq,
489        to: Version,
490    }
491    impl Migration for FailingMigration {
492        fn from_version(&self) -> &semver::VersionReq {
493            &self.from
494        }
495        fn to_version(&self) -> &Version {
496            &self.to
497        }
498        fn name(&self) -> &str {
499            self.name
500        }
501        fn describe(&self) -> &str {
502            "failing stub"
503        }
504        fn applies_to(&self, _: &Repository, _: &str) -> Result<bool, MigrateError> {
505            Ok(true)
506        }
507        fn migrate(&self, _: &Repository, _: &str) -> Result<MigrationOutcome, MigrateError> {
508            Err(MigrateError::MigrationFailed {
509                name: self.name.to_string(),
510                source: "intentional failure".into(),
511            })
512        }
513    }
514
515    // --- existing tests ---
516
517    #[test]
518    fn check_returns_up_to_date_on_fresh_repo() {
519        let repo = fresh_repo();
520        let target = binary_version();
521        let registry = Registry::empty();
522        let r = check(&repo, "main", &target, &registry).unwrap();
523        assert!(matches!(r, CheckResult::UpToDate { .. }), "got {r:?}");
524    }
525
526    #[test]
527    fn check_detects_downgrade() {
528        let repo = fresh_repo();
529        let target = Version::parse("0.1.0").unwrap();
530        let r = check(&repo, "main", &target, &Registry::empty()).unwrap();
531        assert!(matches!(r, CheckResult::Downgrade { .. }), "got {r:?}");
532    }
533
534    #[test]
535    fn read_stored_version_uses_init_stamp() {
536        let repo = fresh_repo();
537        let v = read_stored_version(&repo, "main").unwrap();
538        assert_eq!(v, binary_version());
539    }
540
541    #[test]
542    fn registry_plan_respects_version_bounds() {
543        let mut r = Registry::empty();
544        r.register(Box::new(StubMigration {
545            from: semver::VersionReq::parse(">=0.3, <0.4").unwrap(),
546            to: Version::parse("0.4.0").unwrap(),
547            name: "a",
548            applies: false,
549        }));
550        r.register(Box::new(StubMigration {
551            from: semver::VersionReq::parse(">=0.4, <0.5").unwrap(),
552            to: Version::parse("0.5.0").unwrap(),
553            name: "b",
554            applies: false,
555        }));
556
557        let plan = r.plan(
558            &Version::parse("0.3.0").unwrap(),
559            &Version::parse("0.5.0").unwrap(),
560        );
561        assert_eq!(
562            plan.iter().map(|m| m.name()).collect::<Vec<_>>(),
563            vec!["a", "b"]
564        );
565
566        let plan = r.plan(
567            &Version::parse("0.4.0").unwrap(),
568            &Version::parse("0.5.0").unwrap(),
569        );
570        assert_eq!(plan.iter().map(|m| m.name()).collect::<Vec<_>>(), vec!["b"]);
571
572        let plan = r.plan(
573            &Version::parse("0.5.0").unwrap(),
574            &Version::parse("0.5.0").unwrap(),
575        );
576        assert!(plan.is_empty());
577    }
578
579    // --- new tests ---
580
581    #[test]
582    fn registry_empty_has_no_items() {
583        assert_eq!(Registry::empty().iter().count(), 0);
584    }
585
586    #[test]
587    fn registry_plan_stops_at_gap() {
588        // a: 0.3 → 0.4, b: 0.5 → 0.6 (no migration from 0.4 → 0.5)
589        let mut r = Registry::empty();
590        r.register(Box::new(StubMigration {
591            from: semver::VersionReq::parse(">=0.3, <0.4").unwrap(),
592            to: Version::parse("0.4.0").unwrap(),
593            name: "a",
594            applies: false,
595        }));
596        r.register(Box::new(StubMigration {
597            from: semver::VersionReq::parse(">=0.5, <0.6").unwrap(),
598            to: Version::parse("0.6.0").unwrap(),
599            name: "b",
600            applies: false,
601        }));
602        // Planning from 0.3 to 0.6 should only get "a" — stops at the gap after 0.4
603        let plan = r.plan(
604            &Version::parse("0.3.0").unwrap(),
605            &Version::parse("0.6.0").unwrap(),
606        );
607        assert_eq!(plan.iter().map(|m| m.name()).collect::<Vec<_>>(), vec!["a"]);
608    }
609
610    #[test]
611    fn registry_sorted_by_to_version_regardless_of_insertion_order() {
612        let mut r = Registry::empty();
613        // Register in reverse order
614        r.register(Box::new(StubMigration {
615            from: semver::VersionReq::parse(">=0.4, <0.5").unwrap(),
616            to: Version::parse("0.5.0").unwrap(),
617            name: "second",
618            applies: false,
619        }));
620        r.register(Box::new(StubMigration {
621            from: semver::VersionReq::parse(">=0.3, <0.4").unwrap(),
622            to: Version::parse("0.4.0").unwrap(),
623            name: "first",
624            applies: false,
625        }));
626        let names: Vec<_> = r.iter().map(|m| m.name()).collect();
627        assert_eq!(names, vec!["first", "second"]);
628    }
629
630    #[test]
631    fn run_dry_run_does_not_commit() {
632        let repo = fresh_repo();
633        let current = binary_version();
634        let target = Version::parse("99.0.0").unwrap();
635
636        let mut r = Registry::empty();
637        r.register(Box::new(StubMigration {
638            from: semver::VersionReq::parse(&format!(">={current}")).unwrap(),
639            to: target.clone(),
640            name: "big-upgrade",
641            applies: true,
642        }));
643
644        let report = r.run(&repo, "main", &target, RunMode::DryRun).unwrap();
645        assert_eq!(report.mode, RunMode::DryRun);
646        assert_eq!(report.steps.len(), 1);
647        assert_eq!(report.steps[0].status, StepStatus::WouldApply);
648        assert!(report.steps[0].commit_id.is_none());
649
650        // Version in repo should be unchanged
651        let v = read_stored_version(&repo, "main").unwrap();
652        assert_eq!(v, current);
653    }
654
655    #[test]
656    fn run_apply_executes_migration_and_advances_version() {
657        let repo = fresh_repo();
658        set_version(&repo, "0.3.0");
659        let target = Version::parse("0.4.0").unwrap();
660
661        let mut r = Registry::empty();
662        r.register(Box::new(StubMigration {
663            from: semver::VersionReq::parse(">=0.3, <0.4").unwrap(),
664            to: target.clone(),
665            name: "upgrade",
666            applies: true,
667        }));
668
669        let report = r.run(&repo, "main", &target, RunMode::Apply).unwrap();
670        assert_eq!(report.steps.len(), 1);
671        assert_eq!(report.steps[0].status, StepStatus::Applied);
672        assert!(report.steps[0].commit_id.is_some());
673
674        let v = read_stored_version(&repo, "main").unwrap();
675        assert_eq!(v, target);
676    }
677
678    #[test]
679    fn run_apply_multi_step_executes_in_order() {
680        let repo = fresh_repo();
681        set_version(&repo, "0.3.0");
682        let target = Version::parse("0.5.0").unwrap();
683
684        let mut r = Registry::empty();
685        r.register(Box::new(StubMigration {
686            from: semver::VersionReq::parse(">=0.3, <0.4").unwrap(),
687            to: Version::parse("0.4.0").unwrap(),
688            name: "step-a",
689            applies: true,
690        }));
691        r.register(Box::new(StubMigration {
692            from: semver::VersionReq::parse(">=0.4, <0.5").unwrap(),
693            to: target.clone(),
694            name: "step-b",
695            applies: true,
696        }));
697
698        let report = r.run(&repo, "main", &target, RunMode::Apply).unwrap();
699        assert_eq!(report.steps.len(), 2);
700        assert_eq!(report.steps[0].name, "step-a");
701        assert_eq!(report.steps[1].name, "step-b");
702        assert_eq!(read_stored_version(&repo, "main").unwrap(), target);
703    }
704
705    #[test]
706    fn run_stops_on_failure_and_returns_err() {
707        let repo = fresh_repo();
708        set_version(&repo, "0.3.0");
709        let target = Version::parse("0.5.0").unwrap();
710
711        let mut r = Registry::empty();
712        r.register(Box::new(FailingMigration {
713            from: semver::VersionReq::parse(">=0.3, <0.4").unwrap(),
714            to: Version::parse("0.4.0").unwrap(),
715            name: "will-fail",
716        }));
717        r.register(Box::new(StubMigration {
718            from: semver::VersionReq::parse(">=0.4, <0.5").unwrap(),
719            to: target.clone(),
720            name: "never-runs",
721            applies: true,
722        }));
723
724        let result = r.run(&repo, "main", &target, RunMode::Apply);
725        assert!(result.is_err(), "expected Err on migration failure");
726        // Version should still be 0.3 — the first migration never committed
727        let v = read_stored_version(&repo, "main").unwrap();
728        assert_eq!(v, Version::parse("0.3.0").unwrap());
729    }
730
731    #[test]
732    fn check_returns_upgrade_available_with_migration_names() {
733        let repo = fresh_repo();
734        set_version(&repo, "0.3.0");
735        let target = Version::parse("0.4.0").unwrap();
736
737        let mut registry = Registry::empty();
738        registry.register(Box::new(StubMigration {
739            from: semver::VersionReq::parse(">=0.3, <0.4").unwrap(),
740            to: target.clone(),
741            name: "upgrade-to-0.4",
742            applies: true,
743        }));
744
745        let r = check(&repo, "main", &target, &registry).unwrap();
746        if let CheckResult::UpgradeAvailable {
747            from,
748            to,
749            migrations,
750        } = r
751        {
752            assert_eq!(from, Version::parse("0.3.0").unwrap());
753            assert_eq!(to, target);
754            assert_eq!(migrations, vec!["upgrade-to-0.4"]);
755        } else {
756            panic!("expected UpgradeAvailable, got {r:?}");
757        }
758    }
759
760    #[test]
761    fn check_returns_corrupt_for_non_semver_stored_version() {
762        let repo = fresh_repo();
763        // Write a non-semver value directly
764        repo.set(
765            "main",
766            META_SCHEMA_VERSION_PATH,
767            &Object::Atom(Atom::String("not-semver!!".into())),
768            CommitOptions::new("test/corrupt", IntentCategory::Migrate, "corrupt version"),
769        )
770        .unwrap();
771
772        let r = check(&repo, "main", &binary_version(), &Registry::empty()).unwrap();
773        assert!(matches!(r, CheckResult::Corrupt(_)), "got {r:?}");
774    }
775
776    #[test]
777    fn step_status_display() {
778        assert_eq!(StepStatus::WouldApply.to_string(), "would-apply");
779        assert_eq!(StepStatus::WouldSkip.to_string(), "would-skip");
780        assert_eq!(StepStatus::Applied.to_string(), "ok");
781        assert_eq!(StepStatus::Skipped.to_string(), "skip");
782        assert_eq!(StepStatus::Failed.to_string(), "fail");
783    }
784
785    #[test]
786    fn migrate_commit_options_helper_uses_migrate_category() {
787        let opts = migrate_commit_options("my-migration", "description", "reasoning");
788        assert_eq!(opts.agent_id, "agentstategraph-migrate/my-migration");
789        // IntentCategory::Migrate is the expected category — verified by
790        // checking the commit produced carries it (simplest is just smoke-
791        // test the helper builds without panic)
792    }
793
794    #[test]
795    fn version_object_helper_produces_string_atom() {
796        let v = Version::parse("1.2.3").unwrap();
797        let obj = version_object(&v);
798        assert_eq!(obj, Object::Atom(Atom::String("1.2.3".into())));
799    }
800
801    #[test]
802    fn binary_version_parses_successfully() {
803        let v = binary_version();
804        assert!(v.major > 0 || v.minor > 0 || v.patch > 0);
805    }
806
807    #[test]
808    fn run_dry_run_skipped_step_when_applies_is_false() {
809        let repo = fresh_repo();
810        let current = binary_version();
811        let target = Version::parse("99.0.0").unwrap();
812
813        let mut r = Registry::empty();
814        r.register(Box::new(StubMigration {
815            from: semver::VersionReq::parse(&format!(">={current}")).unwrap(),
816            to: target.clone(),
817            name: "no-op",
818            applies: false, // applies_to returns false → WouldSkip
819        }));
820
821        let report = r.run(&repo, "main", &target, RunMode::DryRun).unwrap();
822        assert_eq!(report.steps[0].status, StepStatus::WouldSkip);
823    }
824}