Skip to main content

omni_dev/atlassian/
error.rs

1//! Error types for Atlassian operations.
2
3use thiserror::Error;
4
5use crate::atlassian::adf_schema::AdfSchemaViolation;
6use crate::atlassian::adf_validated::AdfValidationError;
7
8/// Errors that can occur during Atlassian operations.
9#[derive(Error, Debug)]
10pub enum AtlassianError {
11    /// Atlassian credentials are not configured.
12    #[error("Atlassian credentials not configured. Run `omni-dev atlassian auth login`")]
13    CredentialsNotFound,
14
15    /// An Atlassian API request failed.
16    #[error("Atlassian API request failed: HTTP {status}: {body}")]
17    ApiRequestFailed {
18        /// HTTP status code.
19        status: u16,
20        /// Response body text.
21        body: String,
22    },
23
24    /// A Confluence write/update/create returned HTTP 500 and the submitted ADF
25    /// payload contains a known schema violation that is the likely cause.
26    ///
27    /// Multi-line `Display` matches the format requested in issue #715: a header
28    /// line, a `Diagnosis:` line naming the offending nesting or arity error,
29    /// and an optional `Hint:` line. The raw response body is intentionally
30    /// omitted from the user-facing message — it is already logged at `debug!`
31    /// by the call site.
32    #[error("{}", format_diagnosis(diagnosis, hint.as_deref()))]
33    ApiRequestFailedWithDiagnosis {
34        /// Raw response body (kept for callers that want to log it).
35        body: String,
36        /// The first ADF schema violation found in the submitted document.
37        diagnosis: AdfSchemaViolation,
38        /// Optional human-readable suggestion for resolving the violation.
39        hint: Option<String>,
40    },
41
42    /// The JFM document is invalid or malformed.
43    #[error("Invalid JFM document: {0}")]
44    InvalidDocument(String),
45
46    /// An error occurred during ADF conversion.
47    #[error("ADF conversion error: {0}")]
48    ConversionError(String),
49
50    /// The converted ADF document violates Confluence's nesting constraints.
51    #[error("{0}")]
52    InvalidAdfNesting(#[from] AdfValidationError),
53
54    /// A JIRA write returned HTTP 400 because one or more fields require
55    /// rich-text content in ADF format (e.g. `customfield_19300`) but the
56    /// caller submitted a plain string. The multi-line `Display` matches the
57    /// format requested in issue #867: a header line naming the offending
58    /// field(s), a `To fix:` line pointing at JFM / raw-ADF inputs, and an
59    /// `Original API error:` line preserving JIRA's verbatim wording.
60    #[error("{}", format_jira_adf_field_required(fields, original_message))]
61    JiraAdfFieldRequired {
62        /// Stable JIRA field IDs (e.g. `customfield_19300`) whose error
63        /// message indicated they require an ADF document.
64        fields: Vec<String>,
65        /// Verbatim message from JIRA's `errors.<field>` entry — preserved
66        /// so the `Original API error:` line shows what JIRA actually said
67        /// (and we degrade gracefully if Atlassian changes the wording).
68        original_message: String,
69        /// Raw response body (kept for callers that want to log it).
70        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        // The `jira_write_error` helper never constructs the variant with an
411        // empty `fields` vec, but the defensive `[]` arm of the formatter is
412        // public surface — direct construction must still render sensibly.
413        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}