1pub 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
30pub 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#[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
66pub trait Migration: Send + Sync {
68 #[allow(clippy::wrong_self_convention)]
70 fn from_version(&self) -> &semver::VersionReq;
71
72 fn to_version(&self) -> &Version;
74
75 fn name(&self) -> &str;
77
78 fn describe(&self) -> &str;
80
81 fn applies_to(&self, repo: &Repository, ref_name: &str) -> Result<bool, MigrateError>;
85
86 fn migrate(&self, repo: &Repository, ref_name: &str) -> Result<MigrationOutcome, MigrateError>;
90}
91
92pub 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 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 pub fn plan<'a>(&'a self, current: &Version, target: &Version) -> Vec<&'a dyn Migration> {
123 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 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(¤t, 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); 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#[derive(Debug, Clone)]
287pub enum CheckResult {
288 UpToDate { version: Version },
290 UpgradeAvailable {
292 from: Version,
293 to: Version,
294 migrations: Vec<String>,
295 },
296 Downgrade { db: Version, binary: Version },
298 Unversioned { implicit: Version },
300 Corrupt(String),
302}
303
304pub 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
344pub fn binary_version() -> Version {
346 Version::parse(SCHEMA_VERSION).expect("workspace version parses as semver")
347}
348
349enum 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
387pub 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
402pub fn version_object(v: &Version) -> Object {
404 Object::Atom(Atom::String(v.to_string()))
405}
406
407pub 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 struct StubMigration {
435 name: &'static str,
436 from: semver::VersionReq,
437 to: Version,
438 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 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 #[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, ®istry).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 #[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 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 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 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 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 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, ®istry).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 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 }
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, }));
820
821 let report = r.run(&repo, "main", &target, RunMode::DryRun).unwrap();
822 assert_eq!(report.steps[0].status, StepStatus::WouldSkip);
823 }
824}