Skip to main content

grex_cli/cli/verbs/
sync.rs

1//! `grex sync` — drive the M3 Stage B end-to-end pipeline.
2//!
3//! Thin CLI glue: parse args → build [`grex_core::sync::SyncOptions`] → call
4//! [`grex_core::sync::run`] → format the resulting [`grex_core::sync::SyncReport`].
5//!
6//! Exit codes:
7//! * `0` — success.
8//! * `1` — plan-phase validation errors (manifest + graph).
9//! * `2` — action execution error (wet-run or dry-run executor).
10//! * `3` — unrecoverable orchestrator failure (tree walk, workspace setup).
11//!
12//! When `pack_root` is not provided, the legacy M1 stub behaviour is
13//! preserved: print `"grex sync: unimplemented"` and exit 0. This keeps the
14//! smoke / property tests in `tests/` passing without adding per-test
15//! fixtures.
16
17use crate::cli::args::{GlobalFlags, SyncArgs};
18use anyhow::Result;
19use grex_core::sync::{self, HaltedContext, SyncError, SyncOptions, SyncReport, SyncStep};
20use tokio_util::sync::CancellationToken;
21
22/// Entry point for the `sync` verb.
23///
24/// # Errors
25///
26/// Surface the `anyhow::Result` so `main` can render whatever the
27/// orchestrator layer emitted; exit codes are set via `std::process::exit`
28/// on the halt paths since `anyhow::Error` does not carry them.
29pub fn run(args: SyncArgs, global: &GlobalFlags, cancel: &CancellationToken) -> Result<()> {
30    let Some(pack_root) = args.pack_root.clone() else {
31        // Missing required positional → usage error. `--json` emits the
32        // canonical error envelope (`{verb, error: {kind, message}}`);
33        // text mode prints a hint to stderr. Both paths exit 2 (the
34        // `cli.md` frozen usage-error code), matching how the MCP
35        // surface's `packop_error` reports the same failure.
36        if global.json {
37            emit_json_error(
38                "usage",
39                "`<pack_root>` is required (directory with `.grex/pack.yaml` or the YAML file)",
40                "sync",
41            );
42        } else {
43            eprintln!(
44                "grex sync: <pack_root> required (directory with `.grex/pack.yaml` or the YAML file)"
45            );
46        }
47        std::process::exit(2);
48    };
49    let dry_run = args.dry_run || global.dry_run;
50    let only_patterns = if args.only.is_empty() { None } else { Some(args.only.clone()) };
51
52    // v1.2.1 Item 5b — `--quarantine` only applies when an override
53    // flag is also set. Reject the combination at the CLI boundary so
54    // the operator sees a clear error instead of `--quarantine` being
55    // a silent no-op (Phase 2 wouldn't enter the override path at all
56    // without a `force_prune*` flag, so the snapshot would never fire).
57    if args.quarantine && !(args.force_prune || args.force_prune_with_ignored) {
58        let msg = "--quarantine requires --force-prune or --force-prune-with-ignored";
59        if global.json {
60            emit_json_error("usage", msg, "sync");
61        } else {
62            eprintln!("grex sync: {msg}");
63        }
64        std::process::exit(2);
65    }
66
67    let opts = SyncOptions::new()
68        .with_dry_run(dry_run)
69        .with_validate(!args.no_validate)
70        .with_workspace(args.workspace.clone())
71        .with_ref_override(args.ref_override.clone())
72        .with_only_patterns(only_patterns)
73        .with_force(args.force)
74        .with_force_prune(args.force_prune)
75        .with_force_prune_with_ignored(args.force_prune_with_ignored)
76        .with_quarantine(args.quarantine);
77    match run_impl(&pack_root, &opts, args.quiet, global.json, cancel) {
78        RunOutcome::Ok => Ok(()),
79        RunOutcome::UsageError => std::process::exit(2),
80        RunOutcome::Validation => std::process::exit(1),
81        RunOutcome::Exec => std::process::exit(2),
82        RunOutcome::Tree => std::process::exit(3),
83    }
84}
85
86pub(super) enum RunOutcome {
87    Ok,
88    /// CLI usage error (invalid `--only` glob, etc.). Maps to exit 2 — the
89    /// `cli.md` frozen exit code for usage errors.
90    UsageError,
91    Validation,
92    Exec,
93    Tree,
94}
95
96fn run_impl(
97    pack_root: &std::path::Path,
98    opts: &SyncOptions,
99    quiet: bool,
100    json: bool,
101    cancel: &CancellationToken,
102) -> RunOutcome {
103    match sync::run(pack_root, opts, cancel) {
104        Ok(report) => {
105            if json {
106                emit_json_report(&report, opts.dry_run, "sync");
107            } else {
108                render_report(&report, opts.dry_run, quiet);
109            }
110            if report.halted.is_some() {
111                return RunOutcome::Exec;
112            }
113            RunOutcome::Ok
114        }
115        Err(err) => classify_sync_err(err, json, "sync"),
116    }
117}
118
119/// Map a [`SyncError`] to a [`RunOutcome`] and emit the human or JSON
120/// error block. Extracted from `run_impl` to keep clippy's
121/// `too_many_lines` guard happy — the verb identifier is parameterised
122/// so `teardown` can reuse the same routing.
123pub(super) fn classify_sync_err(err: SyncError, json: bool, verb: &str) -> RunOutcome {
124    match err {
125        SyncError::Validation { errors } => {
126            emit_validation(&errors, json, verb);
127            RunOutcome::Validation
128        }
129        SyncError::Tree(e) => {
130            emit_simple("tree", &e.to_string(), "tree walk failed", json, verb);
131            RunOutcome::Tree
132        }
133        SyncError::Exec(e) => {
134            emit_simple("exec", &e.to_string(), "execution error", json, verb);
135            RunOutcome::Exec
136        }
137        SyncError::Halted(ctx) => {
138            if json {
139                emit_json_halted(&ctx, verb);
140            } else {
141                print_halted_context(&ctx);
142            }
143            RunOutcome::Exec
144        }
145        SyncError::InvalidOnlyGlob { pattern, source } => {
146            let msg = format!("invalid --only glob `{pattern}`: {source}");
147            emit_simple("usage", &msg, "error", json, verb);
148            RunOutcome::UsageError
149        }
150        other => {
151            emit_simple("other", &other.to_string(), &format!("{verb} failed"), json, verb);
152            RunOutcome::Tree
153        }
154    }
155}
156
157fn emit_validation(errors: &[impl std::fmt::Display], json: bool, verb: &str) {
158    if json {
159        let joined = errors.iter().map(|e| e.to_string()).collect::<Vec<_>>().join("; ");
160        emit_json_error("validation", &joined, verb);
161    } else {
162        eprintln!("validation failed:");
163        for e in errors {
164            eprintln!("  - {e}");
165        }
166    }
167}
168
169fn emit_simple(kind: &str, message: &str, human_prefix: &str, json: bool, verb: &str) {
170    if json {
171        emit_json_error(kind, message, verb);
172    } else {
173        eprintln!("{human_prefix}: {message}");
174    }
175}
176
177/// Emit the sync/teardown `SyncReport` as a JSON object mirroring the
178/// human text path. One `steps` entry per action; plus the halted
179/// context (when present) and a summary count block.
180pub(super) fn emit_json_report(report: &SyncReport, dry_run: bool, verb: &str) {
181    let steps: Vec<serde_json::Value> =
182        report.steps.iter().map(|s| step_to_json(s, dry_run)).collect();
183    let halted = report.halted.as_ref().and_then(|h| match h {
184        SyncError::Halted(ctx) => Some(serde_json::json!({
185            "pack": ctx.pack,
186            "action": ctx.action_name,
187            "idx": ctx.action_idx,
188            "error": ctx.error.to_string(),
189            "recovery_hint": ctx.recovery_hint,
190        })),
191        _ => None,
192    });
193    let migrations: Vec<serde_json::Value> = report
194        .workspace_migrations
195        .iter()
196        .map(|m| {
197            serde_json::json!({
198                "from": m.from.display().to_string(),
199                "to": m.to.display().to_string(),
200                "outcome": migration_outcome_tag(&m.outcome),
201                "error": match &m.outcome {
202                    grex_core::sync::MigrationOutcome::Failed { error } => {
203                        serde_json::Value::String(error.clone())
204                    }
205                    _ => serde_json::Value::Null,
206                },
207            })
208        })
209        .collect();
210    let doc = serde_json::json!({
211        "verb": verb,
212        "dry_run": dry_run,
213        "steps": steps,
214        "halted": halted,
215        "event_log_warnings": report.event_log_warnings,
216        "workspace_migrations": migrations,
217        "summary": {"total_steps": report.steps.len()},
218    });
219    if let Ok(s) = serde_json::to_string(&doc) {
220        println!("{s}");
221    }
222}
223
224fn step_to_json(s: &SyncStep, dry_run: bool) -> serde_json::Value {
225    use grex_core::ExecResult;
226    let (result, details) = match &s.exec_step.result {
227        ExecResult::PerformedChange => ("performed_change", serde_json::Value::Null),
228        ExecResult::WouldPerformChange => {
229            if dry_run {
230                ("would_perform_change", serde_json::Value::Null)
231            } else {
232                ("performed_change", serde_json::Value::Null)
233            }
234        }
235        ExecResult::AlreadySatisfied => ("already_satisfied", serde_json::Value::Null),
236        ExecResult::NoOp => ("noop", serde_json::Value::Null),
237        ExecResult::Skipped { pack_path, actions_hash, .. } => (
238            "skipped",
239            serde_json::json!({
240                "pack_path": pack_path.display().to_string(),
241                "actions_hash": actions_hash,
242            }),
243        ),
244        _ => ("other", serde_json::Value::Null),
245    };
246    serde_json::json!({
247        "pack": s.pack,
248        "action": s.exec_step.action_name,
249        "idx": s.action_idx,
250        "result": result,
251        "details": details,
252    })
253}
254
255pub(super) fn emit_json_error(kind: &str, message: &str, verb: &str) {
256    let doc = serde_json::json!({
257        "verb": verb,
258        "error": {
259            "kind": kind,
260            "message": message,
261        },
262    });
263    if let Ok(s) = serde_json::to_string(&doc) {
264        println!("{s}");
265    }
266}
267
268pub(super) fn emit_json_halted(ctx: &HaltedContext, verb: &str) {
269    let doc = serde_json::json!({
270        "verb": verb,
271        "halted": {
272            "pack": ctx.pack,
273            "action": ctx.action_name,
274            "idx": ctx.action_idx,
275            "error": ctx.error.to_string(),
276            "recovery_hint": ctx.recovery_hint,
277        },
278    });
279    if let Ok(s) = serde_json::to_string(&doc) {
280        println!("{s}");
281    }
282}
283
284/// Format a [`HaltedContext`] to stderr with pack + action context and,
285/// when available, a human recovery hint.
286fn print_halted_context(ctx: &HaltedContext) {
287    eprintln!(
288        "sync halted at pack `{}` action #{} ({}):",
289        ctx.pack, ctx.action_idx, ctx.action_name
290    );
291    eprintln!("  error: {}", ctx.error);
292    if let Some(hint) = &ctx.recovery_hint {
293        eprintln!("  hint:  {hint}");
294    }
295}
296
297fn render_report(report: &SyncReport, dry_run: bool, quiet: bool) {
298    if !quiet {
299        if !report.workspace_migrations.is_empty() {
300            print_workspace_migrations(&report.workspace_migrations);
301        }
302        if let Some(rec) = &report.pre_run_recovery {
303            print_recovery_report(rec);
304        }
305        for s in &report.steps {
306            print_step(s, dry_run);
307        }
308    }
309    for w in &report.event_log_warnings {
310        eprintln!("warning: {w}");
311    }
312    if let Some(err) = &report.halted {
313        match err {
314            SyncError::Halted(ctx) => print_halted_context(ctx),
315            other => eprintln!("halted: {other}"),
316        }
317    }
318}
319
320/// Surface the legacy-layout migration outcomes one line each so users
321/// see exactly what happened during the v1.0.x → v1.1.0 upgrade. Empty
322/// list does not print (the common case for any workspace built fresh
323/// on v1.1.0+).
324fn print_workspace_migrations(migrations: &[grex_core::sync::WorkspaceMigration]) {
325    use grex_core::sync::MigrationOutcome;
326    for m in migrations {
327        let from = m.from.display();
328        let to = m.to.display();
329        match &m.outcome {
330            MigrationOutcome::Migrated => {
331                eprintln!("[migrated] legacy={from} -> new={to}");
332            }
333            MigrationOutcome::SkippedBothExist => {
334                eprintln!("[skipped]  legacy={from} AND new={to} both exist; resolve manually",);
335            }
336            MigrationOutcome::SkippedDestOccupied => {
337                eprintln!("[skipped]  destination={to} occupied; legacy={from} kept");
338            }
339            MigrationOutcome::Failed { error } => {
340                eprintln!("[failed]   legacy={from} -> new={to}: {error}");
341            }
342            // MigrationOutcome is #[non_exhaustive]; future variants
343            // render with a generic tag until they earn dedicated copy.
344            other => eprintln!("[unknown]  legacy={from} -> new={to} ({other:?})"),
345        }
346    }
347}
348
349/// Stable string tag per outcome for `--json` consumers. Lowercase
350/// snake-case so it matches the rest of the CLI JSON envelope.
351fn migration_outcome_tag(o: &grex_core::sync::MigrationOutcome) -> &'static str {
352    use grex_core::sync::MigrationOutcome;
353    match o {
354        MigrationOutcome::Migrated => "migrated",
355        MigrationOutcome::SkippedBothExist => "skipped_both_exist",
356        MigrationOutcome::SkippedDestOccupied => "skipped_dest_occupied",
357        MigrationOutcome::Failed { .. } => "failed",
358        // MigrationOutcome is #[non_exhaustive]; future variants stream
359        // through a generic tag until they earn a stable name.
360        _ => "other",
361    }
362}
363
364/// Emit a short, informational block listing any crash-recovery
365/// artifacts found before this sync started. Does not block the run.
366fn print_recovery_report(rec: &grex_core::sync::RecoveryReport) {
367    let total = rec.orphan_backups.len() + rec.orphan_tombstones.len() + rec.dangling_starts.len();
368    if total == 0 {
369        return;
370    }
371    eprintln!("warning: pre-run recovery scan found {total} artifact(s) from prior sync:");
372    for p in &rec.orphan_backups {
373        eprintln!("  orphan backup:    {}", p.display());
374    }
375    for p in &rec.orphan_tombstones {
376        eprintln!("  orphan tombstone: {}", p.display());
377    }
378    for d in &rec.dangling_starts {
379        eprintln!(
380            "  dangling start:   pack `{}` action #{} ({}) at {}",
381            d.pack, d.action_idx, d.action_name, d.started_at
382        );
383    }
384}
385
386fn print_step(s: &SyncStep, dry_run: bool) {
387    use grex_core::ExecResult;
388    // `ExecResult::Skipped { pack_path, actions_hash }` gets a dedicated
389    // line so the pack path + matched hash surface verbatim instead of
390    // being lost into a single tag. M4-B S2 reshaped the variant from
391    // `{ reason }` to carry the richer lockfile context. Every other
392    // variant renders via the single-token tag path. The wildcard arm
393    // at the end is required because `ExecResult` is `#[non_exhaustive]`;
394    // future variants route to a generic `other` tag until they earn
395    // dedicated rendering.
396    if let ExecResult::Skipped { pack_path, actions_hash, .. } = &s.exec_step.result {
397        println!(
398            "[skipped] pack={pack} path={path} hash={hash}",
399            pack = s.pack,
400            path = pack_path.display(),
401            hash = actions_hash,
402        );
403        return;
404    }
405    let tag = match (&s.exec_step.result, dry_run) {
406        (ExecResult::PerformedChange, _) => "ok",
407        (ExecResult::WouldPerformChange, true) => "would",
408        (ExecResult::WouldPerformChange, false) => "ok",
409        (ExecResult::AlreadySatisfied, _) => "skipped",
410        (ExecResult::NoOp, _) => "noop",
411        // ExecResult is #[non_exhaustive]; Skipped is handled above, but
412        // future variants land here until they earn a dedicated tag.
413        _ => "other",
414    };
415    println!(
416        "[{tag}] pack={pack} action={kind} idx={idx}",
417        pack = s.pack,
418        kind = s.exec_step.action_name,
419        idx = s.action_idx,
420    );
421}
422
423// M4-D post-review fix bundle: `--only` glob compilation moved
424// into `grex-core::sync::compile_only_globset` so the `globset`
425// crate version does not leak through the public `SyncOptions`
426// surface. CLI unit tests for glob parsing were retired alongside
427// the `build_only_globset` helper; semantics are exercised end-to-end
428// via `crates/grex/tests/sync_e2e.rs` (`e2e_only_*` cases) and the
429// `cli_non_empty_string_rejects_whitespace` parse-layer test in
430// `cli::args`. Invalid-glob surfacing is covered by the
431// `SyncError::InvalidOnlyGlob` routing in `run_impl`.