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 AdfSchemaViolation::ForbiddenMarkCombination {
119 mark_type,
120 conflicts_with,
121 ..
122 } => format!(
123 "Diagnosis: the submitted ADF combines the `{mark_type}` and `{conflicts_with}` marks on one text run, which ADF does not allow."
124 ),
125 };
126 let mut out = format!("{header}\n{diag_line}");
127 if let Some(hint) = hint {
128 out.push_str("\nHint: ");
129 out.push_str(hint);
130 }
131 out
132}
133
134fn format_jira_adf_field_required(fields: &[String], original_message: &str) -> String {
135 let header = match fields {
136 [] => "JIRA fields require rich-text content in ADF format.".to_string(),
137 [one] => format!("Field `{one}` requires rich-text content in ADF format."),
138 many => {
139 let joined = many
140 .iter()
141 .map(|f| format!("`{f}`"))
142 .collect::<Vec<_>>()
143 .join(", ");
144 format!("Fields {joined} require rich-text content in ADF format.")
145 }
146 };
147 let hint = "\n\nTo fix: pass the value as a JFM markdown string \
148 (it will be auto-converted to ADF), or pass a raw ADF \
149 document object. See `omni-dev://specs/jfm` for JFM syntax.";
150 let original = if original_message.is_empty() {
151 String::new()
152 } else {
153 format!("\n\nOriginal API error: \"{original_message}\"")
154 };
155 format!("{header}{hint}{original}")
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161 use crate::atlassian::adf_schema::Quantifier;
162
163 #[test]
164 fn credentials_not_found_display() {
165 let err = AtlassianError::CredentialsNotFound;
166 assert!(err.to_string().contains("not configured"));
167 }
168
169 #[test]
170 fn api_request_failed_display() {
171 let err = AtlassianError::ApiRequestFailed {
172 status: 404,
173 body: "Not Found".to_string(),
174 };
175 let msg = err.to_string();
176 assert!(msg.contains("404"));
177 assert!(msg.contains("Not Found"));
178 }
179
180 #[test]
181 fn invalid_document_display() {
182 let err = AtlassianError::InvalidDocument("bad format".to_string());
183 assert!(err.to_string().contains("bad format"));
184 }
185
186 #[test]
187 fn conversion_error_display() {
188 let err = AtlassianError::ConversionError("oops".to_string());
189 assert!(err.to_string().contains("oops"));
190 }
191
192 #[test]
193 fn api_request_failed_with_diagnosis_display_with_hint() {
194 let err = AtlassianError::ApiRequestFailedWithDiagnosis {
195 body: "{}".to_string(),
196 diagnosis: AdfSchemaViolation::DisallowedChild {
197 child_type: "expand".to_string(),
198 parent_type: "panel".to_string(),
199 path: vec![0, 0],
200 },
201 hint: Some(
202 "invert the nesting (panel inside expand) or make them siblings".to_string(),
203 ),
204 };
205 let msg = err.to_string();
206 assert!(msg.contains("Confluence API returned HTTP 500 (Internal Server Error)"));
207 assert!(msg.contains("Diagnosis:"));
208 assert!(msg.contains("`expand`"));
209 assert!(msg.contains("`panel`"));
210 assert!(msg.contains("Hint: invert the nesting"));
211 }
212
213 #[test]
214 fn api_request_failed_with_diagnosis_display_without_hint() {
215 let err = AtlassianError::ApiRequestFailedWithDiagnosis {
216 body: String::new(),
217 diagnosis: AdfSchemaViolation::DisallowedChild {
218 child_type: "table".to_string(),
219 parent_type: "nestedExpand".to_string(),
220 path: vec![1],
221 },
222 hint: None,
223 };
224 let msg = err.to_string();
225 assert!(msg.contains("`table`"));
226 assert!(msg.contains("`nestedExpand`"));
227 assert!(!msg.contains("Hint:"));
228 }
229
230 #[test]
231 fn invalid_adf_nesting_display_includes_violations() {
232 let err = AtlassianError::InvalidAdfNesting(AdfValidationError::new(vec![
233 AdfSchemaViolation::DisallowedChild {
234 parent_type: "panel".to_string(),
235 child_type: "expand".to_string(),
236 path: vec![0, 0],
237 },
238 ]));
239 let msg = err.to_string();
240 assert!(msg.contains("invalid ADF nesting"));
241 assert!(msg.contains("`expand` cannot be a child of `panel`"));
242 assert!(msg.contains("hint: invert the nesting"));
243 }
244
245 #[test]
246 fn api_request_failed_with_diagnosis_display_for_arity() {
247 let err = AtlassianError::ApiRequestFailedWithDiagnosis {
248 body: String::new(),
249 diagnosis: AdfSchemaViolation::Arity {
250 parent_type: "bulletList".to_string(),
251 atoms: vec!["listItem"],
252 expected: Quantifier::OneOrMore,
253 actual: 0,
254 path: vec![1],
255 },
256 hint: Some("a list must contain at least one item".to_string()),
257 };
258 let msg = err.to_string();
259 assert!(msg.contains("Confluence API returned HTTP 500 (Internal Server Error)"));
260 assert!(msg.contains("arity violation"), "got: {msg}");
261 assert!(msg.contains("'bulletList'"), "got: {msg}");
262 assert!(msg.contains("at least one"), "got: {msg}");
263 assert!(msg.contains("Hint: a list must contain"), "got: {msg}");
264 }
265
266 #[test]
267 fn api_request_failed_with_diagnosis_display_for_missing_attr() {
268 let err = AtlassianError::ApiRequestFailedWithDiagnosis {
269 body: String::new(),
270 diagnosis: AdfSchemaViolation::MissingAttr {
271 node_type: "panel".to_string(),
272 attr_name: "panelType".to_string(),
273 path: vec![0],
274 },
275 hint: None,
276 };
277 let msg = err.to_string();
278 assert!(msg.contains("`panel`"), "got: {msg}");
279 assert!(msg.contains("missing required attribute"), "got: {msg}");
280 assert!(msg.contains("`panelType`"), "got: {msg}");
281 }
282
283 #[test]
284 fn api_request_failed_with_diagnosis_display_for_invalid_attr() {
285 use crate::atlassian::adf_attr_schema::AttrProblem;
286 let err = AtlassianError::ApiRequestFailedWithDiagnosis {
287 body: String::new(),
288 diagnosis: AdfSchemaViolation::InvalidAttr {
289 node_type: "heading".to_string(),
290 attr_name: "level".to_string(),
291 problem: AttrProblem::OutOfRange {
292 lo: 1,
293 hi: 6,
294 actual: 7,
295 },
296 path: vec![0],
297 },
298 hint: None,
299 };
300 let msg = err.to_string();
301 assert!(msg.contains("`heading.level`"), "got: {msg}");
302 assert!(msg.contains("invalid"), "got: {msg}");
303 assert!(msg.contains("[1, 6]"), "got: {msg}");
304 }
305
306 #[test]
307 fn api_request_failed_with_diagnosis_display_for_disallowed_mark() {
308 let err = AtlassianError::ApiRequestFailedWithDiagnosis {
309 body: String::new(),
310 diagnosis: AdfSchemaViolation::DisallowedMark {
311 mark_type: "code".to_string(),
312 parent_type: "heading".to_string(),
313 inline_index: Some(0),
314 path: vec![0],
315 },
316 hint: None,
317 };
318 let msg = err.to_string();
319 assert!(msg.contains("`code` mark"), "got: {msg}");
320 assert!(msg.contains("`heading`"), "got: {msg}");
321 assert!(msg.contains("not permitted"), "got: {msg}");
322 }
323
324 #[test]
325 fn api_request_failed_with_diagnosis_display_for_invalid_mark_attr() {
326 use crate::atlassian::adf_attr_schema::AttrProblem;
327 let err = AtlassianError::ApiRequestFailedWithDiagnosis {
328 body: String::new(),
329 diagnosis: AdfSchemaViolation::InvalidMarkAttr {
330 mark_type: "link".to_string(),
331 attr_name: "href".to_string(),
332 problem: AttrProblem::BadFormat {
333 reason: "not a valid URL",
334 },
335 inline_index: Some(0),
336 path: vec![0],
337 },
338 hint: None,
339 };
340 let msg = err.to_string();
341 assert!(msg.contains("`link` mark"), "got: {msg}");
342 assert!(msg.contains("`href`"), "got: {msg}");
343 assert!(msg.contains("not a valid URL"), "got: {msg}");
344 }
345
346 #[test]
347 fn api_request_failed_with_diagnosis_display_for_forbidden_mark_combination() {
348 let err = AtlassianError::ApiRequestFailedWithDiagnosis {
349 body: String::new(),
350 diagnosis: AdfSchemaViolation::ForbiddenMarkCombination {
351 mark_type: "strong".to_string(),
352 conflicts_with: "code".to_string(),
353 parent_type: "paragraph".to_string(),
354 inline_index: Some(0),
355 path: vec![0, 0],
356 },
357 hint: None,
358 };
359 let msg = err.to_string();
360 assert!(msg.contains("`strong`"), "got: {msg}");
361 assert!(msg.contains("`code`"), "got: {msg}");
362 assert!(msg.contains("does not allow"), "got: {msg}");
363 }
364
365 #[test]
366 fn jira_adf_field_required_display_single_field() {
367 let err = AtlassianError::JiraAdfFieldRequired {
368 fields: vec!["customfield_19300".to_string()],
369 original_message:
370 "Operation value must be an Atlassian Document (see the Atlassian Document Format)"
371 .to_string(),
372 body: "{}".to_string(),
373 };
374 let msg = err.to_string();
375 assert!(msg.contains("Field `customfield_19300`"), "got: {msg}");
376 assert!(
377 msg.contains("requires rich-text content in ADF format"),
378 "got: {msg}"
379 );
380 assert!(msg.contains("To fix:"), "got: {msg}");
381 assert!(msg.contains("JFM markdown"), "got: {msg}");
382 assert!(msg.contains("omni-dev://specs/jfm"), "got: {msg}");
383 assert!(msg.contains("Original API error:"), "got: {msg}");
384 assert!(
385 msg.contains("Operation value must be an Atlassian Document"),
386 "got: {msg}"
387 );
388 }
389
390 #[test]
391 fn jira_adf_field_required_display_multiple_fields() {
392 let err = AtlassianError::JiraAdfFieldRequired {
393 fields: vec![
394 "customfield_19300".to_string(),
395 "customfield_42000".to_string(),
396 ],
397 original_message: "Operation value must be an Atlassian Document".to_string(),
398 body: String::new(),
399 };
400 let msg = err.to_string();
401 assert!(
402 msg.contains("Fields `customfield_19300`, `customfield_42000`"),
403 "got: {msg}"
404 );
405 assert!(msg.contains("require rich-text content"), "got: {msg}");
406 }
407
408 #[test]
409 fn jira_adf_field_required_display_no_fields_uses_generic_header() {
410 let err = AtlassianError::JiraAdfFieldRequired {
414 fields: vec![],
415 original_message: "Operation value must be an Atlassian Document".to_string(),
416 body: String::new(),
417 };
418 let msg = err.to_string();
419 assert!(
420 msg.contains("JIRA fields require rich-text content in ADF format."),
421 "got: {msg}"
422 );
423 assert!(!msg.contains("Field `"), "got: {msg}");
424 }
425
426 #[test]
427 fn jira_adf_field_required_display_omits_original_when_empty() {
428 let err = AtlassianError::JiraAdfFieldRequired {
429 fields: vec!["customfield_19300".to_string()],
430 original_message: String::new(),
431 body: String::new(),
432 };
433 let msg = err.to_string();
434 assert!(!msg.contains("Original API error:"), "got: {msg}");
435 }
436}