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 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 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
119pub(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
177pub(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
284fn 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
320fn 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 other => eprintln!("[unknown] legacy={from} -> new={to} ({other:?})"),
345 }
346 }
347}
348
349fn 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 _ => "other",
361 }
362}
363
364fn 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 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 _ => "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