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
55fn format_diagnosis(diagnosis: &AdfSchemaViolation, hint: Option<&str>) -> String {
56    let header = "Confluence API returned HTTP 500 (Internal Server Error)";
57    let diag_line = match diagnosis {
58        AdfSchemaViolation::DisallowedChild {
59            child_type,
60            parent_type,
61            ..
62        } => format!(
63            "Diagnosis: the submitted ADF contains `{child_type}` nested inside `{parent_type}` \
64             (not allowed by Confluence's content model)."
65        ),
66        AdfSchemaViolation::Arity { .. } => {
67            format!("Diagnosis: the submitted ADF has an arity violation — {diagnosis}.")
68        }
69        AdfSchemaViolation::MissingAttr {
70            node_type,
71            attr_name,
72            ..
73        } => format!(
74            "Diagnosis: the submitted ADF's `{node_type}` is missing required attribute `{attr_name}`."
75        ),
76        AdfSchemaViolation::InvalidAttr {
77            node_type,
78            attr_name,
79            problem,
80            ..
81        } => format!(
82            "Diagnosis: the submitted ADF's `{node_type}.{attr_name}` is invalid — {problem}."
83        ),
84        AdfSchemaViolation::DisallowedMark {
85            mark_type,
86            parent_type,
87            ..
88        } => format!(
89            "Diagnosis: the submitted ADF carries a `{mark_type}` mark on `{parent_type}` which is not permitted in that context."
90        ),
91        AdfSchemaViolation::InvalidMarkAttr {
92            mark_type,
93            attr_name,
94            problem,
95            ..
96        } => format!(
97            "Diagnosis: the submitted ADF's `{mark_type}` mark has invalid `{attr_name}` — {problem}."
98        ),
99    };
100    let mut out = format!("{header}\n{diag_line}");
101    if let Some(hint) = hint {
102        out.push_str("\nHint: ");
103        out.push_str(hint);
104    }
105    out
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::atlassian::adf_schema::Quantifier;
112
113    #[test]
114    fn credentials_not_found_display() {
115        let err = AtlassianError::CredentialsNotFound;
116        assert!(err.to_string().contains("not configured"));
117    }
118
119    #[test]
120    fn api_request_failed_display() {
121        let err = AtlassianError::ApiRequestFailed {
122            status: 404,
123            body: "Not Found".to_string(),
124        };
125        let msg = err.to_string();
126        assert!(msg.contains("404"));
127        assert!(msg.contains("Not Found"));
128    }
129
130    #[test]
131    fn invalid_document_display() {
132        let err = AtlassianError::InvalidDocument("bad format".to_string());
133        assert!(err.to_string().contains("bad format"));
134    }
135
136    #[test]
137    fn conversion_error_display() {
138        let err = AtlassianError::ConversionError("oops".to_string());
139        assert!(err.to_string().contains("oops"));
140    }
141
142    #[test]
143    fn api_request_failed_with_diagnosis_display_with_hint() {
144        let err = AtlassianError::ApiRequestFailedWithDiagnosis {
145            body: "{}".to_string(),
146            diagnosis: AdfSchemaViolation::DisallowedChild {
147                child_type: "expand".to_string(),
148                parent_type: "panel".to_string(),
149                path: vec![0, 0],
150            },
151            hint: Some(
152                "invert the nesting (panel inside expand) or make them siblings".to_string(),
153            ),
154        };
155        let msg = err.to_string();
156        assert!(msg.contains("Confluence API returned HTTP 500 (Internal Server Error)"));
157        assert!(msg.contains("Diagnosis:"));
158        assert!(msg.contains("`expand`"));
159        assert!(msg.contains("`panel`"));
160        assert!(msg.contains("Hint: invert the nesting"));
161    }
162
163    #[test]
164    fn api_request_failed_with_diagnosis_display_without_hint() {
165        let err = AtlassianError::ApiRequestFailedWithDiagnosis {
166            body: String::new(),
167            diagnosis: AdfSchemaViolation::DisallowedChild {
168                child_type: "table".to_string(),
169                parent_type: "nestedExpand".to_string(),
170                path: vec![1],
171            },
172            hint: None,
173        };
174        let msg = err.to_string();
175        assert!(msg.contains("`table`"));
176        assert!(msg.contains("`nestedExpand`"));
177        assert!(!msg.contains("Hint:"));
178    }
179
180    #[test]
181    fn invalid_adf_nesting_display_includes_violations() {
182        let err = AtlassianError::InvalidAdfNesting(AdfValidationError {
183            violations: vec![AdfSchemaViolation::DisallowedChild {
184                parent_type: "panel".to_string(),
185                child_type: "expand".to_string(),
186                path: vec![0, 0],
187            }],
188        });
189        let msg = err.to_string();
190        assert!(msg.contains("invalid ADF nesting"));
191        assert!(msg.contains("`expand` cannot be a child of `panel`"));
192        assert!(msg.contains("hint: invert the nesting"));
193    }
194
195    #[test]
196    fn api_request_failed_with_diagnosis_display_for_arity() {
197        let err = AtlassianError::ApiRequestFailedWithDiagnosis {
198            body: String::new(),
199            diagnosis: AdfSchemaViolation::Arity {
200                parent_type: "bulletList".to_string(),
201                atoms: vec!["listItem"],
202                expected: Quantifier::OneOrMore,
203                actual: 0,
204                path: vec![1],
205            },
206            hint: Some("a list must contain at least one item".to_string()),
207        };
208        let msg = err.to_string();
209        assert!(msg.contains("Confluence API returned HTTP 500 (Internal Server Error)"));
210        assert!(msg.contains("arity violation"), "got: {msg}");
211        assert!(msg.contains("'bulletList'"), "got: {msg}");
212        assert!(msg.contains("at least one"), "got: {msg}");
213        assert!(msg.contains("Hint: a list must contain"), "got: {msg}");
214    }
215
216    #[test]
217    fn api_request_failed_with_diagnosis_display_for_missing_attr() {
218        let err = AtlassianError::ApiRequestFailedWithDiagnosis {
219            body: String::new(),
220            diagnosis: AdfSchemaViolation::MissingAttr {
221                node_type: "panel".to_string(),
222                attr_name: "panelType".to_string(),
223                path: vec![0],
224            },
225            hint: None,
226        };
227        let msg = err.to_string();
228        assert!(msg.contains("`panel`"), "got: {msg}");
229        assert!(msg.contains("missing required attribute"), "got: {msg}");
230        assert!(msg.contains("`panelType`"), "got: {msg}");
231    }
232
233    #[test]
234    fn api_request_failed_with_diagnosis_display_for_invalid_attr() {
235        use crate::atlassian::adf_attr_schema::AttrProblem;
236        let err = AtlassianError::ApiRequestFailedWithDiagnosis {
237            body: String::new(),
238            diagnosis: AdfSchemaViolation::InvalidAttr {
239                node_type: "heading".to_string(),
240                attr_name: "level".to_string(),
241                problem: AttrProblem::OutOfRange {
242                    lo: 1,
243                    hi: 6,
244                    actual: 7,
245                },
246                path: vec![0],
247            },
248            hint: None,
249        };
250        let msg = err.to_string();
251        assert!(msg.contains("`heading.level`"), "got: {msg}");
252        assert!(msg.contains("invalid"), "got: {msg}");
253        assert!(msg.contains("[1, 6]"), "got: {msg}");
254    }
255
256    #[test]
257    fn api_request_failed_with_diagnosis_display_for_disallowed_mark() {
258        let err = AtlassianError::ApiRequestFailedWithDiagnosis {
259            body: String::new(),
260            diagnosis: AdfSchemaViolation::DisallowedMark {
261                mark_type: "code".to_string(),
262                parent_type: "heading".to_string(),
263                inline_index: Some(0),
264                path: vec![0],
265            },
266            hint: None,
267        };
268        let msg = err.to_string();
269        assert!(msg.contains("`code` mark"), "got: {msg}");
270        assert!(msg.contains("`heading`"), "got: {msg}");
271        assert!(msg.contains("not permitted"), "got: {msg}");
272    }
273
274    #[test]
275    fn api_request_failed_with_diagnosis_display_for_invalid_mark_attr() {
276        use crate::atlassian::adf_attr_schema::AttrProblem;
277        let err = AtlassianError::ApiRequestFailedWithDiagnosis {
278            body: String::new(),
279            diagnosis: AdfSchemaViolation::InvalidMarkAttr {
280                mark_type: "link".to_string(),
281                attr_name: "href".to_string(),
282                problem: AttrProblem::BadFormat {
283                    reason: "not a valid URL",
284                },
285                inline_index: Some(0),
286                path: vec![0],
287            },
288            hint: None,
289        };
290        let msg = err.to_string();
291        assert!(msg.contains("`link` mark"), "got: {msg}");
292        assert!(msg.contains("`href`"), "got: {msg}");
293        assert!(msg.contains("not a valid URL"), "got: {msg}");
294    }
295}