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