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