Skip to main content

grex_cli/cli/verbs/
migrate_lockfile.rs

1//! `grex migrate-lockfile` — opt-in v1.1.x → v1.2.0 lockfile migrator.
2//!
3//! v1.2.1 item 2 (per `openspec/feat-v1.2.1/spec.md` §"CLI `grex
4//! migrate-lockfile` dispatcher"). Thin shim over the v1.2.0 Stage 1.h
5//! library migrator [`grex_core::lockfile::migrate_v1_1_1::migrate_v1_1_1_lockfile`],
6//! which is held under the `pub mod migrate_v1_1_1` long path so that
7//! walker / sync / ls / doctor / add / rm cannot reach it (Stage 0
8//! LOCKED decision #5 — removable-module isolation).
9//!
10//! # Behaviour
11//!
12//! * `--pack <path>` selects the meta whose
13//!   `<pack>/.grex/grex.lock.jsonl` is migrated. Defaults to the
14//!   current working directory. The legacy `--workspace` spelling is
15//!   preserved as a deprecated alias and emits a one-time warning per
16//!   process; removal scheduled for v2.0.0.
17//! * `--dry-run` (`-n`) inspects the on-disk shape and reports what
18//!   would happen without writing. Lockfile bytes are unchanged.
19//! * Without `--dry-run`, the migrator rewrites the lockfile in place
20//!   (atomic temp+rename via the library's existing
21//!   `write_meta_lockfile`).
22//! * Idempotent: running twice on a v1.2.0+ lockfile is a no-op.
23//! * Exits 0 on success (including the no-lockfile and already-migrated
24//!   no-op paths). Exits 1 on IO / corruption errors surfaced by the
25//!   migrator.
26
27use crate::cli::args::{GlobalFlags, MigrateLockfileArgs};
28use anyhow::Result;
29use grex_core::lockfile::migrate_v1_1_1::migrate_v1_1_1_lockfile;
30use grex_core::lockfile::{detect_legacy_lockfile, meta_lockfile_path};
31use std::path::Path;
32use tokio_util::sync::CancellationToken;
33
34pub fn run(
35    args: MigrateLockfileArgs,
36    global: &GlobalFlags,
37    _cancel: &CancellationToken,
38) -> Result<()> {
39    crate::cli::deprecation::warn_workspace_alias_used();
40    let workspace = match args.pack.clone() {
41        Some(p) => p,
42        None => std::env::current_dir()?,
43    };
44    let dry_run = args.dry_run || global.dry_run;
45
46    if dry_run {
47        run_dry_run(&workspace, global.json)
48    } else {
49        run_migrate(&workspace, global.json)
50    }
51}
52
53/// `--dry-run` path. Inspects the lockfile shape via the library's
54/// `detect_legacy_lockfile` predicate (which short-circuits on missing
55/// or already-v1.2.0 files) and reports the would-be outcome without
56/// touching the bytes.
57fn run_dry_run(workspace: &std::path::Path, json: bool) -> Result<()> {
58    let lockfile = meta_lockfile_path(workspace);
59    if !lockfile.exists() {
60        emit_outcome(json, &lockfile, Outcome::NoLockfile, true);
61        return Ok(());
62    }
63    let legacy = detect_legacy_lockfile(workspace)?;
64    let outcome = if legacy { Outcome::WouldMigrate } else { Outcome::AlreadyMigrated };
65    emit_outcome(json, &lockfile, outcome, true);
66    Ok(())
67}
68
69/// Wet-run path. Calls the library migrator and reports its
70/// [`grex_core::lockfile::migrate_v1_1_1::MigrationReport`] outcome.
71fn run_migrate(workspace: &std::path::Path, json: bool) -> Result<()> {
72    let report = migrate_v1_1_1_lockfile(workspace)?;
73    let outcome = if report.no_lockfile {
74        Outcome::NoLockfile
75    } else if report.already_migrated {
76        Outcome::AlreadyMigrated
77    } else {
78        Outcome::Migrated { entries: report.migrated_entries }
79    };
80    emit_outcome(json, &report.lockfile, outcome, false);
81    Ok(())
82}
83
84#[derive(Debug, Clone, Copy)]
85enum Outcome {
86    /// Meta has no lockfile; nothing to migrate.
87    NoLockfile,
88    /// Lockfile already in v1.2.0 shape; no rewrite needed.
89    AlreadyMigrated,
90    /// `--dry-run` only: legacy v1.1.x shape detected; would migrate.
91    WouldMigrate,
92    /// Wet-run only: legacy v1.1.x shape was rewritten to v1.2.0.
93    Migrated { entries: usize },
94}
95
96fn emit_outcome(json: bool, lockfile: &Path, outcome: Outcome, dry_run: bool) {
97    if json {
98        let (status, entries) = match outcome {
99            Outcome::NoLockfile => ("no_lockfile", None),
100            Outcome::AlreadyMigrated => ("already_migrated", None),
101            Outcome::WouldMigrate => ("would_migrate", None),
102            Outcome::Migrated { entries } => ("migrated", Some(entries)),
103        };
104        let doc = serde_json::json!({
105            "verb": "migrate-lockfile",
106            "lockfile": lockfile.display().to_string(),
107            "status": status,
108            "entries": entries,
109            "dry_run": dry_run,
110        });
111        if let Ok(s) = serde_json::to_string(&doc) {
112            println!("{s}");
113        }
114    } else {
115        let path = lockfile.display();
116        match outcome {
117            Outcome::NoLockfile => {
118                println!("no lockfile at {path}; nothing to migrate");
119            }
120            Outcome::AlreadyMigrated => {
121                println!("{path}: already on v1.2.0 schema; no-op");
122            }
123            Outcome::WouldMigrate => {
124                println!("{path}: v1.1.x → v1.2.0 (would migrate; --dry-run, no write)");
125            }
126            Outcome::Migrated { entries } => {
127                println!("{path}: migrated {entries} entries v1.1.x → v1.2.0");
128            }
129        }
130    }
131}