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