diff --git a/src/shelfai/cli/main.py b/src/shelfai/cli/main.py index e0c8927..5fd98c7 100644 --- a/src/shelfai/cli/main.py +++ b/src/shelfai/cli/main.py @@ -269,10 +269,10 @@ def _resolve_shelf_root(path: str) -> Optional[Path]: base = Path(path).expanduser().resolve() if not base.exists(): return None - if (base / "index.md").exists() or (base / "chunks").exists(): + if (base / "index.md").exists() or (base / "chunks").exists() or (base / ".chunk-defaults.yaml").exists(): return base nested = base / "shelf" - if (nested / "index.md").exists() or (nested / "chunks").exists(): + if (nested / "index.md").exists() or (nested / "chunks").exists() or (nested / ".chunk-defaults.yaml").exists(): return nested return None @@ -311,6 +311,19 @@ def _learn_event_count(db_path: Path) -> int: return conn.execute("SELECT COUNT(*) FROM loads").fetchone()[0] +def _warn_if_migration_needed(shelf_root: Path) -> None: + from shelfai.core.migrate import ShelfMigrator + + migrator = ShelfMigrator() + detected = migrator.detect_version(str(shelf_root)) + if detected == migrator.CURRENT_VERSION: + return + console.print( + f"[yellow]⚠ This shelf uses ShelfAI {detected} format. " + "Run `shelfai migrate` to upgrade.[/yellow]" + ) + + def main_cli() -> None: """Entry point for installed `shelfai` and `python -m shelfai` usage.""" if len(sys.argv) == 2 and sys.argv[1] == "--version": @@ -430,6 +443,7 @@ def add( if not shelf.exists(): console.print(f"[red]No shelf found at {shelf_path}. Run `shelfai init` first.[/red]") raise typer.Exit(1) + _warn_if_migration_needed(shelf.path) config = shelf.load_config() allowed_categories = set(config.categories or []) @@ -557,6 +571,7 @@ def index_cmd( if not shelf.exists(): console.print(f"[red]No shelf found at {shelf_path}. Run `shelfai init` first.[/red]") raise typer.Exit(1) + _warn_if_migration_needed(shelf.path) if manual: editor = os.environ.get("EDITOR", "nano") @@ -690,6 +705,7 @@ def session( if not shelf.exists(): console.print(f"[red]No shelf found at {shelf_path}. Run `shelfai init` first.[/red]") raise typer.Exit(1) + _warn_if_migration_needed(shelf.path) agent_dir = None if agent: @@ -755,6 +771,7 @@ def extract( if not shelf.exists(): console.print(f"[red]No shelf found at {shelf_path}. Run `shelfai init` first.[/red]") raise typer.Exit(1) + _warn_if_migration_needed(shelf.path) agent_dir = None if agent: @@ -811,6 +828,7 @@ def search( f"[red]No shelf found at {resolved_shelf_path}. Run `shelfai init` first.[/red]" ) raise typer.Exit(1) + _warn_if_migration_needed(shelf.path) searcher = ChunkSearch(resolved_shelf_path) if searcher._index: @@ -881,6 +899,7 @@ def status( except FileNotFoundError: display_error(f"No shelf found at {shelf_path or path or '.'}. Run `shelfai init` first.") raise typer.Exit(1) + _warn_if_migration_needed(shelf.path) shelf = Shelf(str(shelf_path)) stats = shelf.status() @@ -1125,6 +1144,7 @@ def prune( if not shelf.exists(): console.print(f"[red]No shelf found at {shelf_path}. Run `shelfai init` first.[/red]") raise typer.Exit(1) + _warn_if_migration_needed(shelf.path) stats = shelf.status() @@ -1497,6 +1517,7 @@ def review_cmd( if not shelf.exists(): console.print(f"[red]No shelf found at {shelf_path}. Run `shelfai init` first.[/red]") raise typer.Exit(1) + _warn_if_migration_needed(shelf.path) stage_dir = shelf.path / "generated" / "review" / "new_context" stage_dir.mkdir(parents=True, exist_ok=True) @@ -2399,6 +2420,75 @@ def version( console.print() +# ────────────────────────────────────────────── +# shelfai migrate +# ────────────────────────────────────────────── + + +@app.command("migrate") +def migrate_cmd( + shelf_path: str = typer.Argument(".", help="Path to the shelf directory"), + dry_run: bool = typer.Option(False, "--dry-run", help="Show plan without writing changes"), + source_version: Optional[str] = typer.Option(None, "--from", help="Force source version"), + backup: bool = typer.Option(True, "--backup/--no-backup", help="Create a backup before migrating"), +): + """Detect the shelf format and migrate it to the current version.""" + from shelfai.core.migrate import ShelfMigrator + + migrator = ShelfMigrator() + root = Path(shelf_path).expanduser().resolve() + if not root.exists(): + console.print(f"[red]Path not found: {root}[/red]") + raise typer.Exit(1) + + if source_version is None: + plan = migrator.plan_migration(str(root)) + else: + plan = migrator._plan_from_version(str(root), migrator._normalize_version(source_version)) + + _print_migration_plan(plan) + + if dry_run: + return + + if plan.current_version == migrator.CURRENT_VERSION: + return + + if backup: + backup_path = migrator.backup(str(root)) + console.print(f"[dim]Backup created at {backup_path}[/dim]") + + try: + migrator.migrate(str(root), source_version=source_version) + except FileExistsError as exc: + console.print(f"[red]Migration failed: {exc}[/red]") + raise typer.Exit(1) + console.print(f"[green]Shelf migrated to {migrator.CURRENT_VERSION}[/green]") + + +def _print_migration_plan(plan) -> None: + console.print(f"\n[bold]Migration plan[/bold]\n") + console.print(f" Current version: {plan.current_version}") + console.print(f" Target version : {plan.target_version}\n") + + if not plan.steps: + console.print("[green]No migration needed.[/green]\n") + return + + for i, step in enumerate(plan.steps, 1): + console.print(f" {i}. {step.from_version} -> {step.to_version}") + console.print(f" {step.description}") + for change in step.changes: + console.print(f" - {change}") + console.print() + + if plan.breaking_changes: + console.print("[yellow]Breaking changes:[/yellow]") + for change in plan.breaking_changes: + console.print(f" - {change}") + console.print() + + # ────────────────────────────────────────────── # Helpers # ────────────────────────────────────────────── diff --git a/src/shelfai/core/__init__.py b/src/shelfai/core/__init__.py index 21ec5cd..7669284 100644 --- a/src/shelfai/core/__init__.py +++ b/src/shelfai/core/__init__.py @@ -2,6 +2,7 @@ from shelfai.core.conditions import ConditionalLoader, LoadCondition, LoadContext from shelfai.core.diff_report import ChunkDiff, ShelfDiffReport, compare_before_after, compare_shelves +from shelfai.core.migrate import MigrationPlan, MigrationStep, ShelfMigrator from shelfai.core.priority import ChunkPriority, PriorityManager __all__ = [ @@ -10,8 +11,11 @@ "ConditionalLoader", "LoadCondition", "LoadContext", + "MigrationPlan", + "MigrationStep", "PriorityManager", "ShelfDiffReport", + "ShelfMigrator", "compare_before_after", "compare_shelves", ] diff --git a/src/shelfai/core/migrate.py b/src/shelfai/core/migrate.py new file mode 100644 index 0000000..ef1b77c --- /dev/null +++ b/src/shelfai/core/migrate.py @@ -0,0 +1,389 @@ +"""Shelf format migration helpers.""" + +from __future__ import annotations + +import shutil +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Optional + +import yaml + +from shelfai.core.config import ShelfConfig +from shelfai.core.versioning import ChunkVersionStore + + +@dataclass +class MigrationStep: + from_version: str + to_version: str + description: str + changes: list[str] + + +@dataclass +class MigrationPlan: + current_version: str + target_version: str + steps: list[MigrationStep] + breaking_changes: list[str] + + +class ShelfMigrator: + """Migrate shelves between ShelfAI versions.""" + + CURRENT_VERSION = "0.3.0" + + def detect_version(self, shelf_path: str) -> str: + """ + Detect the shelf format version by inspecting structure. + + - Has .shelfai/ dir -> 0.3.0+ + - Has shelf.config.yaml -> 0.2.x + - Has index.md -> 0.1.x + - Just .md files -> 0.0.x (pre-shelfai manual chunks) + """ + root = Path(shelf_path).expanduser().resolve() + + if (root / ".shelfai").is_dir(): + return self.CURRENT_VERSION + if (root / "shelf.config.yaml").exists(): + return "0.2.x" + if (root / "index.md").exists(): + return "0.1.x" + if any(self._iter_markdown_files(root)): + return "0.0.x" + return "0.0.x" + + def plan_migration(self, shelf_path: str) -> MigrationPlan: + """Create a migration plan without executing it.""" + current_version = self.detect_version(shelf_path) + if current_version == self.CURRENT_VERSION: + return MigrationPlan( + current_version=current_version, + target_version=self.CURRENT_VERSION, + steps=[], + breaking_changes=[], + ) + + steps: list[MigrationStep] = [] + breaking_changes: list[str] = [] + for from_version, to_version, description, changes in self._planned_steps(current_version): + steps.append( + MigrationStep( + from_version=from_version, + to_version=to_version, + description=description, + changes=changes, + ) + ) + breaking_changes.append( + f"{from_version} -> {to_version}: {description}" + ) + + return MigrationPlan( + current_version=current_version, + target_version=self.CURRENT_VERSION, + steps=steps, + breaking_changes=breaking_changes, + ) + + def migrate( + self, + shelf_path: str, + dry_run: bool = False, + source_version: Optional[str] = None, + ) -> MigrationPlan: + """ + Execute migration to current version. + + If dry_run=True, show what would change without writing. + """ + root = Path(shelf_path).expanduser().resolve() + if source_version is None: + current_version = self.detect_version(str(root)) + else: + current_version = self._normalize_version(source_version) + + plan = self.plan_migration(str(root)) if source_version is None else self._plan_from_version( + str(root), current_version + ) + + if dry_run or current_version == self.CURRENT_VERSION: + return plan + + if current_version == "0.0.x": + self._migrate_0_0_to_0_1(str(root)) + current_version = "0.1.x" + + if current_version == "0.1.x": + self._migrate_0_1_to_0_2(str(root)) + current_version = "0.2.x" + + if current_version == "0.2.x": + self._migrate_0_2_to_0_3(str(root)) + + return self.plan_migration(str(root)) + + def _migrate_0_0_to_0_1(self, shelf_path: str): + """Create an index.md for a bare markdown shelf.""" + root = Path(shelf_path).expanduser().resolve() + index_path = root / "index.md" + if index_path.exists(): + return + + groups: dict[str, list[Path]] = {} + for path in self._iter_markdown_files(root): + if path.name in {"index.md", "shelf.config.yaml"}: + continue + if path.parts and path.parts[0] == "generated": + continue + category = path.parent.name.title() if path.parent != root else "Root" + groups.setdefault(category, []).append(path) + + lines = ["# ShelfAI Index", ""] + for category in sorted(groups): + lines.append(f"## {category}") + for path in sorted(groups[category]): + lines.append( + f"- **{path.as_posix()}** — Legacy shelf file preserved during migration." + ) + lines.append("") + + index_path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8") + + def _migrate_0_1_to_0_2(self, shelf_path: str): + """Convert index.md shelf to shelf.config.yaml format.""" + root = Path(shelf_path).expanduser().resolve() + config_path = root / "shelf.config.yaml" + if config_path.exists(): + return + + categories = self._extract_index_categories(root / "index.md") + config = ShelfConfig.default() + config.project_name = root.name + if categories: + config.categories = categories + config_path.write_text(config.to_yaml(), encoding="utf-8") + + def _migrate_0_2_to_0_3(self, shelf_path: str): + """ + Convert to current format: + - Rename shelf.config.yaml -> .chunk-defaults.yaml + - Create .shelfai/ directory + - Initialize version store + - Move chunks to flat structure + """ + root = Path(shelf_path).expanduser().resolve() + config_path = root / "shelf.config.yaml" + defaults_path = root / ".chunk-defaults.yaml" + + if config_path.exists(): + if defaults_path.exists(): + config_path.unlink() + else: + config_path.replace(defaults_path) + elif not defaults_path.exists(): + defaults_path.write_text(yaml.safe_dump({}, sort_keys=False), encoding="utf-8") + + shelfai_dir = root / ".shelfai" + shelfai_dir.mkdir(parents=True, exist_ok=True) + + store = ChunkVersionStore(shelf_path=str(shelfai_dir)) + store._versions = {} + store._save() + + self._flatten_chunks(root) + self._seed_version_store(root, store) + + def backup(self, shelf_path: str) -> str: + """Create a backup before migration. Returns backup path.""" + root = Path(shelf_path).expanduser().resolve() + backup_dir = root / ".shelfai" / "backups" / self._timestamp_id() + backup_dir.mkdir(parents=True, exist_ok=True) + + for item in root.iterdir(): + if item.name == ".shelfai": + continue + self._copy_item(item, backup_dir / item.name) + + return str(backup_dir) + + def _plan_from_version(self, shelf_path: str, current_version: str) -> MigrationPlan: + plan = MigrationPlan( + current_version=current_version, + target_version=self.CURRENT_VERSION, + steps=[], + breaking_changes=[], + ) + for from_version, to_version, description, changes in self._planned_steps(current_version): + plan.steps.append( + MigrationStep( + from_version=from_version, + to_version=to_version, + description=description, + changes=changes, + ) + ) + plan.breaking_changes.append(f"{from_version} -> {to_version}: {description}") + return plan + + def _planned_steps(self, current_version: str): + step_map = { + "0.0.x": [ + ( + "0.0.x", + "0.1.x", + "Create index.md for legacy markdown files.", + [ + "Generate an index for bare markdown files.", + "Preserve existing file content in place.", + ], + ), + ( + "0.1.x", + "0.2.x", + "Write shelf.config.yaml from the indexed shelf.", + [ + "Capture shelf categories from the index.", + "Keep Markdown files unchanged.", + ], + ), + ( + "0.2.x", + "0.3.0", + "Move configuration to .chunk-defaults.yaml and initialize .shelfai.", + [ + "Rename shelf.config.yaml to .chunk-defaults.yaml.", + "Create .shelfai/versions.json.", + "Flatten chunk files under chunks/.", + ], + ), + ], + "0.1.x": [ + ( + "0.1.x", + "0.2.x", + "Write shelf.config.yaml from the indexed shelf.", + [ + "Capture shelf categories from the index.", + "Keep Markdown files unchanged.", + ], + ), + ( + "0.2.x", + "0.3.0", + "Move configuration to .chunk-defaults.yaml and initialize .shelfai.", + [ + "Rename shelf.config.yaml to .chunk-defaults.yaml.", + "Create .shelfai/versions.json.", + "Flatten chunk files under chunks/.", + ], + ), + ], + "0.2.x": [ + ( + "0.2.x", + "0.3.0", + "Move configuration to .chunk-defaults.yaml and initialize .shelfai.", + [ + "Rename shelf.config.yaml to .chunk-defaults.yaml.", + "Create .shelfai/versions.json.", + "Flatten chunk files under chunks/.", + ], + ) + ], + } + + if current_version == self.CURRENT_VERSION: + return [] + if current_version not in step_map: + raise ValueError(f"Unsupported shelf version: {current_version}") + return step_map[current_version] + + def _flatten_chunks(self, root: Path) -> None: + chunks_dir = root / "chunks" + if not chunks_dir.exists(): + return + + files = sorted( + path for path in chunks_dir.rglob("*.md") if path.is_file() + ) + for path in files: + relative = path.relative_to(chunks_dir) + if len(relative.parts) == 1: + continue + flattened_name = "-".join(relative.with_suffix("").parts) + ".md" + destination = chunks_dir / flattened_name + destination.parent.mkdir(parents=True, exist_ok=True) + if destination.exists() and destination != path: + raise FileExistsError( + f"Cannot flatten {path}: destination already exists at {destination}" + ) + if destination != path: + shutil.move(str(path), str(destination)) + + for directory in sorted( + (p for p in chunks_dir.rglob("*") if p.is_dir()), + key=lambda p: len(p.parts), + reverse=True, + ): + try: + directory.rmdir() + except OSError: + pass + + def _seed_version_store(self, root: Path, store: ChunkVersionStore) -> None: + chunks_dir = root / "chunks" + if not chunks_dir.exists(): + return + for path in sorted(chunks_dir.glob("*.md")): + store.record_version(path.stem, path.read_text(encoding="utf-8")) + + def _extract_index_categories(self, index_path: Path) -> list[str]: + if not index_path.exists(): + return [] + categories: list[str] = [] + for line in index_path.read_text(encoding="utf-8").splitlines(): + if line.startswith("## "): + category = line[3:].strip() + if category and category not in categories: + categories.append(category.lower()) + return categories + + def _iter_markdown_files(self, root: Path): + if not root.exists(): + return [] + return ( + path + for path in root.rglob("*.md") + if path.is_file() and ".shelfai" not in path.as_posix().split("/") + ) + + def _copy_item(self, source: Path, destination: Path) -> None: + if source.is_dir(): + shutil.copytree( + source, + destination, + dirs_exist_ok=True, + ignore=shutil.ignore_patterns(".shelfai"), + ) + return + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + + def _timestamp_id(self) -> str: + return datetime.utcnow().strftime("%Y%m%d_%H%M%S_%f") + + def _normalize_version(self, version: str) -> str: + value = version.strip() + if value in {"0.0", "0.0.x", "0.0.0"}: + return "0.0.x" + if value in {"0.1", "0.1.x", "0.1.0"}: + return "0.1.x" + if value in {"0.2", "0.2.x", "0.2.0"}: + return "0.2.x" + if value == self.CURRENT_VERSION: + return self.CURRENT_VERSION + return value diff --git a/src/shelfai/core/shelf.py b/src/shelfai/core/shelf.py index 0beaee0..fff78a3 100644 --- a/src/shelfai/core/shelf.py +++ b/src/shelfai/core/shelf.py @@ -74,6 +74,9 @@ def load_config(self) -> ShelfConfig: config_path = self.path / "shelf.config.yaml" if config_path.exists(): self._config = ShelfConfig.load(config_path) + elif (self.path / ".chunk-defaults.yaml").exists(): + # Migrated shelves may rename the legacy shelf config in place. + self._config = ShelfConfig.load(self.path / ".chunk-defaults.yaml") else: self._config = ShelfConfig.default() return self._config diff --git a/tests/test_migrate.py b/tests/test_migrate.py new file mode 100644 index 0000000..4b8c012 --- /dev/null +++ b/tests/test_migrate.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from pathlib import Path + +from typer.testing import CliRunner + +from shelfai.cli.main import app +from shelfai.core.migrate import ShelfMigrator + + +runner = CliRunner() + + +def _write_legacy_shelf(root: Path, *, with_config: bool = False, with_shelfai: bool = False): + root.mkdir(parents=True, exist_ok=True) + (root / "index.md").write_text( + "# ShelfAI Index\n\n" + "## Skills\n" + "- **chunks/nested/alpha.md** — Alpha chunk.\n" + "- **chunks/beta.md** — Beta chunk.\n", + encoding="utf-8", + ) + (root / "chunks" / "nested").mkdir(parents=True, exist_ok=True) + (root / "chunks" / "nested" / "alpha.md").write_text("Alpha chunk body.\n", encoding="utf-8") + (root / "chunks").mkdir(exist_ok=True) + (root / "chunks" / "beta.md").write_text("Beta chunk body.\n", encoding="utf-8") + + if with_config: + (root / "shelf.config.yaml").write_text( + "version: '1.0'\nproject:\n name: legacy\ncategories:\n - skills\n", + encoding="utf-8", + ) + if with_shelfai: + (root / ".shelfai").mkdir(parents=True, exist_ok=True) + + +def test_detect_version_0_3(tmp_path): + root = tmp_path / "shelf" + (root / ".shelfai").mkdir(parents=True) + + assert ShelfMigrator().detect_version(str(root)) == "0.3.0" + + +def test_detect_version_0_2(tmp_path): + root = tmp_path / "shelf" + root.mkdir() + (root / "shelf.config.yaml").write_text("version: '1.0'\n", encoding="utf-8") + + assert ShelfMigrator().detect_version(str(root)) == "0.2.x" + + +def test_detect_version_0_1(tmp_path): + root = tmp_path / "shelf" + root.mkdir() + (root / "index.md").write_text("# ShelfAI Index\n", encoding="utf-8") + + assert ShelfMigrator().detect_version(str(root)) == "0.1.x" + + +def test_detect_version_0_0(tmp_path): + root = tmp_path / "shelf" + root.mkdir() + (root / "notes.md").write_text("hello\n", encoding="utf-8") + + assert ShelfMigrator().detect_version(str(root)) == "0.0.x" + + +def test_plan_migration_shows_steps(tmp_path): + root = tmp_path / "shelf" + root.mkdir() + (root / "notes.md").write_text("hello\n", encoding="utf-8") + + plan = ShelfMigrator().plan_migration(str(root)) + + assert [step.from_version for step in plan.steps] == ["0.0.x", "0.1.x", "0.2.x"] + assert plan.steps[-1].to_version == "0.3.0" + assert plan.breaking_changes + + +def test_dry_run_no_changes(tmp_path): + root = tmp_path / "shelf" + _write_legacy_shelf(root) + + migrator = ShelfMigrator() + before = sorted(p.relative_to(root).as_posix() for p in root.rglob("*")) + plan = migrator.migrate(str(root), dry_run=True) + after = sorted(p.relative_to(root).as_posix() for p in root.rglob("*")) + + assert plan.steps + assert before == after + assert not (root / ".chunk-defaults.yaml").exists() + assert not (root / ".shelfai").exists() + + +def test_migrate_0_1_to_current(tmp_path): + root = tmp_path / "shelf" + _write_legacy_shelf(root) + + migrator = ShelfMigrator() + plan = migrator.migrate(str(root)) + + assert plan.current_version == "0.3.0" + assert (root / ".chunk-defaults.yaml").exists() + assert not (root / "shelf.config.yaml").exists() + assert (root / ".shelfai" / "versions.json").exists() + assert (root / "chunks" / "nested-alpha.md").read_text(encoding="utf-8") == "Alpha chunk body.\n" + assert (root / "chunks" / "beta.md").read_text(encoding="utf-8") == "Beta chunk body.\n" + + +def test_migrate_0_2_to_current(tmp_path): + root = tmp_path / "shelf" + _write_legacy_shelf(root, with_config=True) + + migrator = ShelfMigrator() + plan = migrator.migrate(str(root)) + + assert plan.current_version == "0.3.0" + assert (root / ".chunk-defaults.yaml").exists() + assert not (root / "shelf.config.yaml").exists() + assert (root / ".shelfai" / "versions.json").exists() + assert (root / "chunks" / "nested-alpha.md").read_text(encoding="utf-8") == "Alpha chunk body.\n" + assert (root / "chunks" / "beta.md").read_text(encoding="utf-8") == "Beta chunk body.\n" + + +def test_already_current_no_op(tmp_path): + root = tmp_path / "shelf" + _write_legacy_shelf(root, with_shelfai=True) + (root / ".chunk-defaults.yaml").write_text("always_load: [soul]\n", encoding="utf-8") + + plan = ShelfMigrator().migrate(str(root), dry_run=True) + + assert plan.current_version == "0.3.0" + assert not plan.steps + + +def test_backup_created(tmp_path): + root = tmp_path / "shelf" + _write_legacy_shelf(root, with_config=True) + + backup_path = Path(ShelfMigrator().backup(str(root))) + + assert backup_path.exists() + assert (backup_path / "index.md").exists() + assert (backup_path / "chunks" / "beta.md").exists() + + +def test_migration_preserves_content(tmp_path): + root = tmp_path / "shelf" + _write_legacy_shelf(root, with_config=True) + + ShelfMigrator().migrate(str(root)) + + assert (root / "chunks" / "nested-alpha.md").read_text(encoding="utf-8") == "Alpha chunk body.\n" + assert (root / "chunks" / "beta.md").read_text(encoding="utf-8") == "Beta chunk body.\n" + + +def test_status_warns_on_migration_needed(tmp_path): + root = tmp_path / "shelf" + _write_legacy_shelf(root) + + result = runner.invoke(app, ["status", "--shelf", str(root)]) + + assert result.exit_code == 0 + assert "Run `shelfai migrate` to upgrade" in result.output + + +def test_migrate_cli_dry_run(tmp_path): + root = tmp_path / "shelf" + _write_legacy_shelf(root) + + result = runner.invoke(app, ["migrate", str(root), "--dry-run"]) + + assert result.exit_code == 0 + assert "Migration plan" in result.output + assert "0.1.x -> 0.2.x" in result.output