1use thiserror::Error;
4
5use crate::atlassian::adf_schema::AdfSchemaViolation;
6use crate::atlassian::adf_validated::AdfValidationError;
7
8#[derive(Error, Debug)]
10pub enum AtlassianError {
11 #[error("Atlassian credentials not configured. Run `omni-dev atlassian auth login`")]
13 CredentialsNotFound,
14
15 #[error("Atlassian API request failed: HTTP {status}: {body}")]
17 ApiRequestFailed {
18 status: u16,
20 body: String,
22 },
23
24 #[error("{}", format_diagnosis(diagnosis, hint.as_deref()))]
33 ApiRequestFailedWithDiagnosis {
34 body: String,
36 diagnosis: AdfSchemaViolation,
38 hint: Option<String>,
40 },
41
42 #[error("Invalid JFM document: {0}")]
44 InvalidDocument(String),
45
46 #[error("ADF conversion error: {0}")]
48 ConversionError(String),
49
50 #[error("{0}")]
52 InvalidAdfNesting(#[from] AdfValidationError),
53
54 #[error("{}", format_jira_adf_field_required(fields, original_message))]
61 JiraAdfFieldRequired {
62 fields: Vec<String>,
65 original_message: String,
69 body: String,
71 },
72}
73
74fn format_diagnosis(diagnosis: &AdfSchemaViolation, hint: Option<&str>) -> String {
75 let header = "Confluence API returned HTTP 500 (Internal Server Error)";
76 let diag_line = match diagnosis {
77 AdfSchemaViolation::DisallowedChild {
78 child_type,
79 parent_type,
80 ..
81 } => format!(
82 "Diagnosis: the submitted ADF contains `{child_type}` nested inside `{parent_type}` \
83 (not allowed by Confluence's content model)."
84 ),
85 AdfSchemaViolation::Arity { .. } => {
86 format!("Diagnosis: the submitted ADF has an arity violation — {diagnosis}.")
87 }
88 AdfSchemaViolation::MissingAttr {
89 node_type,
90 attr_name,
91 ..
92 } => format!(
93 "Diagnosis: the submitted ADF's `{node_type}` is missing required attribute `{attr_name}`."
94 ),
95 AdfSchemaViolation::InvalidAttr {
96 node_type,
97 attr_name,
98 problem,
99 ..
100 } => format!(
101 "Diagnosis: the submitted ADF's `{node_type}.{attr_name}` is invalid — {problem}."
102 ),
103 AdfSchemaViolation::DisallowedMark {
104 mark_type,
105 parent_type,
106 ..
107 } => format!(
108 "Diagnosis: the submitted ADF carries a `{mark_type}` mark on `{parent_type}` which is not permitted in that context."
109 ),
110 AdfSchemaViolation::InvalidMarkAttr {
111 mark_type,
112 attr_name,
113 problem,
114 ..
115 } => format!(
116 "Diagnosis: the submitted ADF's `{mark_type}` mark has invalid `{attr_name}` — {problem}."
117 ),
118 };
119 let mut out = format!("{header}\n{diag_line}");
120 if let Some(hint) = hint {
121 out.push_str("\nHint: ");
122 out.push_str(hint);
123 }
124 out
125}
126
127fn format_jira_adf_field_required(fields: &[String], original_message: &str) -> String {
128 let header = match fields {
129 [] => "JIRA fields require rich-text content in ADF format.".to_string(),
130 [one] => format!("Field `{one}` requires rich-text content in ADF format."),
131 many => {
132 let joined = many
133 .iter()
134 .map(|f| format!("`{f}`"))
135 .collect::<Vec<_>>()
136 .join(", ");
137 format!("Fields {joined} require rich-text content in ADF format.")
138 }
139 };
140 let hint = "\n\nTo fix: pass the value as a JFM markdown string \
141 (it will be auto-converted to ADF), or pass a raw ADF \
142 document object. See `omni-dev://specs/jfm` for JFM syntax.";
143 let original = if original_message.is_empty() {
144 String::new()
145 } else {
146 format!("\n\nOriginal API error: \"{original_message}\"")
147 };
148 format!("{header}{hint}{original}")
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use crate::atlassian::adf_schema::Quantifier;
155
156 #[test]
157 fn credentials_not_found_display() {
158 let err = AtlassianError::CredentialsNotFound;
159 assert!(err.to_string().contains("not configured"));
160 }
161
162 #[test]
163 fn api_request_failed_display() {
164 let err = AtlassianError::ApiRequestFailed {
165 status: 404,
166 body: "Not Found".to_string(),
167 };
168 let msg = err.to_string();
169 assert!(msg.contains("404"));
170 assert!(msg.contains("Not Found"));
171 }
172
173 #[test]
174 fn invalid_document_display() {
175 let err = AtlassianError::InvalidDocument("bad format".to_string());
176 assert!(err.to_string().contains("bad format"));
177 }
178
179 #[test]
180 fn conversion_error_display() {
181 let err = AtlassianError::ConversionError("oops".to_string());
182 assert!(err.to_string().contains("oops"));
183 }
184
185 #[test]
186 fn api_request_failed_with_diagnosis_display_with_hint() {
187 let err = AtlassianError::ApiRequestFailedWithDiagnosis {
188 body: "{}".to_string(),
189 diagnosis: AdfSchemaViolation::DisallowedChild {
190 child_type: "expand".to_string(),
191 parent_type: "panel".to_string(),
192 path: vec![0, 0],
193 },
194 hint: Some(
195 "invert the nesting (panel inside expand) or make them siblings".to_string(),
196 ),
197 };
198 let msg = err.to_string();
199 assert!(msg.contains("Confluence API returned HTTP 500 (Internal Server Error)"));
200 assert!(msg.contains("Diagnosis:"));
201 assert!(msg.contains("`expand`"));
202 assert!(msg.contains("`panel`"));
203 assert!(msg.contains("Hint: invert the nesting"));
204 }
205
206 #[test]
207 fn api_request_failed_with_diagnosis_display_without_hint() {
208 let err = AtlassianError::ApiRequestFailedWithDiagnosis {
209 body: String::new(),
210 diagnosis: AdfSchemaViolation::DisallowedChild {
211 child_type: "table".to_string(),
212 parent_type: "nestedExpand".to_string(),
213 path: vec![1],
214 },
215 hint: None,
216 };
217 let msg = err.to_string();
218 assert!(msg.contains("`table`"));
219 assert!(msg.contains("`nestedExpand`"));
220 assert!(!msg.contains("Hint:"));
221 }
222
223 #[test]
224 fn invalid_adf_nesting_display_includes_violations() {
225 let err = AtlassianError::InvalidAdfNesting(AdfValidationError {
226 violations: vec![AdfSchemaViolation::DisallowedChild {
227 parent_type: "panel".to_string(),
228 child_type: "expand".to_string(),
229 path: vec![0, 0],
230 }],
231 });
232 let msg = err.to_string();
233 assert!(msg.contains("invalid ADF nesting"));
234 assert!(msg.contains("`expand` cannot be a child of `panel`"));
235 assert!(msg.contains("hint: invert the nesting"));
236 }
237
238 #[test]
239 fn api_request_failed_with_diagnosis_display_for_arity() {
240 let err = AtlassianError::ApiRequestFailedWithDiagnosis {
241 body: String::new(),
242 diagnosis: AdfSchemaViolation::Arity {
243 parent_type: "bulletList".to_string(),
244 atoms: vec!["listItem"],
245 expected: Quantifier::OneOrMore,
246 actual: 0,
247 path: vec![1],
248 },
249 hint: Some("a list must contain at least one item".to_string()),
250 };
251 let msg = err.to_string();
252 assert!(msg.contains("Confluence API returned HTTP 500 (Internal Server Error)"));
253 assert!(msg.contains("arity violation"), "got: {msg}");
254 assert!(msg.contains("'bulletList'"), "got: {msg}");
255 assert!(msg.contains("at least one"), "got: {msg}");
256 assert!(msg.contains("Hint: a list must contain"), "got: {msg}");
257 }
258
259 #[test]
260 fn api_request_failed_with_diagnosis_display_for_missing_attr() {
261 let err = AtlassianError::ApiRequestFailedWithDiagnosis {
262 body: String::new(),
263 diagnosis: AdfSchemaViolation::MissingAttr {
264 node_type: "panel".to_string(),
265 attr_name: "panelType".to_string(),
266 path: vec![0],
267 },
268 hint: None,
269 };
270 let msg = err.to_string();
271 assert!(msg.contains("`panel`"), "got: {msg}");
272 assert!(msg.contains("missing required attribute"), "got: {msg}");
273 assert!(msg.contains("`panelType`"), "got: {msg}");
274 }
275
276 #[test]
277 fn api_request_failed_with_diagnosis_display_for_invalid_attr() {
278 use crate::atlassian::adf_attr_schema::AttrProblem;
279 let err = AtlassianError::ApiRequestFailedWithDiagnosis {
280 body: String::new(),
281 diagnosis: AdfSchemaViolation::InvalidAttr {
282 node_type: "heading".to_string(),
283 attr_name: "level".to_string(),
284 problem: AttrProblem::OutOfRange {
285 lo: 1,
286 hi: 6,
287 actual: 7,
288 },
289 path: vec![0],
290 },
291 hint: None,
292 };
293 let msg = err.to_string();
294 assert!(msg.contains("`heading.level`"), "got: {msg}");
295 assert!(msg.contains("invalid"), "got: {msg}");
296 assert!(msg.contains("[1, 6]"), "got: {msg}");
297 }
298
299 #[test]
300 fn api_request_failed_with_diagnosis_display_for_disallowed_mark() {
301 let err = AtlassianError::ApiRequestFailedWithDiagnosis {
302 body: String::new(),
303 diagnosis: AdfSchemaViolation::DisallowedMark {
304 mark_type: "code".to_string(),
305 parent_type: "heading".to_string(),
306 inline_index: Some(0),
307 path: vec![0],
308 },
309 hint: None,
310 };
311 let msg = err.to_string();
312 assert!(msg.contains("`code` mark"), "got: {msg}");
313 assert!(msg.contains("`heading`"), "got: {msg}");
314 assert!(msg.contains("not permitted"), "got: {msg}");
315 }
316
317 #[test]
318 fn api_request_failed_with_diagnosis_display_for_invalid_mark_attr() {
319 use crate::atlassian::adf_attr_schema::AttrProblem;
320 let err = AtlassianError::ApiRequestFailedWithDiagnosis {
321 body: String::new(),
322 diagnosis: AdfSchemaViolation::InvalidMarkAttr {
323 mark_type: "link".to_string(),
324 attr_name: "href".to_string(),
325 problem: AttrProblem::BadFormat {
326 reason: "not a valid URL",
327 },
328 inline_index: Some(0),
329 path: vec![0],
330 },
331 hint: None,
332 };
333 let msg = err.to_string();
334 assert!(msg.contains("`link` mark"), "got: {msg}");
335 assert!(msg.contains("`href`"), "got: {msg}");
336 assert!(msg.contains("not a valid URL"), "got: {msg}");
337 }
338
339 #[test]
340 fn jira_adf_field_required_display_single_field() {
341 let err = AtlassianError::JiraAdfFieldRequired {
342 fields: vec!["customfield_19300".to_string()],
343 original_message:
344 "Operation value must be an Atlassian Document (see the Atlassian Document Format)"
345 .to_string(),
346 body: "{}".to_string(),
347 };
348 let msg = err.to_string();
349 assert!(msg.contains("Field `customfield_19300`"), "got: {msg}");
350 assert!(
351 msg.contains("requires rich-text content in ADF format"),
352 "got: {msg}"
353 );
354 assert!(msg.contains("To fix:"), "got: {msg}");
355 assert!(msg.contains("JFM markdown"), "got: {msg}");
356 assert!(msg.contains("omni-dev://specs/jfm"), "got: {msg}");
357 assert!(msg.contains("Original API error:"), "got: {msg}");
358 assert!(
359 msg.contains("Operation value must be an Atlassian Document"),
360 "got: {msg}"
361 );
362 }
363
364 #[test]
365 fn jira_adf_field_required_display_multiple_fields() {
366 let err = AtlassianError::JiraAdfFieldRequired {
367 fields: vec![
368 "customfield_19300".to_string(),
369 "customfield_42000".to_string(),
370 ],
371 original_message: "Operation value must be an Atlassian Document".to_string(),
372 body: String::new(),
373 };
374 let msg = err.to_string();
375 assert!(
376 msg.contains("Fields `customfield_19300`, `customfield_42000`"),
377 "got: {msg}"
378 );
379 assert!(msg.contains("require rich-text content"), "got: {msg}");
380 }
381
382 #[test]
383 fn jira_adf_field_required_display_no_fields_uses_generic_header() {
384 let err = AtlassianError::JiraAdfFieldRequired {
388 fields: vec![],
389 original_message: "Operation value must be an Atlassian Document".to_string(),
390 body: String::new(),
391 };
392 let msg = err.to_string();
393 assert!(
394 msg.contains("JIRA fields require rich-text content in ADF format."),
395 "got: {msg}"
396 );
397 assert!(!msg.contains("Field `"), "got: {msg}");
398 }
399
400 #[test]
401 fn jira_adf_field_required_display_omits_original_when_empty() {
402 let err = AtlassianError::JiraAdfFieldRequired {
403 fields: vec!["customfield_19300".to_string()],
404 original_message: String::new(),
405 body: String::new(),
406 };
407 let msg = err.to_string();
408 assert!(!msg.contains("Original API error:"), "got: {msg}");
409 }
410}