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
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}