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
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
/// Serializable error envelope emitted as a single NDJSON line.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ErrorJson {
/// Always false on error envelopes (GAP-2026-020 agent sentinel).
///
/// `ok: false` together with `code` is the whole verdict: the redundant
/// `error: true` duplicate left the envelope because every failure paid
/// for it and no caller branched on it alone.
pub ok: bool,
/// Machine-readable error code string.
pub code: &'static str,
/// Process exit status this failure maps to.
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>,
/// Language of `suggestion` when it is not English, so a consumer knows
/// what it is reading.
#[serde(skip_serializing_if = "Option::is_none")]
pub suggestion_lang: Option<&'static str>,
/// 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>,
/// 1-based line of the block that holds the syntax error (v0.1.38).
///
/// Present only for [`AtomwriteError::SyntaxError`]. Without it the caller
/// had to re-read the file to learn where the refusal points.
#[serde(skip_serializing_if = "Option::is_none")]
pub line: Option<usize>,
/// 1-based last line of that block, when the block spans more than a line.
#[serde(skip_serializing_if = "Option::is_none")]
pub line_end: Option<usize>,
}
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 (line, line_end) = match err {
AtomwriteError::SyntaxError { line, line_end, .. } => (*line, *line_end),
_ => (None, 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,
};
// The near-miss list is the actionable part of a missing path: when it
// exists, the suggestion names it instead of telling the caller to
// check a spelling it cannot see. The walk runs once here, so the
// suggestion and the `similar_paths` field read the same answer.
let similar_paths = similar_paths_for(err, ctx);
let suggestion = match &similar_paths {
Some(paths) if !paths.is_empty() => Some(
t!("suggestion.similar-paths", paths = paths.join(", ")).to_string(),
),
_ => suggestion_for(err, ctx),
};
// English is the language `message` is already written in, so the tag
// is published only when the text differs from it.
let suggestion_lang = suggestion.as_ref().and_then(|_| {
let language = crate::locale::resolved_language();
(language != crate::locale::Language::En).then(|| language.as_str())
});
Self {
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_lang,
workspace,
failed_pair_index,
pairs_total,
pair_results,
best_candidate,
candidates,
match_count,
similar_paths,
conflict_count,
missing_flag,
pairs_unmatched,
pairs_applied,
line,
line_end,
}
}
}