1use std::{error::Error, fmt};
5
6use heddle_core::status::next_action::{
7 canonical_git_import_ref_command, canonical_git_repair_ref_preview_command,
8};
9use serde_json::{Map, Value};
10
11pub const DIRTY_WORKTREE_CAPTURE_COMMAND: &str = "heddle capture -m \"...\"";
12pub(crate) const GIT_OVERLAY_CHECKPOINT_COMMAND: &str = "heddle commit -m \"...\"";
13
14#[derive(Debug, Clone)]
15pub struct RecoveryAdvice {
16 pub kind: &'static str,
17 pub error: String,
18 pub hint: String,
19 pub unsafe_condition: String,
20 pub would_change: String,
21 pub preserved: String,
22 pub primary_command: String,
23 pub recovery_commands: Vec<String>,
24 pub extra_json_fields: Map<String, Value>,
25}
26
27impl RecoveryAdvice {
28 #[allow(clippy::too_many_arguments)]
29 pub fn safety_refusal(
30 kind: &'static str,
31 error: impl Into<String>,
32 hint: impl Into<String>,
33 unsafe_condition: impl Into<String>,
34 would_change: impl Into<String>,
35 already_preserved: impl Into<String>,
36 primary_command: impl Into<String>,
37 recovery_commands: Vec<String>,
38 ) -> Self {
39 let primary_command = primary_command.into();
40 let recovery_commands = if recovery_commands.is_empty() {
41 vec![primary_command.clone()]
42 } else {
43 recovery_commands
44 };
45 Self {
46 kind,
47 error: error.into(),
48 hint: hint.into(),
49 unsafe_condition: unsafe_condition.into(),
50 would_change: would_change.into(),
51 preserved: already_preserved.into(),
52 primary_command,
53 recovery_commands,
54 extra_json_fields: Map::new(),
55 }
56 }
57
58 pub fn dirty_worktree(
59 action: &str,
60 dirty_paths: Vec<String>,
61 already_preserved: impl Into<String>,
62 ) -> Self {
63 let path_list = if dirty_paths.is_empty() {
64 "uncommitted paths were detected".to_string()
65 } else {
66 dirty_paths
67 .iter()
68 .take(12)
69 .cloned()
70 .collect::<Vec<_>>()
71 .join(", ")
72 };
73 let overflow = dirty_paths.len().saturating_sub(12);
74 let unsafe_condition = if overflow == 0 {
75 format!("unsaved worktree path(s): {path_list}")
76 } else {
77 format!("unsaved worktree path(s): {path_list}, and {overflow} more")
78 };
79 let primary_command = DIRTY_WORKTREE_CAPTURE_COMMAND.to_string();
80 Self {
81 kind: "dirty_worktree",
82 error: format!("Capture worktree changes before {action}"),
83 hint: format!(
84 "Capture the intended work with `{DIRTY_WORKTREE_CAPTURE_COMMAND}`, then retry."
85 ),
86 unsafe_condition,
87 would_change: format!(
88 "{action} would write another tree into the worktree; saving first prevents those path changes from being overwritten"
89 ),
90 preserved: already_preserved.into(),
91 primary_command,
92 recovery_commands: dirty_worktree_recovery_commands(),
93 extra_json_fields: Map::new(),
94 }
95 }
96
97 pub fn git_head_mismatch(
98 action: &str,
99 current_oid: impl Into<String>,
100 expected_oid: impl Into<String>,
101 branch: impl Into<String>,
102 dirty_paths: Vec<String>,
103 ) -> Self {
104 let current_oid = current_oid.into();
105 let expected_oid = expected_oid.into();
106 let branch = branch.into();
107 let primary_command = canonical_git_repair_ref_preview_command(Some("heddle"), &branch);
108 let dirty_summary = if dirty_paths.is_empty() {
109 "dirty paths: none".to_string()
110 } else {
111 format!(
112 "dirty paths: {}",
113 dirty_paths
114 .iter()
115 .take(12)
116 .cloned()
117 .collect::<Vec<_>>()
118 .join(", ")
119 )
120 };
121 let mut recovery_commands = vec![primary_command.clone()];
122 if !dirty_paths.is_empty() {
123 recovery_commands.extend(dirty_worktree_recovery_commands());
124 }
125 Self {
126 kind: "git_head_mismatch",
127 error: format!("Refusing to {action}: Git HEAD is not at the expected checkpoint"),
128 hint: format!("Inspect recovery with `{primary_command}`."),
129 unsafe_condition: format!(
130 "current Git OID {current_oid}, expected {expected_oid}; {dirty_summary}"
131 ),
132 would_change: "moving Git now could overwrite commits Heddle did not checkpoint"
133 .to_string(),
134 preserved: "Heddle state was left unchanged".to_string(),
135 primary_command: primary_command.clone(),
136 recovery_commands,
137 extra_json_fields: Map::new(),
138 }
139 }
140
141 pub fn destructive_requires_force(
142 action: &str,
143 unsafe_condition: impl Into<String>,
144 would_change: impl Into<String>,
145 preview_command: impl Into<String>,
146 force_command: impl Into<String>,
147 already_preserved: impl Into<String>,
148 ) -> Self {
149 let preview_command = preview_command.into();
150 let force_command = force_command.into();
151 Self {
152 kind: "destructive_requires_force",
153 error: format!("Refusing to {action}: destructive action requires --force"),
154 hint: format!(
155 "Inspect with `{preview_command}`; rerun `{force_command}` only if the removals are intentional."
156 ),
157 unsafe_condition: unsafe_condition.into(),
158 would_change: would_change.into(),
159 preserved: already_preserved.into(),
160 primary_command: preview_command.clone(),
161 recovery_commands: vec![preview_command, force_command],
162 extra_json_fields: Map::new(),
163 }
164 }
165
166 pub fn op_id_conflict(
167 command: &str,
168 dedup_scope: &str,
169 incoming_argv: &[String],
170 incoming_request_hash: [u8; 32],
171 existing: Option<repo::operation_dedup::DedupConflictMetadata>,
172 ) -> Self {
173 let existing_status = existing
174 .as_ref()
175 .map(|entry| {
176 if entry.pending {
177 "in_flight"
178 } else {
179 "completed"
180 }
181 })
182 .unwrap_or("unknown");
183 let mut extra_json_fields = Map::new();
184 let recorded_command = existing
185 .as_ref()
186 .map(|entry| entry.verb.as_str())
187 .unwrap_or(command);
188 extra_json_fields.insert(
189 "recorded_command".to_string(),
190 Value::String(recorded_command.to_string()),
191 );
192 extra_json_fields.insert(
193 "incoming_command".to_string(),
194 Value::String(command.to_string()),
195 );
196 extra_json_fields.insert(
197 "dedup_scope".to_string(),
198 Value::String(dedup_scope.to_string()),
199 );
200 extra_json_fields.insert(
201 "incoming_argv".to_string(),
202 Value::Array(incoming_argv.iter().cloned().map(Value::String).collect()),
203 );
204 extra_json_fields.insert(
205 "incoming_request_hash".to_string(),
206 Value::String(hex_hash(incoming_request_hash)),
207 );
208 extra_json_fields.insert(
209 "recorded_status".to_string(),
210 Value::String(existing_status.to_string()),
211 );
212 if let Some(existing) = existing {
213 extra_json_fields.insert(
214 "recorded_request_hash".to_string(),
215 Value::String(hex_hash(existing.request_hash)),
216 );
217 extra_json_fields.insert(
218 "recorded_created_at_secs".to_string(),
219 Value::Number(existing.created_at_secs.into()),
220 );
221 }
222 Self {
223 kind: "op_id_conflict",
224 error: "--op-id was already used with different arguments".to_string(),
225 hint: format!(
226 "Retry with the original arguments for this --op-id in scope `{dedup_scope}` or generate a fresh operation id."
227 ),
228 unsafe_condition: format!(
229 "the same operation id maps to a different request body for `heddle {command}` in scope `{dedup_scope}`"
230 ),
231 would_change:
232 "reusing it for different arguments would make idempotent replay ambiguous"
233 .to_string(),
234 preserved: "no command body was executed for this retry".to_string(),
235 primary_command: "heddle help --output json".to_string(),
236 recovery_commands: vec!["heddle help --output json".to_string()],
237 extra_json_fields,
238 }
239 }
240
241 pub fn op_id_in_flight() -> Self {
242 Self {
243 kind: "op_id_in_flight",
244 error: "--op-id is currently being executed by another process".to_string(),
245 hint: "Retry after the in-flight command completes; successful retries replay the cached result.".to_string(),
246 unsafe_condition: "another process owns this operation id reservation".to_string(),
247 would_change: "running a second copy could duplicate a mutating operation".to_string(),
248 preserved: "no command body was executed for this retry".to_string(),
249 primary_command: "heddle status".to_string(),
250 recovery_commands: vec!["heddle status".to_string()],
251 extra_json_fields: Map::new(),
252 }
253 }
254
255 pub fn op_id_unsupported(command: &str) -> Self {
256 Self {
257 kind: "op_id_unsupported",
258 error: format!("--op-id is not supported by `heddle {command}`"),
259 hint: "Inspect op-id support with `heddle help --output json` and retry without --op-id for this command.".to_string(),
260 unsafe_condition: "the command contract marks this command as not idempotent".to_string(),
261 would_change: "silently accepting --op-id here would imply a replay guarantee this command does not provide".to_string(),
262 preserved: "no command body was executed".to_string(),
263 primary_command: "heddle help --output json".to_string(),
264 recovery_commands: vec!["heddle help --output json".to_string()],
265 extra_json_fields: Map::new(),
266 }
267 }
268
269 pub fn op_id_invalid(raw: &str, parse_error: impl fmt::Display) -> Self {
270 Self {
271 kind: "op_id_invalid",
272 error: format!("invalid --op-id `{raw}`: {parse_error}"),
273 hint: "Pass a UUID v4 operation id, or omit --op-id to run without replay protection."
274 .to_string(),
275 unsafe_condition: "--op-id does not parse as a UUID v4".to_string(),
276 would_change:
277 "accepting a malformed operation id would make replay and conflict detection ambiguous"
278 .to_string(),
279 preserved: "no command body was executed".to_string(),
280 primary_command: "heddle help --output json".to_string(),
281 recovery_commands: vec!["heddle help --output json".to_string()],
282 extra_json_fields: Map::new(),
283 }
284 }
285
286 pub fn json_unsupported(command: &str) -> Self {
287 Self {
288 kind: "json_unsupported",
289 error: format!("--output json is not supported by `heddle {command}`"),
290 hint: "Inspect JSON-capable commands with `heddle help --output json` or rerun with `--output text`.".to_string(),
291 unsafe_condition: "the command contract marks this command as text-only".to_string(),
292 would_change: "emitting ad hoc JSON here would create a machine contract outside the command table".to_string(),
293 preserved: "no command body was executed".to_string(),
294 primary_command: "heddle help --output json".to_string(),
295 recovery_commands: vec![
296 "heddle help --output json".to_string(),
297 format!("heddle {command} --output text"),
298 ],
299 extra_json_fields: Map::new(),
300 }
301 }
302
303 pub fn json_compact_unsupported(command: &str) -> Self {
304 Self {
305 kind: "json_compact_unsupported",
306 error: format!("--output json-compact is not supported by `heddle {command}`"),
307 hint: "Use `--output json` for the full machine contract, or choose a command that exposes a compact decision surface.".to_string(),
308 unsafe_condition: "the command has no compact decision-surface projection".to_string(),
309 would_change: "falling back to the full JSON contract would leak non-decision-surface fields under json-compact".to_string(),
310 preserved: "no command body was executed".to_string(),
311 primary_command: format!("heddle {command} --output json"),
312 recovery_commands: vec![
313 format!("heddle {command} --output json"),
314 "heddle help --output json".to_string(),
315 ],
316 extra_json_fields: Map::new(),
317 }
318 }
319
320 pub(crate) fn machine_contract_drift(
321 error: impl Into<String>,
322 unsafe_condition: impl Into<String>,
323 ) -> Self {
324 Self::safety_refusal(
325 "machine_contract_drift",
326 error,
327 "Inspect the schema contract with `heddle doctor schemas --output json`, then update the schema registry or documented samples.",
328 unsafe_condition,
329 "continuing to rely on this machine contract could make JSON callers parse stale or undocumented fields",
330 "repository state, refs, metadata, and worktree files were left unchanged",
331 "heddle doctor schemas --output json",
332 vec!["heddle doctor schemas --output json".to_string()],
333 )
334 }
335
336 pub fn stale_daemon_protocol(their_version: u32, our_version: u32) -> Self {
337 Self::safety_refusal(
338 "daemon_protocol_version_mismatch",
339 format!("heddled daemon is older (v{their_version}) than this CLI (v{our_version})"),
340 "Stop the stale daemon so Heddle can respawn it with the current protocol, then retry.",
341 format!(
342 "daemon protocol version {their_version} is older than CLI protocol version {our_version}"
343 ),
344 "continuing over a stale daemon protocol could misread daemon responses or leave mount state unclear",
345 "repository state, refs, metadata, and worktree files were left unchanged",
346 "heddle daemon stop",
347 vec![
348 "heddle daemon stop".to_string(),
349 "heddle status".to_string(),
350 ],
351 )
352 }
353
354 pub fn git_import_metadata_required(map_path: &str, git_path: &str) -> Self {
355 let command = format!("heddle import git --path {git_path}");
356 Self::safety_refusal(
357 "git_import_metadata_required",
358 format!("No Git SHA map exists at {map_path}"),
359 format!("Build the SHA map with `{command}`, then retry."),
360 format!("git import metadata is missing at {map_path}"),
361 "reasoning import cannot map transcript references to Git commits without the SHA map",
362 "repository state, refs, metadata, and worktree files were left unchanged",
363 command.clone(),
364 vec![command],
365 )
366 }
367
368 pub fn adopt_path_conflict(positional: &str, repo_path: &str) -> Self {
369 Self::invalid_usage(
370 "adopt_path_conflict",
371 format!(
372 "`heddle adopt` received both a positional path ({positional}) and --repo ({repo_path})"
373 ),
374 "Pass exactly one repository path so adoption targets a single Git worktree.",
375 "heddle adopt <path>",
376 )
377 }
378
379 pub fn adopt_requires_git_worktree(details: Option<String>) -> Self {
380 let error = match details {
381 Some(details) => format!("`heddle adopt` needs a Git worktree: {details}"),
382 None => "`heddle adopt` needs a Git worktree".to_string(),
383 };
384 Self::safety_refusal(
385 "adopt_requires_git_worktree",
386 error,
387 "Run `heddle init` for a new native Heddle repository, or run `heddle adopt` from inside a Git worktree.",
388 "the selected path is not a Git worktree",
389 "adoption would otherwise initialize mapping metadata for an unknown Git checkout",
390 "repository state, refs, metadata, and worktree files were left unchanged",
391 "heddle init",
392 vec!["heddle init".to_string(), "heddle status".to_string()],
393 )
394 }
395
396 pub fn git_overlay_tip_bind_failed(details: impl Into<String>) -> Self {
397 let primary = "heddle adopt".to_string();
398 Self::safety_refusal(
399 "git_overlay_tip_bind_failed",
400 "Could not bind the active Git tip into Heddle",
401 format!(
402 "Run `{primary}` to import Git history explicitly, then retry. Heddle refused to invent a parentless bootstrap root while a Git tip exists."
403 ),
404 details.into(),
405 "binding the active Git tip would otherwise be skipped in favor of an orphan Heddle root",
406 "Git refs, Heddle refs, and worktree files were left unchanged",
407 primary.clone(),
408 vec![primary, "heddle status".to_string()],
409 )
410 }
411
412 pub fn init_path_conflict(positional: &str, repo_path: &str) -> Self {
413 Self::invalid_usage(
414 "init_path_conflict",
415 format!(
416 "`heddle init` received both a positional path ({positional}) and --repo ({repo_path})"
417 ),
418 "Pass exactly one repository path so initialization targets one checkout.",
419 "heddle init <path>",
420 )
421 }
422
423 pub fn init_principal_field_required(field: &str) -> Self {
424 Self::invalid_usage(
425 "init_principal_field_required",
426 format!("{field} is required when configuring a principal during init"),
427 "Pass both `--principal-name` and `--principal-email`, or omit both and configure identity later.",
428 "heddle init",
429 )
430 }
431
432 #[cfg(not(feature = "client"))]
433 pub fn network_feature_unavailable(operation: &str) -> Self {
434 Self::safety_refusal(
435 "network_feature_unavailable",
436 format!(
437 "network {operation} support is not available in this build; enable the `client` feature"
438 ),
439 "Use a Heddle binary built with the `client` feature for hosted network remotes, or use a local Git-overlay remote.",
440 "this Heddle binary was built without hosted network transport support",
441 format!("network {operation} cannot contact or mutate the requested hosted remote"),
442 "repository state, refs, metadata, and worktree files were left unchanged",
443 "heddle remote list",
444 vec![
445 "heddle remote list".to_string(),
446 "heddle help --output json".to_string(),
447 ],
448 )
449 }
450
451 pub fn hook_veto(hook: &str, action: &str, reason: impl Into<String>) -> Self {
452 let reason = reason.into();
453 Self::safety_refusal(
454 "hook_veto",
455 format!("{hook} hook vetoed: {reason}"),
456 format!(
457 "Inspect `{hook}` with `heddle hook list`, update the hook policy or inputs, then retry."
458 ),
459 format!("{hook} hook vetoed {action}: {reason}"),
460 format!(
461 "{action} would continue after repository policy explicitly aborted the operation"
462 ),
463 "the operation stopped at the hook boundary before the protected action ran",
464 "heddle hook list",
465 vec!["heddle hook list".to_string()],
466 )
467 }
468
469 pub fn invalid_usage(
470 kind: &'static str,
471 error: impl Into<String>,
472 hint: impl Into<String>,
473 primary_command: impl Into<String>,
474 ) -> Self {
475 let primary_command = primary_command.into();
476 Self::safety_refusal(
477 kind,
478 error,
479 hint,
480 "the command arguments do not describe a valid operation",
481 "running with ambiguous or invalid arguments could target the wrong repository state or metadata",
482 "no repository objects, refs, metadata, or worktree files were changed",
483 primary_command.clone(),
484 vec![primary_command],
485 )
486 }
487
488 pub fn missing_option(
489 kind: &'static str,
490 option: &'static str,
491 required_for: &'static str,
492 primary_command: impl Into<String>,
493 ) -> Self {
494 let primary_command = primary_command.into();
495 Self::invalid_usage(
496 kind,
497 format!("{option} is required for {required_for}"),
498 format!("Retry with `{option}` set: `{primary_command}`."),
499 primary_command,
500 )
501 }
502
503 pub fn malformed_option_value(
504 kind: &'static str,
505 option: &'static str,
506 raw: &str,
507 expected: &'static str,
508 primary_command: impl Into<String>,
509 ) -> Self {
510 let primary_command = primary_command.into();
511 Self::invalid_usage(
512 kind,
513 format!("{option} expects {expected}, got '{raw}'"),
514 format!("Retry with {option} in the expected form: `{primary_command}`."),
515 primary_command,
516 )
517 }
518
519 pub(crate) fn missing_integration_target(
520 kind: &'static str,
521 error: impl Into<String>,
522 hint: impl Into<String>,
523 primary_command: impl Into<String>,
524 recovery_commands: Vec<String>,
525 ) -> Self {
526 let primary_command = primary_command.into();
527 Self::safety_refusal(
528 kind,
529 error,
530 hint,
531 "the command has no recorded target to integrate into",
532 "guessing an integration target could merge or move work into the wrong thread",
533 "no repository objects, refs, metadata, or worktree files were changed",
534 primary_command.clone(),
535 if recovery_commands.is_empty() {
536 vec![primary_command]
537 } else {
538 recovery_commands
539 },
540 )
541 }
542
543 pub fn discuss_resolve_missing_dismiss_reason() -> Self {
544 Self::missing_option(
545 "discuss_resolve_missing_dismiss_reason",
546 "--reason",
547 "dismiss",
548 "heddle discuss resolve <id> --mode dismiss --reason \"...\"",
549 )
550 }
551
552 pub fn review_symbols_malformed(raw: &str) -> Self {
553 Self::malformed_option_value(
554 "review_symbols_malformed",
555 "--symbols",
556 raw,
557 "'file:symbol'",
558 "heddle review sign <state> --kind read --symbols <file>:<symbol> --public-key <hex> --signature <hex> --signed-at-unix <secs>",
559 )
560 }
561
562 pub fn thread_absorb_parent_required(thread: &str) -> Self {
563 let primary_command = format!("heddle thread absorb {thread} --into <parent-thread>");
564 Self::missing_integration_target(
565 "thread_absorb_parent_required",
566 format!("Thread '{thread}' has no recorded parent; pass --into"),
567 format!(
568 "Choose a parent with `heddle thread list`, then retry with `{primary_command}`."
569 ),
570 primary_command.clone(),
571 vec![primary_command, "heddle thread list".to_string()],
572 )
573 }
574
575 pub fn repository_no_head_capture_first(action: &str) -> Self {
576 Self::safety_refusal(
577 "repository_no_head",
578 format!("Repository has no HEAD state for {action}"),
579 "Capture the current worktree with `heddle capture -m \"...\"`, then retry.",
580 "the repository has no current HEAD state",
581 format!("`{action}` needs a concrete state id and cannot safely infer one"),
582 "no repository objects, refs, metadata, or worktree files were changed",
583 DIRTY_WORKTREE_CAPTURE_COMMAND,
584 vec![DIRTY_WORKTREE_CAPTURE_COMMAND.to_string()],
585 )
586 }
587
588 pub fn repository_no_head_anchor_first(action: &str) -> Self {
589 Self::safety_refusal(
590 "repository_no_head",
591 format!("Repository has no HEAD state for {action}"),
592 "Create a Heddle anchor with `heddle capture -m \"...\"`; commit Git-owned source history with `heddle commit -m \"...\"`, then retry.",
593 "the repository has no current HEAD state",
594 format!("`{action}` needs a concrete Heddle state id and cannot safely infer one"),
595 "no repository objects, refs, metadata, or worktree files were changed",
596 DIRTY_WORKTREE_CAPTURE_COMMAND,
597 vec![
598 DIRTY_WORKTREE_CAPTURE_COMMAND.to_string(),
599 GIT_OVERLAY_CHECKPOINT_COMMAND.to_string(),
600 "heddle status".to_string(),
601 ],
602 )
603 }
604
605 pub fn context_empty() -> Self {
606 Self::safety_refusal(
607 "context_annotations_empty",
608 "No context annotations in this repository",
609 "Inspect context with `heddle context list`, or add an annotation with `heddle context set --path <path> --scope file -m \"...\"`.",
610 "the current state has no context annotation root",
611 "guessing a missing annotation would target metadata that does not exist",
612 "no repository objects, refs, metadata, or worktree files were changed",
613 "heddle context list",
614 vec![
615 "heddle context list".to_string(),
616 "heddle context set --path <path> --scope file -m \"...\"".to_string(),
617 ],
618 )
619 }
620
621 pub fn annotation_not_found(annotation_id: &str) -> Self {
622 Self::safety_refusal(
623 "context_annotation_not_found",
624 format!("Annotation not found: {annotation_id}"),
625 "List existing annotations with `heddle context list`, then retry with an annotation id from `heddle context get --path <path>`.",
626 format!("no context annotation matched `{annotation_id}` in the current state"),
627 "guessing an annotation id could inspect or mutate the wrong context metadata",
628 "no repository objects, refs, metadata, or worktree files were changed",
629 "heddle context list",
630 vec![
631 "heddle context list".to_string(),
632 "heddle context get --path <path>".to_string(),
633 ],
634 )
635 }
636
637 pub fn no_current_thread(
638 command: &'static str,
639 explicit_selector: Option<&'static str>,
640 primary_command: impl Into<String>,
641 ) -> Self {
642 let error = match explicit_selector {
643 Some(selector) => format!("No current thread; pass {selector}"),
644 None => "No current thread".to_string(),
645 };
646 let hint = match explicit_selector {
647 Some(selector) => format!(
648 "Run `heddle {command}` from an active thread checkout, or retry with `{selector}` to choose a thread explicitly."
649 ),
650 None => format!("Run `heddle {command}` from an active thread checkout."),
651 };
652 Self::safety_refusal(
653 "no_current_thread",
654 error,
655 hint,
656 "the current checkout is not associated with an active thread",
657 format!(
658 "`heddle {command}` without an explicit thread would have to guess which thread to target"
659 ),
660 "no repository objects, refs, metadata, or worktree files were changed",
661 primary_command,
662 Vec::new(),
663 )
664 }
665
666 pub fn no_current_session(
667 command: &'static str,
668 explicit_selector: Option<&'static str>,
669 primary_command: impl Into<String>,
670 ) -> Self {
671 let error = match explicit_selector {
672 Some(selector) => format!("No active session; pass {selector}"),
673 None => "No active session".to_string(),
674 };
675 let hint = match explicit_selector {
676 Some(selector) => format!(
677 "Begin provenance with `heddle agent provenance begin`, or retry `heddle {command}` with `{selector}` to choose a session explicitly."
678 ),
679 None => {
680 "Begin provenance with `heddle agent provenance begin`, then retry.".to_string()
681 }
682 };
683 Self::safety_refusal(
684 "no_current_session",
685 error,
686 hint,
687 "no active session is recorded for this repository",
688 format!(
689 "`heddle {command}` without an explicit session would have to guess which session to use"
690 ),
691 "no session metadata, repository objects, refs, or worktree files were changed",
692 primary_command,
693 Vec::new(),
694 )
695 }
696
697 pub fn thread_worktree_unavailable(
698 thread: &str,
699 action: &str,
700 unsafe_condition: impl Into<String>,
701 primary_command: impl Into<String>,
702 ) -> Self {
703 let primary_command = primary_command.into();
704 Self::safety_refusal(
705 "thread_worktree_unavailable",
706 format!("Thread `{thread}` has no available filesystem checkout"),
707 format!(
708 "Use `{primary_command}` to create or inspect an on-disk checkout for this thread."
709 ),
710 unsafe_condition,
711 format!(
712 "`heddle {action}` needs a concrete directory path and cannot safely guess one"
713 ),
714 "repository objects, refs, metadata, and worktree files were left unchanged",
715 primary_command.clone(),
716 vec![primary_command, "heddle thread list".to_string()],
717 )
718 }
719
720 pub fn land_checkpoint_partial_failure(
721 thread: &str,
722 checkpoint_error: impl fmt::Display,
723 performed_steps: Vec<String>,
724 ) -> Self {
725 let completed = if performed_steps.is_empty() {
726 "no completed steps were recorded".to_string()
727 } else {
728 performed_steps.join(", ")
729 };
730 Self::safety_refusal(
731 "land_checkpoint_partial_failure",
732 format!(
733 "Land partially completed for `{thread}`, but Git checkpoint failed: {checkpoint_error}"
734 ),
735 "Run `heddle undo` to roll back the local land, or resolve the Git issue and run `heddle commit -m \"...\"`.",
736 "Git checkpoint failed after Heddle had already completed local land steps",
737 "retrying blindly could obscure the already-landed local merge state",
738 format!("completed steps: {completed}. No Git checkpoint was written."),
739 "heddle undo",
740 vec![
741 "heddle undo".to_string(),
742 "heddle commit -m \"...\"".to_string(),
743 ],
744 )
745 }
746
747 pub fn land_checkpoint_rolled_back(
748 thread: &str,
749 checkpoint_error: impl fmt::Display,
750 performed_steps: Vec<String>,
751 ) -> Self {
752 let completed = if performed_steps.is_empty() {
753 "no completed steps were recorded".to_string()
754 } else {
755 performed_steps.join(", ")
756 };
757 let retry = super::command_catalog::land_local_command(thread);
758 Self::safety_refusal(
759 "land_checkpoint_rolled_back",
760 format!(
761 "Land for `{thread}` failed at Git checkpoint and was rolled back: {checkpoint_error}"
762 ),
763 format!("Resolve the Git checkpoint issue, then retry `{retry}`."),
764 "Git checkpoint failed after local land steps; land auto-undid the integration so Git and Heddle tips stay consistent",
765 "retrying after fixing the checkpoint issue is safe because the Heddle integration was rolled back",
766 format!(
767 "completed steps before rollback: {completed}. No Git checkpoint was written; land-owned Heddle integration was undone."
768 ),
769 retry.clone(),
770 vec!["heddle verify".to_string(), retry],
771 )
772 }
773
774 pub fn land_checkpoint_partial_failure_undo_failed(
775 thread: &str,
776 checkpoint_error: impl fmt::Display,
777 undo_error: impl fmt::Display,
778 performed_steps: Vec<String>,
779 ) -> Self {
780 let completed = if performed_steps.is_empty() {
781 "no completed steps were recorded".to_string()
782 } else {
783 performed_steps.join(", ")
784 };
785 Self::safety_refusal(
786 "land_checkpoint_partial_failure",
787 format!(
788 "Land partially completed for `{thread}`, but Git checkpoint failed: {checkpoint_error}. Auto-undo also failed: {undo_error}"
789 ),
790 "Run `heddle undo` to roll back the local land, or resolve the Git issue and run `heddle commit -m \"...\"`.",
791 "Git checkpoint failed after local land steps, and automatic rollback did not complete",
792 "retrying blindly could obscure the already-landed local merge state; manual undo is required",
793 format!("completed steps: {completed}. No Git checkpoint was written."),
794 "heddle undo",
795 vec![
796 "heddle undo".to_string(),
797 "heddle commit -m \"...\"".to_string(),
798 ],
799 )
800 }
801
802 pub fn land_checkpoint_recovery_required(
803 thread: &str,
804 checkpoint_error: impl fmt::Display,
805 recovery_error: impl fmt::Display,
806 performed_steps: Vec<String>,
807 ) -> Self {
808 let completed = if performed_steps.is_empty() {
809 "no completed steps were recorded".to_string()
810 } else {
811 performed_steps.join(", ")
812 };
813 Self::safety_refusal(
814 "land_checkpoint_recovery_required",
815 format!(
816 "Land checkpoint recovery for `{thread}` did not complete: {checkpoint_error}. Recovery also failed: {recovery_error}"
817 ),
818 "Run `heddle status` to retry durable checkpoint recovery before changing either tip.",
819 "Git checkpoint publication may have completed, but its durable Heddle metadata could not be finalized",
820 "rolling back Heddle while Git may already point at the landed state would split the two tips",
821 format!(
822 "completed steps: {completed}. The incomplete-land marker was preserved for a later recovery attempt."
823 ),
824 "heddle status",
825 vec!["heddle status".to_string(), "heddle verify".to_string()],
826 )
827 }
828
829 pub fn land_checkpoint_rollback_marker_cleanup_failed(
830 thread: &str,
831 checkpoint_error: impl fmt::Display,
832 cleanup_error: impl fmt::Display,
833 performed_steps: Vec<String>,
834 ) -> Self {
835 let completed = if performed_steps.is_empty() {
836 "no completed steps were recorded".to_string()
837 } else {
838 performed_steps.join(", ")
839 };
840 Self::safety_refusal(
841 "land_checkpoint_marker_cleanup_failed",
842 format!(
843 "Land for `{thread}` was rolled back after checkpoint failure, but its recovery marker could not be removed: {checkpoint_error}. Marker cleanup failed: {cleanup_error}"
844 ),
845 "Restore write access to `.heddle`, remove `.heddle/incomplete-land.json`, then retry the land.",
846 "the land-owned Heddle integration was undone, but the stale recovery marker remains",
847 "leaving the marker unreported would make the next command attempt to recover work that is already rolled back",
848 format!(
849 "completed steps before rollback: {completed}. Git was not checkpointed; the land-owned integration was undone."
850 ),
851 "heddle status",
852 vec!["heddle status".to_string()],
853 )
854 }
855
856 pub fn from_git_projection_error(
857 error: &heddle_git_projection::git_core::GitProjectionError,
858 ) -> Option<Self> {
859 use heddle_git_projection::git_core::GitProjectionError;
860 match error {
861 GitProjectionError::NonFastForwardRef { name, .. }
862 if name == heddle_git_projection::git_notes::NOTES_REF =>
863 {
864 Some(Self::git_overlay_note_ref_conflict())
865 }
866 GitProjectionError::NonFastForwardRef {
867 name,
868 remote_destination: true,
869 ..
870 } => name
871 .strip_prefix("refs/heads/")
872 .map(Self::git_overlay_remote_push_rejected),
873 GitProjectionError::NonFastForwardRef {
874 name,
875 remote_destination: false,
876 ..
877 } => name
878 .strip_prefix("refs/heads/")
879 .map(Self::git_overlay_local_non_fast_forward),
880 GitProjectionError::MappingConflict { .. } => {
881 Some(Self::git_overlay_mapping_conflict())
882 }
883 GitProjectionError::GitHeddleThreadDiverged { thread, branch, .. } => {
884 Some(Self::git_heddle_thread_diverged(thread, branch))
885 }
886 GitProjectionError::RemoteDiverged {
887 branch, upstream, ..
888 } => Some(Self::git_overlay_remote_diverged(branch, upstream)),
889 GitProjectionError::ShallowClone {
890 repository,
891 retry_command,
892 } => Some(Self::git_overlay_shallow_clone(repository, retry_command)),
893 _ => None,
894 }
895 }
896
897 pub(crate) fn git_overlay_note_ref_conflict() -> Self {
898 Self::safety_refusal(
899 "git_overlay_note_ref_conflict",
900 "Remote Heddle notes do not fast-forward",
901 "Pull the remote Heddle metadata, then retry the push. If the conflict remains, create a fresh Heddle clone from the remote so Git-to-Heddle identity metadata stays authoritative.",
902 "updating refs/notes/heddle would replace remote Git-to-Heddle identity metadata instead of fast-forwarding it",
903 "pushing would remap commits that another Heddle checkout already identified",
904 "remote refs/notes/heddle was left unchanged",
905 "heddle pull",
906 vec![
907 "heddle pull".to_string(),
908 "heddle push".to_string(),
909 "heddle clone <remote> <fresh-path>".to_string(),
910 ],
911 )
912 }
913
914 pub(crate) fn git_overlay_mapping_conflict() -> Self {
915 Self::safety_refusal(
916 "git_overlay_mapping_conflict",
917 "Git Projection Mapping metadata disagrees with refs/notes/heddle",
918 "Git Projection Mapping and refs/notes/heddle disagree about Git-to-Heddle identity. Use a fresh Heddle clone from the remote, or restore the notes ref from the checkout whose mapping is authoritative before retrying.",
919 "one Git commit maps to different Heddle change ids across Git Projection Mapping and refs/notes/heddle",
920 "continuing would corrupt or hide the Git/Heddle identity mapping",
921 "the command stopped before applying the requested ref or worktree update",
922 "heddle clone <remote> <fresh-path>",
923 vec!["heddle clone <remote> <fresh-path>".to_string()],
924 )
925 }
926
927 pub(crate) fn git_overlay_shallow_clone(
928 repository: &std::path::Path,
929 retry_command: &str,
930 ) -> Self {
931 let primary_command = "heddle clone <remote> <fresh-path>".to_string();
932 Self::safety_refusal(
933 "git_overlay_shallow_clone",
934 "Shallow Git repository cannot be imported",
935 format!(
936 "Import needs complete ancestry. Create or choose a complete checkout with `{primary_command}`, then retry with `{retry_command}`."
937 ),
938 format!(
939 "Git repository '{}' is shallow and does not contain the full commit ancestry Heddle needs to preserve stable change identity",
940 repository.display()
941 ),
942 "importing from this checkout would create an incomplete Git-to-Heddle history map",
943 "Heddle refs, Git refs, index, and worktree files were left unchanged",
944 primary_command.clone(),
945 vec![primary_command, retry_command.to_string()],
946 )
947 }
948
949 pub fn git_heddle_thread_diverged(thread: &str, branch: &str) -> Self {
950 let primary_command = canonical_git_repair_ref_preview_command(None, branch);
951 Self::safety_refusal(
952 "git_heddle_thread_diverged",
953 "Git branch and Heddle thread have diverged",
954 format!(
955 "Inspect the local repair choices with `{primary_command}`. Preview mode does not move refs, update the index, change worktree files, push, or pull."
956 ),
957 format!(
958 "Heddle thread '{thread}' and Git branch '{branch}' both contain history the other side lacks"
959 ),
960 "importing or syncing now would need to choose whether the local Git branch or Heddle thread is authoritative",
961 "Heddle thread refs, Git refs, index, and worktree files were left unchanged; imported commit states and Git/Heddle mapping records may have been preserved for inspection or retry",
962 primary_command.clone(),
963 vec![primary_command],
964 )
965 }
966
967 pub(crate) fn git_overlay_remote_push_rejected(branch: &str) -> Self {
968 let primary_command = "heddle pull".to_string();
969 Self::safety_refusal(
970 "git_overlay_remote_diverged",
971 "Remote branch does not fast-forward the local Git checkpoint",
972 "Run `heddle pull` so Heddle can stream and inspect the remote tip, then run `heddle verify` for the exact integration command.",
973 format!(
974 "pushing branch '{branch}' would rewrite the remote branch instead of fast-forwarding it"
975 ),
976 "pushing now would replace work that exists on the remote",
977 "the remote branch, local Git branch, Heddle refs, index, and worktree files were left unchanged",
978 primary_command.clone(),
979 vec![primary_command, "heddle verify".to_string()],
980 )
981 }
982
983 pub(crate) fn git_overlay_local_non_fast_forward(branch: &str) -> Self {
987 let import_command = format!("heddle import git --ref {branch}");
988 Self::safety_refusal(
989 "git_overlay_local_non_fast_forward",
990 format!("Local Git branch '{branch}' does not fast-forward from the Heddle tip"),
991 format!(
992 "Inspect with `heddle status`, then reconcile the authoritative Git branch with `{import_command}` before retrying."
993 ),
994 format!(
995 "updating local branch '{branch}' would replace work that exists only on the Git tip"
996 ),
997 "forcing the local Git branch could discard Git-owned history",
998 "Heddle refs, the local Git branch, index, and worktree were left unchanged by this ref update",
999 "heddle status",
1000 vec!["heddle status".to_string(), import_command],
1001 )
1002 }
1003
1004 pub(crate) fn git_overlay_remote_diverged(branch: &str, upstream: &str) -> Self {
1005 let import_command = canonical_git_import_ref_command(upstream);
1006 let merge_preview = super::command_catalog::merge_preview_command(upstream);
1007 Self::safety_refusal(
1008 "git_overlay_remote_diverged",
1009 "Remote branch does not fast-forward the local Git checkpoint",
1010 format!(
1011 "Import the fetched upstream tip with `{import_command}`, then preview integration with `{merge_preview}`."
1012 ),
1013 format!(
1014 "local branch '{branch}' and upstream '{upstream}' both contain commits the other side lacks"
1015 ),
1016 "pulling now would need to integrate upstream work with local Heddle work before moving the branch",
1017 "Heddle refs, the visible Git branch, and worktree files were left unchanged",
1018 import_command.clone(),
1019 vec![import_command, merge_preview],
1020 )
1021 }
1022
1023 pub fn remote_transport_mismatch(action: &str, remote: &str) -> Self {
1024 Self::safety_refusal(
1025 "remote_transport_mismatch",
1026 format!(
1027 "Refusing to {action}: remote '{remote}' is a Git remote, not a Heddle-native remote"
1028 ),
1029 "Use a Heddle-native remote here, or clone/adopt that Git remote in a Git-overlay checkout.",
1030 format!("remote '{remote}' resolves to Git storage"),
1031 format!(
1032 "{action} would route a Git repository through Heddle-native sync and fail after setup work"
1033 ),
1034 "remote configuration, Heddle refs, Git refs, and worktree files were left unchanged",
1035 "heddle clone <remote> <fresh-path>",
1036 vec![
1037 "heddle clone <remote> <fresh-path>".to_string(),
1038 "heddle remote add <name> <url>".to_string(),
1039 ],
1040 )
1041 }
1042
1043 pub fn remote_not_configured(action: &str) -> Self {
1044 Self::safety_refusal(
1045 "remote_not_configured",
1046 format!("No default remote is configured for {action}"),
1047 format!(
1048 "Add a remote with `heddle remote add <name> <url>`, inspect remotes with `heddle remote list`, or choose one with `heddle remote set-default <name>`. Ad-hoc targets are supported without configuration: `heddle {action} <remote>` accepts a remote name, URL, local path, or hosted address positionally."
1049 ),
1050 "the command did not receive a remote argument and no default remote is configured",
1051 format!(
1052 "{action} needs a concrete remote target before it can move remote refs or transfer objects"
1053 ),
1054 "repository state, refs, remote configuration, and worktree files were left unchanged",
1055 "heddle remote add <name> <url>",
1056 vec![
1057 "heddle remote add <name> <url>".to_string(),
1058 "heddle remote list".to_string(),
1059 "heddle remote set-default <name>".to_string(),
1060 ],
1061 )
1062 }
1063
1064 pub fn remote_not_found(name: &str) -> Self {
1065 Self::safety_refusal(
1066 "remote_not_found",
1067 format!("Remote '{name}' not found"),
1068 "Inspect configured remotes with `heddle remote list`, or add one with `heddle remote add <name> <url>`.",
1069 format!("no configured Heddle or Git remote named '{name}' was found"),
1070 "the command cannot inspect, fetch, pull, or push a remote until the remote name is resolved",
1071 "remote configuration, refs, objects, and worktree files were left unchanged",
1072 "heddle remote list",
1073 vec![
1074 "heddle remote list".to_string(),
1075 "heddle remote add <name> <url>".to_string(),
1076 ],
1077 )
1078 }
1079
1080 pub fn local_lazy_pull_unsupported(source_path: &std::path::Path) -> Self {
1081 let source = source_path.display().to_string();
1082 let pull_without_lazy = format!("heddle pull {source}");
1083 Self::safety_refusal(
1084 "local_lazy_pull_unsupported",
1085 "Refusing lazy pull from local remote: lazy materialization requires a hosted or network remote",
1086 format!(
1087 "Run `{pull_without_lazy}` without `--lazy`, or configure a hosted remote and retry lazy pull there."
1088 ),
1089 format!("selected remote resolves to local path file://{source}"),
1090 "lazy pull would leave the worktree depending on on-demand object fetches that the local transport does not provide",
1091 "repository state was left unchanged",
1092 pull_without_lazy.clone(),
1093 vec![pull_without_lazy, "heddle remote list".to_string()],
1094 )
1095 }
1096
1097 #[cfg(feature = "client")]
1098 pub fn remote_push_failed(track_name: &str, error: &str) -> Self {
1099 let primary_command = format!("heddle push {track_name}");
1100 Self::safety_refusal(
1101 "remote_push_failed",
1102 format!("Push failed for {track_name}: {error}"),
1103 format!(
1104 "Inspect `heddle verify`, then retry with `{primary_command}` after fixing the remote."
1105 ),
1106 format!("remote push to {track_name} failed: {error}"),
1107 "the remote branch was not confirmed updated",
1108 "local Heddle state, Git refs, and worktree files were left unchanged by the failed push result",
1109 primary_command.clone(),
1110 vec![primary_command, "heddle verify".to_string()],
1111 )
1112 }
1113
1114 #[cfg(feature = "client")]
1115 pub fn remote_pull_failed(
1116 remote_thread: &str,
1117 local_thread: Option<&str>,
1118 error: &str,
1119 ) -> Self {
1120 let primary_command = if let Some(local_thread) = local_thread {
1121 format!("heddle pull {remote_thread} {local_thread}")
1122 } else {
1123 format!("heddle pull {remote_thread}")
1124 };
1125 Self::safety_refusal(
1126 "remote_pull_failed",
1127 format!("Pull failed from {remote_thread}: {error}"),
1128 format!(
1129 "Inspect `heddle verify`, then retry with `{primary_command}` after fixing the remote."
1130 ),
1131 format!("remote pull from {remote_thread} failed: {error}"),
1132 "the local thread was not confirmed updated from the remote",
1133 "local Heddle state, Git refs, and worktree files were left unchanged by the failed pull result",
1134 primary_command.clone(),
1135 vec![primary_command, "heddle verify".to_string()],
1136 )
1137 }
1138
1139 #[cfg(feature = "client")]
1140 pub fn network_clone_failed(error: &str, local_path: &std::path::Path) -> Self {
1141 Self::safety_refusal(
1142 "network_clone_failed",
1143 format!("Clone failed: {error}"),
1144 "Check the remote, credentials, and requested ref, then retry `heddle clone`.",
1145 format!(
1146 "network clone reported failure for '{}': {error}",
1147 local_path.display()
1148 ),
1149 "clone cannot prove that all requested remote objects and refs were materialized",
1150 "any created destination files or metadata were left for inspection",
1151 "heddle clone <remote> <path>",
1152 vec!["heddle clone <remote> <path>".to_string()],
1153 )
1154 }
1155
1156 pub fn missing_target_thread(thread_id: &str, attempted_verb: &str) -> Self {
1168 let show_command = format!("heddle thread show {thread_id}");
1169 let refresh_command = format!("heddle thread refresh {thread_id}");
1170 Self::safety_refusal(
1171 "missing_target_thread",
1172 format!("Thread '{thread_id}' has no target thread"),
1173 format!(
1174 "Inspect the thread's metadata with `{show_command}` to see which target_thread to set, then retry the refresh once the thread has a recorded target."
1175 ),
1176 format!(
1177 "{attempted_verb} was requested for thread '{thread_id}', but the thread record has no `target_thread` field"
1178 ),
1179 format!(
1180 "{attempted_verb} needs a concrete target thread to rebase onto and cannot safely guess one"
1181 ),
1182 "no thread refs, checkout directories, mounts, or agent reservations were changed",
1183 show_command.clone(),
1184 vec![
1185 show_command,
1186 refresh_command,
1187 "heddle thread list".to_string(),
1188 ],
1189 )
1190 }
1191
1192 pub fn rebase_referenced_state_missing(state_id: &str, role: &str) -> Self {
1199 let primary = "heddle abort".to_string();
1200 Self::safety_refusal(
1201 "rebase_referenced_state_missing",
1202 format!("{role} '{state_id}' not found while continuing rebase"),
1203 format!(
1204 "Abort with `{primary}` to rewind to the pre-rebase head, then inspect the object store with `heddle log` and `heddle maintenance gc --dry-run` before restarting the rebase."
1205 ),
1206 format!(
1207 "rebase replay referenced {role} '{state_id}', but the object store does not contain it (possibly pruned or imported from a sibling repository)"
1208 ),
1209 "continuing the rebase without the referenced object could replay against the wrong tree or leave the rebase half-applied",
1210 "the working tree, refs, and rebase state were left at the failure point so the abort path can rewind cleanly",
1211 primary.clone(),
1212 vec![primary, "heddle log".to_string()],
1213 )
1214 }
1215
1216 pub fn rebase_state_corrupted(field: &str, detail: impl fmt::Display) -> Self {
1229 let primary = "heddle abort".to_string();
1230 let detail_str = detail.to_string();
1231 let error = if detail_str.trim().is_empty() {
1232 field.to_string()
1233 } else {
1234 format!("{field}: {detail_str}")
1235 };
1236 Self::safety_refusal(
1237 "rebase_state_corrupted",
1238 error.clone(),
1239 format!(
1240 "Abort with `{primary}` — the tolerant loader rewinds to the pre-rebase head even when the strict --continue loader rejects this file."
1241 ),
1242 format!("REBASE_STATE failed strict validation: {error}"),
1243 "resuming a corrupted rebase could replay an incomplete batch or commit a blank transaction id, polluting the oplog",
1244 "the working tree, refs, and rebase state were left untouched so the abort path can read original_head",
1245 primary.clone(),
1246 vec![primary],
1247 )
1248 }
1249
1250 pub fn thread_referenced_state_missing(state_id: &str, role: &str) -> Self {
1256 let show = format!("heddle thread show {state_id}");
1257 Self::safety_refusal(
1258 "thread_referenced_state_missing",
1259 format!("{role} '{state_id}' not found"),
1260 format!(
1261 "List recent states with `heddle log` to find a reachable id, inspect threads with `heddle thread list`, or use `{show}` if the id is a thread name."
1262 ),
1263 format!("{role} '{state_id}' is not in the object store"),
1264 "starting or anchoring a thread to a missing state would create thread metadata that no inspection path can resolve",
1265 "thread refs, checkout directories, and thread metadata were left unchanged",
1266 "heddle log",
1267 vec![
1268 "heddle log".to_string(),
1269 "heddle thread list".to_string(),
1270 show,
1271 ],
1272 )
1273 }
1274
1275 pub fn thread_checkout_unavailable(thread_name: &str, attempted_verb: &str) -> Self {
1281 let start_command = format!("heddle thread start {thread_name} --path ../{thread_name}");
1282 let show_command = format!("heddle thread show {thread_name}");
1283 Self::safety_refusal(
1284 "thread_checkout_unavailable",
1285 format!(
1286 "thread '{thread_name}' has no on-disk worktree; `--print-cd-path` only works for materialized threads"
1287 ),
1288 format!(
1289 "Materialize the thread with `{start_command}`, or inspect its mode with `{show_command}` to see whether it should be promoted from lightweight."
1290 ),
1291 format!(
1292 "{attempted_verb} requires a concrete filesystem path, but thread '{thread_name}' is registered as lightweight (no materialized checkout)"
1293 ),
1294 format!(
1295 "{attempted_verb} cannot print a checkout path for a thread that never had one materialized"
1296 ),
1297 "thread refs, checkout directories, and thread metadata were left unchanged",
1298 show_command.clone(),
1299 vec![show_command, "heddle thread list".to_string()],
1300 )
1301 }
1302
1303 pub fn primary_hint(&self) -> &str {
1304 &self.hint
1305 }
1306}
1307
1308pub(crate) fn dirty_worktree_recovery_commands() -> Vec<String> {
1309 vec![DIRTY_WORKTREE_CAPTURE_COMMAND.to_string()]
1310}
1311
1312fn hex_hash(hash: [u8; 32]) -> String {
1313 hash.iter().map(|byte| format!("{byte:02x}")).collect()
1314}
1315
1316impl fmt::Display for RecoveryAdvice {
1317 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1318 write!(
1319 f,
1320 "{}. Unsafe: {}. Would change: {}. Preserved: {}. Primary recovery: `{}`.",
1321 self.error,
1322 self.unsafe_condition,
1323 self.would_change,
1324 self.preserved,
1325 self.primary_command
1326 )?;
1327 if self.recovery_commands.len() > 1 {
1328 write!(f, " Other recovery: ")?;
1329 for (index, command) in self.recovery_commands.iter().enumerate().skip(1) {
1330 if index > 1 {
1331 write!(f, ", ")?;
1332 }
1333 write!(f, "`{command}`")?;
1334 }
1335 write!(f, ".")?;
1336 }
1337 Ok(())
1338 }
1339}
1340
1341impl Error for RecoveryAdvice {}
1342
1343#[cfg(test)]
1344mod tests {
1345 use heddle_git_projection::git_core::GitProjectionError;
1346
1347 use super::RecoveryAdvice;
1348
1349 #[test]
1350 fn git_projection_mapping_conflict_returns_typed_advice() {
1351 let error = GitProjectionError::MappingConflict {
1352 message: "git oid abc mapped to old-change (new new-change)".to_string(),
1353 };
1354
1355 let advice = RecoveryAdvice::from_git_projection_error(&error)
1356 .expect("mapping conflict should be classified");
1357
1358 assert_eq!(advice.kind, "git_overlay_mapping_conflict");
1359 assert_eq!(advice.primary_command, "heddle clone <remote> <fresh-path>");
1360 assert!(
1361 advice
1362 .unsafe_condition
1363 .contains("one Git commit maps to different Heddle change ids")
1364 );
1365 }
1366
1367 #[test]
1368 fn git_projection_plain_conflict_message_does_not_classify_as_mapping_conflict() {
1369 let error = GitProjectionError::Conflict(
1370 "git oid abc mapped to old-change (new new-change)".to_string(),
1371 );
1372
1373 assert!(RecoveryAdvice::from_git_projection_error(&error).is_none());
1374 }
1375
1376 #[test]
1377 fn git_projection_shallow_clone_returns_typed_advice() {
1378 let retry_command = "heddle import git --ref main";
1379 let error = GitProjectionError::ShallowClone {
1380 repository: std::path::PathBuf::from("/tmp/shallow"),
1381 retry_command: retry_command.to_string(),
1382 };
1383
1384 let advice = RecoveryAdvice::from_git_projection_error(&error)
1385 .expect("shallow clone should be classified");
1386
1387 assert_eq!(advice.kind, "git_overlay_shallow_clone");
1388 assert_eq!(
1389 advice.recovery_commands,
1390 vec![
1391 "heddle clone <remote> <fresh-path>".to_string(),
1392 retry_command.to_string()
1393 ]
1394 );
1395 assert!(
1396 !advice.hint.contains("git fetch"),
1397 "shallow recovery should stay no-git friendly: {}",
1398 advice.hint
1399 );
1400 }
1401
1402 #[test]
1403 fn rejected_git_push_recovers_through_heddle_pull() {
1404 let advice = RecoveryAdvice::git_overlay_remote_push_rejected("main");
1405
1406 assert_eq!(advice.primary_command, "heddle pull");
1407 assert_eq!(
1408 advice.recovery_commands,
1409 vec!["heddle pull", "heddle verify"]
1410 );
1411 assert!(!advice.hint.contains("git fetch"));
1412 }
1413}