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) = super::resolve_pack_root_or_cwd(args.pack_root.as_deref()) else {
37 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 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 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
126pub(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
184pub(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
291fn 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
327fn 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 other => eprintln!("[unknown] legacy={from} -> new={to} ({other:?})"),
352 }
353 }
354}
355
356fn 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 _ => "other",
368 }
369}
370
371fn 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 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 _ => "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