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
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
/// 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.
///
/// Emitted by multi-pair edit (G117) and by a partial `edit-loop` run
/// (v0.1.36) — one key, two producers.
#[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>>,
/// Number of conflicting regions a three-way merge could not align (v0.1.36).
#[serde(skip_serializing_if = "Option::is_none")]
pub conflict_count: Option<u64>,
/// Flag literal the operation required but did not receive (v0.1.36).
///
/// Present only for [`AtomwriteError::MissingFlag`]; the duplicate `code`
/// on that variant is deliberate — callers branch on `missing_flag` and
/// still see the `INVALID_INPUT` family in `code`.
#[serde(skip_serializing_if = "Option::is_none")]
pub missing_flag: Option<String>,
/// Pairs that did not match in a partial `edit-loop` run (v0.1.36).
///
/// `pairs_total` already exists above for the multi-pair edit family and
/// is reused by this variant instead of being duplicated.
#[serde(skip_serializing_if = "Option::is_none")]
pub pairs_unmatched: Option<u64>,
/// Pairs that were written to the target before the error (v0.1.37).
///
/// Always `0` for the all-or-nothing `EditPairFailed` abort (nothing is
/// written) and `total - unmatched` for `edit-loop --partial`.
#[serde(skip_serializing_if = "Option::is_none")]
pub pairs_applied: Option<u64>,
/// UI language the catalogue-derived `suggestion` was rendered in (B4).
///
/// The two human strings on this envelope do not share a source: `message`
/// is built from the error variant, which is English by contract, while
/// `suggestion` comes from the i18n catalogue and follows the resolved UI
/// language. One envelope can therefore carry an English `message` and a
/// Portuguese `suggestion`. Without this field a consumer cannot tell which
/// language the suggestion is in, and cannot decide whether to re-render
/// it. `code` and every other field stay machine literals.
pub suggestion_lang: &'static str,
}
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()),
),
AtomwriteError::EditLoopPartial { total, .. } => (None, Some(*total), None),
_ => (None, None, None),
};
let conflict_count = match err {
AtomwriteError::MergeConflict { count, .. } => Some(*count),
_ => None,
};
let missing_flag = match err {
AtomwriteError::MissingFlag { flag, .. } => Some(flag.clone()),
_ => None,
};
let pairs_unmatched = match err {
AtomwriteError::EditLoopPartial { unmatched, .. } => Some(*unmatched),
_ => None,
};
let pairs_applied = match err {
AtomwriteError::EditPairFailed { .. } => Some(0),
AtomwriteError::EditLoopPartial { unmatched, total } => {
Some(total.saturating_sub(*unmatched))
}
_ => 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),
conflict_count,
missing_flag,
pairs_unmatched,
pairs_applied,
suggestion_lang: crate::locale::resolved_language().as_str(),
}
}
}