1use std::collections::HashMap;
2use std::fmt;
3
4use async_trait::async_trait;
5use devboy_core::{
6 CreatePageParams, Error, KbPage, KbPageContent, KbSpace, KnowledgeBaseProvider,
7 ListPagesParams, Pagination, ProviderResult, Result, SearchKbParams, UpdatePageParams,
8};
9use reqwest::RequestBuilder;
10use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
11use secrecy::{ExposeSecret, SecretString};
12use serde::Deserialize;
13use serde::Serialize;
14use serde::de::DeserializeOwned;
15use serde_json::{Value, json};
16
17use crate::DEFAULT_CONFLUENCE_API_PATH;
18
19const ATLASSIAN_OAUTH_AUTHORIZE_URL: &str = "https://auth.atlassian.com/authorize";
20const ATLASSIAN_OAUTH_TOKEN_URL: &str = "https://auth.atlassian.com/oauth/token";
21
22#[derive(Clone, Copy, Default)]
23pub enum ConfluenceFlavor {
24 #[default]
25 SelfHosted,
26 Cloud,
27}
28
29impl fmt::Debug for ConfluenceFlavor {
30 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31 match self {
32 Self::SelfHosted => f.write_str("SelfHosted"),
33 Self::Cloud => f.write_str("Cloud"),
34 }
35 }
36}
37
38fn detect_flavor(url: &str) -> ConfluenceFlavor {
39 if url.contains(".atlassian.net") {
40 ConfluenceFlavor::Cloud
41 } else {
42 ConfluenceFlavor::SelfHosted
43 }
44}
45
46#[derive(Clone)]
47pub enum ConfluenceAuth {
48 None,
49 BearerToken(SecretString),
50 Basic {
51 username: String,
52 password: SecretString,
53 },
54}
55
56#[derive(Debug, Clone, Deserialize)]
57pub struct ConfluenceOAuthTokens {
58 pub access_token: SecretString,
59 #[serde(default)]
60 pub refresh_token: Option<SecretString>,
61 #[serde(default)]
62 pub expires_in: Option<u64>,
63 #[serde(default)]
64 pub scope: Option<String>,
65 #[serde(default)]
66 pub token_type: Option<String>,
67}
68
69impl fmt::Debug for ConfluenceAuth {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 match self {
72 Self::None => f.write_str("None"),
73 Self::BearerToken(_) => f.debug_tuple("BearerToken").field(&"<redacted>").finish(),
74 Self::Basic { username, .. } => f
75 .debug_struct("Basic")
76 .field("username", username)
77 .field("password", &"<redacted>")
78 .finish(),
79 }
80 }
81}
82
83impl ConfluenceAuth {
84 pub fn bearer(token: impl Into<String>) -> Self {
86 Self::BearerToken(SecretString::from(token.into()))
87 }
88
89 pub fn basic(username: impl Into<String>, password: impl Into<String>) -> Self {
91 Self::Basic {
92 username: username.into(),
93 password: SecretString::from(password.into()),
94 }
95 }
96}
97
98#[derive(Clone)]
99pub struct ConfluenceClient {
100 base_url: String,
101 flavor: ConfluenceFlavor,
102 cloud_id: Option<String>,
103 cloud_api_base_url: Option<String>,
104 instance_url: String,
111 api_path: String,
112 page_api_path: String,
113 space_api_path: String,
114 auth: ConfluenceAuth,
115 proxy_headers: Option<HashMap<String, String>>,
116 http: reqwest::Client,
117}
118
119impl fmt::Debug for ConfluenceClient {
120 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121 f.debug_struct("ConfluenceClient")
122 .field("base_url", &self.base_url)
123 .field("flavor", &self.flavor)
124 .field("cloud_id", &self.cloud_id)
125 .field("cloud_api_base_url", &self.cloud_api_base_url)
126 .field("instance_url", &self.instance_url)
127 .field("api_path", &self.api_path)
128 .field("page_api_path", &self.page_api_path)
129 .field("space_api_path", &self.space_api_path)
130 .field("auth", &self.auth)
131 .field("http", &self.http)
132 .finish()
133 }
134}
135
136impl ConfluenceClient {
137 pub fn oauth_authorize_url(
138 client_id: &str,
139 redirect_uri: &str,
140 state: &str,
141 scopes: &[&str],
142 ) -> String {
143 let scope = if scopes.is_empty() {
144 "offline_access read:confluence-content.all write:confluence-content read:confluence-space.summary".to_string()
145 } else {
146 scopes.join(" ")
147 };
148 format!(
149 "{ATLASSIAN_OAUTH_AUTHORIZE_URL}?audience=api.atlassian.com&client_id={}&scope={}&redirect_uri={}&state={}&response_type=code&prompt=consent",
150 encode_query_value(client_id),
151 encode_query_value(&scope),
152 encode_query_value(redirect_uri),
153 encode_query_value(state),
154 )
155 }
156
157 pub async fn exchange_oauth_code(
158 client_id: &str,
159 client_secret: &str,
160 redirect_uri: &str,
161 code: &str,
162 ) -> Result<ConfluenceOAuthTokens> {
163 let http = reqwest::Client::new();
164 let request = http
165 .post(ATLASSIAN_OAUTH_TOKEN_URL)
166 .header(reqwest::header::ACCEPT, "application/json")
167 .json(&json!({
168 "grant_type": "authorization_code",
169 "client_id": client_id,
170 "client_secret": client_secret,
171 "code": code,
172 "redirect_uri": redirect_uri
173 }));
174 let response = request
175 .send()
176 .await
177 .map_err(|e| Error::Network(e.to_string()))?;
178 let status = response.status();
179 let body = response
180 .bytes()
181 .await
182 .map_err(|e| Error::Network(e.to_string()))?;
183 if !status.is_success() {
184 return Err(Error::from_status(
185 status.as_u16(),
186 String::from_utf8_lossy(&body).into_owned(),
187 ));
188 }
189 serde_json::from_slice::<ConfluenceOAuthTokens>(&body)
190 .map_err(|e| Error::InvalidData(format!("invalid Atlassian OAuth response: {e}")))
191 }
192
193 pub async fn refresh_oauth_token(
194 client_id: &str,
195 client_secret: &str,
196 refresh_token: &str,
197 ) -> Result<ConfluenceOAuthTokens> {
198 let http = reqwest::Client::new();
199 let request = http
200 .post(ATLASSIAN_OAUTH_TOKEN_URL)
201 .header(reqwest::header::ACCEPT, "application/json")
202 .json(&json!({
203 "grant_type": "refresh_token",
204 "client_id": client_id,
205 "client_secret": client_secret,
206 "refresh_token": refresh_token
207 }));
208 let response = request
209 .send()
210 .await
211 .map_err(|e| Error::Network(e.to_string()))?;
212 let status = response.status();
213 let body = response
214 .bytes()
215 .await
216 .map_err(|e| Error::Network(e.to_string()))?;
217 if !status.is_success() {
218 return Err(Error::from_status(
219 status.as_u16(),
220 String::from_utf8_lossy(&body).into_owned(),
221 ));
222 }
223 serde_json::from_slice::<ConfluenceOAuthTokens>(&body)
224 .map_err(|e| Error::InvalidData(format!("invalid Atlassian OAuth response: {e}")))
225 }
226
227 pub fn new(base_url: impl Into<String>, auth: ConfluenceAuth) -> Self {
228 let base = normalize_base_url(base_url.into());
229 let flavor = detect_flavor(&base);
230 Self {
231 instance_url: base.clone(),
232 base_url: base,
233 flavor,
234 cloud_id: None,
235 cloud_api_base_url: None,
236 api_path: api_path_for_flavor(flavor, None),
237 page_api_path: api_path_for_flavor(flavor, None),
238 space_api_path: api_path_for_flavor(flavor, None),
239 auth,
240 proxy_headers: None,
241 http: reqwest::Client::new(),
242 }
243 }
244
245 pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
246 self.http = http;
247 self
248 }
249
250 pub fn base_url(&self) -> &str {
251 &self.base_url
252 }
253
254 pub fn instance_url(&self) -> &str {
258 &self.instance_url
259 }
260
261 pub fn auth(&self) -> &ConfluenceAuth {
262 &self.auth
263 }
264
265 pub fn flavor(&self) -> &ConfluenceFlavor {
266 &self.flavor
267 }
268
269 pub fn with_flavor(mut self, flavor: ConfluenceFlavor) -> Self {
270 self.flavor = flavor;
271 self.api_path = api_path_for_flavor(self.flavor, None);
272 self.page_api_path = api_path_for_flavor(self.flavor, None);
273 self.space_api_path = api_path_for_flavor(self.flavor, None);
274 self
275 }
276
277 pub fn with_cloud_id(mut self, cloud_id: impl Into<String>) -> Self {
278 self.cloud_id = Some(cloud_id.into());
279 self
280 }
281
282 pub fn with_cloud_api_base_url(mut self, base_url: impl Into<String>) -> Self {
283 self.cloud_api_base_url = Some(normalize_base_url(base_url.into()));
284 self
285 }
286
287 pub fn cloud_api_base_url(&self) -> Option<String> {
288 self.cloud_api_base_url.clone()
289 }
290
291 pub fn with_api_version(mut self, api_version: Option<&str>) -> Self {
292 self.page_api_path = api_path_for_flavor(self.flavor, api_version);
293 self.space_api_path = api_path_for_flavor(self.flavor, api_version);
294 self
295 }
296
297 pub fn with_proxy(mut self, headers: HashMap<String, String>) -> Self {
303 self.proxy_headers = Some(headers);
304 self
305 }
306
307 pub fn with_instance_url(mut self, url: impl Into<String>) -> Self {
311 self.instance_url = normalize_base_url(url.into());
312 self
313 }
314
315 pub fn rest_api_url(&self, path: &str) -> String {
316 self.api_url(&self.api_path, path)
317 }
318
319 pub fn cloud_api_root_url(&self) -> Option<String> {
320 self.cloud_id.as_ref().map(|cloud_id| {
321 format!(
322 "{}/ex/confluence/{cloud_id}",
323 self.cloud_api_base_url
324 .as_deref()
325 .unwrap_or("https://api.atlassian.com")
326 )
327 })
328 }
329
330 pub fn cloud_api_url(&self, path: &str) -> Option<String> {
331 self.cloud_api_root_url().map(|root| {
332 let path = path.trim_start_matches('/');
333 format!("{root}/{path}")
334 })
335 }
336
337 fn api_url(&self, api_path: &str, path: &str) -> String {
338 let path = path.trim_start_matches('/');
339 format!("{}{}/{}", self.base_url, api_path, path)
340 }
341
342 async fn api_request_url(&self, api_path: &str, path: &str) -> Result<String> {
343 if matches!(self.flavor, ConfluenceFlavor::Cloud) {
344 let cloud_root = self.resolve_cloud_api_root_url().await?;
345 return Ok(format!(
346 "{}/{}/{}",
347 cloud_root,
348 api_path.trim_matches('/'),
349 path.trim_start_matches('/')
350 ));
351 }
352 Ok(self.api_url(api_path, path))
353 }
354
355 fn legacy_api_path(&self) -> &'static str {
362 match self.flavor {
363 ConfluenceFlavor::Cloud => "/wiki/rest/api",
364 ConfluenceFlavor::SelfHosted => DEFAULT_CONFLUENCE_API_PATH,
365 }
366 }
367
368 async fn legacy_rest_api_url(&self, path: &str) -> Result<String> {
369 Ok(self.api_url(self.legacy_api_path(), path))
370 }
371
372 #[cfg(test)]
373 fn space_api_url(&self, path: &str) -> String {
374 self.api_url(&self.space_api_path, path)
375 }
376
377 pub async fn get_json<T>(&self, path: &str) -> Result<T>
378 where
379 T: DeserializeOwned,
380 {
381 let request = self
382 .http
383 .get(self.rest_api_url(path))
384 .header(reqwest::header::ACCEPT, "application/json");
385 self.send_json(request).await
386 }
387
388 async fn get_json_from_api<T>(&self, api_path: &str, path: &str) -> Result<T>
389 where
390 T: DeserializeOwned,
391 {
392 let url = self.api_request_url(api_path, path).await?;
393 let request = self
394 .http
395 .get(url)
396 .header(reqwest::header::ACCEPT, "application/json");
397 self.send_json(request).await
398 }
399
400 async fn post_json_to_api<T, B>(&self, api_path: &str, path: &str, body: &B) -> Result<T>
401 where
402 T: DeserializeOwned,
403 B: Serialize + ?Sized,
404 {
405 let url = self.api_request_url(api_path, path).await?;
406 let request = self
407 .http
408 .post(url)
409 .header(reqwest::header::ACCEPT, "application/json")
410 .header(reqwest::header::CONTENT_TYPE, "application/json")
411 .json(body);
412 self.send_json(request).await
413 }
414
415 async fn put_json_to_api<T, B>(&self, api_path: &str, path: &str, body: &B) -> Result<T>
416 where
417 T: DeserializeOwned,
418 B: Serialize + ?Sized,
419 {
420 let url = self.api_request_url(api_path, path).await?;
421 let request = self
422 .http
423 .put(url)
424 .header(reqwest::header::ACCEPT, "application/json")
425 .header(reqwest::header::CONTENT_TYPE, "application/json")
426 .json(body);
427 self.send_json(request).await
428 }
429
430 async fn get_json_from_legacy_api<T>(&self, path: &str) -> Result<T>
431 where
432 T: DeserializeOwned,
433 {
434 let url = self.legacy_rest_api_url(path).await?;
435 let request = self
436 .http
437 .get(url)
438 .header(reqwest::header::ACCEPT, "application/json");
439 self.send_json(request).await
440 }
441
442 async fn post_empty_json_to_legacy_api<B>(&self, path: &str, body: &B) -> Result<()>
443 where
444 B: Serialize + ?Sized,
445 {
446 let url = self.legacy_rest_api_url(path).await?;
447 let request = self
448 .http
449 .post(url)
450 .header(reqwest::header::ACCEPT, "application/json")
451 .header(reqwest::header::CONTENT_TYPE, "application/json")
452 .json(body);
453 self.send_empty(request).await
454 }
455
456 async fn delete_empty_from_legacy_api(&self, path: &str) -> Result<()> {
457 let url = self.legacy_rest_api_url(path).await?;
458 let request = self
459 .http
460 .delete(url)
461 .header(reqwest::header::ACCEPT, "application/json");
462 self.send_empty(request).await
463 }
464
465 pub async fn post_json<T, B>(&self, path: &str, body: &B) -> Result<T>
466 where
467 T: DeserializeOwned,
468 B: Serialize + ?Sized,
469 {
470 let request = self
471 .http
472 .post(self.rest_api_url(path))
473 .header(reqwest::header::ACCEPT, "application/json")
474 .header(reqwest::header::CONTENT_TYPE, "application/json")
475 .json(body);
476 self.send_json(request).await
477 }
478
479 pub async fn put_json<T, B>(&self, path: &str, body: &B) -> Result<T>
480 where
481 T: DeserializeOwned,
482 B: Serialize + ?Sized,
483 {
484 let request = self
485 .http
486 .put(self.rest_api_url(path))
487 .header(reqwest::header::ACCEPT, "application/json")
488 .header(reqwest::header::CONTENT_TYPE, "application/json")
489 .json(body);
490 self.send_json(request).await
491 }
492
493 async fn post_json_to_legacy_api<T, B>(&self, path: &str, body: &B) -> Result<T>
500 where
501 T: DeserializeOwned,
502 B: Serialize + ?Sized,
503 {
504 let request = self
505 .http
506 .post(self.legacy_rest_api_url(path).await?)
507 .header(reqwest::header::ACCEPT, "application/json")
508 .header(reqwest::header::CONTENT_TYPE, "application/json")
509 .json(body);
510 self.send_json(request).await
511 }
512
513 async fn put_json_to_legacy_api<T, B>(&self, path: &str, body: &B) -> Result<T>
515 where
516 T: DeserializeOwned,
517 B: Serialize + ?Sized,
518 {
519 let request = self
520 .http
521 .put(self.legacy_rest_api_url(path).await?)
522 .header(reqwest::header::ACCEPT, "application/json")
523 .header(reqwest::header::CONTENT_TYPE, "application/json")
524 .json(body);
525 self.send_json(request).await
526 }
527
528 pub async fn post_empty_json<B>(&self, path: &str, body: &B) -> Result<()>
529 where
530 B: Serialize + ?Sized,
531 {
532 let request = self
533 .http
534 .post(self.rest_api_url(path))
535 .header(reqwest::header::ACCEPT, "application/json")
536 .header(reqwest::header::CONTENT_TYPE, "application/json")
537 .json(body);
538 self.send_empty(request).await
539 }
540
541 pub async fn delete_empty(&self, path: &str) -> Result<()> {
542 let request = self
543 .http
544 .delete(self.rest_api_url(path))
545 .header(reqwest::header::ACCEPT, "application/json");
546 self.send_empty(request).await
547 }
548
549 async fn send_json<T>(&self, request: RequestBuilder) -> Result<T>
550 where
551 T: DeserializeOwned,
552 {
553 let response = self
554 .apply_auth(request)
555 .send()
556 .await
557 .map_err(|e| Error::Network(e.to_string()))?;
558
559 let status = response.status();
560 let content_type = response
561 .headers()
562 .get(reqwest::header::CONTENT_TYPE)
563 .and_then(|v| v.to_str().ok())
564 .unwrap_or("")
565 .to_string();
566 let body = response
572 .bytes()
573 .await
574 .map_err(|e| Error::Network(e.to_string()))?;
575
576 if !status.is_success() {
577 return Err(Error::from_status(
578 status.as_u16(),
579 String::from_utf8_lossy(&body).into_owned(),
580 ));
581 }
582
583 serde_json::from_slice::<T>(&body).map_err(|e| {
584 let preview_bytes = &body[..body.len().min(200)];
589 let preview = String::from_utf8_lossy(preview_bytes);
590 Error::InvalidData(format!(
591 "JSON decode failed (status={}, content-type='{}'): {}; body preview: {:?}",
592 status, content_type, e, preview
593 ))
594 })
595 }
596
597 async fn send_empty(&self, request: RequestBuilder) -> Result<()> {
598 let response = self
599 .apply_auth(request)
600 .send()
601 .await
602 .map_err(|e| Error::Network(e.to_string()))?;
603
604 let status = response.status();
605 if !status.is_success() {
606 let message = response.text().await.unwrap_or_default();
607 return Err(Error::from_status(status.as_u16(), message));
608 }
609
610 Ok(())
611 }
612
613 fn apply_auth(&self, request: RequestBuilder) -> RequestBuilder {
614 if let Some(headers) = &self.proxy_headers {
615 return request.headers(proxy_headers_to_headermap(headers));
616 }
617
618 match &self.auth {
619 ConfluenceAuth::None => request,
620 ConfluenceAuth::BearerToken(token) => request.bearer_auth(token.expose_secret()),
621 ConfluenceAuth::Basic { username, password } => {
622 request.basic_auth(username, Some(password.expose_secret()))
623 }
624 }
625 }
626}
627
628fn should_fallback_to_rest_api(error: &Error) -> bool {
629 matches!(
630 error,
631 Error::NotFound(_)
632 | Error::Api {
633 status: 400 | 404 | 405,
634 ..
635 }
636 )
637}
638
639fn uses_v2_api(api_path: &str) -> bool {
640 api_path.ends_with("/api/v2")
641}
642
643fn proxy_headers_to_headermap(headers: &HashMap<String, String>) -> HeaderMap {
644 let mut map = HeaderMap::new();
645 for (key, value) in headers {
646 if let (Ok(name), Ok(value)) = (
647 HeaderName::try_from(key.as_str()),
648 HeaderValue::try_from(value.as_str()),
649 ) {
650 map.insert(name, value);
651 }
652 }
653 map
654}
655
656fn normalize_base_url(base_url: String) -> String {
657 base_url.trim_end_matches('/').to_string()
658}
659
660fn api_path_for_flavor(flavor: ConfluenceFlavor, api_version: Option<&str>) -> String {
661 let version = api_version.map(str::trim).filter(|v| !v.is_empty());
662 match flavor {
663 ConfluenceFlavor::Cloud => match version {
664 Some("v2") | None => "/wiki/api/v2".to_string(),
665 Some(_) => "/wiki/api/v2".to_string(),
666 },
667 ConfluenceFlavor::SelfHosted => match version {
668 Some("v2") => "/api/v2".to_string(),
669 _ => DEFAULT_CONFLUENCE_API_PATH.to_string(),
670 },
671 }
672}
673
674fn deserialize_id_string_or_int<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
678where
679 D: serde::Deserializer<'de>,
680{
681 #[derive(Deserialize)]
682 #[serde(untagged)]
683 enum StringOrInt {
684 String(String),
685 Int(i64),
686 }
687 Ok(match StringOrInt::deserialize(deserializer)? {
688 StringOrInt::String(s) => s,
689 StringOrInt::Int(i) => i.to_string(),
690 })
691}
692
693fn deserialize_opt_id_string_or_int<'de, D>(
694 deserializer: D,
695) -> std::result::Result<Option<String>, D::Error>
696where
697 D: serde::Deserializer<'de>,
698{
699 #[derive(Deserialize)]
700 #[serde(untagged)]
701 enum StringOrInt {
702 String(String),
703 Int(i64),
704 }
705 Ok(
706 Option::<StringOrInt>::deserialize(deserializer)?.map(|v| match v {
707 StringOrInt::String(s) => s,
708 StringOrInt::Int(i) => i.to_string(),
709 }),
710 )
711}
712
713#[derive(Debug, Deserialize)]
714#[serde(bound(deserialize = "T: Deserialize<'de>"))]
715struct ConfluenceListResponse<T> {
716 #[serde(default)]
717 results: Vec<T>,
718 #[serde(default)]
719 start: Option<u32>,
720 #[serde(default)]
721 limit: Option<u32>,
722 #[serde(default)]
723 size: Option<u32>,
724 #[serde(default, rename = "totalSize")]
725 total_size: Option<u32>,
726 #[serde(default)]
727 _links: ConfluenceLinks,
728}
729
730#[derive(Debug, Clone, Default, Deserialize)]
731struct ConfluenceLinks {
732 #[serde(default)]
733 base: Option<String>,
734 #[serde(default)]
735 webui: Option<String>,
736 #[serde(default)]
737 next: Option<String>,
738}
739
740#[derive(Debug, Deserialize)]
741struct ConfluenceSpace {
742 #[serde(deserialize_with = "deserialize_id_string_or_int")]
743 id: String,
744 key: String,
745 name: String,
746 #[serde(rename = "type", default)]
747 space_type: Option<String>,
748 #[serde(default)]
749 status: Option<String>,
750 #[serde(default)]
751 description: Option<ConfluenceSpaceDescription>,
752 #[serde(default)]
753 _links: ConfluenceLinks,
754}
755
756#[derive(Debug, Deserialize)]
757struct ConfluenceSpaceDescription {
758 #[serde(default)]
759 plain: Option<ConfluenceValueContainer>,
760 #[serde(default)]
761 view: Option<ConfluenceValueContainer>,
762}
763
764#[derive(Debug, Deserialize)]
765struct ConfluenceValueContainer {
766 #[serde(default)]
767 value: Option<String>,
768}
769
770#[derive(Debug, Clone, Deserialize)]
771struct ConfluencePage {
772 #[serde(deserialize_with = "deserialize_id_string_or_int")]
773 id: String,
774 title: String,
775 #[serde(default)]
776 space: Option<ConfluenceSpaceRef>,
777 #[serde(
778 default,
779 rename = "spaceId",
780 deserialize_with = "deserialize_opt_id_string_or_int"
781 )]
782 space_id: Option<String>,
783 #[serde(
784 default,
785 rename = "parentId",
786 deserialize_with = "deserialize_opt_id_string_or_int"
787 )]
788 parent_id: Option<String>,
789 #[serde(default)]
790 version: Option<ConfluenceVersion>,
791 #[serde(default)]
792 history: Option<ConfluenceHistory>,
793 #[serde(default)]
794 body: Option<ConfluenceBody>,
795 #[serde(default)]
796 metadata: Option<ConfluenceMetadata>,
797 #[serde(default)]
798 labels: Option<ConfluenceLabelList>,
799 #[serde(default)]
800 ancestors: Vec<ConfluenceAncestor>,
801 #[serde(default)]
802 _links: ConfluenceLinks,
803}
804
805#[derive(Debug, Clone, Deserialize)]
806struct ConfluenceSpaceRef {
807 #[serde(default, deserialize_with = "deserialize_opt_id_string_or_int")]
808 id: Option<String>,
809 #[serde(default)]
810 key: Option<String>,
811}
812
813#[derive(Debug, Clone, Deserialize)]
814struct ConfluenceVersion {
815 #[serde(default)]
816 number: Option<u32>,
817 #[serde(default, rename = "createdAt")]
818 created_at: Option<String>,
819 #[serde(default)]
820 when: Option<String>,
821 #[serde(default)]
822 by: Option<ConfluenceUser>,
823}
824
825#[derive(Debug, Clone, Deserialize)]
826struct ConfluenceHistory {
827 #[serde(default, rename = "lastUpdated")]
828 last_updated: Option<ConfluenceVersion>,
829 #[serde(default, rename = "createdBy")]
830 created_by: Option<ConfluenceUser>,
831}
832
833#[derive(Debug, Clone, Deserialize)]
834struct ConfluenceUser {
835 #[serde(default, rename = "displayName")]
836 display_name: Option<String>,
837 #[serde(default)]
838 username: Option<String>,
839 #[serde(default, rename = "accountId")]
840 account_id: Option<String>,
841}
842
843#[derive(Debug, Clone, Deserialize)]
844struct ConfluenceBody {
845 #[serde(default)]
846 storage: Option<ConfluenceBodyValue>,
847 #[serde(default, rename = "atlas_doc_format")]
848 atlas_doc_format: Option<ConfluenceBodyJsonValue>,
849 #[serde(default)]
850 view: Option<ConfluenceBodyValue>,
851 #[serde(default)]
852 representation: Option<String>,
853 #[serde(default)]
854 value: Option<String>,
855}
856
857#[derive(Debug, Clone, Deserialize)]
858struct ConfluenceBodyValue {
859 #[serde(default)]
860 representation: Option<String>,
861 #[serde(default)]
862 value: Option<String>,
863}
864
865#[derive(Debug, Clone, Deserialize)]
866struct ConfluenceBodyJsonValue {
867 #[serde(default)]
868 value: Option<Value>,
869}
870
871#[derive(Debug, Clone, Deserialize)]
872struct ConfluenceMetadata {
873 #[serde(default)]
874 labels: Option<ConfluenceLabelList>,
875}
876
877#[derive(Debug, Clone, Deserialize)]
878struct ConfluenceLabelList {
879 #[serde(default)]
880 results: Vec<ConfluenceLabel>,
881}
882
883#[derive(Debug, Clone, Deserialize)]
884struct ConfluenceLabel {
885 #[serde(default)]
886 name: Option<String>,
887 #[serde(default)]
888 label: Option<String>,
889}
890
891#[derive(Debug, Clone, Deserialize)]
892struct AtlassianAccessibleResource {
893 id: String,
894 #[serde(default)]
895 url: Option<String>,
896}
897
898#[derive(Debug, Serialize)]
899struct ConfluenceWriteLabel<'a> {
900 prefix: &'static str,
901 name: &'a str,
902}
903
904#[derive(Debug, Clone, Deserialize)]
905struct ConfluenceAncestor {
906 id: String,
907 #[serde(default)]
908 title: String,
909 #[serde(default)]
910 _links: ConfluenceLinks,
911}
912
913#[derive(Debug, Serialize)]
914struct ConfluenceContentBody {
915 value: Value,
916 representation: &'static str,
917}
918
919#[derive(Debug, Serialize)]
920struct ConfluenceContentPayload<'a> {
921 #[serde(rename = "type")]
922 content_type: &'static str,
923 title: &'a str,
924 space: ConfluenceCreateSpaceRef<'a>,
925 body: ConfluenceCreateBodyPayload,
926 #[serde(skip_serializing_if = "Vec::is_empty")]
927 ancestors: Vec<ConfluenceCreateAncestorRef<'a>>,
928}
929
930#[derive(Debug, Serialize)]
931struct ConfluenceCreateSpaceRef<'a> {
932 key: &'a str,
933}
934
935#[derive(Debug, Serialize)]
936struct ConfluenceCreateBodyPayload {
937 storage: ConfluenceContentBody,
938}
939
940#[derive(Debug, Serialize)]
941struct ConfluenceCreateAncestorRef<'a> {
942 id: &'a str,
943}
944
945#[derive(Debug, Serialize)]
946struct ConfluenceUpdatePayload<'a> {
947 id: &'a str,
948 #[serde(rename = "type")]
949 content_type: &'static str,
950 title: &'a str,
951 version: ConfluenceUpdateVersion,
952 body: ConfluenceCreateBodyPayload,
953 #[serde(skip_serializing_if = "Option::is_none")]
954 ancestors: Option<Vec<ConfluenceCreateAncestorRef<'a>>>,
955}
956
957#[derive(Debug, Serialize)]
958struct ConfluenceUpdateVersion {
959 number: u32,
960}
961
962#[derive(Debug, Serialize)]
963struct ConfluenceV2PagePayload<'a> {
964 #[serde(rename = "spaceId")]
965 space_id: &'a str,
966 status: &'static str,
967 title: &'a str,
968 #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
969 parent_id: Option<&'a str>,
970 body: ConfluenceContentBody,
971}
972
973#[derive(Debug, Serialize)]
974struct ConfluenceV2UpdatePayload<'a> {
975 id: &'a str,
976 status: &'static str,
977 title: &'a str,
978 #[serde(rename = "spaceId")]
979 space_id: &'a str,
980 #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
981 parent_id: Option<&'a str>,
982 body: ConfluenceContentBody,
983 version: ConfluenceUpdateVersion,
984}
985
986fn join_link(base_url: &str, base_hint: Option<&str>, path: Option<&str>) -> Option<String> {
1003 let path = path?;
1004 if path.starts_with("http://") || path.starts_with("https://") {
1005 return Some(path.to_string());
1006 }
1007 let base = base_url.trim_end_matches('/');
1008 let effective_base = match base_hint {
1013 Some(hint) if same_host_prefix(base, hint) => hint.trim_end_matches('/'),
1014 _ => base,
1015 };
1016 if path.starts_with('/') {
1017 Some(format!("{effective_base}{path}"))
1018 } else {
1019 Some(format!("{effective_base}/{path}"))
1020 }
1021}
1022
1023fn same_host_prefix(base_url: &str, hint: &str) -> bool {
1027 if hint.starts_with('/') || !hint.contains("://") {
1028 return true;
1030 }
1031 let base_origin = url_origin(base_url);
1032 let hint_origin = url_origin(hint);
1033 match (base_origin, hint_origin) {
1034 (Some(a), Some(b)) => a == b,
1035 _ => false,
1036 }
1037}
1038
1039fn url_origin(url: &str) -> Option<String> {
1043 let (scheme, rest) = url.split_once("://")?;
1044 let host = rest.split('/').next()?;
1045 if host.is_empty() {
1046 return None;
1047 }
1048 Some(format!("{}://{}", scheme.to_ascii_lowercase(), host))
1049}
1050
1051fn display_name(user: Option<&ConfluenceUser>) -> Option<String> {
1052 user.and_then(|u| {
1053 u.display_name
1054 .clone()
1055 .or_else(|| u.username.clone())
1056 .or_else(|| u.account_id.clone())
1057 })
1058}
1059
1060fn normalize_body_content(value: Option<&str>, representation: Option<&str>) -> Option<String> {
1061 let value = value?;
1062 match representation
1063 .map(str::trim)
1064 .filter(|value| !value.is_empty())
1065 {
1066 Some("storage") | Some("view") => Some(confluence_storage_to_markdown(value)),
1067 _ => Some(value.to_string()),
1068 }
1069}
1070
1071fn adf_body_value(body: &ConfluenceBody) -> Option<&Value> {
1072 body.atlas_doc_format
1073 .as_ref()
1074 .and_then(|value| value.value.as_ref())
1075}
1076
1077fn page_content_markdown(page: &ConfluencePage) -> String {
1078 page.body
1079 .as_ref()
1080 .and_then(adf_body_value)
1081 .map(adf_to_markdown)
1082 .or_else(|| {
1083 page.body
1084 .as_ref()
1085 .and_then(|body| body.storage.as_ref())
1086 .and_then(|storage| {
1087 normalize_body_content(
1088 storage.value.as_deref(),
1089 storage.representation.as_deref().or(Some("storage")),
1090 )
1091 })
1092 })
1093 .or_else(|| {
1094 page.body.as_ref().and_then(|body| {
1095 body.view.as_ref().and_then(|view| {
1096 normalize_body_content(
1097 view.value.as_deref(),
1098 view.representation.as_deref().or(Some("view")),
1099 )
1100 })
1101 })
1102 })
1103 .or_else(|| {
1104 page.body.as_ref().and_then(|body| {
1105 normalize_body_content(body.value.as_deref(), body.representation.as_deref())
1106 })
1107 })
1108 .unwrap_or_default()
1109}
1110
1111fn extract_labels(page: &ConfluencePage) -> Vec<String> {
1112 page.labels
1113 .as_ref()
1114 .or_else(|| {
1115 page.metadata
1116 .as_ref()
1117 .and_then(|metadata| metadata.labels.as_ref())
1118 })
1119 .map(|labels| {
1120 labels
1121 .results
1122 .iter()
1123 .filter_map(|label| label.name.clone().or_else(|| label.label.clone()))
1124 .collect::<Vec<_>>()
1125 })
1126 .unwrap_or_default()
1127}
1128
1129fn normalize_labels(labels: &[String]) -> Vec<String> {
1130 let mut out = Vec::new();
1131 for label in labels {
1132 let trimmed = label.trim();
1133 if trimmed.is_empty() {
1134 continue;
1135 }
1136 if !out.iter().any(|existing| existing == trimmed) {
1137 out.push(trimmed.to_string());
1138 }
1139 }
1140 out
1141}
1142
1143fn page_excerpt(page: &ConfluencePage) -> Option<String> {
1144 let content = page_content_markdown(page);
1145 let normalized = collapse_markdown_whitespace(&content);
1146 if normalized.is_empty() {
1147 None
1148 } else {
1149 Some(truncate_string(normalized, 280))
1150 }
1151}
1152
1153fn strip_html_tags(input: &str) -> String {
1154 let mut out = String::with_capacity(input.len());
1155 let mut in_tag = false;
1156 for ch in input.chars() {
1157 match ch {
1158 '<' => in_tag = true,
1159 '>' => in_tag = false,
1160 _ if !in_tag => out.push(ch),
1161 _ => {}
1162 }
1163 }
1164 out.split_whitespace().collect::<Vec<_>>().join(" ")
1165}
1166
1167fn strip_html_tags_preserve_layout(input: &str) -> String {
1168 let mut out = String::with_capacity(input.len());
1169 let mut in_tag = false;
1170 for ch in input.chars() {
1171 match ch {
1172 '<' => in_tag = true,
1173 '>' => in_tag = false,
1174 _ if !in_tag => out.push(ch),
1175 _ => {}
1176 }
1177 }
1178 out
1179}
1180
1181fn truncate_string(input: String, max_chars: usize) -> String {
1182 if input.chars().count() <= max_chars {
1183 return input;
1184 }
1185 input.chars().take(max_chars).collect::<String>()
1186}
1187
1188fn normalize_confluence_write_content(content: &str, content_type: Option<&str>) -> Result<String> {
1189 match content_type
1190 .map(str::trim)
1191 .filter(|value| !value.is_empty())
1192 .unwrap_or("markdown")
1193 {
1194 "markdown" => Ok(markdown_to_confluence_storage(content)),
1195 "html" => Ok(html_to_confluence_storage(content)),
1196 "storage" => Ok(content.to_string()),
1197 other => Err(Error::InvalidData(format!(
1198 "unsupported confluence content_type '{other}', expected markdown, html, or storage"
1199 ))),
1200 }
1201}
1202
1203fn normalize_confluence_v2_write_content(
1204 flavor: ConfluenceFlavor,
1205 content: &str,
1206 content_type: Option<&str>,
1207) -> Result<ConfluenceContentBody> {
1208 if matches!(flavor, ConfluenceFlavor::Cloud) {
1209 let adf = match content_type
1210 .map(str::trim)
1211 .filter(|value| !value.is_empty())
1212 .unwrap_or("markdown")
1213 {
1214 "markdown" => markdown_to_adf(content),
1215 "html" => markdown_to_adf(&strip_html_tags_preserve_layout(content)),
1216 "storage" => markdown_to_adf(&confluence_storage_to_markdown(content)),
1217 other => {
1218 return Err(Error::InvalidData(format!(
1219 "unsupported confluence cloud content_type '{other}', expected markdown, html, or storage"
1220 )));
1221 }
1222 };
1223 Ok(ConfluenceContentBody {
1224 value: adf,
1225 representation: "atlas_doc_format",
1226 })
1227 } else {
1228 Ok(ConfluenceContentBody {
1229 value: Value::String(normalize_confluence_write_content(content, content_type)?),
1230 representation: "storage",
1231 })
1232 }
1233}
1234
1235fn html_to_confluence_storage(content: &str) -> String {
1236 let trimmed = content.trim();
1237 if trimmed.is_empty() {
1238 return String::new();
1239 }
1240 if trimmed.contains('<') && trimmed.contains('>') {
1241 trimmed.to_string()
1242 } else {
1243 format!("<p>{}</p>", escape_html(trimmed))
1244 }
1245}
1246
1247fn markdown_to_confluence_storage(markdown: &str) -> String {
1248 let markdown = markdown.replace("\r\n", "\n");
1249 let mut out = String::new();
1250 let mut paragraph: Vec<String> = Vec::new();
1251 let mut in_ul = false;
1252 let mut in_ol = false;
1253 let mut lines = markdown.lines().peekable();
1254
1255 let flush_paragraph = |out: &mut String, paragraph: &mut Vec<String>| {
1256 if paragraph.is_empty() {
1257 return;
1258 }
1259 let text = paragraph.join(" ");
1260 out.push_str("<p>");
1261 out.push_str(&markdown_inline_to_html(&text));
1262 out.push_str("</p>");
1263 paragraph.clear();
1264 };
1265
1266 let close_lists = |out: &mut String, in_ul: &mut bool, in_ol: &mut bool| {
1267 if *in_ul {
1268 out.push_str("</ul>");
1269 *in_ul = false;
1270 }
1271 if *in_ol {
1272 out.push_str("</ol>");
1273 *in_ol = false;
1274 }
1275 };
1276
1277 while let Some(line) = lines.next() {
1278 let trimmed = line.trim();
1279
1280 if trimmed.starts_with("```") {
1281 flush_paragraph(&mut out, &mut paragraph);
1282 close_lists(&mut out, &mut in_ul, &mut in_ol);
1283
1284 let mut code_lines = Vec::new();
1285 for code_line in lines.by_ref() {
1286 if code_line.trim_start().starts_with("```") {
1287 break;
1288 }
1289 code_lines.push(code_line);
1290 }
1291
1292 let code_content = code_lines.join("\n").replace("]]>", "]]]]><![CDATA[>");
1293 out.push_str(r#"<ac:structured-macro ac:name="code"><ac:plain-text-body><![CDATA["#);
1294 out.push_str(&code_content);
1295 out.push_str("]]></ac:plain-text-body></ac:structured-macro>");
1296 continue;
1297 }
1298
1299 if trimmed.is_empty() {
1300 flush_paragraph(&mut out, &mut paragraph);
1301 close_lists(&mut out, &mut in_ul, &mut in_ol);
1302 continue;
1303 }
1304
1305 if let Some((level, title)) = parse_markdown_heading(trimmed) {
1306 flush_paragraph(&mut out, &mut paragraph);
1307 close_lists(&mut out, &mut in_ul, &mut in_ol);
1308 out.push_str(&format!(
1309 "<h{level}>{}</h{level}>",
1310 markdown_inline_to_html(title)
1311 ));
1312 continue;
1313 }
1314
1315 if let Some(item) = parse_unordered_list_item(trimmed) {
1316 flush_paragraph(&mut out, &mut paragraph);
1317 if in_ol {
1318 out.push_str("</ol>");
1319 in_ol = false;
1320 }
1321 if !in_ul {
1322 out.push_str("<ul>");
1323 in_ul = true;
1324 }
1325 out.push_str("<li>");
1326 out.push_str(&markdown_inline_to_html(item));
1327 out.push_str("</li>");
1328 continue;
1329 }
1330
1331 if let Some(item) = parse_ordered_list_item(trimmed) {
1332 flush_paragraph(&mut out, &mut paragraph);
1333 if in_ul {
1334 out.push_str("</ul>");
1335 in_ul = false;
1336 }
1337 if !in_ol {
1338 out.push_str("<ol>");
1339 in_ol = true;
1340 }
1341 out.push_str("<li>");
1342 out.push_str(&markdown_inline_to_html(item));
1343 out.push_str("</li>");
1344 continue;
1345 }
1346
1347 close_lists(&mut out, &mut in_ul, &mut in_ol);
1348 paragraph.push(trimmed.to_string());
1349 }
1350
1351 flush_paragraph(&mut out, &mut paragraph);
1352 close_lists(&mut out, &mut in_ul, &mut in_ol);
1353 out
1354}
1355
1356fn markdown_to_adf(markdown: &str) -> Value {
1357 let markdown = markdown.replace("\r\n", "\n");
1358 let mut content = Vec::new();
1359 let mut paragraph: Vec<String> = Vec::new();
1360 let mut lines = markdown.lines().peekable();
1361
1362 let flush_paragraph = |content: &mut Vec<Value>, paragraph: &mut Vec<String>| {
1363 if paragraph.is_empty() {
1364 return;
1365 }
1366 let text = paragraph.join(" ");
1367 content.push(json!({
1368 "type": "paragraph",
1369 "content": markdown_inline_to_adf(&text)
1370 }));
1371 paragraph.clear();
1372 };
1373
1374 while let Some(line) = lines.next() {
1375 let trimmed = line.trim();
1376 if trimmed.starts_with("```") {
1377 flush_paragraph(&mut content, &mut paragraph);
1378 let mut code_lines = Vec::new();
1379 for code_line in lines.by_ref() {
1380 if code_line.trim_start().starts_with("```") {
1381 break;
1382 }
1383 code_lines.push(code_line);
1384 }
1385 content.push(json!({
1386 "type": "codeBlock",
1387 "content": [{ "type": "text", "text": code_lines.join("\n") }]
1388 }));
1389 continue;
1390 }
1391 if trimmed.is_empty() {
1392 flush_paragraph(&mut content, &mut paragraph);
1393 continue;
1394 }
1395 if let Some((level, title)) = parse_markdown_heading(trimmed) {
1396 flush_paragraph(&mut content, &mut paragraph);
1397 content.push(json!({
1398 "type": "heading",
1399 "attrs": { "level": level },
1400 "content": markdown_inline_to_adf(title)
1401 }));
1402 continue;
1403 }
1404
1405 let mut list_items = Vec::new();
1406 if let Some(item) = parse_unordered_list_item(trimmed) {
1407 flush_paragraph(&mut content, &mut paragraph);
1408 list_items.push(item.to_string());
1409 while let Some(next) = lines.peek() {
1410 if let Some(item) = parse_unordered_list_item(next.trim()) {
1411 list_items.push(item.to_string());
1412 lines.next();
1413 } else {
1414 break;
1415 }
1416 }
1417 content.push(json!({
1418 "type": "bulletList",
1419 "content": list_items.into_iter().map(|item| json!({
1420 "type": "listItem",
1421 "content": [{ "type": "paragraph", "content": markdown_inline_to_adf(&item) }]
1422 })).collect::<Vec<_>>()
1423 }));
1424 continue;
1425 }
1426 if let Some(item) = parse_ordered_list_item(trimmed) {
1427 flush_paragraph(&mut content, &mut paragraph);
1428 list_items.push(item.to_string());
1429 while let Some(next) = lines.peek() {
1430 if let Some(item) = parse_ordered_list_item(next.trim()) {
1431 list_items.push(item.to_string());
1432 lines.next();
1433 } else {
1434 break;
1435 }
1436 }
1437 content.push(json!({
1438 "type": "orderedList",
1439 "content": list_items.into_iter().map(|item| json!({
1440 "type": "listItem",
1441 "content": [{ "type": "paragraph", "content": markdown_inline_to_adf(&item) }]
1442 })).collect::<Vec<_>>()
1443 }));
1444 continue;
1445 }
1446 paragraph.push(trimmed.to_string());
1447 }
1448
1449 flush_paragraph(&mut content, &mut paragraph);
1450 json!({
1451 "type": "doc",
1452 "version": 1,
1453 "content": content
1454 })
1455}
1456
1457fn markdown_inline_to_adf(input: &str) -> Vec<Value> {
1458 let mut nodes = Vec::new();
1459 let mut chars = input.chars().peekable();
1460 let mut plain = String::new();
1461
1462 let flush_plain = |nodes: &mut Vec<Value>, plain: &mut String| {
1463 if !plain.is_empty() {
1464 nodes.push(json!({ "type": "text", "text": plain.clone() }));
1465 plain.clear();
1466 }
1467 };
1468
1469 while let Some(ch) = chars.next() {
1470 if ch == '`' {
1471 flush_plain(&mut nodes, &mut plain);
1472 let mut code = String::new();
1473 while let Some(&next) = chars.peek() {
1474 chars.next();
1475 if next == '`' {
1476 break;
1477 }
1478 code.push(next);
1479 }
1480 nodes.push(json!({
1481 "type": "text",
1482 "text": code,
1483 "marks": [{ "type": "code" }]
1484 }));
1485 continue;
1486 }
1487 if ch == '*' && chars.peek() == Some(&'*') {
1488 chars.next();
1489 flush_plain(&mut nodes, &mut plain);
1490 let mut bold = String::new();
1491 while let Some(next) = chars.next() {
1492 if next == '*' && chars.peek() == Some(&'*') {
1493 chars.next();
1494 break;
1495 }
1496 bold.push(next);
1497 }
1498 nodes.push(json!({
1499 "type": "text",
1500 "text": bold,
1501 "marks": [{ "type": "strong" }]
1502 }));
1503 continue;
1504 }
1505 if ch == '*' {
1506 flush_plain(&mut nodes, &mut plain);
1507 let mut em = String::new();
1508 while let Some(&next) = chars.peek() {
1509 chars.next();
1510 if next == '*' {
1511 break;
1512 }
1513 em.push(next);
1514 }
1515 nodes.push(json!({
1516 "type": "text",
1517 "text": em,
1518 "marks": [{ "type": "em" }]
1519 }));
1520 continue;
1521 }
1522 if ch == '[' {
1523 let mut label = String::new();
1524 while let Some(&next) = chars.peek() {
1525 chars.next();
1526 if next == ']' {
1527 break;
1528 }
1529 label.push(next);
1530 }
1531 if chars.peek() == Some(&'(') {
1532 chars.next();
1533 let mut href = String::new();
1534 while let Some(&next) = chars.peek() {
1535 chars.next();
1536 if next == ')' {
1537 break;
1538 }
1539 href.push(next);
1540 }
1541 flush_plain(&mut nodes, &mut plain);
1542 nodes.push(json!({
1543 "type": "text",
1544 "text": label,
1545 "marks": [{ "type": "link", "attrs": { "href": href } }]
1546 }));
1547 continue;
1548 }
1549 plain.push('[');
1550 plain.push_str(&label);
1551 continue;
1552 }
1553 plain.push(ch);
1554 }
1555 flush_plain(&mut nodes, &mut plain);
1556 if nodes.is_empty() {
1557 vec![json!({ "type": "text", "text": "" })]
1558 } else {
1559 nodes
1560 }
1561}
1562
1563fn parse_markdown_heading(line: &str) -> Option<(usize, &str)> {
1564 let hashes = line.chars().take_while(|&ch| ch == '#').count();
1565 if !(1..=6).contains(&hashes) {
1566 return None;
1567 }
1568 let rest = line.get(hashes..)?.trim_start();
1569 if rest.is_empty() {
1570 return None;
1571 }
1572 Some((hashes, rest))
1573}
1574
1575fn parse_unordered_list_item(line: &str) -> Option<&str> {
1576 line.strip_prefix("- ")
1577 .or_else(|| line.strip_prefix("* "))
1578 .map(str::trim)
1579}
1580
1581fn parse_ordered_list_item(line: &str) -> Option<&str> {
1582 let digits = line.chars().take_while(|ch| ch.is_ascii_digit()).count();
1583 if digits == 0 {
1584 return None;
1585 }
1586 let rest = line.get(digits..)?;
1587 rest.strip_prefix(". ").map(str::trim)
1588}
1589
1590fn markdown_inline_to_html(input: &str) -> String {
1591 let escaped = escape_html(input);
1592 let linked = replace_markdown_links(&escaped);
1593 let code = replace_inline_delimited(&linked, "`", "<code>", "</code>");
1594 let bold = replace_inline_delimited(&code, "**", "<strong>", "</strong>");
1595 replace_inline_delimited(&bold, "*", "<em>", "</em>")
1596}
1597
1598fn replace_markdown_links(input: &str) -> String {
1599 let mut out = String::with_capacity(input.len());
1600 let mut cursor = 0usize;
1601
1602 while let Some(start_rel) = input[cursor..].find('[') {
1603 let start = cursor + start_rel;
1604 out.push_str(&input[cursor..start]);
1605
1606 let Some(text_end_rel) = input[start + 1..].find(']') else {
1607 out.push_str(&input[start..]);
1608 return out;
1609 };
1610 let text_end = start + 1 + text_end_rel;
1611 let after_bracket = text_end + 1;
1612 if !input[after_bracket..].starts_with('(') {
1613 out.push('[');
1614 cursor = start + 1;
1615 continue;
1616 }
1617
1618 let Some(url_end_rel) = input[after_bracket + 1..].find(')') else {
1619 out.push_str(&input[start..]);
1620 return out;
1621 };
1622 let url_end = after_bracket + 1 + url_end_rel;
1623 let text = &input[start + 1..text_end];
1624 let url = &input[after_bracket + 1..url_end];
1625 out.push_str(&format!(r#"<a href="{url}">{text}</a>"#));
1626 cursor = url_end + 1;
1627 }
1628
1629 out.push_str(&input[cursor..]);
1630 out
1631}
1632
1633fn replace_inline_delimited(input: &str, delimiter: &str, open: &str, close: &str) -> String {
1634 let mut out = String::with_capacity(input.len());
1635 let mut cursor = 0usize;
1636 let mut is_open = false;
1637
1638 while let Some(found_rel) = input[cursor..].find(delimiter) {
1639 let found = cursor + found_rel;
1640 out.push_str(&input[cursor..found]);
1641 if is_open {
1642 out.push_str(close);
1643 } else {
1644 out.push_str(open);
1645 }
1646 is_open = !is_open;
1647 cursor = found + delimiter.len();
1648 }
1649
1650 out.push_str(&input[cursor..]);
1651 if is_open && let Some(position) = out.rfind(open) {
1652 out.replace_range(position..position + open.len(), delimiter);
1653 out.push_str(delimiter);
1654 }
1655 out
1656}
1657
1658fn adf_to_markdown(adf: &Value) -> String {
1659 let mut out = String::new();
1660 if let Some(content) = adf.get("content").and_then(Value::as_array) {
1661 for (index, node) in content.iter().enumerate() {
1662 if index > 0 && !out.ends_with("\n\n") {
1663 out.push_str("\n\n");
1664 }
1665 render_adf_block(node, &mut out);
1666 }
1667 }
1668 collapse_markdown_whitespace(&out)
1669}
1670
1671fn render_adf_table(node: &Value, out: &mut String) {
1677 let Some(rows) = node.get("content").and_then(Value::as_array) else {
1678 return;
1679 };
1680
1681 let mut rendered: Vec<Vec<String>> = Vec::new();
1682 let mut header_is_first_row = false;
1683
1684 for (row_idx, row) in rows.iter().enumerate() {
1685 if row.get("type").and_then(Value::as_str) != Some("tableRow") {
1686 continue;
1687 }
1688 let Some(cells) = row.get("content").and_then(Value::as_array) else {
1689 continue;
1690 };
1691 let mut cols = Vec::with_capacity(cells.len());
1692 for cell in cells {
1693 if row_idx == 0 && cell.get("type").and_then(Value::as_str) == Some("tableHeader") {
1694 header_is_first_row = true;
1695 }
1696 let mut text = String::new();
1697 if let Some(blocks) = cell.get("content").and_then(Value::as_array) {
1698 for (idx, block) in blocks.iter().enumerate() {
1699 if idx > 0 {
1700 text.push(' ');
1701 }
1702 render_adf_block(block, &mut text);
1703 }
1704 }
1705 cols.push(
1709 text.trim()
1710 .replace('|', "\\|")
1711 .replace("\r\n", "<br>")
1712 .replace('\n', "<br>"),
1713 );
1714 }
1715 rendered.push(cols);
1716 }
1717
1718 if rendered.is_empty() {
1719 return;
1720 }
1721
1722 let width = rendered.iter().map(Vec::len).max().unwrap_or(0);
1723 for (idx, row) in rendered.iter().enumerate() {
1724 let mut cols = row.clone();
1725 cols.resize(width, String::new());
1726 out.push_str(&format!("| {} |", cols.join(" | ")));
1727 out.push('\n');
1728 if idx == 0 {
1729 let _ = header_is_first_row;
1732 out.push_str(&format!("| {} |", vec!["---"; width].join(" | ")));
1733 out.push('\n');
1734 }
1735 }
1736 while out.ends_with('\n') {
1738 out.pop();
1739 }
1740}
1741
1742fn render_adf_block(node: &Value, out: &mut String) {
1743 match node.get("type").and_then(Value::as_str).unwrap_or_default() {
1744 "paragraph" => render_adf_inline_nodes(node.get("content"), out),
1745 "table" => render_adf_table(node, out),
1746 "heading" => {
1747 let level = node
1748 .get("attrs")
1749 .and_then(|attrs| attrs.get("level"))
1750 .and_then(Value::as_u64)
1751 .unwrap_or(1)
1752 .clamp(1, 6) as usize;
1753 out.push_str(&"#".repeat(level));
1754 out.push(' ');
1755 render_adf_inline_nodes(node.get("content"), out);
1756 }
1757 "bulletList" => {
1758 if let Some(items) = node.get("content").and_then(Value::as_array) {
1759 for (idx, item) in items.iter().enumerate() {
1760 if idx > 0 {
1761 out.push('\n');
1762 }
1763 out.push_str("- ");
1764 render_adf_list_item(item, out);
1765 }
1766 }
1767 }
1768 "orderedList" => {
1769 if let Some(items) = node.get("content").and_then(Value::as_array) {
1770 for (idx, item) in items.iter().enumerate() {
1771 if idx > 0 {
1772 out.push('\n');
1773 }
1774 out.push_str(&(idx + 1).to_string());
1775 out.push_str(". ");
1776 render_adf_list_item(item, out);
1777 }
1778 }
1779 }
1780 "codeBlock" => {
1781 out.push_str("```");
1782 out.push('\n');
1783 render_adf_inline_nodes(node.get("content"), out);
1784 out.push('\n');
1785 out.push_str("```");
1786 }
1787 _ => render_adf_inline_nodes(node.get("content"), out),
1788 }
1789}
1790
1791fn render_adf_list_item(node: &Value, out: &mut String) {
1792 if let Some(content) = node.get("content").and_then(Value::as_array) {
1793 for block in content {
1794 render_adf_inline_nodes(block.get("content"), out);
1795 }
1796 }
1797}
1798
1799fn render_adf_inline_nodes(content: Option<&Value>, out: &mut String) {
1800 if let Some(nodes) = content.and_then(Value::as_array) {
1801 for node in nodes {
1802 match node.get("type").and_then(Value::as_str).unwrap_or_default() {
1803 "text" => {
1804 let mut text = node
1805 .get("text")
1806 .and_then(Value::as_str)
1807 .unwrap_or_default()
1808 .to_string();
1809 if let Some(marks) = node.get("marks").and_then(Value::as_array) {
1810 for mark in marks {
1811 match mark.get("type").and_then(Value::as_str).unwrap_or_default() {
1812 "strong" => text = format!("**{text}**"),
1813 "em" => text = format!("*{text}*"),
1814 "code" => text = format!("`{text}`"),
1815 "link" => {
1816 if let Some(href) = mark
1817 .get("attrs")
1818 .and_then(|attrs| attrs.get("href"))
1819 .and_then(Value::as_str)
1820 {
1821 text = format!("[{text}]({href})");
1822 }
1823 }
1824 _ => {}
1825 }
1826 }
1827 }
1828 out.push_str(&text);
1829 }
1830 "hardBreak" => out.push('\n'),
1831 "mention" => {
1836 let attrs = node.get("attrs");
1837 let text = attrs
1838 .and_then(|a| a.get("text"))
1839 .and_then(Value::as_str)
1840 .map(str::to_string)
1841 .or_else(|| {
1842 attrs
1843 .and_then(|a| a.get("id"))
1844 .and_then(Value::as_str)
1845 .map(|id| format!("@{id}"))
1846 })
1847 .unwrap_or_else(|| "@unknown".to_string());
1848 out.push_str(&text);
1849 }
1850 "emoji" => {
1851 let attrs = node.get("attrs");
1852 let text = attrs
1853 .and_then(|a| a.get("text"))
1854 .and_then(Value::as_str)
1855 .map(str::to_string)
1856 .or_else(|| {
1857 attrs
1858 .and_then(|a| a.get("shortName"))
1859 .and_then(Value::as_str)
1860 .map(str::to_string)
1861 })
1862 .unwrap_or_default();
1863 out.push_str(&text);
1864 }
1865 "status" | "date" => {
1866 let attrs = node.get("attrs");
1867 if let Some(text) = attrs.and_then(|a| a.get("text")).and_then(Value::as_str) {
1868 out.push_str(text);
1869 } else if let Some(ts) = attrs
1870 .and_then(|a| a.get("timestamp"))
1871 .and_then(Value::as_str)
1872 {
1873 out.push_str(ts);
1874 }
1875 }
1876 "inlineCard" => {
1877 if let Some(url) = node
1878 .get("attrs")
1879 .and_then(|a| a.get("url"))
1880 .and_then(Value::as_str)
1881 {
1882 out.push_str(url);
1883 }
1884 }
1885 "media" | "mediaInline" => {
1886 let attrs = node.get("attrs");
1887 let alt = attrs
1888 .and_then(|a| a.get("alt"))
1889 .and_then(Value::as_str)
1890 .unwrap_or("attachment");
1891 out.push_str(&format!("[{alt}]"));
1892 }
1893 _ => render_adf_inline_nodes(node.get("content"), out),
1894 }
1895 }
1896 }
1897}
1898
1899fn insert_gfm_header_delimiter(input: &str) -> String {
1904 let mut out = String::with_capacity(input.len());
1905 let mut previous_was_row = false;
1906
1907 for line in input.split_inclusive('\n') {
1908 let trimmed = line.trim();
1909 let is_row = trimmed.starts_with('|') && trimmed.ends_with('|');
1910 out.push_str(line);
1911 if is_row && !previous_was_row {
1912 let columns = trimmed.matches('|').count().saturating_sub(1).max(1);
1913 out.push_str(&format!("| {} |\n", vec!["---"; columns].join(" | ")));
1914 }
1915 previous_was_row = is_row;
1916 }
1917 out
1918}
1919
1920fn confluence_storage_to_markdown(storage: &str) -> String {
1921 let with_code_blocks = replace_confluence_code_macros(storage);
1922 let with_links = replace_anchor_tags(&with_code_blocks);
1923 let with_formatting = replace_paired_tag(
1924 &replace_paired_tag(
1925 &replace_paired_tag(
1926 &replace_paired_tag(&with_links, "strong", "**", "**"),
1927 "b",
1928 "**",
1929 "**",
1930 ),
1931 "em",
1932 "*",
1933 "*",
1934 ),
1935 "i",
1936 "*",
1937 "*",
1938 );
1939 let with_inline_code = replace_paired_tag(&with_formatting, "code", "`", "`");
1940 let markdownish = with_inline_code
1941 .replace("<br />", "\n")
1942 .replace("<br/>", "\n")
1943 .replace("<br>", "\n")
1944 .replace("<p>", "")
1945 .replace("</p>", "\n\n")
1946 .replace("<div>", "")
1947 .replace("</div>", "\n\n")
1948 .replace("<ul>", "")
1949 .replace("</ul>", "\n")
1950 .replace("<ol>", "")
1951 .replace("</ol>", "\n")
1952 .replace("<li>", "- ")
1953 .replace("</li>", "\n")
1954 .replace("<h1>", "# ")
1955 .replace("</h1>", "\n\n")
1956 .replace("<h2>", "## ")
1957 .replace("</h2>", "\n\n")
1958 .replace("<h3>", "### ")
1959 .replace("</h3>", "\n\n")
1960 .replace("<h4>", "#### ")
1961 .replace("</h4>", "\n\n")
1962 .replace("<h5>", "##### ")
1963 .replace("</h5>", "\n\n")
1964 .replace("<h6>", "###### ")
1965 .replace("</h6>", "\n\n")
1966 .replace("<table>", "\n")
1971 .replace("</table>", "\n")
1972 .replace("<tbody>", "")
1973 .replace("</tbody>", "")
1974 .replace("<thead>", "")
1975 .replace("</thead>", "")
1976 .replace("<tr>", "|")
1977 .replace("</tr>", "\n")
1978 .replace("<th>", " ")
1979 .replace("</th>", " |")
1980 .replace("<td>", " ")
1981 .replace("</td>", " |");
1982
1983 let markdownish = insert_gfm_header_delimiter(&markdownish);
1984 let text = strip_html_tags_preserve_layout(&markdownish);
1985 collapse_markdown_whitespace(&decode_html_entities(&text))
1986}
1987
1988fn replace_confluence_code_macros(input: &str) -> String {
1989 let mut out = String::new();
1990 let mut cursor = 0usize;
1991 let macro_start = r#"<ac:structured-macro ac:name="code">"#;
1992 let body_start = "<ac:plain-text-body><![CDATA[";
1993 let body_end = "]]></ac:plain-text-body>";
1994 let macro_end = "</ac:structured-macro>";
1995
1996 while let Some(start_rel) = input[cursor..].find(macro_start) {
1997 let start = cursor + start_rel;
1998 out.push_str(&input[cursor..start]);
1999 let Some(code_start_rel) = input[start..].find(body_start) else {
2000 out.push_str(&input[start..]);
2001 return out;
2002 };
2003 let code_start = start + code_start_rel + body_start.len();
2004 let Some(code_end_rel) = input[code_start..].find(body_end) else {
2005 out.push_str(&input[start..]);
2006 return out;
2007 };
2008 let code_end = code_start + code_end_rel;
2009 let Some(macro_end_rel) = input[code_end..].find(macro_end) else {
2010 out.push_str(&input[start..]);
2011 return out;
2012 };
2013 let end = code_end + macro_end_rel + macro_end.len();
2014 let code = &input[code_start..code_end];
2015 out.push_str("```");
2016 out.push('\n');
2017 out.push_str(code);
2018 out.push('\n');
2019 out.push_str("```");
2020 cursor = end;
2021 }
2022
2023 out.push_str(&input[cursor..]);
2024 out
2025}
2026
2027fn replace_anchor_tags(input: &str) -> String {
2028 let mut out = String::new();
2029 let mut cursor = 0usize;
2030
2031 while let Some(start_rel) = input[cursor..].find("<a ") {
2032 let start = cursor + start_rel;
2033 out.push_str(&input[cursor..start]);
2034 let Some(tag_end_rel) = input[start..].find('>') else {
2035 out.push_str(&input[start..]);
2036 return out;
2037 };
2038 let tag_end = start + tag_end_rel;
2039 let tag = &input[start..=tag_end];
2040 let Some(close_rel) = input[tag_end + 1..].find("</a>") else {
2041 out.push_str(&input[start..]);
2042 return out;
2043 };
2044 let close = tag_end + 1 + close_rel;
2045 let label = &input[tag_end + 1..close];
2046 let href = extract_attribute(tag, "href").unwrap_or_default();
2047 out.push('[');
2048 out.push_str(label);
2049 out.push_str("](");
2050 out.push_str(&href);
2051 out.push(')');
2052 cursor = close + "</a>".len();
2053 }
2054
2055 out.push_str(&input[cursor..]);
2056 out
2057}
2058
2059fn replace_paired_tag(input: &str, tag: &str, open: &str, close: &str) -> String {
2060 input
2061 .replace(&format!("<{tag}>"), open)
2062 .replace(&format!("</{tag}>"), close)
2063}
2064
2065fn extract_attribute(tag: &str, attr: &str) -> Option<String> {
2066 let needle = format!(r#"{attr}=""#);
2067 let start = tag.find(&needle)? + needle.len();
2068 let rest = tag.get(start..)?;
2069 let end = rest.find('"')?;
2070 Some(rest[..end].to_string())
2071}
2072
2073fn collapse_markdown_whitespace(input: &str) -> String {
2074 let mut normalized = Vec::new();
2075 let mut previous_blank = false;
2076
2077 for line in input.lines() {
2078 let trimmed = line.trim();
2079 if trimmed.is_empty() {
2080 if !normalized.is_empty() && !previous_blank {
2081 normalized.push(String::new());
2082 previous_blank = true;
2083 }
2084 continue;
2085 }
2086
2087 normalized.push(trimmed.to_string());
2088 previous_blank = false;
2089 }
2090
2091 normalized.join("\n").trim().to_string()
2092}
2093
2094fn decode_html_entities(input: &str) -> String {
2095 input
2096 .replace(" ", " ")
2097 .replace("<", "<")
2098 .replace(">", ">")
2099 .replace(""", "\"")
2100 .replace("'", "'")
2101 .replace("&", "&")
2102}
2103
2104fn escape_html(input: &str) -> String {
2105 input
2106 .replace('&', "&")
2107 .replace('<', "<")
2108 .replace('>', ">")
2109 .replace('"', """)
2110}
2111
2112fn map_space(base_url: &str, raw: ConfluenceSpace) -> KbSpace {
2113 let description = raw
2114 .description
2115 .and_then(|d| {
2116 d.plain
2117 .and_then(|v| v.value)
2118 .or_else(|| d.view.and_then(|v| v.value))
2119 })
2120 .map(|value| truncate_string(strip_html_tags(&value), 500))
2121 .filter(|value| !value.is_empty());
2122
2123 KbSpace {
2124 id: raw.id,
2125 key: raw.key,
2126 name: raw.name,
2127 space_type: raw.space_type,
2128 status: raw.status,
2129 description,
2130 url: join_link(
2131 base_url,
2132 raw._links.base.as_deref(),
2133 raw._links.webui.as_deref(),
2134 ),
2135 }
2136}
2137
2138fn map_page_summary(base_url: &str, raw: &ConfluencePage) -> KbPage {
2139 let version = raw
2140 .history
2141 .as_ref()
2142 .and_then(|h| h.last_updated.as_ref())
2143 .or(raw.version.as_ref());
2144 let version_number = version.and_then(|v| v.number);
2145 let last_modified = version.and_then(|v| v.when.clone().or_else(|| v.created_at.clone()));
2146
2147 KbPage {
2148 id: raw.id.clone(),
2149 title: raw.title.clone(),
2150 space_key: raw.space.as_ref().and_then(|space| space.key.clone()),
2151 url: join_link(
2152 base_url,
2153 raw._links.base.as_deref(),
2154 raw._links.webui.as_deref(),
2155 ),
2156 version: version_number,
2157 last_modified,
2158 author: display_name(version.and_then(|v| v.by.as_ref()))
2159 .or_else(|| display_name(raw.history.as_ref().and_then(|h| h.created_by.as_ref()))),
2160 excerpt: page_excerpt(raw),
2161 }
2162}
2163
2164fn map_pagination<T>(
2165 response: &ConfluenceListResponse<T>,
2166 requested_limit: Option<u32>,
2167) -> Pagination {
2168 let offset = response.start.unwrap_or(0);
2169 let limit = requested_limit
2170 .or(response.limit)
2171 .or(response.size)
2172 .unwrap_or(response.results.len() as u32);
2173 let total = response.total_size;
2174 let has_more = response._links.next.is_some()
2175 || total
2176 .map(|total| {
2177 offset.saturating_add(response.size.unwrap_or(response.results.len() as u32))
2178 < total
2179 })
2180 .unwrap_or(false);
2181
2182 Pagination {
2183 offset,
2184 limit,
2185 total,
2186 has_more,
2187 next_cursor: response._links.next.clone(),
2188 }
2189}
2190
2191fn encode_query_value(value: &str) -> String {
2192 let mut encoded = String::with_capacity(value.len());
2193 for byte in value.bytes() {
2194 match byte {
2195 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
2196 encoded.push(byte as char)
2197 }
2198 _ => {
2199 const HEX: &[u8; 16] = b"0123456789ABCDEF";
2200 encoded.push('%');
2201 encoded.push(HEX[(byte >> 4) as usize] as char);
2202 encoded.push(HEX[(byte & 0x0F) as usize] as char);
2203 }
2204 }
2205 }
2206 encoded
2207}
2208
2209fn escape_cql_string(value: &str) -> String {
2210 value.replace('\\', r"\\").replace('"', r#"\""#)
2211}
2212
2213fn build_search_cql(params: &SearchKbParams) -> String {
2214 if params.raw_query {
2215 return params.query.clone();
2216 }
2217
2218 let mut parts = vec!["type = page".to_string()];
2219 if let Some(space_key) = params.space_key.as_ref() {
2220 parts.push(format!("space = \"{}\"", escape_cql_string(space_key)));
2221 }
2222 parts.push(format!("text ~ \"{}\"", escape_cql_string(¶ms.query)));
2223 parts.join(" AND ")
2224}
2225
2226fn path_from_cursor(cursor: &str, api_path: &str) -> String {
2227 let api_prefix = format!("{}/", api_path.trim_end_matches('/'));
2228 if let Some(path) = cursor.strip_prefix(&api_prefix) {
2229 path.to_string()
2230 } else if let Some(path) = cursor.strip_prefix(api_path) {
2231 path.trim_start_matches('/').to_string()
2232 } else if let Some(path) = cursor.strip_prefix("http://") {
2233 let path = path.split_once(&api_prefix).map(|(_, rhs)| rhs);
2234 path.unwrap_or(cursor).to_string()
2235 } else if let Some(path) = cursor.strip_prefix("https://") {
2236 let path = path.split_once(&api_prefix).map(|(_, rhs)| rhs);
2237 path.unwrap_or(cursor).to_string()
2238 } else {
2239 cursor.trim_start_matches('/').to_string()
2240 }
2241}
2242
2243impl ConfluenceClient {
2244 async fn resolve_cloud_api_root_url(&self) -> Result<String> {
2245 if let Some(url) = self.cloud_api_root_url() {
2246 return Ok(url);
2247 }
2248
2249 if !matches!(self.flavor, ConfluenceFlavor::Cloud) {
2250 return Err(Error::InvalidData(
2251 "cloud API root requested for non-cloud Confluence client".to_string(),
2252 ));
2253 }
2254
2255 let cloud_id = self.discover_cloud_id().await?;
2256 Ok(format!(
2257 "{}/ex/confluence/{cloud_id}",
2258 self.cloud_api_base_url
2259 .as_deref()
2260 .unwrap_or("https://api.atlassian.com")
2261 ))
2262 }
2263
2264 async fn discover_cloud_id(&self) -> Result<String> {
2265 match &self.auth {
2266 ConfluenceAuth::BearerToken(_) => {}
2267 ConfluenceAuth::Basic { .. } => {
2268 return Err(Error::InvalidData(
2269 "Confluence Cloud with basic auth requires explicit cloud_id".to_string(),
2270 ));
2271 }
2272 ConfluenceAuth::None => {
2273 return Err(Error::InvalidData(
2274 "Confluence Cloud requires bearer auth or explicit cloud_id".to_string(),
2275 ));
2276 }
2277 }
2278
2279 let url = format!(
2280 "{}/oauth/token/accessible-resources",
2281 self.cloud_api_base_url
2282 .as_deref()
2283 .unwrap_or("https://api.atlassian.com")
2284 );
2285 let request = self
2286 .http
2287 .get(url)
2288 .header(reqwest::header::ACCEPT, "application/json");
2289 let resources: Vec<AtlassianAccessibleResource> = self.send_json(request).await?;
2290
2291 let wanted_origin = url_origin(&self.instance_url)
2292 .or_else(|| url_origin(&self.base_url))
2293 .ok_or_else(|| {
2294 Error::InvalidData(format!(
2295 "cannot determine Confluence Cloud origin from base URL '{}'",
2296 self.base_url
2297 ))
2298 })?;
2299
2300 resources
2301 .into_iter()
2302 .find(|resource| {
2303 resource
2304 .url
2305 .as_deref()
2306 .and_then(url_origin)
2307 .map(|origin| origin == wanted_origin)
2308 .unwrap_or(false)
2309 })
2310 .map(|resource| resource.id)
2311 .ok_or_else(|| {
2312 Error::NotFound(format!(
2313 "no Atlassian accessible resource matched Confluence base URL '{}'",
2314 self.instance_url
2315 ))
2316 })
2317 }
2318
2319 async fn find_space<F>(&self, predicate: F) -> Result<Option<KbSpace>>
2326 where
2327 F: Fn(&KbSpace) -> bool,
2328 {
2329 const MAX_PAGES: usize = 100;
2331
2332 let mut page = self.get_spaces().await?;
2333 for _ in 0..MAX_PAGES {
2334 if let Some(found) = page.items.iter().find(|space| predicate(space)) {
2335 return Ok(Some(found.clone()));
2336 }
2337 let Some(pagination) = page.pagination.as_ref().filter(|p| p.has_more) else {
2338 return Ok(None);
2339 };
2340 let Some(cursor) = pagination.next_cursor.clone() else {
2341 return Ok(None);
2342 };
2343 page = self.list_spaces_page(&cursor).await?;
2344 }
2345 Err(Error::InvalidData(
2346 "Confluence kept reporting more spaces than expected".to_string(),
2347 ))
2348 }
2349
2350 async fn list_spaces_page(&self, cursor: &str) -> Result<ProviderResult<KbSpace>> {
2351 let v2_prefix = self.space_api_path.trim_end_matches('/');
2356 let from_v2 = uses_v2_api(&self.space_api_path) && cursor.contains(v2_prefix);
2357
2358 let response: ConfluenceListResponse<ConfluenceSpace> = if from_v2 {
2359 let path = path_from_cursor(cursor, &self.space_api_path);
2360 self.get_json_from_api(&self.space_api_path, &path).await?
2361 } else {
2362 let path = path_from_cursor(cursor, self.legacy_api_path());
2363 self.get_json_from_legacy_api(&path).await?
2364 };
2365 let pagination = map_pagination(&response, Some(100));
2366 let items = response
2367 .results
2368 .into_iter()
2369 .map(|space| map_space(&self.instance_url, space))
2370 .collect::<Vec<_>>();
2371 Ok(ProviderResult::new(items).with_pagination(pagination))
2372 }
2373
2374 async fn resolve_space_by_key(&self, space_key: &str) -> Result<ConfluenceSpace> {
2375 self.find_space(|space| space.key == space_key)
2376 .await?
2377 .map(|space| ConfluenceSpace {
2378 id: space.id,
2379 key: space.key,
2380 name: space.name,
2381 space_type: space.space_type,
2382 status: space.status,
2383 description: None,
2384 _links: ConfluenceLinks::default(),
2385 })
2386 .ok_or_else(|| Error::NotFound(format!("confluence space '{space_key}' not found")))
2387 }
2388
2389 async fn resolve_space_key_by_id(&self, space_id: &str) -> Result<Option<String>> {
2390 Ok(self
2391 .find_space(|space| space.id == space_id)
2392 .await?
2393 .map(|space| space.key))
2394 }
2395
2396 async fn get_page_ancestor_chain_v2(&self, page_id: &str) -> Result<Vec<KbPage>> {
2397 let path = format!("pages/{page_id}/ancestors?limit=100");
2398 let response: ConfluenceListResponse<ConfluenceAncestor> =
2399 self.get_json_from_api(&self.page_api_path, &path).await?;
2400 let mut tasks = tokio::task::JoinSet::new();
2401 for (index, ancestor) in response.results.into_iter().enumerate() {
2402 let client = self.clone();
2403 tasks.spawn(async move {
2404 let detail_path = format!("pages/{}", ancestor.id);
2405 let detail: ConfluencePage = client
2406 .get_json_from_api(&client.page_api_path, &detail_path)
2407 .await?;
2408 let mut summary = map_page_summary(&client.instance_url, &detail);
2409 if summary.url.is_none() {
2410 summary.url = Some(format!("{}/pages/{}", client.instance_url, detail.id));
2411 }
2412 Ok::<(usize, KbPage), Error>((index, summary))
2413 });
2414 }
2415
2416 let mut ancestors = Vec::with_capacity(tasks.len());
2417 while let Some(result) = tasks.join_next().await {
2418 let (index, summary) = result.map_err(|error| {
2419 Error::Network(format!("ancestor fetch task failed: {error}"))
2420 })??;
2421 ancestors.push((index, summary));
2422 }
2423 ancestors.sort_by_key(|(index, _)| *index);
2424 Ok(ancestors.into_iter().map(|(_, summary)| summary).collect())
2425 }
2426
2427 async fn add_labels(&self, page_id: &str, labels: &[String]) -> Result<()> {
2428 let labels = normalize_labels(labels);
2429 if labels.is_empty() {
2430 return Ok(());
2431 }
2432
2433 let payload = labels
2434 .iter()
2435 .map(|label| ConfluenceWriteLabel {
2436 prefix: "global",
2437 name: label.as_str(),
2438 })
2439 .collect::<Vec<_>>();
2440 self.post_empty_json_to_legacy_api(&format!("content/{page_id}/label"), &payload)
2441 .await
2442 }
2443
2444 async fn sync_labels(
2445 &self,
2446 page_id: &str,
2447 desired: &[String],
2448 current: &[String],
2449 ) -> Result<()> {
2450 let desired = normalize_labels(desired);
2451 let current = normalize_labels(current);
2452
2453 for label in current.iter().filter(|label| !desired.contains(*label)) {
2454 let path = format!("content/{page_id}/label?name={}", encode_query_value(label));
2455 self.delete_empty_from_legacy_api(&path).await?;
2456 }
2457
2458 let to_add = desired
2459 .iter()
2460 .filter(|label| !current.contains(*label))
2461 .cloned()
2462 .collect::<Vec<_>>();
2463 self.add_labels(page_id, &to_add).await
2464 }
2465
2466 async fn get_spaces_v1(&self) -> Result<ProviderResult<KbSpace>> {
2467 let response: ConfluenceListResponse<ConfluenceSpace> = self
2468 .get_json_from_legacy_api("space?limit=100&type=global,personal")
2469 .await?;
2470 let pagination = map_pagination(&response, Some(100));
2471 let items = response
2472 .results
2473 .into_iter()
2474 .map(|space| map_space(&self.instance_url, space))
2475 .collect::<Vec<_>>();
2476
2477 Ok(ProviderResult::new(items).with_pagination(pagination))
2478 }
2479
2480 async fn list_pages_v1(&self, params: ListPagesParams) -> Result<ProviderResult<KbPage>> {
2481 let limit = params.limit.unwrap_or(25);
2482 let path = if let Some(cursor) = params.cursor.as_ref() {
2483 path_from_cursor(cursor, self.legacy_api_path())
2485 } else if let Some(parent_id) = params.parent_id.as_ref() {
2486 let offset = params.offset.unwrap_or(0);
2487 let query = [
2488 format!("limit={limit}"),
2489 format!("start={offset}"),
2490 "expand=space,version,history.lastUpdated,body.view".to_string(),
2491 ];
2492 format!("content/{parent_id}/child/page?{}", query.join("&"))
2493 } else {
2494 let offset = params.offset.unwrap_or(0);
2495 let query = [
2496 format!("spaceKey={}", encode_query_value(¶ms.space_key)),
2497 "type=page".to_string(),
2498 format!("limit={limit}"),
2499 format!("start={offset}"),
2500 "expand=space,version,history.lastUpdated,body.view,ancestors".to_string(),
2501 ];
2502 format!("content?{}", query.join("&"))
2503 };
2504
2505 let response: ConfluenceListResponse<ConfluencePage> =
2506 self.get_json_from_legacy_api(&path).await?;
2507 let pagination = map_pagination(&response, Some(limit));
2508 let mut items = response
2509 .results
2510 .iter()
2511 .map(|page| map_page_summary(&self.instance_url, page))
2512 .collect::<Vec<_>>();
2513
2514 if let Some(search) = params.search.as_ref() {
2515 let search = search.to_ascii_lowercase();
2516 items.retain(|page| {
2517 page.title.to_ascii_lowercase().contains(&search)
2518 || page
2519 .excerpt
2520 .as_ref()
2521 .map(|excerpt| excerpt.to_ascii_lowercase().contains(&search))
2522 .unwrap_or(false)
2523 });
2524 }
2525
2526 Ok(ProviderResult::new(items).with_pagination(pagination))
2527 }
2528
2529 async fn get_page_v1(&self, page_id: &str) -> Result<KbPageContent> {
2530 let path = format!(
2531 "content/{page_id}?expand=space,version,history.lastUpdated,body.storage,metadata.labels,ancestors"
2532 );
2533 let page: ConfluencePage = self.get_json_from_legacy_api(&path).await?;
2534 let summary = map_page_summary(&self.instance_url, &page);
2535 let storage_content = page
2536 .body
2537 .as_ref()
2538 .and_then(|body| body.storage.as_ref())
2539 .and_then(|storage| storage.value.clone())
2540 .unwrap_or_default();
2541 let content = confluence_storage_to_markdown(&storage_content);
2542 let content_type = "markdown".to_string();
2543 let ancestors = page
2544 .ancestors
2545 .iter()
2546 .map(|ancestor| KbPage {
2547 id: ancestor.id.clone(),
2548 title: ancestor.title.clone(),
2549 space_key: None,
2550 url: join_link(
2551 &self.instance_url,
2552 ancestor._links.base.as_deref(),
2553 ancestor._links.webui.as_deref(),
2554 ),
2555 version: None,
2556 last_modified: None,
2557 author: None,
2558 excerpt: None,
2559 })
2560 .collect();
2561 let labels = extract_labels(&page);
2562
2563 Ok(KbPageContent {
2564 page: summary,
2565 content,
2566 content_type,
2567 ancestors,
2568 labels,
2569 })
2570 }
2571
2572 async fn create_page_v1(&self, params: CreatePageParams) -> Result<KbPage> {
2573 let storage_content =
2574 normalize_confluence_write_content(¶ms.content, params.content_type.as_deref())?;
2575
2576 let payload = ConfluenceContentPayload {
2577 content_type: "page",
2578 title: ¶ms.title,
2579 space: ConfluenceCreateSpaceRef {
2580 key: ¶ms.space_key,
2581 },
2582 body: ConfluenceCreateBodyPayload {
2583 storage: ConfluenceContentBody {
2584 value: Value::String(storage_content),
2585 representation: "storage",
2586 },
2587 },
2588 ancestors: params
2589 .parent_id
2590 .as_deref()
2591 .map(|id| vec![ConfluenceCreateAncestorRef { id }])
2592 .unwrap_or_default(),
2593 };
2594
2595 let page: ConfluencePage = self.post_json_to_legacy_api("content", &payload).await?;
2596 self.add_labels(&page.id, ¶ms.labels).await?;
2597 Ok(map_page_summary(&self.instance_url, &page))
2598 }
2599
2600 async fn update_page_v1(&self, params: UpdatePageParams) -> Result<KbPage> {
2601 let current_expand = if params.labels.is_some() {
2602 "space,version,body.storage,ancestors,metadata.labels"
2603 } else {
2604 "space,version,body.storage,ancestors"
2605 };
2606 let current_path = format!("content/{}?expand={current_expand}", params.page_id);
2607 let current: ConfluencePage = self.get_json_from_legacy_api(¤t_path).await?;
2608
2609 let current_title = current.title.clone();
2610 let current_content = current
2611 .body
2612 .as_ref()
2613 .and_then(|body| body.storage.as_ref())
2614 .and_then(|storage| storage.value.clone())
2615 .unwrap_or_default();
2616 let current_version = current
2617 .version
2618 .as_ref()
2619 .and_then(|version| version.number)
2620 .ok_or_else(|| {
2621 Error::InvalidData(format!(
2622 "confluence page {} is missing a version number",
2623 params.page_id
2624 ))
2625 })?;
2626
2627 if let Some(expected_version) = params.version
2628 && expected_version != current_version
2629 {
2630 return Err(Error::Api {
2631 status: 409,
2632 message: format!(
2633 "version conflict for page {}: expected current version {}, found {}",
2634 params.page_id, expected_version, current_version
2635 ),
2636 });
2637 }
2638
2639 let title = params.title.as_deref().unwrap_or(¤t_title);
2640 let content = match params.content.as_deref() {
2641 Some(updated) => {
2642 normalize_confluence_write_content(updated, params.content_type.as_deref())?
2643 }
2644 None => current_content,
2645 };
2646 let ancestors = params
2647 .parent_id
2648 .as_deref()
2649 .map(|id| vec![ConfluenceCreateAncestorRef { id }]);
2650
2651 let payload = ConfluenceUpdatePayload {
2652 id: ¶ms.page_id,
2653 content_type: "page",
2654 title,
2655 version: ConfluenceUpdateVersion {
2656 number: current_version.saturating_add(1),
2657 },
2658 body: ConfluenceCreateBodyPayload {
2659 storage: ConfluenceContentBody {
2660 value: Value::String(content),
2661 representation: "storage",
2662 },
2663 },
2664 ancestors,
2665 };
2666
2667 let path = format!("content/{}", params.page_id);
2668 let page: ConfluencePage = self.put_json_to_legacy_api(&path, &payload).await?;
2669 if let Some(labels) = params.labels.as_ref() {
2670 let current_labels = extract_labels(¤t);
2671 self.sync_labels(¶ms.page_id, labels, ¤t_labels)
2672 .await?;
2673 }
2674 Ok(map_page_summary(&self.instance_url, &page))
2675 }
2676
2677 async fn list_pages_v2(&self, params: ListPagesParams) -> Result<ProviderResult<KbPage>> {
2678 let limit = params.limit.unwrap_or(25);
2679 let path = if let Some(cursor) = params.cursor.as_ref() {
2680 path_from_cursor(cursor, &self.page_api_path)
2681 } else {
2682 let mut query = vec![format!("limit={limit}")];
2683 if let Some(parent_id) = params.parent_id.as_ref() {
2684 format!("pages/{parent_id}/children?{}", query.join("&"))
2685 } else {
2686 let space = self.resolve_space_by_key(¶ms.space_key).await?;
2687 query.push("body-format=view".to_string());
2688 if let Some(search) = params.search.as_ref() {
2689 query.push(format!("title={}", encode_query_value(search)));
2690 }
2691 format!("spaces/{}/pages?{}", space.id, query.join("&"))
2692 }
2693 };
2694
2695 let response: ConfluenceListResponse<ConfluencePage> =
2696 self.get_json_from_api(&self.page_api_path, &path).await?;
2697 let pagination = map_pagination(&response, Some(limit));
2698 let mut items = response
2699 .results
2700 .iter()
2701 .map(|page| {
2702 let mut summary = map_page_summary(&self.instance_url, page);
2703 if summary.space_key.is_none() {
2704 summary.space_key = Some(params.space_key.clone());
2705 }
2706 if summary.url.is_none() {
2707 summary.url = Some(format!("{}/pages/{}", self.instance_url, page.id));
2708 }
2709 summary
2710 })
2711 .collect::<Vec<_>>();
2712
2713 if let Some(search) = params.search.as_ref() {
2714 let search = search.to_ascii_lowercase();
2715 items.retain(|page| {
2716 page.title.to_ascii_lowercase().contains(&search)
2717 || page
2718 .excerpt
2719 .as_ref()
2720 .map(|excerpt| excerpt.to_ascii_lowercase().contains(&search))
2721 .unwrap_or(false)
2722 });
2723 }
2724
2725 Ok(ProviderResult::new(items).with_pagination(pagination))
2726 }
2727
2728 async fn get_page_v2(&self, page_id: &str) -> Result<KbPageContent> {
2729 let body_format = if matches!(self.flavor, ConfluenceFlavor::Cloud) {
2730 "atlas_doc_format"
2731 } else {
2732 "storage"
2733 };
2734 let path = format!("pages/{page_id}?body-format={body_format}&include-labels=true");
2735 let page: ConfluencePage = self.get_json_from_api(&self.page_api_path, &path).await?;
2736 let mut summary = map_page_summary(&self.instance_url, &page);
2737 if summary.space_key.is_none()
2738 && let Some(space_id) = page.space_id.as_deref()
2739 {
2740 summary.space_key = self.resolve_space_key_by_id(space_id).await?;
2741 }
2742 if summary.url.is_none() {
2743 summary.url = Some(format!("{}/pages/{}", self.instance_url, page.id));
2744 }
2745
2746 let content = page_content_markdown(&page);
2747 let content_type = "markdown".to_string();
2748 let ancestors = match self.get_page_ancestor_chain_v2(page_id).await {
2749 Ok(ancestors) => ancestors,
2750 Err(error) if should_fallback_to_rest_api(&error) => Vec::new(),
2751 Err(error) => return Err(error),
2752 };
2753 let labels = extract_labels(&page);
2754
2755 Ok(KbPageContent {
2756 page: summary,
2757 content,
2758 content_type,
2759 ancestors,
2760 labels,
2761 })
2762 }
2763
2764 async fn create_page_v2(&self, params: CreatePageParams) -> Result<KbPage> {
2765 let body = normalize_confluence_v2_write_content(
2766 self.flavor,
2767 ¶ms.content,
2768 params.content_type.as_deref(),
2769 )?;
2770 let space = self.resolve_space_by_key(¶ms.space_key).await?;
2771 let payload = ConfluenceV2PagePayload {
2772 space_id: &space.id,
2773 status: "current",
2774 title: ¶ms.title,
2775 parent_id: params.parent_id.as_deref(),
2776 body,
2777 };
2778
2779 let page: ConfluencePage = self
2780 .post_json_to_api(&self.page_api_path, "pages", &payload)
2781 .await?;
2782 self.add_labels(&page.id, ¶ms.labels).await?;
2783 let mut summary = map_page_summary(&self.instance_url, &page);
2784 if summary.space_key.is_none() {
2785 summary.space_key = Some(params.space_key);
2786 }
2787 if summary.url.is_none() {
2788 summary.url = Some(format!("{}/pages/{}", self.instance_url, page.id));
2789 }
2790 Ok(summary)
2791 }
2792
2793 async fn update_page_v2(&self, params: UpdatePageParams) -> Result<KbPage> {
2794 let body_format = if matches!(self.flavor, ConfluenceFlavor::Cloud) {
2795 "atlas_doc_format"
2796 } else {
2797 "storage"
2798 };
2799 let current_path = if params.labels.is_some() {
2800 format!(
2801 "pages/{}?body-format={body_format}&include-labels=true",
2802 params.page_id,
2803 )
2804 } else {
2805 format!("pages/{}?body-format={body_format}", params.page_id)
2806 };
2807 let current: ConfluencePage = self
2808 .get_json_from_api(&self.page_api_path, ¤t_path)
2809 .await?;
2810 let current_title = current.title.clone();
2811 let current_content = page_content_markdown(¤t);
2812 let current_version = current
2813 .version
2814 .as_ref()
2815 .and_then(|version| version.number)
2816 .ok_or_else(|| {
2817 Error::InvalidData(format!(
2818 "confluence page {} is missing a version number",
2819 params.page_id
2820 ))
2821 })?;
2822
2823 if let Some(expected_version) = params.version
2824 && expected_version != current_version
2825 {
2826 return Err(Error::Api {
2827 status: 409,
2828 message: format!(
2829 "version conflict for page {}: expected current version {}, found {}",
2830 params.page_id, expected_version, current_version
2831 ),
2832 });
2833 }
2834
2835 let title = params.title.as_deref().unwrap_or(¤t_title);
2836 let content = match params.content.as_deref() {
2837 Some(updated) => normalize_confluence_v2_write_content(
2838 self.flavor,
2839 updated,
2840 params.content_type.as_deref(),
2841 )?,
2842 None => normalize_confluence_v2_write_content(
2843 self.flavor,
2844 ¤t_content,
2845 Some("markdown"),
2846 )?,
2847 };
2848 let space_id = current
2849 .space_id
2850 .as_deref()
2851 .or_else(|| current.space.as_ref().and_then(|space| space.id.as_deref()))
2852 .ok_or_else(|| {
2853 Error::InvalidData(format!(
2854 "confluence page {} is missing a space id",
2855 params.page_id
2856 ))
2857 })?;
2858 let parent_id = params.parent_id.as_deref().or(current.parent_id.as_deref());
2859 let payload = ConfluenceV2UpdatePayload {
2860 id: ¶ms.page_id,
2861 status: "current",
2862 title,
2863 space_id,
2864 parent_id,
2865 body: content,
2866 version: ConfluenceUpdateVersion {
2867 number: current_version.saturating_add(1),
2868 },
2869 };
2870
2871 let path = format!("pages/{}", params.page_id);
2872 let page: ConfluencePage = self
2873 .put_json_to_api(&self.page_api_path, &path, &payload)
2874 .await?;
2875 if let Some(labels) = params.labels.as_ref() {
2876 let current_labels = extract_labels(¤t);
2877 self.sync_labels(¶ms.page_id, labels, ¤t_labels)
2878 .await?;
2879 }
2880 let mut summary = map_page_summary(&self.instance_url, &page);
2881 if summary.space_key.is_none() {
2882 summary.space_key = self.resolve_space_key_by_id(space_id).await?;
2883 }
2884 if summary.url.is_none() {
2885 summary.url = Some(format!("{}/pages/{}", self.instance_url, page.id));
2886 }
2887 Ok(summary)
2888 }
2889}
2890
2891#[async_trait]
2892impl KnowledgeBaseProvider for ConfluenceClient {
2893 fn provider_name(&self) -> &'static str {
2894 "confluence"
2895 }
2896
2897 async fn get_spaces(&self) -> Result<ProviderResult<KbSpace>> {
2898 if uses_v2_api(&self.space_api_path) {
2899 let path = "space?limit=100&type=global,personal";
2900 let response: ConfluenceListResponse<ConfluenceSpace> =
2901 match self.get_json_from_api(&self.space_api_path, path).await {
2902 Ok(response) => response,
2903 Err(error) if should_fallback_to_rest_api(&error) => {
2904 return self.get_spaces_v1().await;
2905 }
2906 Err(error) => return Err(error),
2907 };
2908 let pagination = map_pagination(&response, Some(100));
2909 let items = response
2910 .results
2911 .into_iter()
2912 .map(|space| map_space(&self.instance_url, space))
2913 .collect::<Vec<_>>();
2914
2915 Ok(ProviderResult::new(items).with_pagination(pagination))
2916 } else {
2917 self.get_spaces_v1().await
2918 }
2919 }
2920
2921 async fn list_pages(&self, params: ListPagesParams) -> Result<ProviderResult<KbPage>> {
2922 if uses_v2_api(&self.page_api_path) {
2923 match self.list_pages_v2(params.clone()).await {
2924 Ok(result) => Ok(result),
2925 Err(error) if should_fallback_to_rest_api(&error) => {
2926 self.list_pages_v1(params).await
2927 }
2928 Err(error) => Err(error),
2929 }
2930 } else {
2931 self.list_pages_v1(params).await
2932 }
2933 }
2934
2935 async fn get_page(&self, page_id: &str) -> Result<KbPageContent> {
2936 if uses_v2_api(&self.page_api_path) {
2937 match self.get_page_v2(page_id).await {
2938 Ok(result) => Ok(result),
2939 Err(error) if should_fallback_to_rest_api(&error) => {
2940 self.get_page_v1(page_id).await
2941 }
2942 Err(error) => Err(error),
2943 }
2944 } else {
2945 self.get_page_v1(page_id).await
2946 }
2947 }
2948
2949 async fn create_page(&self, params: CreatePageParams) -> Result<KbPage> {
2950 if uses_v2_api(&self.page_api_path) {
2951 match self.create_page_v2(params.clone()).await {
2952 Ok(result) => Ok(result),
2953 Err(error) if should_fallback_to_rest_api(&error) => {
2954 self.create_page_v1(params).await
2955 }
2956 Err(error) => Err(error),
2957 }
2958 } else {
2959 self.create_page_v1(params).await
2960 }
2961 }
2962
2963 async fn update_page(&self, params: UpdatePageParams) -> Result<KbPage> {
2964 if uses_v2_api(&self.page_api_path) {
2965 match self.update_page_v2(params.clone()).await {
2966 Ok(result) => Ok(result),
2967 Err(error) if should_fallback_to_rest_api(&error) => {
2968 self.update_page_v1(params).await
2969 }
2970 Err(error) => Err(error),
2971 }
2972 } else {
2973 self.update_page_v1(params).await
2974 }
2975 }
2976
2977 async fn search(&self, params: SearchKbParams) -> Result<ProviderResult<KbPage>> {
2978 let limit = params.limit.unwrap_or(25);
2979
2980 let path = if let Some(cursor) = params.cursor.as_ref() {
2981 path_from_cursor(cursor, self.legacy_api_path())
2983 } else {
2984 let cql = build_search_cql(¶ms);
2985 format!(
2986 "content/search?cql={}&limit={limit}&expand=space,version,history.lastUpdated,body.view",
2987 encode_query_value(&cql)
2988 )
2989 };
2990
2991 let response: ConfluenceListResponse<ConfluencePage> =
2992 self.get_json_from_legacy_api(&path).await?;
2993 let pagination = map_pagination(&response, Some(limit));
2994 let items = response
2995 .results
2996 .iter()
2997 .map(|page| map_page_summary(&self.instance_url, page))
2998 .collect::<Vec<_>>();
2999
3000 Ok(ProviderResult::new(items).with_pagination(pagination))
3001 }
3002}
3003
3004#[cfg(test)]
3005mod tests {
3006 use httpmock::Method::{GET, POST, PUT};
3007 use httpmock::MockServer;
3008 use serde::{Deserialize, Serialize};
3009
3010 use super::*;
3011
3012 #[derive(Debug, Deserialize)]
3013 struct EchoResponse {
3014 ok: bool,
3015 }
3016
3017 #[derive(Debug, Serialize)]
3018 struct CreatePayload {
3019 title: String,
3020 }
3021
3022 #[tokio::test]
3023 async fn rest_api_url_normalizes_base_url() {
3024 let client =
3025 ConfluenceClient::new("https://wiki.example.com/", ConfluenceAuth::bearer("token"));
3026
3027 assert_eq!(
3028 client.rest_api_url("content"),
3029 "https://wiki.example.com/rest/api/content"
3030 );
3031 }
3032
3033 #[tokio::test]
3034 async fn new_defaults_to_self_hosted_flavor() {
3035 let client =
3036 ConfluenceClient::new("https://wiki.example.com/", ConfluenceAuth::bearer("token"));
3037
3038 assert!(matches!(client.flavor(), ConfluenceFlavor::SelfHosted));
3039 }
3040
3041 #[tokio::test]
3042 async fn new_auto_detects_cloud_flavor_for_atlassian_net() {
3043 let client = ConfluenceClient::new(
3044 "https://team.atlassian.net",
3045 ConfluenceAuth::bearer("token"),
3046 );
3047
3048 assert!(matches!(client.flavor(), ConfluenceFlavor::Cloud));
3049 assert_eq!(
3050 client.rest_api_url("spaces"),
3051 "https://team.atlassian.net/wiki/api/v2/spaces"
3052 );
3053 }
3054
3055 #[tokio::test]
3056 async fn with_flavor_cloud_switches_to_cloud_api_paths() {
3057 let client =
3058 ConfluenceClient::new("https://wiki.example.com/", ConfluenceAuth::bearer("t"))
3059 .with_flavor(ConfluenceFlavor::Cloud);
3060
3061 assert!(matches!(client.flavor(), ConfluenceFlavor::Cloud));
3062 assert_eq!(
3063 client.rest_api_url("spaces"),
3064 "https://wiki.example.com/wiki/api/v2/spaces"
3065 );
3066 }
3067
3068 #[tokio::test]
3069 async fn cloud_api_root_url_uses_atlassian_cloud_base() {
3070 let client =
3071 ConfluenceClient::new("https://wiki.example.com/", ConfluenceAuth::bearer("t"))
3072 .with_flavor(ConfluenceFlavor::Cloud)
3073 .with_cloud_id("abc123");
3074
3075 assert_eq!(
3076 client.cloud_api_root_url().as_deref(),
3077 Some("https://api.atlassian.com/ex/confluence/abc123")
3078 );
3079 assert_eq!(
3080 client.cloud_api_url("/wiki/api/v2/spaces").as_deref(),
3081 Some("https://api.atlassian.com/ex/confluence/abc123/wiki/api/v2/spaces")
3082 );
3083 }
3084
3085 #[tokio::test]
3086 async fn api_request_url_uses_atlassian_cloud_root_for_cloud_v2_calls() {
3087 let client =
3088 ConfluenceClient::new("https://example.atlassian.net", ConfluenceAuth::bearer("t"))
3089 .with_flavor(ConfluenceFlavor::Cloud)
3090 .with_cloud_id("abc123");
3091
3092 assert_eq!(
3093 client
3094 .api_request_url("/wiki/api/v2", "spaces/42/pages")
3095 .await
3096 .unwrap(),
3097 "https://api.atlassian.com/ex/confluence/abc123/wiki/api/v2/spaces/42/pages"
3098 );
3099 }
3100
3101 #[test]
3102 fn oauth_authorize_url_contains_required_atlassian_parameters() {
3103 let url = ConfluenceClient::oauth_authorize_url(
3104 "client-123",
3105 "http://localhost:8787/callback",
3106 "state-1",
3107 &["offline_access", "read:confluence-content.all"],
3108 );
3109 assert!(url.contains("https://auth.atlassian.com/authorize?"));
3110 assert!(url.contains("audience=api.atlassian.com"));
3111 assert!(url.contains("client_id=client-123"));
3112 assert!(url.contains("state=state-1"));
3113 assert!(url.contains("response_type=code"));
3114 assert!(url.contains("prompt=consent"));
3115 }
3116
3117 #[tokio::test]
3118 async fn legacy_rest_api_url_uses_wiki_rest_api_for_cloud() {
3119 let client =
3120 ConfluenceClient::new("https://example.atlassian.net", ConfluenceAuth::bearer("t"))
3121 .with_flavor(ConfluenceFlavor::Cloud);
3122
3123 assert_eq!(
3124 client
3125 .legacy_rest_api_url("content/42/label")
3126 .await
3127 .unwrap(),
3128 "https://example.atlassian.net/wiki/rest/api/content/42/label"
3129 );
3130 }
3131
3132 #[tokio::test]
3133 async fn rest_api_url_honors_v2_api_version() {
3134 let client =
3135 ConfluenceClient::new("https://wiki.example.com/", ConfluenceAuth::bearer("token"))
3136 .with_api_version(Some("v2"));
3137
3138 assert_eq!(
3139 client.space_api_url("space"),
3140 "https://wiki.example.com/api/v2/space"
3141 );
3142 }
3143
3144 #[tokio::test]
3145 async fn with_instance_url_keeps_browse_links_on_real_host_in_proxy_mode() {
3146 let api = MockServer::start();
3150 let _mock = api.mock(|when, then| {
3151 when.method(GET).path("/rest/api/space");
3152 then.status(200)
3153 .header("content-type", "application/json")
3154 .body(
3155 r#"{"results":[{"id":"42","key":"OPS","name":"Ops","type":"global","_links":{"webui":"/display/OPS"}}],"start":0,"limit":100,"size":1,"_links":{}}"#,
3156 );
3157 });
3158
3159 let client = ConfluenceClient::new(api.base_url(), ConfluenceAuth::bearer("t"))
3160 .with_proxy(HashMap::new())
3161 .with_instance_url("https://wiki.example.com");
3162
3163 assert_eq!(client.instance_url(), "https://wiki.example.com");
3164 assert_ne!(client.base_url(), client.instance_url());
3165
3166 let resp = client.get_spaces().await.unwrap();
3167 let url = resp.items[0].url.as_deref().unwrap_or_default();
3168 assert!(
3169 url.starts_with("https://wiki.example.com"),
3170 "browse link must point at the real instance, not the proxy. got: {url}"
3171 );
3172 }
3173
3174 #[tokio::test]
3175 async fn with_instance_url_defaults_to_base_url_when_unset() {
3176 let client =
3177 ConfluenceClient::new("https://wiki.example.com/", ConfluenceAuth::bearer("t"));
3178 assert_eq!(client.base_url(), client.instance_url());
3179 assert_eq!(client.instance_url(), "https://wiki.example.com");
3180 }
3181
3182 #[tokio::test]
3183 async fn get_spaces_accepts_integer_id_from_self_hosted_server() {
3184 let server = MockServer::start();
3188 let _mock = server.mock(|when, then| {
3189 when.method(GET).path("/rest/api/space");
3190 then.status(200)
3191 .header("content-type", "application/json")
3192 .body(
3193 r#"{"results":[{"id":190119946,"key":"1LS","name":"1 line support","type":"global","_links":{"webui":"/display/1LS"}}],"start":0,"limit":100,"size":1,"_links":{}}"#,
3194 );
3195 });
3196
3197 let client =
3198 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
3199
3200 let response = client.get_spaces().await.unwrap();
3201 assert_eq!(response.items.len(), 1);
3202 assert_eq!(response.items[0].id, "190119946");
3203 assert_eq!(response.items[0].key, "1LS");
3204 }
3205
3206 #[tokio::test]
3207 async fn send_json_decode_failure_surfaces_status_and_body_preview() {
3208 let server = MockServer::start();
3214 let _mock = server.mock(|when, then| {
3215 when.method(GET).path("/rest/api/space");
3216 then.status(200)
3217 .header("content-type", "text/html")
3218 .body("<html><body>Login required</body></html>");
3219 });
3220
3221 let client =
3222 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
3223
3224 let err = client.get_spaces().await.unwrap_err();
3225 let msg = err.to_string();
3226 assert!(msg.contains("JSON decode failed"), "got: {msg}");
3227 assert!(msg.contains("status=200"), "got: {msg}");
3228 assert!(msg.contains("text/html"), "got: {msg}");
3229 assert!(msg.contains("Login required"), "got: {msg}");
3230 }
3231
3232 #[tokio::test]
3233 async fn get_spaces_falls_back_to_rest_api_when_v2_is_unavailable() {
3234 let server = MockServer::start();
3235 let v2_mock = server.mock(|when, then| {
3236 when.method(GET)
3237 .path("/api/v2/space")
3238 .query_param("limit", "100")
3239 .query_param("type", "global,personal");
3240 then.status(404);
3241 });
3242 let v1_mock = server.mock(|when, then| {
3243 when.method(GET)
3244 .path("/rest/api/space")
3245 .query_param("limit", "100")
3246 .query_param("type", "global,personal");
3247 then.status(200)
3248 .header("content-type", "application/json")
3249 .body(r#"{"results":[],"start":0,"limit":100,"size":0,"_links":{}}"#);
3250 });
3251
3252 let client =
3253 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"))
3254 .with_api_version(Some("v2"));
3255
3256 let response = client.get_spaces().await.unwrap();
3257
3258 assert!(response.items.is_empty());
3259 v2_mock.assert();
3260 v1_mock.assert();
3261 }
3262
3263 #[tokio::test]
3264 async fn get_json_uses_bearer_auth() {
3265 let server = MockServer::start();
3266 let mock = server.mock(|when, then| {
3267 when.method(GET)
3268 .path("/rest/api/content")
3269 .header("authorization", "Bearer secret-token");
3270 then.status(200)
3271 .header("content-type", "application/json")
3272 .body(r#"{"ok":true}"#);
3273 });
3274
3275 let client =
3276 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
3277 let response: EchoResponse = client.get_json("content").await.unwrap();
3278
3279 mock.assert();
3280 assert!(response.ok);
3281 }
3282
3283 #[tokio::test]
3284 async fn post_json_uses_basic_auth() {
3285 let server = MockServer::start();
3286 let mock = server.mock(|when, then| {
3287 when.method(POST)
3288 .path("/rest/api/content")
3289 .header(
3290 "authorization",
3291 "Basic dXNlckBleGFtcGxlLmNvbTpwYXNzd29yZA==",
3292 )
3293 .json_body_obj(&serde_json::json!({ "title": "ADR-001" }));
3294 then.status(200)
3295 .header("content-type", "application/json")
3296 .body(r#"{"ok":true}"#);
3297 });
3298
3299 let client = ConfluenceClient::new(
3300 server.base_url(),
3301 ConfluenceAuth::basic("user@example.com", "password"),
3302 );
3303 let response: EchoResponse = client
3304 .post_json(
3305 "content",
3306 &CreatePayload {
3307 title: "ADR-001".into(),
3308 },
3309 )
3310 .await
3311 .unwrap();
3312
3313 mock.assert();
3314 assert!(response.ok);
3315 }
3316
3317 #[tokio::test]
3318 async fn proxy_headers_suppress_provider_auth() {
3319 let server = MockServer::start();
3320 let mock = server.mock(|when, then| {
3321 when.method(GET)
3322 .path("/rest/api/content")
3323 .header("x-proxy-auth", "secret")
3324 .header_missing("authorization");
3325 then.status(200)
3326 .header("content-type", "application/json")
3327 .body(r#"{"ok":true}"#);
3328 });
3329
3330 let mut headers = HashMap::new();
3331 headers.insert("x-proxy-auth".into(), "secret".into());
3332
3333 let client =
3334 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"))
3335 .with_proxy(headers);
3336 let response: EchoResponse = client.get_json("content").await.unwrap();
3337
3338 mock.assert();
3339 assert!(response.ok);
3340 }
3341
3342 #[tokio::test]
3343 async fn add_labels_uses_cloud_legacy_rest_api() {
3344 let server = MockServer::start();
3345 let mock = server.mock(|when, then| {
3346 when.method(POST)
3347 .path("/wiki/rest/api/content/42/label")
3348 .header("authorization", "Bearer secret-token");
3349 then.status(204);
3350 });
3351
3352 let client =
3353 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"))
3354 .with_flavor(ConfluenceFlavor::Cloud);
3355
3356 client
3357 .add_labels("42", &[String::from("adr")])
3358 .await
3359 .unwrap();
3360
3361 mock.assert();
3362 }
3363
3364 #[tokio::test]
3365 async fn get_spaces_discovers_cloud_id_from_accessible_resources() {
3366 let server = MockServer::start();
3367 let resources_mock = server.mock(|when, then| {
3368 when.method(GET)
3369 .path("/oauth/token/accessible-resources")
3370 .header("authorization", "Bearer secret-token");
3371 then.status(200)
3372 .header("content-type", "application/json")
3373 .body(
3374 r#"[
3375 {
3376 "id": "cloud-123",
3377 "url": "https://team.atlassian.net"
3378 }
3379 ]"#,
3380 );
3381 });
3382 let spaces_mock = server.mock(|when, then| {
3383 when.method(GET)
3384 .path("/ex/confluence/cloud-123/wiki/api/v2/space")
3385 .header("authorization", "Bearer secret-token")
3386 .query_param("limit", "100")
3387 .query_param("type", "global,personal");
3388 then.status(200)
3389 .header("content-type", "application/json")
3390 .body(r#"{"results":[],"start":0,"limit":100,"size":0,"_links":{}}"#);
3391 });
3392
3393 let client = ConfluenceClient::new(
3394 "https://team.atlassian.net",
3395 ConfluenceAuth::bearer("secret-token"),
3396 )
3397 .with_flavor(ConfluenceFlavor::Cloud)
3398 .with_cloud_api_base_url(server.base_url());
3399
3400 let response = client.get_spaces().await.unwrap();
3401
3402 assert!(response.items.is_empty());
3403 resources_mock.assert();
3404 spaces_mock.assert();
3405 }
3406
3407 #[tokio::test]
3408 async fn get_spaces_cloud_basic_auth_requires_explicit_cloud_id() {
3409 let client = ConfluenceClient::new(
3410 "https://team.atlassian.net",
3411 ConfluenceAuth::basic("dev@example.com", "secret-token"),
3412 )
3413 .with_flavor(ConfluenceFlavor::Cloud);
3414
3415 let err = client.get_spaces().await.unwrap_err();
3416 assert!(err.to_string().contains("explicit cloud_id"));
3417 }
3418
3419 #[tokio::test]
3420 async fn get_spaces_maps_confluence_spaces() {
3421 let server = MockServer::start();
3422 let server_origin = server.base_url();
3427 let body = format!(
3428 r#"{{
3429 "results": [
3430 {{
3431 "id": "123",
3432 "key": "ENG",
3433 "name": "Engineering",
3434 "type": "global",
3435 "status": "current",
3436 "description": {{ "plain": {{ "value": "Team docs" }} }},
3437 "_links": {{ "base": "{server_origin}", "webui": "/spaces/ENG/overview" }}
3438 }}
3439 ],
3440 "start": 0,
3441 "limit": 100,
3442 "size": 1,
3443 "totalSize": 1,
3444 "_links": {{}}
3445 }}"#,
3446 );
3447 let mock = server.mock(|when, then| {
3448 when.method(GET)
3449 .path("/rest/api/space")
3450 .query_param("limit", "100")
3451 .query_param("type", "global,personal");
3452 then.status(200)
3453 .header("content-type", "application/json")
3454 .body(&body);
3455 });
3456
3457 let client =
3458 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
3459 let result = client.get_spaces().await.unwrap();
3460
3461 mock.assert();
3462 assert_eq!(result.items.len(), 1);
3463 assert_eq!(result.items[0].key, "ENG");
3464 assert_eq!(result.items[0].name, "Engineering");
3465 assert_eq!(result.items[0].description.as_deref(), Some("Team docs"));
3466 assert_eq!(
3467 result.items[0].url.as_deref(),
3468 Some(format!("{server_origin}/spaces/ENG/overview").as_str())
3469 );
3470 assert_eq!(result.pagination.unwrap().total, Some(1));
3471 }
3472
3473 #[tokio::test]
3474 async fn map_link_ignores_cross_host_links_base_in_proxy_mode() {
3475 let server = MockServer::start();
3481 let _mock = server.mock(|when, then| {
3482 when.method(GET)
3483 .path("/rest/api/space")
3484 .query_param("limit", "100")
3485 .query_param("type", "global,personal");
3486 then.status(200)
3487 .header("content-type", "application/json")
3488 .body(
3489 r#"{
3490 "results": [
3491 {
3492 "id": "123",
3493 "key": "OPS",
3494 "name": "Ops",
3495 "type": "global",
3496 "_links": {
3497 "base": "https://internal-proxy.local",
3498 "webui": "/display/OPS"
3499 }
3500 }
3501 ],
3502 "start": 0,
3503 "limit": 100,
3504 "size": 1,
3505 "_links": {}
3506 }"#,
3507 );
3508 });
3509
3510 let client = ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("t"))
3511 .with_instance_url("https://wiki.example.com");
3512 let result = client.get_spaces().await.unwrap();
3513
3514 let url = result.items[0].url.as_deref().unwrap_or_default();
3515 assert!(
3516 url.starts_with("https://wiki.example.com"),
3517 "cross-host `_links.base` must be ignored in favour of \
3518 `instance_url`. got: {url}"
3519 );
3520 }
3521
3522 #[tokio::test]
3523 async fn list_pages_falls_back_to_rest_content_api_when_v2_pages_are_unavailable() {
3524 let server = MockServer::start();
3525 server.mock(|when, then| {
3526 when.method(GET)
3527 .path("/api/v2/space")
3528 .query_param("limit", "100")
3529 .query_param("type", "global,personal");
3530 then.status(200)
3531 .header("content-type", "application/json")
3532 .body(
3533 r#"{
3534 "results": [
3535 { "id": "123", "key": "ENG", "name": "Engineering" }
3536 ],
3537 "_links": {}
3538 }"#,
3539 );
3540 });
3541 server.mock(|when, then| {
3542 when.method(GET)
3543 .path("/api/v2/spaces/123/pages")
3544 .query_param("limit", "25")
3545 .query_param("body-format", "view");
3546 then.status(404);
3547 });
3548 let mock = server.mock(|when, then| {
3549 when.method(GET)
3550 .path("/rest/api/content")
3551 .query_param("spaceKey", "ENG")
3552 .query_param("type", "page")
3553 .query_param("limit", "25")
3554 .query_param("start", "0")
3555 .query_param(
3556 "expand",
3557 "space,version,history.lastUpdated,body.view,ancestors",
3558 );
3559 then.status(200)
3560 .header("content-type", "application/json")
3561 .body(r#"{"results":[],"start":0,"limit":25,"size":0,"_links":{}}"#);
3562 });
3563
3564 let client =
3565 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"))
3566 .with_api_version(Some("v2"));
3567 let result = client
3568 .list_pages(ListPagesParams {
3569 space_key: "ENG".into(),
3570 limit: Some(25),
3571 offset: Some(0),
3572 cursor: None,
3573 search: None,
3574 parent_id: None,
3575 })
3576 .await
3577 .unwrap();
3578
3579 mock.assert();
3580 assert!(result.items.is_empty());
3581 }
3582
3583 #[tokio::test]
3584 async fn list_pages_uses_v2_pages_when_preferred() {
3585 let server = MockServer::start();
3586 let pages_mock = server.mock(|when, then| {
3587 when.method(GET)
3588 .path("/api/v2/pages/10/children")
3589 .query_param("limit", "25");
3590 then.status(200)
3591 .header("content-type", "application/json")
3592 .body(
3593 r#"{
3594 "results": [
3595 {
3596 "id": "42",
3597 "title": "ADR-001",
3598 "spaceId": "123",
3599 "parentId": "10",
3600 "_links": { "next": "/api/v2/pages/10/children?cursor=abc" }
3601 }
3602 ],
3603 "limit": 25,
3604 "size": 1,
3605 "_links": { "next": "/api/v2/pages/10/children?cursor=abc" }
3606 }"#,
3607 );
3608 });
3609
3610 let client =
3611 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"))
3612 .with_api_version(Some("v2"));
3613 let result = client
3614 .list_pages(ListPagesParams {
3615 space_key: "ENG".into(),
3616 limit: Some(25),
3617 offset: Some(0),
3618 cursor: None,
3619 search: None,
3620 parent_id: Some("10".into()),
3621 })
3622 .await
3623 .unwrap();
3624
3625 pages_mock.assert();
3626 assert_eq!(result.items.len(), 1);
3627 assert_eq!(result.items[0].id, "42");
3628 assert_eq!(result.items[0].space_key.as_deref(), Some("ENG"));
3629 assert_eq!(
3630 result
3631 .pagination
3632 .and_then(|pagination| pagination.next_cursor),
3633 Some("/api/v2/pages/10/children?cursor=abc".into())
3634 );
3635 }
3636
3637 #[tokio::test]
3638 async fn list_pages_uses_v1_child_endpoint_when_parent_filter_is_set() {
3639 let server = MockServer::start();
3640 let mock = server.mock(|when, then| {
3641 when.method(GET)
3642 .path("/rest/api/content/10/child/page")
3643 .query_param("limit", "25")
3644 .query_param("start", "0")
3645 .query_param("expand", "space,version,history.lastUpdated,body.view");
3646 then.status(200)
3647 .header("content-type", "application/json")
3648 .body(
3649 r#"{
3650 "results": [
3651 {
3652 "id": "42",
3653 "title": "ADR-001",
3654 "space": { "key": "ENG" },
3655 "version": { "number": 7 },
3656 "body": {
3657 "view": { "value": "<p>Architecture decision record</p>" }
3658 },
3659 "_links": { "base": "https://wiki.example.com", "webui": "/pages/viewpage.action?pageId=42" }
3660 }
3661 ],
3662 "start": 0,
3663 "limit": 25,
3664 "size": 1,
3665 "_links": {}
3666 }"#,
3667 );
3668 });
3669
3670 let client =
3671 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
3672 let result = client
3673 .list_pages(ListPagesParams {
3674 space_key: "ENG".into(),
3675 limit: Some(25),
3676 offset: Some(0),
3677 cursor: None,
3678 search: None,
3679 parent_id: Some("10".into()),
3680 })
3681 .await
3682 .unwrap();
3683
3684 mock.assert();
3685 assert_eq!(result.items.len(), 1);
3686 assert_eq!(result.items[0].id, "42");
3687 assert_eq!(result.items[0].space_key.as_deref(), Some("ENG"));
3688 }
3689
3690 #[tokio::test]
3691 async fn list_pages_maps_page_summaries_and_pagination() {
3692 let server = MockServer::start();
3693 let mock = server.mock(|when, then| {
3694 when.method(GET)
3695 .path("/rest/api/content")
3696 .query_param("spaceKey", "ENG")
3697 .query_param("type", "page")
3698 .query_param("limit", "25")
3699 .query_param("start", "0")
3700 .query_param("expand", "space,version,history.lastUpdated,body.view,ancestors");
3701 then.status(200)
3702 .header("content-type", "application/json")
3703 .body(
3704 r#"{
3705 "results": [
3706 {
3707 "id": "42",
3708 "title": "ADR-001",
3709 "space": { "key": "ENG" },
3710 "version": {
3711 "number": 7,
3712 "when": "2026-04-26T10:00:00.000Z",
3713 "by": { "displayName": "Alice" }
3714 },
3715 "body": {
3716 "view": { "value": "<p>Architecture decision record</p>", "representation": "view" }
3717 },
3718 "ancestors": [],
3719 "_links": { "base": "https://wiki.example.com", "webui": "/pages/viewpage.action?pageId=42", "next": "/rest/api/content?start=25" }
3720 }
3721 ],
3722 "start": 0,
3723 "limit": 25,
3724 "size": 1,
3725 "totalSize": 30,
3726 "_links": { "next": "/rest/api/content?start=25" }
3727 }"#,
3728 );
3729 });
3730
3731 let client =
3732 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
3733 let result = client
3734 .list_pages(ListPagesParams {
3735 space_key: "ENG".into(),
3736 limit: Some(25),
3737 offset: Some(0),
3738 cursor: None,
3739 search: None,
3740 parent_id: None,
3741 })
3742 .await
3743 .unwrap();
3744
3745 mock.assert();
3746 assert_eq!(result.items.len(), 1);
3747 assert_eq!(result.items[0].id, "42");
3748 assert_eq!(result.items[0].space_key.as_deref(), Some("ENG"));
3749 assert_eq!(result.items[0].version, Some(7));
3750 assert_eq!(result.items[0].author.as_deref(), Some("Alice"));
3751 assert_eq!(
3752 result.items[0].excerpt.as_deref(),
3753 Some("Architecture decision record")
3754 );
3755 let pagination = result.pagination.unwrap();
3756 assert!(pagination.has_more);
3757 assert_eq!(
3758 pagination.next_cursor.as_deref(),
3759 Some("/rest/api/content?start=25")
3760 );
3761 assert_eq!(pagination.total, Some(30));
3762 }
3763
3764 #[tokio::test]
3765 async fn list_pages_uses_cursor_path_for_followup_requests() {
3766 let server = MockServer::start();
3767 let mock = server.mock(|when, then| {
3768 when.method(GET)
3769 .path("/rest/api/content")
3770 .query_param("limit", "25")
3771 .query_param("start", "25");
3772 then.status(200)
3773 .header("content-type", "application/json")
3774 .body(
3775 r#"{
3776 "results": [
3777 {
3778 "id": "77",
3779 "title": "Next Page",
3780 "space": { "key": "ENG" },
3781 "_links": { "base": "https://wiki.example.com", "webui": "/pages/viewpage.action?pageId=77" }
3782 }
3783 ],
3784 "start": 25,
3785 "limit": 25,
3786 "size": 1,
3787 "totalSize": 26,
3788 "_links": {}
3789 }"#,
3790 );
3791 });
3792
3793 let client =
3794 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
3795 let result = client
3796 .list_pages(ListPagesParams {
3797 space_key: "ENG".into(),
3798 limit: Some(25),
3799 offset: Some(0),
3800 cursor: Some("/rest/api/content?limit=25&start=25".into()),
3801 search: None,
3802 parent_id: None,
3803 })
3804 .await
3805 .unwrap();
3806
3807 mock.assert();
3808 assert_eq!(result.items.len(), 1);
3809 assert_eq!(result.items[0].id, "77");
3810 }
3811
3812 #[tokio::test]
3813 async fn get_page_maps_storage_content_labels_and_ancestors() {
3814 let server = MockServer::start();
3815 let mock = server.mock(|when, then| {
3816 when.method(GET)
3817 .path("/rest/api/content/42")
3818 .query_param(
3819 "expand",
3820 "space,version,history.lastUpdated,body.storage,metadata.labels,ancestors",
3821 );
3822 then.status(200)
3823 .header("content-type", "application/json")
3824 .body(
3825 r#"{
3826 "id": "42",
3827 "title": "ADR-001",
3828 "space": { "key": "ENG" },
3829 "version": {
3830 "number": 7,
3831 "when": "2026-04-26T10:00:00.000Z",
3832 "by": { "displayName": "Alice" }
3833 },
3834 "body": {
3835 "storage": {
3836 "value": "<p>Hello <strong>world</strong></p>",
3837 "representation": "storage"
3838 }
3839 },
3840 "metadata": {
3841 "labels": {
3842 "results": [
3843 { "name": "adr" },
3844 { "name": "architecture" }
3845 ]
3846 }
3847 },
3848 "ancestors": [
3849 {
3850 "id": "10",
3851 "title": "Architecture Decisions",
3852 "_links": { "base": "https://wiki.example.com", "webui": "/pages/viewpage.action?pageId=10" }
3853 }
3854 ],
3855 "_links": { "base": "https://wiki.example.com", "webui": "/pages/viewpage.action?pageId=42" }
3856 }"#,
3857 );
3858 });
3859
3860 let client =
3861 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
3862 let page = client.get_page("42").await.unwrap();
3863
3864 mock.assert();
3865 assert_eq!(page.page.id, "42");
3866 assert_eq!(page.page.title, "ADR-001");
3867 assert_eq!(page.page.version, Some(7));
3868 assert_eq!(page.content_type, "markdown");
3869 assert_eq!(page.content, "Hello **world**");
3870 assert_eq!(page.labels, vec!["adr", "architecture"]);
3871 assert_eq!(page.ancestors.len(), 1);
3872 assert_eq!(page.ancestors[0].id, "10");
3873 assert_eq!(page.ancestors[0].title, "Architecture Decisions");
3874 }
3875
3876 #[tokio::test]
3877 async fn get_page_uses_v2_page_and_ancestors_when_preferred() {
3878 let server = MockServer::start();
3879 let space_mock = server.mock(|when, then| {
3880 when.method(GET)
3881 .path("/api/v2/space")
3882 .query_param("limit", "100")
3883 .query_param("type", "global,personal");
3884 then.status(200)
3885 .header("content-type", "application/json")
3886 .body(
3887 r#"{
3888 "results": [
3889 { "id": "123", "key": "ENG", "name": "Engineering" }
3890 ],
3891 "_links": {}
3892 }"#,
3893 );
3894 });
3895 let page_mock = server.mock(|when, then| {
3896 when.method(GET)
3897 .path("/api/v2/pages/42")
3898 .query_param("body-format", "storage")
3899 .query_param("include-labels", "true");
3900 then.status(200)
3901 .header("content-type", "application/json")
3902 .body(
3903 r#"{
3904 "id": "42",
3905 "title": "ADR-001",
3906 "spaceId": "123",
3907 "parentId": "10",
3908 "version": {
3909 "number": 7,
3910 "createdAt": "2026-04-26T10:00:00.000Z"
3911 },
3912 "body": {
3913 "representation": "storage",
3914 "value": "<p>Hello <strong>world</strong></p>"
3915 },
3916 "labels": {
3917 "results": [
3918 { "label": "adr" },
3919 { "label": "architecture" }
3920 ]
3921 }
3922 }"#,
3923 );
3924 });
3925 let ancestors_mock = server.mock(|when, then| {
3926 when.method(GET)
3927 .path("/api/v2/pages/42/ancestors")
3928 .query_param("limit", "100");
3929 then.status(200)
3930 .header("content-type", "application/json")
3931 .body(r#"{ "results": [ { "id": "10", "type": "page" } ], "_links": {} }"#);
3932 });
3933 let ancestor_page_mock = server.mock(|when, then| {
3934 when.method(GET).path("/api/v2/pages/10");
3935 then.status(200)
3936 .header("content-type", "application/json")
3937 .body(
3938 r#"{
3939 "id": "10",
3940 "title": "Architecture Decisions",
3941 "spaceId": "123"
3942 }"#,
3943 );
3944 });
3945
3946 let client =
3947 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"))
3948 .with_api_version(Some("v2"));
3949 let page = client.get_page("42").await.unwrap();
3950
3951 space_mock.assert();
3952 page_mock.assert();
3953 ancestors_mock.assert();
3954 ancestor_page_mock.assert();
3955 assert_eq!(page.page.id, "42");
3956 assert_eq!(page.page.space_key.as_deref(), Some("ENG"));
3957 assert_eq!(page.page.version, Some(7));
3958 assert_eq!(page.content, "Hello **world**");
3959 assert_eq!(page.labels, vec!["adr", "architecture"]);
3960 assert_eq!(page.ancestors.len(), 1);
3961 assert_eq!(page.ancestors[0].title, "Architecture Decisions");
3962 }
3963
3964 #[tokio::test]
3965 async fn get_page_cloud_v2_reads_adf_as_markdown() {
3966 let server = MockServer::start();
3967 let _resource_mock = server.mock(|when, then| {
3968 when.method(GET).path("/oauth/token/accessible-resources");
3969 then.status(200)
3970 .header("content-type", "application/json")
3971 .body(r#"[{ "id": "cloud-123", "url": "https://team.atlassian.net" }]"#);
3972 });
3973 let _space_mock = server.mock(|when, then| {
3974 when.method(GET)
3975 .path("/ex/confluence/cloud-123/wiki/api/v2/space")
3976 .query_param("limit", "100")
3977 .query_param("type", "global,personal");
3978 then.status(200)
3979 .header("content-type", "application/json")
3980 .body(r#"{ "results": [{ "id": "123", "key": "ENG", "name": "Engineering" }], "_links": {} }"#);
3981 });
3982 let page_mock = server.mock(|when, then| {
3983 when.method(GET)
3984 .path("/ex/confluence/cloud-123/wiki/api/v2/pages/42")
3985 .query_param("body-format", "atlas_doc_format")
3986 .query_param("include-labels", "true");
3987 then.status(200).header("content-type", "application/json").body(r#"{
3988 "id": "42",
3989 "title": "ADR-001",
3990 "spaceId": "123",
3991 "version": { "number": 7 },
3992 "body": {
3993 "atlas_doc_format": {
3994 "value": {
3995 "type": "doc",
3996 "version": 1,
3997 "content": [
3998 { "type": "heading", "attrs": { "level": 2 }, "content": [{ "type": "text", "text": "ADR" }] },
3999 { "type": "paragraph", "content": [{ "type": "text", "text": "Hello " }, { "type": "text", "text": "world", "marks": [{ "type": "strong" }] }] }
4000 ]
4001 }
4002 }
4003 },
4004 "labels": { "results": [{ "label": "adr" }] }
4005 }"#);
4006 });
4007 let ancestors_mock = server.mock(|when, then| {
4008 when.method(GET)
4009 .path("/ex/confluence/cloud-123/wiki/api/v2/pages/42/ancestors")
4010 .query_param("limit", "100");
4011 then.status(200)
4012 .header("content-type", "application/json")
4013 .body(r#"{ "results": [], "_links": {} }"#);
4014 });
4015
4016 let client = ConfluenceClient::new(
4017 "https://team.atlassian.net",
4018 ConfluenceAuth::bearer("secret-token"),
4019 )
4020 .with_flavor(ConfluenceFlavor::Cloud)
4021 .with_cloud_api_base_url(server.base_url());
4022 let page = client.get_page("42").await.unwrap();
4023
4024 page_mock.assert();
4025 ancestors_mock.assert();
4026 assert_eq!(page.content, "## ADR\n\nHello **world**");
4027 assert_eq!(page.labels, vec!["adr"]);
4028 }
4029
4030 #[tokio::test]
4031 async fn get_page_v2_propagates_non_fallback_ancestor_errors() {
4032 let server = MockServer::start();
4033 let space_mock = server.mock(|when, then| {
4034 when.method(GET)
4035 .path("/api/v2/space")
4036 .query_param("limit", "100")
4037 .query_param("type", "global,personal");
4038 then.status(200)
4039 .header("content-type", "application/json")
4040 .body(
4041 r#"{
4042 "results": [
4043 { "id": "123", "key": "ENG", "name": "Engineering" }
4044 ],
4045 "_links": {}
4046 }"#,
4047 );
4048 });
4049 let page_mock = server.mock(|when, then| {
4050 when.method(GET)
4051 .path("/api/v2/pages/42")
4052 .query_param("body-format", "storage")
4053 .query_param("include-labels", "true");
4054 then.status(200)
4055 .header("content-type", "application/json")
4056 .body(
4057 r#"{
4058 "id": "42",
4059 "title": "ADR-001",
4060 "spaceId": "123",
4061 "version": { "number": 7 },
4062 "body": {
4063 "representation": "storage",
4064 "value": "<p>Hello</p>"
4065 }
4066 }"#,
4067 );
4068 });
4069 let ancestors_mock = server.mock(|when, then| {
4070 when.method(GET)
4071 .path("/api/v2/pages/42/ancestors")
4072 .query_param("limit", "100");
4073 then.status(401).body("unauthorized");
4074 });
4075
4076 let client =
4077 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"))
4078 .with_api_version(Some("v2"));
4079 let error = client.get_page("42").await.unwrap_err();
4080
4081 space_mock.assert();
4082 page_mock.assert();
4083 ancestors_mock.assert();
4084 assert!(matches!(error, Error::Unauthorized(_)));
4085 }
4086
4087 #[tokio::test]
4088 async fn create_page_accepts_markdown_and_posts_storage_payload() {
4089 let server = MockServer::start();
4090 let mock = server.mock(|when, then| {
4091 when.method(POST)
4092 .path("/rest/api/content")
4093 .header("authorization", "Bearer secret-token")
4094 .header("content-type", "application/json")
4095 .json_body_obj(&serde_json::json!({
4096 "type": "page",
4097 "title": "ADR-002",
4098 "space": { "key": "ENG" },
4099 "body": {
4100 "storage": {
4101 "value": "<h1>Decision</h1><p>Hello <strong>world</strong></p>",
4102 "representation": "storage"
4103 }
4104 },
4105 "ancestors": [{ "id": "10" }]
4106 }));
4107 then.status(200)
4108 .header("content-type", "application/json")
4109 .body(
4110 r#"{
4111 "id": "43",
4112 "title": "ADR-002",
4113 "space": { "key": "ENG" },
4114 "version": {
4115 "number": 1,
4116 "when": "2026-04-26T10:00:00.000Z",
4117 "by": { "displayName": "Alice" }
4118 },
4119 "_links": { "base": "https://wiki.example.com", "webui": "/pages/viewpage.action?pageId=43" }
4120 }"#,
4121 );
4122 });
4123
4124 let client =
4125 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
4126 let page = client
4127 .create_page(CreatePageParams {
4128 space_key: "ENG".into(),
4129 title: "ADR-002".into(),
4130 content: "# Decision\n\nHello **world**".into(),
4131 content_type: Some("markdown".into()),
4132 parent_id: Some("10".into()),
4133 labels: vec![],
4134 })
4135 .await
4136 .unwrap();
4137
4138 mock.assert();
4139 assert_eq!(page.id, "43");
4140 assert_eq!(page.title, "ADR-002");
4141 assert_eq!(page.space_key.as_deref(), Some("ENG"));
4142 assert_eq!(page.version, Some(1));
4143 }
4144
4145 #[tokio::test]
4146 async fn create_page_posts_labels_after_create() {
4147 let server = MockServer::start();
4148 let create_mock = server.mock(|when, then| {
4149 when.method(POST)
4150 .path("/rest/api/content")
4151 .header("authorization", "Bearer secret-token")
4152 .header("content-type", "application/json")
4153 .json_body_obj(&serde_json::json!({
4154 "type": "page",
4155 "title": "ADR-002",
4156 "space": { "key": "ENG" },
4157 "body": {
4158 "storage": {
4159 "value": "<p>Hello</p>",
4160 "representation": "storage"
4161 }
4162 }
4163 }));
4164 then.status(200)
4165 .header("content-type", "application/json")
4166 .body(
4167 r#"{
4168 "id": "43",
4169 "title": "ADR-002",
4170 "space": { "key": "ENG" },
4171 "version": { "number": 1 }
4172 }"#,
4173 );
4174 });
4175 let labels_mock = server.mock(|when, then| {
4176 when.method(POST)
4177 .path("/rest/api/content/43/label")
4178 .header("authorization", "Bearer secret-token")
4179 .header("content-type", "application/json")
4180 .json_body_obj(&serde_json::json!([
4181 { "prefix": "global", "name": "adr" },
4182 { "prefix": "global", "name": "architecture" }
4183 ]));
4184 then.status(200)
4185 .header("content-type", "application/json")
4186 .body("[]");
4187 });
4188
4189 let client =
4190 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
4191 let page = client
4192 .create_page(CreatePageParams {
4193 space_key: "ENG".into(),
4194 title: "ADR-002".into(),
4195 content: "<p>Hello</p>".into(),
4196 content_type: Some("storage".into()),
4197 parent_id: None,
4198 labels: vec!["adr".into(), "architecture".into()],
4199 })
4200 .await
4201 .unwrap();
4202
4203 create_mock.assert();
4204 labels_mock.assert();
4205 assert_eq!(page.id, "43");
4206 }
4207
4208 #[tokio::test]
4209 async fn create_page_uses_v2_pages_when_preferred() {
4210 let server = MockServer::start();
4211 let space_mock = server.mock(|when, then| {
4212 when.method(GET)
4213 .path("/api/v2/space")
4214 .query_param("limit", "100")
4215 .query_param("type", "global,personal");
4216 then.status(200)
4217 .header("content-type", "application/json")
4218 .body(
4219 r#"{
4220 "results": [
4221 { "id": "123", "key": "ENG", "name": "Engineering" }
4222 ],
4223 "_links": {}
4224 }"#,
4225 );
4226 });
4227 let create_mock = server.mock(|when, then| {
4228 when.method(POST)
4229 .path("/api/v2/pages")
4230 .header("authorization", "Bearer secret-token")
4231 .header("content-type", "application/json")
4232 .json_body_obj(&serde_json::json!({
4233 "spaceId": "123",
4234 "status": "current",
4235 "title": "ADR-002",
4236 "parentId": "10",
4237 "body": {
4238 "value": "<h1>Decision</h1><p>Hello <strong>world</strong></p>",
4239 "representation": "storage"
4240 }
4241 }));
4242 then.status(200)
4243 .header("content-type", "application/json")
4244 .body(
4245 r#"{
4246 "id": "43",
4247 "title": "ADR-002",
4248 "spaceId": "123",
4249 "version": { "number": 1 }
4250 }"#,
4251 );
4252 });
4253
4254 let client =
4255 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"))
4256 .with_api_version(Some("v2"));
4257 let page = client
4258 .create_page(CreatePageParams {
4259 space_key: "ENG".into(),
4260 title: "ADR-002".into(),
4261 content: "# Decision\n\nHello **world**".into(),
4262 content_type: Some("markdown".into()),
4263 parent_id: Some("10".into()),
4264 labels: vec![],
4265 })
4266 .await
4267 .unwrap();
4268
4269 space_mock.assert();
4270 create_mock.assert();
4271 assert_eq!(page.id, "43");
4272 assert_eq!(page.space_key.as_deref(), Some("ENG"));
4273 assert_eq!(page.version, Some(1));
4274 }
4275
4276 #[tokio::test]
4277 async fn create_page_cloud_v2_writes_adf_payload() {
4278 let server = MockServer::start();
4279 let _resource_mock = server.mock(|when, then| {
4280 when.method(GET).path("/oauth/token/accessible-resources");
4281 then.status(200)
4282 .header("content-type", "application/json")
4283 .body(r#"[{ "id": "cloud-123", "url": "https://team.atlassian.net" }]"#);
4284 });
4285 let _space_mock = server.mock(|when, then| {
4286 when.method(GET)
4287 .path("/ex/confluence/cloud-123/wiki/api/v2/space")
4288 .query_param("limit", "100")
4289 .query_param("type", "global,personal");
4290 then.status(200)
4291 .header("content-type", "application/json")
4292 .body(r#"{ "results": [{ "id": "123", "key": "ENG", "name": "Engineering" }], "_links": {} }"#);
4293 });
4294 let create_mock = server.mock(|when, then| {
4295 when.method(POST)
4296 .path("/ex/confluence/cloud-123/wiki/api/v2/pages")
4297 .header("authorization", "Bearer secret-token")
4298 .header("content-type", "application/json")
4299 .json_body_obj(&json!({
4300 "spaceId": "123",
4301 "status": "current",
4302 "title": "ADR-002",
4303 "body": {
4304 "representation": "atlas_doc_format",
4305 "value": {
4306 "type": "doc",
4307 "version": 1,
4308 "content": [
4309 {
4310 "type": "heading",
4311 "attrs": { "level": 1 },
4312 "content": [{ "type": "text", "text": "Decision" }]
4313 },
4314 {
4315 "type": "paragraph",
4316 "content": [
4317 { "type": "text", "text": "Hello " },
4318 { "type": "text", "text": "world", "marks": [{ "type": "strong" }] }
4319 ]
4320 }
4321 ]
4322 }
4323 }
4324 }));
4325 then.status(200).header("content-type", "application/json").body(r#"{
4326 "id": "43",
4327 "title": "ADR-002",
4328 "spaceId": "123",
4329 "version": { "number": 1 }
4330 }"#);
4331 });
4332
4333 let client = ConfluenceClient::new(
4334 "https://team.atlassian.net",
4335 ConfluenceAuth::bearer("secret-token"),
4336 )
4337 .with_flavor(ConfluenceFlavor::Cloud)
4338 .with_cloud_api_base_url(server.base_url());
4339 let page = client
4340 .create_page(CreatePageParams {
4341 space_key: "ENG".into(),
4342 title: "ADR-002".into(),
4343 content: "# Decision\n\nHello **world**".into(),
4344 content_type: Some("markdown".into()),
4345 parent_id: None,
4346 labels: vec![],
4347 })
4348 .await
4349 .unwrap();
4350
4351 create_mock.assert();
4352 assert_eq!(page.id, "43");
4353 }
4354
4355 #[tokio::test]
4356 async fn update_page_accepts_markdown_and_puts_incremented_version() {
4357 let server = MockServer::start();
4358 let get_mock = server.mock(|when, then| {
4359 when.method(GET)
4360 .path("/rest/api/content/42")
4361 .query_param("expand", "space,version,body.storage,ancestors");
4362 then.status(200)
4363 .header("content-type", "application/json")
4364 .body(
4365 r#"{
4366 "id": "42",
4367 "title": "ADR-001",
4368 "space": { "key": "ENG" },
4369 "version": { "number": 7 },
4370 "body": {
4371 "storage": {
4372 "value": "<p>Old</p>",
4373 "representation": "storage"
4374 }
4375 },
4376 "ancestors": [
4377 { "id": "10", "title": "Architecture", "_links": {} }
4378 ],
4379 "_links": { "base": "https://wiki.example.com", "webui": "/pages/viewpage.action?pageId=42" }
4380 }"#,
4381 );
4382 });
4383 let put_mock = server.mock(|when, then| {
4384 when.method(PUT)
4385 .path("/rest/api/content/42")
4386 .header("authorization", "Bearer secret-token")
4387 .header("content-type", "application/json")
4388 .json_body_obj(&serde_json::json!({
4389 "id": "42",
4390 "type": "page",
4391 "title": "ADR-001 Revised",
4392 "version": { "number": 8 },
4393 "body": {
4394 "storage": {
4395 "value": "<p>New <strong>decision</strong></p>",
4396 "representation": "storage"
4397 }
4398 },
4399 "ancestors": [{ "id": "11" }]
4400 }));
4401 then.status(200)
4402 .header("content-type", "application/json")
4403 .body(
4404 r#"{
4405 "id": "42",
4406 "title": "ADR-001 Revised",
4407 "space": { "key": "ENG" },
4408 "version": {
4409 "number": 8,
4410 "when": "2026-04-26T11:00:00.000Z",
4411 "by": { "displayName": "Bob" }
4412 },
4413 "_links": { "base": "https://wiki.example.com", "webui": "/pages/viewpage.action?pageId=42" }
4414 }"#,
4415 );
4416 });
4417
4418 let client =
4419 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
4420 let page = client
4421 .update_page(UpdatePageParams {
4422 page_id: "42".into(),
4423 title: Some("ADR-001 Revised".into()),
4424 content: Some("New **decision**".into()),
4425 content_type: Some("markdown".into()),
4426 version: Some(7),
4427 labels: None,
4428 parent_id: Some("11".into()),
4429 })
4430 .await
4431 .unwrap();
4432
4433 get_mock.assert();
4434 put_mock.assert();
4435 assert_eq!(page.id, "42");
4436 assert_eq!(page.title, "ADR-001 Revised");
4437 assert_eq!(page.version, Some(8));
4438 assert_eq!(page.author.as_deref(), Some("Bob"));
4439 }
4440
4441 #[tokio::test]
4442 async fn update_page_uses_v2_pages_when_preferred() {
4443 let server = MockServer::start();
4444 let get_mock = server.mock(|when, then| {
4445 when.method(GET)
4446 .path("/api/v2/pages/42")
4447 .query_param("body-format", "storage");
4448 then.status(200)
4449 .header("content-type", "application/json")
4450 .body(
4451 r#"{
4452 "id": "42",
4453 "title": "ADR-001",
4454 "spaceId": "123",
4455 "parentId": "10",
4456 "version": { "number": 7 },
4457 "body": {
4458 "representation": "storage",
4459 "value": "<p>Old</p>"
4460 }
4461 }"#,
4462 );
4463 });
4464 let put_mock = server.mock(|when, then| {
4465 when.method(PUT)
4466 .path("/api/v2/pages/42")
4467 .header("authorization", "Bearer secret-token")
4468 .header("content-type", "application/json")
4469 .json_body_obj(&serde_json::json!({
4470 "id": "42",
4471 "status": "current",
4472 "title": "ADR-001 Revised",
4473 "spaceId": "123",
4474 "parentId": "11",
4475 "body": {
4476 "value": "<p>New <strong>decision</strong></p>",
4477 "representation": "storage"
4478 },
4479 "version": { "number": 8 }
4480 }));
4481 then.status(200)
4482 .header("content-type", "application/json")
4483 .body(
4484 r#"{
4485 "id": "42",
4486 "title": "ADR-001 Revised",
4487 "spaceId": "123",
4488 "version": { "number": 8, "createdAt": "2026-04-26T11:00:00.000Z" }
4489 }"#,
4490 );
4491 });
4492 let space_mock = server.mock(|when, then| {
4493 when.method(GET)
4494 .path("/api/v2/space")
4495 .query_param("limit", "100")
4496 .query_param("type", "global,personal");
4497 then.status(200)
4498 .header("content-type", "application/json")
4499 .body(
4500 r#"{
4501 "results": [
4502 { "id": "123", "key": "ENG", "name": "Engineering" }
4503 ],
4504 "_links": {}
4505 }"#,
4506 );
4507 });
4508
4509 let client =
4510 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"))
4511 .with_api_version(Some("v2"));
4512 let page = client
4513 .update_page(UpdatePageParams {
4514 page_id: "42".into(),
4515 title: Some("ADR-001 Revised".into()),
4516 content: Some("New **decision**".into()),
4517 content_type: Some("markdown".into()),
4518 version: Some(7),
4519 labels: None,
4520 parent_id: Some("11".into()),
4521 })
4522 .await
4523 .unwrap();
4524
4525 get_mock.assert();
4526 put_mock.assert();
4527 space_mock.assert();
4528 assert_eq!(page.id, "42");
4529 assert_eq!(page.title, "ADR-001 Revised");
4530 assert_eq!(page.space_key.as_deref(), Some("ENG"));
4531 assert_eq!(page.version, Some(8));
4532 }
4533
4534 #[tokio::test]
4535 async fn update_page_returns_conflict_when_expected_version_is_stale() {
4536 let server = MockServer::start();
4537 let get_mock = server.mock(|when, then| {
4538 when.method(GET)
4539 .path("/rest/api/content/42")
4540 .query_param("expand", "space,version,body.storage,ancestors");
4541 then.status(200)
4542 .header("content-type", "application/json")
4543 .body(
4544 r#"{
4545 "id": "42",
4546 "title": "ADR-001",
4547 "space": { "key": "ENG" },
4548 "version": { "number": 7 },
4549 "body": {
4550 "storage": {
4551 "value": "<p>Old</p>",
4552 "representation": "storage"
4553 }
4554 },
4555 "ancestors": [],
4556 "_links": {}
4557 }"#,
4558 );
4559 });
4560
4561 let client =
4562 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
4563 let error = client
4564 .update_page(UpdatePageParams {
4565 page_id: "42".into(),
4566 title: Some("ADR-001 Revised".into()),
4567 content: Some("<p>New</p>".into()),
4568 content_type: Some("storage".into()),
4569 version: Some(6),
4570 labels: None,
4571 parent_id: None,
4572 })
4573 .await
4574 .unwrap_err();
4575
4576 get_mock.assert();
4577 match error {
4578 Error::Api { status, message } => {
4579 assert_eq!(status, 409);
4580 assert!(message.contains("expected current version 6"));
4581 assert!(message.contains("found 7"));
4582 }
4583 other => panic!("expected conflict error, got {other:?}"),
4584 }
4585 }
4586
4587 #[tokio::test]
4588 async fn update_page_replaces_labels() {
4589 let server = MockServer::start();
4590 let get_mock = server.mock(|when, then| {
4591 when.method(GET).path("/rest/api/content/42").query_param(
4592 "expand",
4593 "space,version,body.storage,ancestors,metadata.labels",
4594 );
4595 then.status(200)
4596 .header("content-type", "application/json")
4597 .body(
4598 r#"{
4599 "id": "42",
4600 "title": "ADR-001",
4601 "space": { "key": "ENG" },
4602 "version": { "number": 7 },
4603 "body": {
4604 "storage": {
4605 "value": "<p>Old</p>",
4606 "representation": "storage"
4607 }
4608 },
4609 "metadata": {
4610 "labels": {
4611 "results": [
4612 { "name": "adr" },
4613 { "name": "obsolete" }
4614 ]
4615 }
4616 },
4617 "ancestors": [],
4618 "_links": {}
4619 }"#,
4620 );
4621 });
4622 let put_mock = server.mock(|when, then| {
4623 when.method(PUT)
4624 .path("/rest/api/content/42")
4625 .header("authorization", "Bearer secret-token")
4626 .header("content-type", "application/json");
4627 then.status(200)
4628 .header("content-type", "application/json")
4629 .body(
4630 r#"{
4631 "id": "42",
4632 "title": "ADR-001 Revised",
4633 "space": { "key": "ENG" },
4634 "version": { "number": 8 }
4635 }"#,
4636 );
4637 });
4638 let delete_mock = server.mock(|when, then| {
4639 when.method(httpmock::Method::DELETE)
4640 .path("/rest/api/content/42/label")
4641 .query_param("name", "obsolete")
4642 .header("authorization", "Bearer secret-token");
4643 then.status(204);
4644 });
4645 let add_mock = server.mock(|when, then| {
4646 when.method(POST)
4647 .path("/rest/api/content/42/label")
4648 .header("authorization", "Bearer secret-token")
4649 .header("content-type", "application/json")
4650 .json_body_obj(&serde_json::json!([
4651 { "prefix": "global", "name": "architecture" }
4652 ]));
4653 then.status(200)
4654 .header("content-type", "application/json")
4655 .body("[]");
4656 });
4657
4658 let client =
4659 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
4660 let page = client
4661 .update_page(UpdatePageParams {
4662 page_id: "42".into(),
4663 title: Some("ADR-001 Revised".into()),
4664 content: Some("<p>New</p>".into()),
4665 content_type: Some("storage".into()),
4666 version: Some(7),
4667 labels: Some(vec!["adr".into(), "architecture".into()]),
4668 parent_id: None,
4669 })
4670 .await
4671 .unwrap();
4672
4673 get_mock.assert();
4674 put_mock.assert();
4675 delete_mock.assert();
4676 add_mock.assert();
4677 assert_eq!(page.version, Some(8));
4678 }
4679
4680 #[test]
4681 fn storage_and_markdown_converters_cover_basic_formatting() {
4682 let markdown = confluence_storage_to_markdown(
4683 r#"<h2>ADR</h2><p>Hello <strong>world</strong> and <a href="https://example.com">link</a></p><ul><li>One</li><li>Two</li></ul>"#,
4684 );
4685 assert_eq!(
4686 markdown,
4687 "## ADR\n\nHello **world** and [link](https://example.com)\n\n- One\n- Two"
4688 );
4689
4690 let storage = markdown_to_confluence_storage(
4691 "## ADR\n\nHello **world** and [link](https://example.com)\n\n- One\n- Two",
4692 );
4693 assert_eq!(
4694 storage,
4695 "<h2>ADR</h2><p>Hello <strong>world</strong> and <a href=\"https://example.com\">link</a></p><ul><li>One</li><li>Two</li></ul>"
4696 );
4697 }
4698
4699 #[test]
4700 fn adf_and_markdown_converters_cover_basic_formatting() {
4701 let adf = markdown_to_adf("## ADR\n\nHello **world** and [link](https://example.com)");
4702 assert_eq!(adf["type"], "doc");
4703 assert_eq!(adf["content"][0]["type"], "heading");
4704 assert_eq!(adf["content"][1]["type"], "paragraph");
4705
4706 let markdown = adf_to_markdown(&json!({
4707 "type": "doc",
4708 "version": 1,
4709 "content": [
4710 {
4711 "type": "heading",
4712 "attrs": { "level": 2 },
4713 "content": [{ "type": "text", "text": "ADR" }]
4714 },
4715 {
4716 "type": "paragraph",
4717 "content": [
4718 { "type": "text", "text": "Hello " },
4719 { "type": "text", "text": "world", "marks": [{ "type": "strong" }] },
4720 { "type": "text", "text": " and " },
4721 { "type": "text", "text": "link", "marks": [{ "type": "link", "attrs": { "href": "https://example.com" } }] }
4722 ]
4723 }
4724 ]
4725 }));
4726 assert_eq!(
4727 markdown,
4728 "## ADR\n\nHello **world** and [link](https://example.com)"
4729 );
4730 }
4731
4732 #[test]
4733 fn markdown_code_blocks_escape_cdata_terminators() {
4734 let storage = markdown_to_confluence_storage("```xml\nbefore ]]> after\n```");
4735 assert!(storage.contains("<![CDATA[before ]]]]><![CDATA[> after"));
4736 }
4737
4738 #[tokio::test]
4739 async fn search_builds_free_text_cql_and_maps_results() {
4740 let server = MockServer::start();
4741 let mock = server.mock(|when, then| {
4742 when.method(GET)
4743 .path("/rest/api/content/search")
4744 .query_param("cql", "type = page AND space = \"ENG\" AND text ~ \"architecture\"")
4745 .query_param("limit", "10")
4746 .query_param("expand", "space,version,history.lastUpdated,body.view");
4747 then.status(200)
4748 .header("content-type", "application/json")
4749 .body(
4750 r#"{
4751 "results": [
4752 {
4753 "id": "99",
4754 "title": "Architecture Overview",
4755 "space": { "key": "ENG" },
4756 "version": {
4757 "number": 3,
4758 "when": "2026-04-26T10:00:00.000Z",
4759 "by": { "displayName": "Alice" }
4760 },
4761 "body": {
4762 "view": { "value": "<p>System architecture</p>", "representation": "view" }
4763 },
4764 "_links": { "base": "https://wiki.example.com", "webui": "/pages/viewpage.action?pageId=99" }
4765 }
4766 ],
4767 "start": 0,
4768 "limit": 10,
4769 "size": 1,
4770 "totalSize": 1,
4771 "_links": {}
4772 }"#,
4773 );
4774 });
4775
4776 let client =
4777 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
4778 let result = client
4779 .search(SearchKbParams {
4780 query: "architecture".into(),
4781 space_key: Some("ENG".into()),
4782 cursor: None,
4783 limit: Some(10),
4784 raw_query: false,
4785 })
4786 .await
4787 .unwrap();
4788
4789 mock.assert();
4790 assert_eq!(result.items.len(), 1);
4791 assert_eq!(result.items[0].id, "99");
4792 assert_eq!(result.items[0].title, "Architecture Overview");
4793 assert_eq!(result.items[0].space_key.as_deref(), Some("ENG"));
4794 }
4795
4796 #[tokio::test]
4797 async fn search_uses_cloud_legacy_rest_api_path() {
4798 let server = MockServer::start();
4799 let mock = server.mock(|when, then| {
4800 when.method(GET).path("/wiki/rest/api/content/search");
4801 then.status(200)
4802 .header("content-type", "application/json")
4803 .body(r#"{"results":[],"start":0,"limit":10,"size":0,"_links":{}}"#);
4804 });
4805
4806 let client =
4807 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"))
4808 .with_flavor(ConfluenceFlavor::Cloud);
4809 let result = client
4810 .search(SearchKbParams {
4811 query: "architecture".into(),
4812 space_key: None,
4813 cursor: None,
4814 limit: Some(10),
4815 raw_query: false,
4816 })
4817 .await
4818 .unwrap();
4819
4820 mock.assert();
4821 assert!(result.items.is_empty());
4822 }
4823
4824 #[test]
4825 fn storage_tables_render_as_markdown_instead_of_a_run_on_string() {
4826 let storage = "<table><tbody>\
4830 <tr><th>Name</th><th>Age</th></tr>\
4831 <tr><td>Alice</td><td>30</td></tr>\
4832 </tbody></table>";
4833
4834 let md = confluence_storage_to_markdown(storage);
4835 assert!(!md.contains("NameAge"), "cells ran together: {md}");
4836 assert!(md.contains("| Name | Age |"), "missing header row: {md}");
4837 assert!(md.contains("| --- | --- |"), "missing delimiter row: {md}");
4838 assert!(md.contains("| Alice | 30 |"), "missing body row: {md}");
4839 }
4840
4841 #[test]
4842 fn adf_tables_render_as_markdown_instead_of_a_run_on_string() {
4843 let adf = json!({
4844 "type": "doc",
4845 "content": [{
4846 "type": "table",
4847 "content": [
4848 { "type": "tableRow", "content": [
4849 { "type": "tableHeader", "content": [
4850 { "type": "paragraph", "content": [{ "type": "text", "text": "Name" }] }]},
4851 { "type": "tableHeader", "content": [
4852 { "type": "paragraph", "content": [{ "type": "text", "text": "Age" }] }]}
4853 ]},
4854 { "type": "tableRow", "content": [
4855 { "type": "tableCell", "content": [
4856 { "type": "paragraph", "content": [{ "type": "text", "text": "Alice" }] }]},
4857 { "type": "tableCell", "content": [
4858 { "type": "paragraph", "content": [{ "type": "text", "text": "30" }] }]}
4859 ]}
4860 ]
4861 }]
4862 });
4863
4864 let md = adf_to_markdown(&adf);
4865 assert!(md.contains("| Name | Age |"), "missing header row: {md}");
4867 assert!(md.contains("| --- | --- |"), "missing delimiter row: {md}");
4868 assert!(md.contains("| Alice | 30 |"), "missing body row: {md}");
4869 }
4870
4871 #[test]
4872 fn adf_table_cells_never_break_the_row_across_lines() {
4873 let adf = json!({
4874 "type": "doc",
4875 "content": [{ "type": "table", "content": [
4876 { "type": "tableRow", "content": [
4877 { "type": "tableCell", "content": [
4878 { "type": "paragraph", "content": [
4879 { "type": "text", "text": "one" },
4880 { "type": "hardBreak" },
4881 { "type": "text", "text": "two" }]}]},
4882 { "type": "tableCell", "content": [
4883 { "type": "bulletList", "content": [
4884 { "type": "listItem", "content": [
4885 { "type": "paragraph", "content": [{ "type": "text", "text": "a" }]}]},
4886 { "type": "listItem", "content": [
4887 { "type": "paragraph", "content": [{ "type": "text", "text": "b" }]}]}]}]}
4888 ]}
4889 ]}]
4890 });
4891
4892 let md = adf_to_markdown(&adf);
4893 for line in md.lines().filter(|l| l.starts_with('|')) {
4896 assert!(line.ends_with('|'), "row broken across lines: {md}");
4897 }
4898 assert!(md.contains("one<br>two"), "hardBreak not folded: {md}");
4899 }
4900
4901 #[test]
4902 fn adf_inline_nodes_with_attrs_payloads_are_not_dropped() {
4903 let adf = json!({
4904 "type": "doc",
4905 "content": [{ "type": "paragraph", "content": [
4906 { "type": "mention", "attrs": { "id": "u1", "text": "@Bob" } },
4907 { "type": "text", "text": ", see " },
4908 { "type": "inlineCard", "attrs": { "url": "https://example.com/x" } },
4909 { "type": "text", "text": " " },
4910 { "type": "status", "attrs": { "text": "DONE" } },
4911 { "type": "emoji", "attrs": { "shortName": ":tada:" } },
4912 { "type": "media", "attrs": { "alt": "diagram" } }
4913 ]}]
4914 });
4915
4916 let md = adf_to_markdown(&adf);
4917 for expected in [
4919 "@Bob",
4920 "https://example.com/x",
4921 "DONE",
4922 ":tada:",
4923 "[diagram]",
4924 ] {
4925 assert!(md.contains(expected), "{expected:?} was dropped from: {md}");
4926 }
4927 }
4928
4929 #[tokio::test]
4930 async fn resolve_space_by_key_follows_a_v2_cursor_on_the_v2_surface() {
4931 let server = MockServer::start();
4936 let page1 = server.mock(|when, then| {
4937 when.method(GET).path("/api/v2/space").query_param("limit", "100");
4938 then.status(200)
4939 .header("content-type", "application/json")
4940 .body(
4941 r#"{"results":[{"id":"1","key":"OTHER","name":"Other","type":"global","status":"current"}],"start":0,"limit":1,"size":1,"_links":{"next":"/api/v2/space?cursor=abc"}}"#,
4942 );
4943 });
4944 let page2 = server.mock(|when, then| {
4945 when.method(GET).path("/api/v2/space").query_param("cursor", "abc");
4946 then.status(200)
4947 .header("content-type", "application/json")
4948 .body(
4949 r#"{"results":[{"id":"2","key":"WANTED","name":"Wanted","type":"global","status":"current"}],"start":1,"limit":1,"size":1,"_links":{}}"#,
4950 );
4951 });
4952
4953 let client =
4954 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"))
4955 .with_flavor(ConfluenceFlavor::SelfHosted)
4956 .with_api_version(Some("v2"));
4957 let space = client.resolve_space_by_key("WANTED").await.unwrap();
4958
4959 assert_eq!(space.key, "WANTED");
4960 page1.assert();
4961 page2.assert();
4962 }
4963
4964 #[tokio::test]
4965 async fn search_follows_a_cloud_cursor_without_doubling_the_legacy_prefix() {
4966 let server = MockServer::start();
4971 let mock = server.mock(|when, then| {
4972 when.method(GET)
4973 .path("/wiki/rest/api/content/search")
4974 .query_param("start", "25");
4975 then.status(200)
4976 .header("content-type", "application/json")
4977 .body(r#"{"results":[],"start":25,"limit":25,"size":0,"_links":{}}"#);
4978 });
4979
4980 let client =
4981 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"))
4982 .with_flavor(ConfluenceFlavor::Cloud);
4983 let result = client
4984 .search(SearchKbParams {
4985 query: "architecture".into(),
4986 space_key: None,
4987 cursor: Some(
4988 "/wiki/rest/api/content/search?cql=text~%22architecture%22&limit=25&start=25"
4989 .into(),
4990 ),
4991 limit: Some(25),
4992 raw_query: false,
4993 })
4994 .await
4995 .unwrap();
4996
4997 mock.assert();
4998 assert!(result.items.is_empty());
4999 }
5000
5001 #[tokio::test]
5002 async fn search_uses_raw_cql_and_cursor_path() {
5003 let server = MockServer::start();
5004 let mock = server.mock(|when, then| {
5005 when.method(GET)
5006 .path("/rest/api/content/search")
5007 .query_param("cql", "label = \"adr\"")
5008 .query_param("limit", "5")
5009 .query_param("expand", "space,version,history.lastUpdated,body.view");
5010 then.status(200)
5011 .header("content-type", "application/json")
5012 .body(
5013 r#"{
5014 "results": [],
5015 "start": 0,
5016 "limit": 5,
5017 "size": 0,
5018 "totalSize": 6,
5019 "_links": { "next": "/rest/api/content/search?cql=label%20%3D%20%22adr%22&limit=5&start=5" }
5020 }"#,
5021 );
5022 });
5023 let next_mock = server.mock(|when, then| {
5024 when.method(GET)
5025 .path("/rest/api/content/search")
5026 .query_param("cql", "label = \"adr\"")
5027 .query_param("limit", "5")
5028 .query_param("start", "5");
5029 then.status(200)
5030 .header("content-type", "application/json")
5031 .body(
5032 r#"{
5033 "results": [
5034 {
5035 "id": "123",
5036 "title": "ADR-123",
5037 "space": { "key": "ENG" },
5038 "_links": { "base": "https://wiki.example.com", "webui": "/pages/viewpage.action?pageId=123" }
5039 }
5040 ],
5041 "start": 5,
5042 "limit": 5,
5043 "size": 1,
5044 "totalSize": 6,
5045 "_links": {}
5046 }"#,
5047 );
5048 });
5049
5050 let client =
5051 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
5052 let first = client
5053 .search(SearchKbParams {
5054 query: r#"label = "adr""#.into(),
5055 space_key: None,
5056 cursor: None,
5057 limit: Some(5),
5058 raw_query: true,
5059 })
5060 .await
5061 .unwrap();
5062 let next_cursor = first
5063 .pagination
5064 .as_ref()
5065 .and_then(|p| p.next_cursor.clone());
5066
5067 mock.assert();
5068 assert!(first.items.is_empty());
5069 assert_eq!(
5070 next_cursor.as_deref(),
5071 Some("/rest/api/content/search?cql=label%20%3D%20%22adr%22&limit=5&start=5")
5072 );
5073
5074 let second = client
5075 .search(SearchKbParams {
5076 query: String::new(),
5077 space_key: None,
5078 cursor: next_cursor,
5079 limit: Some(5),
5080 raw_query: true,
5081 })
5082 .await
5083 .unwrap();
5084
5085 next_mock.assert();
5086 assert_eq!(second.items.len(), 1);
5087 assert_eq!(second.items[0].id, "123");
5088 assert_eq!(second.items[0].title, "ADR-123");
5089 }
5090
5091 #[tokio::test]
5092 async fn search_percent_encodes_reserved_query_characters() {
5093 let server = MockServer::start();
5094 let mock = server.mock(|when, then| {
5095 when.method(GET)
5096 .path("/rest/api/content/search")
5097 .query_param("cql", "type = page AND text ~ \"R&D?x=y+z\"")
5098 .query_param("limit", "5")
5099 .query_param("expand", "space,version,history.lastUpdated,body.view");
5100 then.status(200)
5101 .header("content-type", "application/json")
5102 .body(r#"{"results":[],"start":0,"limit":5,"size":0,"_links":{}}"#);
5103 });
5104
5105 let client =
5106 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
5107 let result = client
5108 .search(SearchKbParams {
5109 query: "R&D?x=y+z".into(),
5110 space_key: None,
5111 cursor: None,
5112 limit: Some(5),
5113 raw_query: false,
5114 })
5115 .await
5116 .unwrap();
5117
5118 mock.assert();
5119 assert!(result.items.is_empty());
5120 }
5121}