1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
/// Serializable error envelope emitted as a single NDJSON line.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ErrorJson {
/// Always true, marks this line as an error event.
pub error: bool,
/// Always false on error envelopes (GAP-2026-020 agent sentinel).
///
/// Success envelopes keep their own `status` field; agents MUST check
/// `error` / `ok` / process exit before reading success-only keys.
pub ok: bool,
/// Machine-readable error code string.
pub code: &'static str,
/// Suggested process exit code.
pub exit: u8,
/// Human-readable error message.
pub message: String,
/// Filesystem path related to the error, if applicable.
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
/// Error class: transient, conflict, `precondition_failed`, or permanent.
pub error_class: &'static str,
/// Whether a retry may resolve this error.
pub retryable: bool,
/// Optional actionable suggestion for the caller.
#[serde(skip_serializing_if = "Option::is_none")]
pub suggestion: Option<String>,
/// Workspace root used for jail validation, if applicable.
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace: Option<String>,
/// 1-based index of the failed `--old`/`--new` pair (multi-pair edit, G117).
#[serde(skip_serializing_if = "Option::is_none")]
pub failed_pair_index: Option<u64>,
/// Total number of `--old`/`--new` pairs in the invocation (multi-pair edit, G117).
#[serde(skip_serializing_if = "Option::is_none")]
pub pairs_total: Option<u64>,
/// Per-pair diagnostics up to and including the failed pair (multi-pair edit, G117).
/// Pairs after the failure were never attempted and are absent.
#[serde(skip_serializing_if = "Option::is_none")]
pub pair_results: Option<Vec<PairResult>>,
/// Closest near-miss when a match cascade fails (v0.1.29 P0-2).
#[serde(skip_serializing_if = "Option::is_none")]
pub best_candidate: Option<BestCandidate>,
/// Additional near-miss candidates for `did_you_mean` (v0.1.30).
#[serde(skip_serializing_if = "Option::is_none")]
pub candidates: Option<Vec<BestCandidate>>,
/// Match count when ambiguity is reported (v0.1.30).
#[serde(skip_serializing_if = "Option::is_none")]
pub match_count: Option<u64>,
/// Similar basenames when path is missing (v0.1.30).
#[serde(skip_serializing_if = "Option::is_none")]
pub similar_paths: Option<Vec<String>>,
}
impl ErrorJson {
/// Build an [`ErrorJson`] from a domain error with default empty context.
///
/// Equivalent to `from_error_with_context(err, &ErrorContext::default())`.
/// Use [`Self::from_error_with_context`] when workspace provenance is known
/// so the suggestion for `WorkspaceJail` is precise.
#[cold]
#[track_caller]
pub fn from_error(err: &AtomwriteError) -> Self {
Self::from_error_with_context(err, &ErrorContext::default())
}
/// Build an [`ErrorJson`] from a domain error and a diagnostic context.
///
/// The context allows the suggestion text to be precise. In particular,
/// `WorkspaceJail` errors report different remediation depending on
/// whether the user already supplied a workspace root via `--workspace`
/// or CLI `--workspace` (GAP 13 fix).
#[cold]
#[track_caller]
pub fn from_error_with_context(err: &AtomwriteError, ctx: &ErrorContext) -> Self {
let workspace = match err {
AtomwriteError::WorkspaceJail { workspace, .. } => {
Some(workspace.display().to_string())
}
_ => None,
};
// Cold error path: clone out of `Box` / `Option` fields for the
// public JSON surface. Source remains borrowed (`&AtomwriteError`).
let (failed_pair_index, pairs_total, pair_results) = match err {
AtomwriteError::EditPairFailed {
index,
total,
pair_results,
..
} => (
Some(*index),
Some(*total),
Some(pair_results.as_ref().clone()),
),
_ => (None, None, None),
};
let best_candidate = match err {
AtomwriteError::MatchFailed { best_candidate, .. }
| AtomwriteError::MatchAmbiguous { best_candidate, .. }
| AtomwriteError::EditPairFailed { best_candidate, .. } => {
best_candidate.as_deref().cloned()
}
_ => None,
};
let candidates = match err {
AtomwriteError::MatchFailed { candidates, .. } => candidates.clone(),
_ => None,
};
let match_count = match err {
AtomwriteError::MatchAmbiguous { count, .. } => Some(*count),
// GAP-2026-020: stable sentinels so agents never see "all null" success fields.
AtomwriteError::MatchFailed { .. } | AtomwriteError::NoMatches => Some(0),
_ => None,
};
Self {
error: true,
ok: false,
code: err.error_code(),
exit: err.exit_code(),
message: err.to_string(),
path: err.path().map(|p| p.display().to_string()),
error_class: err.error_class().as_str(),
retryable: err.is_retryable(),
suggestion: suggestion_for(err, ctx),
workspace,
failed_pair_index,
pairs_total,
pair_results,
best_candidate,
candidates,
match_count,
similar_paths: similar_paths_for(err, ctx),
}
}
}