Skip to main content

heddle_cli_contract/cli/commands/
advice.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Typed refusal and recovery advice shared by command surfaces.
3
4use 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    /// Updating the authoritative checkout branch would replace a local Git
984    /// commit. Keep this distinct from a remote push rejection so recovery is
985    /// executable in the current checkout.
986    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    /// `thread refresh` was asked to refresh a thread that has no
1157    /// recorded `target_thread`. The thread metadata lives on disk but
1158    /// the integration target was never written, so the refresh has no
1159    /// concrete destination to rebase onto.
1160    ///
1161    /// Surfaces Priya's #1 untyped error site: the bare
1162    /// `Thread '<id>' has no target thread` line gave the operator no
1163    /// next step. The typed envelope hands back the inspection commands
1164    /// (`heddle thread show`, `heddle thread list`) and the
1165    /// re-configuration command shape so the JSON envelope's
1166    /// `recovery_action_templates` field carries executable argv.
1167    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    /// A rebase replay step referenced a state, commit, or tree the
1193    /// object store no longer has. Usually means a pruning operation
1194    /// ran between rebase start and rebase resume, or the persisted
1195    /// `REBASE_STATE` references objects from a sibling repository.
1196    /// Abort recovers — the tolerant loader will rewind to
1197    /// `original_head` without needing the missing objects.
1198    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    /// A persisted `REBASE_STATE` file could not be parsed or violated
1217    /// an invariant. The strict loader (used by `--continue`) hard-fails
1218    /// so a half-applied batch never reaches the oplog; the tolerant
1219    /// loader (used by `--abort`) absorbs most of these cases and rewinds
1220    /// to `original_head`.
1221    ///
1222    /// `field` describes which part of REBASE_STATE failed validation
1223    /// (e.g. `"Missing 'onto'"`, `"decode pending_advance"`,
1224    /// `"Inconsistent rebase state"`); `detail` carries the underlying
1225    /// cause when there is one (e.g. a hex/msgpack decode error) and may
1226    /// be empty. Tests assert on the `field` substring, so the user-
1227    /// visible `error` always starts with `field`.
1228    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    /// A thread command resolved a state spec or anchor and the
1251    /// referenced state was not in the object store. Distinct from
1252    /// `state_not_found` because the lookup happens inside thread
1253    /// command flow (start/create/anchor) rather than the generic state
1254    /// resolver.
1255    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    /// `--print-cd-path` (or another path-only output mode) was passed
1276    /// to a thread command but the thread has no on-disk worktree to
1277    /// `cd` into. Lightweight (non-materialized) threads do not own a
1278    /// directory — the operator needs to materialize the thread or use
1279    /// a different command that doesn't require a checkout path.
1280    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}