1use crate::cli::args::{GlobalFlags, SyncArgs};
18use anyhow::Result;
19use grex_core::sync::{self, HaltedContext, SyncError, SyncOptions, SyncReport, SyncStep};
20use tokio_util::sync::CancellationToken;
21
22pub fn run(args: SyncArgs, global: &GlobalFlags, cancel: &CancellationToken) -> Result<()> {
30 let Some(pack_root) = args.pack_root.clone() else {
31 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 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 .with_retain_days(args.retain_days);
78 match run_impl(&pack_root, &opts, args.quiet, global.json, cancel) {
79 RunOutcome::Ok => Ok(()),
80 RunOutcome::UsageError => std::process::exit(2),
81 RunOutcome::Validation => std::process::exit(1),
82 RunOutcome::Exec => std::process::exit(2),
83 RunOutcome::Tree => std::process::exit(3),
84 }
85}
86
87pub(super) enum RunOutcome {
88 Ok,
89 UsageError,
92 Validation,
93 Exec,
94 Tree,
95}
96
97fn run_impl(
98 pack_root: &std::path::Path,
99 opts: &SyncOptions,
100 quiet: bool,
101 json: bool,
102 cancel: &CancellationToken,
103) -> RunOutcome {
104 match sync::run(pack_root, opts, cancel) {
105 Ok(report) => {
106 if json {
107 emit_json_report(&report, opts.dry_run, "sync");
108 } else {
109 render_report(&report, opts.dry_run, quiet);
110 }
111 if report.halted.is_some() {
112 return RunOutcome::Exec;
113 }
114 RunOutcome::Ok
115 }
116 Err(err) => classify_sync_err(err, json, "sync"),
117 }
118}
119
120pub(super) fn classify_sync_err(err: SyncError, json: bool, verb: &str) -> RunOutcome {
125 match err {
126 SyncError::Validation { errors } => {
127 emit_validation(&errors, json, verb);
128 RunOutcome::Validation
129 }
130 SyncError::Tree(e) => {
131 emit_simple("tree", &e.to_string(), "tree walk failed", json, verb);
132 RunOutcome::Tree
133 }
134 SyncError::Exec(e) => {
135 emit_simple("exec", &e.to_string(), "execution error", json, verb);
136 RunOutcome::Exec
137 }
138 SyncError::Halted(ctx) => {
139 if json {
140 emit_json_halted(&ctx, verb);
141 } else {
142 print_halted_context(&ctx);
143 }
144 RunOutcome::Exec
145 }
146 SyncError::InvalidOnlyGlob { pattern, source } => {
147 let msg = format!("invalid --only glob `{pattern}`: {source}");
148 emit_simple("usage", &msg, "error", json, verb);
149 RunOutcome::UsageError
150 }
151 other => {
152 emit_simple("other", &other.to_string(), &format!("{verb} failed"), json, verb);
153 RunOutcome::Tree
154 }
155 }
156}
157
158fn emit_validation(errors: &[impl std::fmt::Display], json: bool, verb: &str) {
159 if json {
160 let joined = errors.iter().map(|e| e.to_string()).collect::<Vec<_>>().join("; ");
161 emit_json_error("validation", &joined, verb);
162 } else {
163 eprintln!("validation failed:");
164 for e in errors {
165 eprintln!(" - {e}");
166 }
167 }
168}
169
170fn emit_simple(kind: &str, message: &str, human_prefix: &str, json: bool, verb: &str) {
171 if json {
172 emit_json_error(kind, message, verb);
173 } else {
174 eprintln!("{human_prefix}: {message}");
175 }
176}
177
178pub(super) fn emit_json_report(report: &SyncReport, dry_run: bool, verb: &str) {
182 let steps: Vec<serde_json::Value> =
183 report.steps.iter().map(|s| step_to_json(s, dry_run)).collect();
184 let halted = report.halted.as_ref().and_then(|h| match h {
185 SyncError::Halted(ctx) => Some(serde_json::json!({
186 "pack": ctx.pack,
187 "action": ctx.action_name,
188 "idx": ctx.action_idx,
189 "error": ctx.error.to_string(),
190 "recovery_hint": ctx.recovery_hint,
191 })),
192 _ => None,
193 });
194 let migrations: Vec<serde_json::Value> = report
195 .workspace_migrations
196 .iter()
197 .map(|m| {
198 serde_json::json!({
199 "from": m.from.display().to_string(),
200 "to": m.to.display().to_string(),
201 "outcome": migration_outcome_tag(&m.outcome),
202 "error": match &m.outcome {
203 grex_core::sync::MigrationOutcome::Failed { error } => {
204 serde_json::Value::String(error.clone())
205 }
206 _ => serde_json::Value::Null,
207 },
208 })
209 })
210 .collect();
211 let doc = serde_json::json!({
212 "verb": verb,
213 "dry_run": dry_run,
214 "steps": steps,
215 "halted": halted,
216 "event_log_warnings": report.event_log_warnings,
217 "workspace_migrations": migrations,
218 "summary": {"total_steps": report.steps.len()},
219 });
220 if let Ok(s) = serde_json::to_string(&doc) {
221 println!("{s}");
222 }
223}
224
225fn step_to_json(s: &SyncStep, dry_run: bool) -> serde_json::Value {
226 use grex_core::ExecResult;
227 let (result, details) = match &s.exec_step.result {
228 ExecResult::PerformedChange => ("performed_change", serde_json::Value::Null),
229 ExecResult::WouldPerformChange => {
230 if dry_run {
231 ("would_perform_change", serde_json::Value::Null)
232 } else {
233 ("performed_change", serde_json::Value::Null)
234 }
235 }
236 ExecResult::AlreadySatisfied => ("already_satisfied", serde_json::Value::Null),
237 ExecResult::NoOp => ("noop", serde_json::Value::Null),
238 ExecResult::Skipped { pack_path, actions_hash, .. } => (
239 "skipped",
240 serde_json::json!({
241 "pack_path": pack_path.display().to_string(),
242 "actions_hash": actions_hash,
243 }),
244 ),
245 _ => ("other", serde_json::Value::Null),
246 };
247 serde_json::json!({
248 "pack": s.pack,
249 "action": s.exec_step.action_name,
250 "idx": s.action_idx,
251 "result": result,
252 "details": details,
253 })
254}
255
256pub(super) fn emit_json_error(kind: &str, message: &str, verb: &str) {
257 let doc = serde_json::json!({
258 "verb": verb,
259 "error": {
260 "kind": kind,
261 "message": message,
262 },
263 });
264 if let Ok(s) = serde_json::to_string(&doc) {
265 println!("{s}");
266 }
267}
268
269pub(super) fn emit_json_halted(ctx: &HaltedContext, verb: &str) {
270 let doc = serde_json::json!({
271 "verb": verb,
272 "halted": {
273 "pack": ctx.pack,
274 "action": ctx.action_name,
275 "idx": ctx.action_idx,
276 "error": ctx.error.to_string(),
277 "recovery_hint": ctx.recovery_hint,
278 },
279 });
280 if let Ok(s) = serde_json::to_string(&doc) {
281 println!("{s}");
282 }
283}
284
285fn print_halted_context(ctx: &HaltedContext) {
288 eprintln!(
289 "sync halted at pack `{}` action #{} ({}):",
290 ctx.pack, ctx.action_idx, ctx.action_name
291 );
292 eprintln!(" error: {}", ctx.error);
293 if let Some(hint) = &ctx.recovery_hint {
294 eprintln!(" hint: {hint}");
295 }
296}
297
298fn render_report(report: &SyncReport, dry_run: bool, quiet: bool) {
299 if !quiet {
300 if !report.workspace_migrations.is_empty() {
301 print_workspace_migrations(&report.workspace_migrations);
302 }
303 if let Some(rec) = &report.pre_run_recovery {
304 print_recovery_report(rec);
305 }
306 for s in &report.steps {
307 print_step(s, dry_run);
308 }
309 }
310 for w in &report.event_log_warnings {
311 eprintln!("warning: {w}");
312 }
313 if let Some(err) = &report.halted {
314 match err {
315 SyncError::Halted(ctx) => print_halted_context(ctx),
316 other => eprintln!("halted: {other}"),
317 }
318 }
319}
320
321fn print_workspace_migrations(migrations: &[grex_core::sync::WorkspaceMigration]) {
326 use grex_core::sync::MigrationOutcome;
327 for m in migrations {
328 let from = m.from.display();
329 let to = m.to.display();
330 match &m.outcome {
331 MigrationOutcome::Migrated => {
332 eprintln!("[migrated] legacy={from} -> new={to}");
333 }
334 MigrationOutcome::SkippedBothExist => {
335 eprintln!("[skipped] legacy={from} AND new={to} both exist; resolve manually",);
336 }
337 MigrationOutcome::SkippedDestOccupied => {
338 eprintln!("[skipped] destination={to} occupied; legacy={from} kept");
339 }
340 MigrationOutcome::Failed { error } => {
341 eprintln!("[failed] legacy={from} -> new={to}: {error}");
342 }
343 other => eprintln!("[unknown] legacy={from} -> new={to} ({other:?})"),
346 }
347 }
348}
349
350fn migration_outcome_tag(o: &grex_core::sync::MigrationOutcome) -> &'static str {
353 use grex_core::sync::MigrationOutcome;
354 match o {
355 MigrationOutcome::Migrated => "migrated",
356 MigrationOutcome::SkippedBothExist => "skipped_both_exist",
357 MigrationOutcome::SkippedDestOccupied => "skipped_dest_occupied",
358 MigrationOutcome::Failed { .. } => "failed",
359 _ => "other",
362 }
363}
364
365fn print_recovery_report(rec: &grex_core::sync::RecoveryReport) {
368 let total = rec.orphan_backups.len() + rec.orphan_tombstones.len() + rec.dangling_starts.len();
369 if total == 0 {
370 return;
371 }
372 eprintln!("warning: pre-run recovery scan found {total} artifact(s) from prior sync:");
373 for p in &rec.orphan_backups {
374 eprintln!(" orphan backup: {}", p.display());
375 }
376 for p in &rec.orphan_tombstones {
377 eprintln!(" orphan tombstone: {}", p.display());
378 }
379 for d in &rec.dangling_starts {
380 eprintln!(
381 " dangling start: pack `{}` action #{} ({}) at {}",
382 d.pack, d.action_idx, d.action_name, d.started_at
383 );
384 }
385}
386
387fn print_step(s: &SyncStep, dry_run: bool) {
388 use grex_core::ExecResult;
389 if let ExecResult::Skipped { pack_path, actions_hash, .. } = &s.exec_step.result {
398 println!(
399 "[skipped] pack={pack} path={path} hash={hash}",
400 pack = s.pack,
401 path = pack_path.display(),
402 hash = actions_hash,
403 );
404 return;
405 }
406 let tag = match (&s.exec_step.result, dry_run) {
407 (ExecResult::PerformedChange, _) => "ok",
408 (ExecResult::WouldPerformChange, true) => "would",
409 (ExecResult::WouldPerformChange, false) => "ok",
410 (ExecResult::AlreadySatisfied, _) => "skipped",
411 (ExecResult::NoOp, _) => "noop",
412 _ => "other",
415 };
416 println!(
417 "[{tag}] pack={pack} action={kind} idx={idx}",
418 pack = s.pack,
419 kind = s.exec_step.action_name,
420 idx = s.action_idx,
421 );
422}
423
424