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