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;
15
16use crate::DEFAULT_CONFLUENCE_API_PATH;
17
18#[derive(Clone)]
19pub enum ConfluenceAuth {
20 None,
21 BearerToken(SecretString),
22 Basic {
23 username: String,
24 password: SecretString,
25 },
26}
27
28impl fmt::Debug for ConfluenceAuth {
29 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30 match self {
31 Self::None => f.write_str("None"),
32 Self::BearerToken(_) => f.debug_tuple("BearerToken").field(&"<redacted>").finish(),
33 Self::Basic { username, .. } => f
34 .debug_struct("Basic")
35 .field("username", username)
36 .field("password", &"<redacted>")
37 .finish(),
38 }
39 }
40}
41
42impl ConfluenceAuth {
43 pub fn bearer(token: impl Into<String>) -> Self {
45 Self::BearerToken(SecretString::from(token.into()))
46 }
47
48 pub fn basic(username: impl Into<String>, password: impl Into<String>) -> Self {
50 Self::Basic {
51 username: username.into(),
52 password: SecretString::from(password.into()),
53 }
54 }
55}
56
57#[derive(Clone)]
58pub struct ConfluenceClient {
59 base_url: String,
60 api_path: String,
61 page_api_path: String,
62 space_api_path: String,
63 auth: ConfluenceAuth,
64 proxy_headers: Option<HashMap<String, String>>,
65 http: reqwest::Client,
66}
67
68impl fmt::Debug for ConfluenceClient {
69 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70 f.debug_struct("ConfluenceClient")
71 .field("base_url", &self.base_url)
72 .field("api_path", &self.api_path)
73 .field("page_api_path", &self.page_api_path)
74 .field("space_api_path", &self.space_api_path)
75 .field("auth", &self.auth)
76 .field("http", &self.http)
77 .finish()
78 }
79}
80
81impl ConfluenceClient {
82 pub fn new(base_url: impl Into<String>, auth: ConfluenceAuth) -> Self {
83 Self {
84 base_url: normalize_base_url(base_url.into()),
85 api_path: DEFAULT_CONFLUENCE_API_PATH.to_string(),
86 page_api_path: DEFAULT_CONFLUENCE_API_PATH.to_string(),
87 space_api_path: DEFAULT_CONFLUENCE_API_PATH.to_string(),
88 auth,
89 proxy_headers: None,
90 http: reqwest::Client::new(),
91 }
92 }
93
94 pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
95 self.http = http;
96 self
97 }
98
99 pub fn base_url(&self) -> &str {
100 &self.base_url
101 }
102
103 pub fn auth(&self) -> &ConfluenceAuth {
104 &self.auth
105 }
106
107 pub fn with_api_version(mut self, api_version: Option<&str>) -> Self {
108 self.page_api_path = api_path_for_version(api_version);
109 self.space_api_path = api_path_for_version(api_version);
110 self
111 }
112
113 pub fn with_proxy(mut self, headers: HashMap<String, String>) -> Self {
116 self.proxy_headers = Some(headers);
117 self
118 }
119
120 pub fn rest_api_url(&self, path: &str) -> String {
121 self.api_url(&self.api_path, path)
122 }
123
124 fn api_url(&self, api_path: &str, path: &str) -> String {
125 let path = path.trim_start_matches('/');
126 format!("{}{}/{}", self.base_url, api_path, path)
127 }
128
129 #[cfg(test)]
130 fn space_api_url(&self, path: &str) -> String {
131 self.api_url(&self.space_api_path, path)
132 }
133
134 pub async fn get_json<T>(&self, path: &str) -> Result<T>
135 where
136 T: DeserializeOwned,
137 {
138 let request = self
139 .http
140 .get(self.rest_api_url(path))
141 .header(reqwest::header::ACCEPT, "application/json");
142 self.send_json(request).await
143 }
144
145 async fn get_json_from_api<T>(&self, api_path: &str, path: &str) -> Result<T>
146 where
147 T: DeserializeOwned,
148 {
149 let request = self
150 .http
151 .get(self.api_url(api_path, path))
152 .header(reqwest::header::ACCEPT, "application/json");
153 self.send_json(request).await
154 }
155
156 async fn post_json_to_api<T, B>(&self, api_path: &str, path: &str, body: &B) -> Result<T>
157 where
158 T: DeserializeOwned,
159 B: Serialize + ?Sized,
160 {
161 let request = self
162 .http
163 .post(self.api_url(api_path, path))
164 .header(reqwest::header::ACCEPT, "application/json")
165 .header(reqwest::header::CONTENT_TYPE, "application/json")
166 .json(body);
167 self.send_json(request).await
168 }
169
170 async fn put_json_to_api<T, B>(&self, api_path: &str, path: &str, body: &B) -> Result<T>
171 where
172 T: DeserializeOwned,
173 B: Serialize + ?Sized,
174 {
175 let request = self
176 .http
177 .put(self.api_url(api_path, path))
178 .header(reqwest::header::ACCEPT, "application/json")
179 .header(reqwest::header::CONTENT_TYPE, "application/json")
180 .json(body);
181 self.send_json(request).await
182 }
183
184 pub async fn post_json<T, B>(&self, path: &str, body: &B) -> Result<T>
185 where
186 T: DeserializeOwned,
187 B: Serialize + ?Sized,
188 {
189 let request = self
190 .http
191 .post(self.rest_api_url(path))
192 .header(reqwest::header::ACCEPT, "application/json")
193 .header(reqwest::header::CONTENT_TYPE, "application/json")
194 .json(body);
195 self.send_json(request).await
196 }
197
198 pub async fn put_json<T, B>(&self, path: &str, body: &B) -> Result<T>
199 where
200 T: DeserializeOwned,
201 B: Serialize + ?Sized,
202 {
203 let request = self
204 .http
205 .put(self.rest_api_url(path))
206 .header(reqwest::header::ACCEPT, "application/json")
207 .header(reqwest::header::CONTENT_TYPE, "application/json")
208 .json(body);
209 self.send_json(request).await
210 }
211
212 pub async fn post_empty_json<B>(&self, path: &str, body: &B) -> Result<()>
213 where
214 B: Serialize + ?Sized,
215 {
216 let request = self
217 .http
218 .post(self.rest_api_url(path))
219 .header(reqwest::header::ACCEPT, "application/json")
220 .header(reqwest::header::CONTENT_TYPE, "application/json")
221 .json(body);
222 self.send_empty(request).await
223 }
224
225 pub async fn delete_empty(&self, path: &str) -> Result<()> {
226 let request = self
227 .http
228 .delete(self.rest_api_url(path))
229 .header(reqwest::header::ACCEPT, "application/json");
230 self.send_empty(request).await
231 }
232
233 async fn send_json<T>(&self, request: RequestBuilder) -> Result<T>
234 where
235 T: DeserializeOwned,
236 {
237 let response = self
238 .apply_auth(request)
239 .send()
240 .await
241 .map_err(|e| Error::Network(e.to_string()))?;
242
243 let status = response.status();
244 if !status.is_success() {
245 let message = response.text().await.unwrap_or_default();
246 return Err(Error::from_status(status.as_u16(), message));
247 }
248
249 response
250 .json()
251 .await
252 .map_err(|e| Error::InvalidData(e.to_string()))
253 }
254
255 async fn send_empty(&self, request: RequestBuilder) -> Result<()> {
256 let response = self
257 .apply_auth(request)
258 .send()
259 .await
260 .map_err(|e| Error::Network(e.to_string()))?;
261
262 let status = response.status();
263 if !status.is_success() {
264 let message = response.text().await.unwrap_or_default();
265 return Err(Error::from_status(status.as_u16(), message));
266 }
267
268 Ok(())
269 }
270
271 fn apply_auth(&self, request: RequestBuilder) -> RequestBuilder {
272 if let Some(headers) = &self.proxy_headers {
273 return request.headers(proxy_headers_to_headermap(headers));
274 }
275
276 match &self.auth {
277 ConfluenceAuth::None => request,
278 ConfluenceAuth::BearerToken(token) => request.bearer_auth(token.expose_secret()),
279 ConfluenceAuth::Basic { username, password } => {
280 request.basic_auth(username, Some(password.expose_secret()))
281 }
282 }
283 }
284}
285
286fn should_fallback_to_rest_api(error: &Error) -> bool {
287 matches!(
288 error,
289 Error::NotFound(_)
290 | Error::Api {
291 status: 400 | 404 | 405,
292 ..
293 }
294 )
295}
296
297fn uses_v2_api(api_path: &str) -> bool {
298 api_path == "/api/v2"
299}
300
301fn proxy_headers_to_headermap(headers: &HashMap<String, String>) -> HeaderMap {
302 let mut map = HeaderMap::new();
303 for (key, value) in headers {
304 if let (Ok(name), Ok(value)) = (
305 HeaderName::try_from(key.as_str()),
306 HeaderValue::try_from(value.as_str()),
307 ) {
308 map.insert(name, value);
309 }
310 }
311 map
312}
313
314fn normalize_base_url(base_url: String) -> String {
315 base_url.trim_end_matches('/').to_string()
316}
317
318fn api_path_for_version(api_version: Option<&str>) -> String {
319 match api_version.map(str::trim).filter(|v| !v.is_empty()) {
320 Some("v2") => "/api/v2".to_string(),
321 _ => DEFAULT_CONFLUENCE_API_PATH.to_string(),
322 }
323}
324
325#[derive(Debug, Deserialize)]
326#[serde(bound(deserialize = "T: Deserialize<'de>"))]
327struct ConfluenceListResponse<T> {
328 #[serde(default)]
329 results: Vec<T>,
330 #[serde(default)]
331 start: Option<u32>,
332 #[serde(default)]
333 limit: Option<u32>,
334 #[serde(default)]
335 size: Option<u32>,
336 #[serde(default, rename = "totalSize")]
337 total_size: Option<u32>,
338 #[serde(default)]
339 _links: ConfluenceLinks,
340}
341
342#[derive(Debug, Clone, Default, Deserialize)]
343struct ConfluenceLinks {
344 #[serde(default)]
345 base: Option<String>,
346 #[serde(default)]
347 webui: Option<String>,
348 #[serde(default)]
349 next: Option<String>,
350}
351
352#[derive(Debug, Deserialize)]
353struct ConfluenceSpace {
354 id: String,
355 key: String,
356 name: String,
357 #[serde(rename = "type", default)]
358 space_type: Option<String>,
359 #[serde(default)]
360 status: Option<String>,
361 #[serde(default)]
362 description: Option<ConfluenceSpaceDescription>,
363 #[serde(default)]
364 _links: ConfluenceLinks,
365}
366
367#[derive(Debug, Deserialize)]
368struct ConfluenceSpaceDescription {
369 #[serde(default)]
370 plain: Option<ConfluenceValueContainer>,
371 #[serde(default)]
372 view: Option<ConfluenceValueContainer>,
373}
374
375#[derive(Debug, Deserialize)]
376struct ConfluenceValueContainer {
377 #[serde(default)]
378 value: Option<String>,
379}
380
381#[derive(Debug, Clone, Deserialize)]
382struct ConfluencePage {
383 id: String,
384 title: String,
385 #[serde(default)]
386 space: Option<ConfluenceSpaceRef>,
387 #[serde(default, rename = "spaceId")]
388 space_id: Option<String>,
389 #[serde(default, rename = "parentId")]
390 parent_id: Option<String>,
391 #[serde(default)]
392 version: Option<ConfluenceVersion>,
393 #[serde(default)]
394 history: Option<ConfluenceHistory>,
395 #[serde(default)]
396 body: Option<ConfluenceBody>,
397 #[serde(default)]
398 metadata: Option<ConfluenceMetadata>,
399 #[serde(default)]
400 labels: Option<ConfluenceLabelList>,
401 #[serde(default)]
402 ancestors: Vec<ConfluenceAncestor>,
403 #[serde(default)]
404 _links: ConfluenceLinks,
405}
406
407#[derive(Debug, Clone, Deserialize)]
408struct ConfluenceSpaceRef {
409 #[serde(default)]
410 id: Option<String>,
411 #[serde(default)]
412 key: Option<String>,
413}
414
415#[derive(Debug, Clone, Deserialize)]
416struct ConfluenceVersion {
417 #[serde(default)]
418 number: Option<u32>,
419 #[serde(default, rename = "createdAt")]
420 created_at: Option<String>,
421 #[serde(default)]
422 when: Option<String>,
423 #[serde(default)]
424 by: Option<ConfluenceUser>,
425}
426
427#[derive(Debug, Clone, Deserialize)]
428struct ConfluenceHistory {
429 #[serde(default, rename = "lastUpdated")]
430 last_updated: Option<ConfluenceVersion>,
431 #[serde(default, rename = "createdBy")]
432 created_by: Option<ConfluenceUser>,
433}
434
435#[derive(Debug, Clone, Deserialize)]
436struct ConfluenceUser {
437 #[serde(default, rename = "displayName")]
438 display_name: Option<String>,
439 #[serde(default)]
440 username: Option<String>,
441 #[serde(default, rename = "accountId")]
442 account_id: Option<String>,
443}
444
445#[derive(Debug, Clone, Deserialize)]
446struct ConfluenceBody {
447 #[serde(default)]
448 storage: Option<ConfluenceBodyValue>,
449 #[serde(default)]
450 view: Option<ConfluenceBodyValue>,
451 #[serde(default)]
452 value: Option<String>,
453}
454
455#[derive(Debug, Clone, Deserialize)]
456struct ConfluenceBodyValue {
457 #[serde(default)]
458 value: Option<String>,
459}
460
461#[derive(Debug, Clone, Deserialize)]
462struct ConfluenceMetadata {
463 #[serde(default)]
464 labels: Option<ConfluenceLabelList>,
465}
466
467#[derive(Debug, Clone, Deserialize)]
468struct ConfluenceLabelList {
469 #[serde(default)]
470 results: Vec<ConfluenceLabel>,
471}
472
473#[derive(Debug, Clone, Deserialize)]
474struct ConfluenceLabel {
475 #[serde(default)]
476 name: Option<String>,
477 #[serde(default)]
478 label: Option<String>,
479}
480
481#[derive(Debug, Serialize)]
482struct ConfluenceWriteLabel<'a> {
483 prefix: &'static str,
484 name: &'a str,
485}
486
487#[derive(Debug, Clone, Deserialize)]
488struct ConfluenceAncestor {
489 id: String,
490 #[serde(default)]
491 title: String,
492 #[serde(default)]
493 _links: ConfluenceLinks,
494}
495
496#[derive(Debug, Serialize)]
497struct ConfluenceContentBody<'a> {
498 value: &'a str,
499 representation: &'static str,
500}
501
502#[derive(Debug, Serialize)]
503struct ConfluenceContentPayload<'a> {
504 #[serde(rename = "type")]
505 content_type: &'static str,
506 title: &'a str,
507 space: ConfluenceCreateSpaceRef<'a>,
508 body: ConfluenceCreateBodyPayload<'a>,
509 #[serde(skip_serializing_if = "Vec::is_empty")]
510 ancestors: Vec<ConfluenceCreateAncestorRef<'a>>,
511}
512
513#[derive(Debug, Serialize)]
514struct ConfluenceCreateSpaceRef<'a> {
515 key: &'a str,
516}
517
518#[derive(Debug, Serialize)]
519struct ConfluenceCreateBodyPayload<'a> {
520 storage: ConfluenceContentBody<'a>,
521}
522
523#[derive(Debug, Serialize)]
524struct ConfluenceCreateAncestorRef<'a> {
525 id: &'a str,
526}
527
528#[derive(Debug, Serialize)]
529struct ConfluenceUpdatePayload<'a> {
530 id: &'a str,
531 #[serde(rename = "type")]
532 content_type: &'static str,
533 title: &'a str,
534 version: ConfluenceUpdateVersion,
535 body: ConfluenceCreateBodyPayload<'a>,
536 #[serde(skip_serializing_if = "Option::is_none")]
537 ancestors: Option<Vec<ConfluenceCreateAncestorRef<'a>>>,
538}
539
540#[derive(Debug, Serialize)]
541struct ConfluenceUpdateVersion {
542 number: u32,
543}
544
545#[derive(Debug, Serialize)]
546struct ConfluenceV2PagePayload<'a> {
547 #[serde(rename = "spaceId")]
548 space_id: &'a str,
549 status: &'static str,
550 title: &'a str,
551 #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
552 parent_id: Option<&'a str>,
553 body: ConfluenceContentBody<'a>,
554}
555
556#[derive(Debug, Serialize)]
557struct ConfluenceV2UpdatePayload<'a> {
558 id: &'a str,
559 status: &'static str,
560 title: &'a str,
561 #[serde(rename = "spaceId")]
562 space_id: &'a str,
563 #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
564 parent_id: Option<&'a str>,
565 body: ConfluenceContentBody<'a>,
566 version: ConfluenceUpdateVersion,
567}
568
569fn join_link(base_url: &str, base_hint: Option<&str>, path: Option<&str>) -> Option<String> {
570 let path = path?;
571 if path.starts_with("http://") || path.starts_with("https://") {
572 return Some(path.to_string());
573 }
574 let base = base_hint.unwrap_or(base_url).trim_end_matches('/');
575 if path.starts_with('/') {
576 Some(format!("{base}{path}"))
577 } else {
578 Some(format!("{base}/{path}"))
579 }
580}
581
582fn display_name(user: Option<&ConfluenceUser>) -> Option<String> {
583 user.and_then(|u| {
584 u.display_name
585 .clone()
586 .or_else(|| u.username.clone())
587 .or_else(|| u.account_id.clone())
588 })
589}
590
591fn body_value(body: &ConfluenceBody) -> Option<String> {
592 body.view
593 .as_ref()
594 .and_then(|value| value.value.clone())
595 .or_else(|| body.storage.as_ref().and_then(|value| value.value.clone()))
596 .or_else(|| body.value.clone())
597}
598
599fn extract_labels(page: &ConfluencePage) -> Vec<String> {
600 page.labels
601 .as_ref()
602 .or_else(|| {
603 page.metadata
604 .as_ref()
605 .and_then(|metadata| metadata.labels.as_ref())
606 })
607 .map(|labels| {
608 labels
609 .results
610 .iter()
611 .filter_map(|label| label.name.clone().or_else(|| label.label.clone()))
612 .collect::<Vec<_>>()
613 })
614 .unwrap_or_default()
615}
616
617fn normalize_labels(labels: &[String]) -> Vec<String> {
618 let mut out = Vec::new();
619 for label in labels {
620 let trimmed = label.trim();
621 if trimmed.is_empty() {
622 continue;
623 }
624 if !out.iter().any(|existing| existing == trimmed) {
625 out.push(trimmed.to_string());
626 }
627 }
628 out
629}
630
631fn page_excerpt(page: &ConfluencePage) -> Option<String> {
632 page.body
633 .as_ref()
634 .and_then(body_value)
635 .map(|value| truncate_string(strip_html_tags(&value), 280))
636 .filter(|value| !value.is_empty())
637}
638
639fn strip_html_tags(input: &str) -> String {
640 let mut out = String::with_capacity(input.len());
641 let mut in_tag = false;
642 for ch in input.chars() {
643 match ch {
644 '<' => in_tag = true,
645 '>' => in_tag = false,
646 _ if !in_tag => out.push(ch),
647 _ => {}
648 }
649 }
650 out.split_whitespace().collect::<Vec<_>>().join(" ")
651}
652
653fn strip_html_tags_preserve_layout(input: &str) -> String {
654 let mut out = String::with_capacity(input.len());
655 let mut in_tag = false;
656 for ch in input.chars() {
657 match ch {
658 '<' => in_tag = true,
659 '>' => in_tag = false,
660 _ if !in_tag => out.push(ch),
661 _ => {}
662 }
663 }
664 out
665}
666
667fn truncate_string(input: String, max_chars: usize) -> String {
668 if input.chars().count() <= max_chars {
669 return input;
670 }
671 input.chars().take(max_chars).collect::<String>()
672}
673
674fn normalize_confluence_write_content(content: &str, content_type: Option<&str>) -> Result<String> {
675 match content_type
676 .map(str::trim)
677 .filter(|value| !value.is_empty())
678 .unwrap_or("markdown")
679 {
680 "markdown" => Ok(markdown_to_confluence_storage(content)),
681 "html" => Ok(html_to_confluence_storage(content)),
682 "storage" => Ok(content.to_string()),
683 other => Err(Error::InvalidData(format!(
684 "unsupported confluence content_type '{other}', expected markdown, html, or storage"
685 ))),
686 }
687}
688
689fn html_to_confluence_storage(content: &str) -> String {
690 let trimmed = content.trim();
691 if trimmed.is_empty() {
692 return String::new();
693 }
694 if trimmed.contains('<') && trimmed.contains('>') {
695 trimmed.to_string()
696 } else {
697 format!("<p>{}</p>", escape_html(trimmed))
698 }
699}
700
701fn markdown_to_confluence_storage(markdown: &str) -> String {
702 let markdown = markdown.replace("\r\n", "\n");
703 let mut out = String::new();
704 let mut paragraph: Vec<String> = Vec::new();
705 let mut in_ul = false;
706 let mut in_ol = false;
707 let mut lines = markdown.lines().peekable();
708
709 let flush_paragraph = |out: &mut String, paragraph: &mut Vec<String>| {
710 if paragraph.is_empty() {
711 return;
712 }
713 let text = paragraph.join(" ");
714 out.push_str("<p>");
715 out.push_str(&markdown_inline_to_html(&text));
716 out.push_str("</p>");
717 paragraph.clear();
718 };
719
720 let close_lists = |out: &mut String, in_ul: &mut bool, in_ol: &mut bool| {
721 if *in_ul {
722 out.push_str("</ul>");
723 *in_ul = false;
724 }
725 if *in_ol {
726 out.push_str("</ol>");
727 *in_ol = false;
728 }
729 };
730
731 while let Some(line) = lines.next() {
732 let trimmed = line.trim();
733
734 if trimmed.starts_with("```") {
735 flush_paragraph(&mut out, &mut paragraph);
736 close_lists(&mut out, &mut in_ul, &mut in_ol);
737
738 let mut code_lines = Vec::new();
739 for code_line in lines.by_ref() {
740 if code_line.trim_start().starts_with("```") {
741 break;
742 }
743 code_lines.push(code_line);
744 }
745
746 let code_content = code_lines.join("\n").replace("]]>", "]]]]><![CDATA[>");
747 out.push_str(r#"<ac:structured-macro ac:name="code"><ac:plain-text-body><![CDATA["#);
748 out.push_str(&code_content);
749 out.push_str("]]></ac:plain-text-body></ac:structured-macro>");
750 continue;
751 }
752
753 if trimmed.is_empty() {
754 flush_paragraph(&mut out, &mut paragraph);
755 close_lists(&mut out, &mut in_ul, &mut in_ol);
756 continue;
757 }
758
759 if let Some((level, title)) = parse_markdown_heading(trimmed) {
760 flush_paragraph(&mut out, &mut paragraph);
761 close_lists(&mut out, &mut in_ul, &mut in_ol);
762 out.push_str(&format!(
763 "<h{level}>{}</h{level}>",
764 markdown_inline_to_html(title)
765 ));
766 continue;
767 }
768
769 if let Some(item) = parse_unordered_list_item(trimmed) {
770 flush_paragraph(&mut out, &mut paragraph);
771 if in_ol {
772 out.push_str("</ol>");
773 in_ol = false;
774 }
775 if !in_ul {
776 out.push_str("<ul>");
777 in_ul = true;
778 }
779 out.push_str("<li>");
780 out.push_str(&markdown_inline_to_html(item));
781 out.push_str("</li>");
782 continue;
783 }
784
785 if let Some(item) = parse_ordered_list_item(trimmed) {
786 flush_paragraph(&mut out, &mut paragraph);
787 if in_ul {
788 out.push_str("</ul>");
789 in_ul = false;
790 }
791 if !in_ol {
792 out.push_str("<ol>");
793 in_ol = true;
794 }
795 out.push_str("<li>");
796 out.push_str(&markdown_inline_to_html(item));
797 out.push_str("</li>");
798 continue;
799 }
800
801 close_lists(&mut out, &mut in_ul, &mut in_ol);
802 paragraph.push(trimmed.to_string());
803 }
804
805 flush_paragraph(&mut out, &mut paragraph);
806 close_lists(&mut out, &mut in_ul, &mut in_ol);
807 out
808}
809
810fn parse_markdown_heading(line: &str) -> Option<(usize, &str)> {
811 let hashes = line.chars().take_while(|&ch| ch == '#').count();
812 if !(1..=6).contains(&hashes) {
813 return None;
814 }
815 let rest = line.get(hashes..)?.trim_start();
816 if rest.is_empty() {
817 return None;
818 }
819 Some((hashes, rest))
820}
821
822fn parse_unordered_list_item(line: &str) -> Option<&str> {
823 line.strip_prefix("- ")
824 .or_else(|| line.strip_prefix("* "))
825 .map(str::trim)
826}
827
828fn parse_ordered_list_item(line: &str) -> Option<&str> {
829 let digits = line.chars().take_while(|ch| ch.is_ascii_digit()).count();
830 if digits == 0 {
831 return None;
832 }
833 let rest = line.get(digits..)?;
834 rest.strip_prefix(". ").map(str::trim)
835}
836
837fn markdown_inline_to_html(input: &str) -> String {
838 let escaped = escape_html(input);
839 let linked = replace_markdown_links(&escaped);
840 let code = replace_inline_delimited(&linked, "`", "<code>", "</code>");
841 let bold = replace_inline_delimited(&code, "**", "<strong>", "</strong>");
842 replace_inline_delimited(&bold, "*", "<em>", "</em>")
843}
844
845fn replace_markdown_links(input: &str) -> String {
846 let mut out = String::with_capacity(input.len());
847 let mut cursor = 0usize;
848
849 while let Some(start_rel) = input[cursor..].find('[') {
850 let start = cursor + start_rel;
851 out.push_str(&input[cursor..start]);
852
853 let Some(text_end_rel) = input[start + 1..].find(']') else {
854 out.push_str(&input[start..]);
855 return out;
856 };
857 let text_end = start + 1 + text_end_rel;
858 let after_bracket = text_end + 1;
859 if !input[after_bracket..].starts_with('(') {
860 out.push('[');
861 cursor = start + 1;
862 continue;
863 }
864
865 let Some(url_end_rel) = input[after_bracket + 1..].find(')') else {
866 out.push_str(&input[start..]);
867 return out;
868 };
869 let url_end = after_bracket + 1 + url_end_rel;
870 let text = &input[start + 1..text_end];
871 let url = &input[after_bracket + 1..url_end];
872 out.push_str(&format!(r#"<a href="{url}">{text}</a>"#));
873 cursor = url_end + 1;
874 }
875
876 out.push_str(&input[cursor..]);
877 out
878}
879
880fn replace_inline_delimited(input: &str, delimiter: &str, open: &str, close: &str) -> String {
881 let mut out = String::with_capacity(input.len());
882 let mut cursor = 0usize;
883 let mut is_open = false;
884
885 while let Some(found_rel) = input[cursor..].find(delimiter) {
886 let found = cursor + found_rel;
887 out.push_str(&input[cursor..found]);
888 if is_open {
889 out.push_str(close);
890 } else {
891 out.push_str(open);
892 }
893 is_open = !is_open;
894 cursor = found + delimiter.len();
895 }
896
897 out.push_str(&input[cursor..]);
898 if is_open && let Some(position) = out.rfind(open) {
899 out.replace_range(position..position + open.len(), delimiter);
900 out.push_str(delimiter);
901 }
902 out
903}
904
905fn confluence_storage_to_markdown(storage: &str) -> String {
906 let with_code_blocks = replace_confluence_code_macros(storage);
907 let with_links = replace_anchor_tags(&with_code_blocks);
908 let with_formatting = replace_paired_tag(
909 &replace_paired_tag(
910 &replace_paired_tag(
911 &replace_paired_tag(&with_links, "strong", "**", "**"),
912 "b",
913 "**",
914 "**",
915 ),
916 "em",
917 "*",
918 "*",
919 ),
920 "i",
921 "*",
922 "*",
923 );
924 let with_inline_code = replace_paired_tag(&with_formatting, "code", "`", "`");
925 let markdownish = with_inline_code
926 .replace("<br />", "\n")
927 .replace("<br/>", "\n")
928 .replace("<br>", "\n")
929 .replace("<p>", "")
930 .replace("</p>", "\n\n")
931 .replace("<div>", "")
932 .replace("</div>", "\n\n")
933 .replace("<ul>", "")
934 .replace("</ul>", "\n")
935 .replace("<ol>", "")
936 .replace("</ol>", "\n")
937 .replace("<li>", "- ")
938 .replace("</li>", "\n")
939 .replace("<h1>", "# ")
940 .replace("</h1>", "\n\n")
941 .replace("<h2>", "## ")
942 .replace("</h2>", "\n\n")
943 .replace("<h3>", "### ")
944 .replace("</h3>", "\n\n")
945 .replace("<h4>", "#### ")
946 .replace("</h4>", "\n\n")
947 .replace("<h5>", "##### ")
948 .replace("</h5>", "\n\n")
949 .replace("<h6>", "###### ")
950 .replace("</h6>", "\n\n");
951
952 let text = strip_html_tags_preserve_layout(&markdownish);
953 collapse_markdown_whitespace(&decode_html_entities(&text))
954}
955
956fn replace_confluence_code_macros(input: &str) -> String {
957 let mut out = String::new();
958 let mut cursor = 0usize;
959 let macro_start = r#"<ac:structured-macro ac:name="code">"#;
960 let body_start = "<ac:plain-text-body><![CDATA[";
961 let body_end = "]]></ac:plain-text-body>";
962 let macro_end = "</ac:structured-macro>";
963
964 while let Some(start_rel) = input[cursor..].find(macro_start) {
965 let start = cursor + start_rel;
966 out.push_str(&input[cursor..start]);
967 let Some(code_start_rel) = input[start..].find(body_start) else {
968 out.push_str(&input[start..]);
969 return out;
970 };
971 let code_start = start + code_start_rel + body_start.len();
972 let Some(code_end_rel) = input[code_start..].find(body_end) else {
973 out.push_str(&input[start..]);
974 return out;
975 };
976 let code_end = code_start + code_end_rel;
977 let Some(macro_end_rel) = input[code_end..].find(macro_end) else {
978 out.push_str(&input[start..]);
979 return out;
980 };
981 let end = code_end + macro_end_rel + macro_end.len();
982 let code = &input[code_start..code_end];
983 out.push_str("```");
984 out.push('\n');
985 out.push_str(code);
986 out.push('\n');
987 out.push_str("```");
988 cursor = end;
989 }
990
991 out.push_str(&input[cursor..]);
992 out
993}
994
995fn replace_anchor_tags(input: &str) -> String {
996 let mut out = String::new();
997 let mut cursor = 0usize;
998
999 while let Some(start_rel) = input[cursor..].find("<a ") {
1000 let start = cursor + start_rel;
1001 out.push_str(&input[cursor..start]);
1002 let Some(tag_end_rel) = input[start..].find('>') else {
1003 out.push_str(&input[start..]);
1004 return out;
1005 };
1006 let tag_end = start + tag_end_rel;
1007 let tag = &input[start..=tag_end];
1008 let Some(close_rel) = input[tag_end + 1..].find("</a>") else {
1009 out.push_str(&input[start..]);
1010 return out;
1011 };
1012 let close = tag_end + 1 + close_rel;
1013 let label = &input[tag_end + 1..close];
1014 let href = extract_attribute(tag, "href").unwrap_or_default();
1015 out.push('[');
1016 out.push_str(label);
1017 out.push_str("](");
1018 out.push_str(&href);
1019 out.push(')');
1020 cursor = close + "</a>".len();
1021 }
1022
1023 out.push_str(&input[cursor..]);
1024 out
1025}
1026
1027fn replace_paired_tag(input: &str, tag: &str, open: &str, close: &str) -> String {
1028 input
1029 .replace(&format!("<{tag}>"), open)
1030 .replace(&format!("</{tag}>"), close)
1031}
1032
1033fn extract_attribute(tag: &str, attr: &str) -> Option<String> {
1034 let needle = format!(r#"{attr}=""#);
1035 let start = tag.find(&needle)? + needle.len();
1036 let rest = tag.get(start..)?;
1037 let end = rest.find('"')?;
1038 Some(rest[..end].to_string())
1039}
1040
1041fn collapse_markdown_whitespace(input: &str) -> String {
1042 let mut normalized = Vec::new();
1043 let mut previous_blank = false;
1044
1045 for line in input.lines() {
1046 let trimmed = line.trim();
1047 if trimmed.is_empty() {
1048 if !normalized.is_empty() && !previous_blank {
1049 normalized.push(String::new());
1050 previous_blank = true;
1051 }
1052 continue;
1053 }
1054
1055 normalized.push(trimmed.to_string());
1056 previous_blank = false;
1057 }
1058
1059 normalized.join("\n").trim().to_string()
1060}
1061
1062fn decode_html_entities(input: &str) -> String {
1063 input
1064 .replace(" ", " ")
1065 .replace("<", "<")
1066 .replace(">", ">")
1067 .replace(""", "\"")
1068 .replace("'", "'")
1069 .replace("&", "&")
1070}
1071
1072fn escape_html(input: &str) -> String {
1073 input
1074 .replace('&', "&")
1075 .replace('<', "<")
1076 .replace('>', ">")
1077 .replace('"', """)
1078}
1079
1080fn map_space(base_url: &str, raw: ConfluenceSpace) -> KbSpace {
1081 let description = raw
1082 .description
1083 .and_then(|d| {
1084 d.plain
1085 .and_then(|v| v.value)
1086 .or_else(|| d.view.and_then(|v| v.value))
1087 })
1088 .map(|value| truncate_string(strip_html_tags(&value), 500))
1089 .filter(|value| !value.is_empty());
1090
1091 KbSpace {
1092 id: raw.id,
1093 key: raw.key,
1094 name: raw.name,
1095 space_type: raw.space_type,
1096 status: raw.status,
1097 description,
1098 url: join_link(
1099 base_url,
1100 raw._links.base.as_deref(),
1101 raw._links.webui.as_deref(),
1102 ),
1103 }
1104}
1105
1106fn map_page_summary(base_url: &str, raw: &ConfluencePage) -> KbPage {
1107 let version = raw
1108 .history
1109 .as_ref()
1110 .and_then(|h| h.last_updated.as_ref())
1111 .or(raw.version.as_ref());
1112 let version_number = version.and_then(|v| v.number);
1113 let last_modified = version.and_then(|v| v.when.clone().or_else(|| v.created_at.clone()));
1114
1115 KbPage {
1116 id: raw.id.clone(),
1117 title: raw.title.clone(),
1118 space_key: raw.space.as_ref().and_then(|space| space.key.clone()),
1119 url: join_link(
1120 base_url,
1121 raw._links.base.as_deref(),
1122 raw._links.webui.as_deref(),
1123 ),
1124 version: version_number,
1125 last_modified,
1126 author: display_name(version.and_then(|v| v.by.as_ref()))
1127 .or_else(|| display_name(raw.history.as_ref().and_then(|h| h.created_by.as_ref()))),
1128 excerpt: page_excerpt(raw),
1129 }
1130}
1131
1132fn map_pagination<T>(
1133 response: &ConfluenceListResponse<T>,
1134 requested_limit: Option<u32>,
1135) -> Pagination {
1136 let offset = response.start.unwrap_or(0);
1137 let limit = requested_limit
1138 .or(response.limit)
1139 .or(response.size)
1140 .unwrap_or(response.results.len() as u32);
1141 let total = response.total_size;
1142 let has_more = response._links.next.is_some()
1143 || total
1144 .map(|total| {
1145 offset.saturating_add(response.size.unwrap_or(response.results.len() as u32))
1146 < total
1147 })
1148 .unwrap_or(false);
1149
1150 Pagination {
1151 offset,
1152 limit,
1153 total,
1154 has_more,
1155 next_cursor: response._links.next.clone(),
1156 }
1157}
1158
1159fn encode_query_value(value: &str) -> String {
1160 let mut encoded = String::with_capacity(value.len());
1161 for byte in value.bytes() {
1162 match byte {
1163 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1164 encoded.push(byte as char)
1165 }
1166 _ => {
1167 const HEX: &[u8; 16] = b"0123456789ABCDEF";
1168 encoded.push('%');
1169 encoded.push(HEX[(byte >> 4) as usize] as char);
1170 encoded.push(HEX[(byte & 0x0F) as usize] as char);
1171 }
1172 }
1173 }
1174 encoded
1175}
1176
1177fn escape_cql_string(value: &str) -> String {
1178 value.replace('\\', r"\\").replace('"', r#"\""#)
1179}
1180
1181fn build_search_cql(params: &SearchKbParams) -> String {
1182 if params.raw_query {
1183 return params.query.clone();
1184 }
1185
1186 let mut parts = vec!["type = page".to_string()];
1187 if let Some(space_key) = params.space_key.as_ref() {
1188 parts.push(format!("space = \"{}\"", escape_cql_string(space_key)));
1189 }
1190 parts.push(format!("text ~ \"{}\"", escape_cql_string(¶ms.query)));
1191 parts.join(" AND ")
1192}
1193
1194fn path_from_cursor(cursor: &str, api_path: &str) -> String {
1195 let api_prefix = format!("{}/", api_path.trim_end_matches('/'));
1196 if let Some(path) = cursor.strip_prefix(&api_prefix) {
1197 path.to_string()
1198 } else if let Some(path) = cursor.strip_prefix(api_path) {
1199 path.trim_start_matches('/').to_string()
1200 } else if let Some(path) = cursor.strip_prefix("http://") {
1201 let path = path.split_once(&api_prefix).map(|(_, rhs)| rhs);
1202 path.unwrap_or(cursor).to_string()
1203 } else if let Some(path) = cursor.strip_prefix("https://") {
1204 let path = path.split_once(&api_prefix).map(|(_, rhs)| rhs);
1205 path.unwrap_or(cursor).to_string()
1206 } else {
1207 cursor.trim_start_matches('/').to_string()
1208 }
1209}
1210
1211impl ConfluenceClient {
1212 async fn resolve_space_by_key(&self, space_key: &str) -> Result<ConfluenceSpace> {
1213 let spaces = self.get_spaces().await?;
1214 spaces
1215 .items
1216 .into_iter()
1217 .find(|space| space.key == space_key)
1218 .map(|space| ConfluenceSpace {
1219 id: space.id,
1220 key: space.key,
1221 name: space.name,
1222 space_type: space.space_type,
1223 status: space.status,
1224 description: None,
1225 _links: ConfluenceLinks::default(),
1226 })
1227 .ok_or_else(|| Error::NotFound(format!("confluence space '{space_key}' not found")))
1228 }
1229
1230 async fn resolve_space_key_by_id(&self, space_id: &str) -> Result<Option<String>> {
1231 let spaces = self.get_spaces().await?;
1232 Ok(spaces
1233 .items
1234 .into_iter()
1235 .find(|space| space.id == space_id)
1236 .map(|space| space.key))
1237 }
1238
1239 async fn get_page_ancestor_chain_v2(&self, page_id: &str) -> Result<Vec<KbPage>> {
1240 let path = format!("pages/{page_id}/ancestors?limit=100");
1241 let response: ConfluenceListResponse<ConfluenceAncestor> =
1242 self.get_json_from_api(&self.page_api_path, &path).await?;
1243 let mut tasks = tokio::task::JoinSet::new();
1244 for (index, ancestor) in response.results.into_iter().enumerate() {
1245 let client = self.clone();
1246 tasks.spawn(async move {
1247 let detail_path = format!("pages/{}", ancestor.id);
1248 let detail: ConfluencePage = client
1249 .get_json_from_api(&client.page_api_path, &detail_path)
1250 .await?;
1251 let mut summary = map_page_summary(&client.base_url, &detail);
1252 if summary.url.is_none() {
1253 summary.url = Some(format!("{}/pages/{}", client.base_url, detail.id));
1254 }
1255 Ok::<(usize, KbPage), Error>((index, summary))
1256 });
1257 }
1258
1259 let mut ancestors = Vec::with_capacity(tasks.len());
1260 while let Some(result) = tasks.join_next().await {
1261 let (index, summary) = result.map_err(|error| {
1262 Error::Network(format!("ancestor fetch task failed: {error}"))
1263 })??;
1264 ancestors.push((index, summary));
1265 }
1266 ancestors.sort_by_key(|(index, _)| *index);
1267 Ok(ancestors.into_iter().map(|(_, summary)| summary).collect())
1268 }
1269
1270 async fn add_labels(&self, page_id: &str, labels: &[String]) -> Result<()> {
1271 let labels = normalize_labels(labels);
1272 if labels.is_empty() {
1273 return Ok(());
1274 }
1275
1276 let payload = labels
1277 .iter()
1278 .map(|label| ConfluenceWriteLabel {
1279 prefix: "global",
1280 name: label.as_str(),
1281 })
1282 .collect::<Vec<_>>();
1283 self.post_empty_json(&format!("content/{page_id}/label"), &payload)
1284 .await
1285 }
1286
1287 async fn sync_labels(
1288 &self,
1289 page_id: &str,
1290 desired: &[String],
1291 current: &[String],
1292 ) -> Result<()> {
1293 let desired = normalize_labels(desired);
1294 let current = normalize_labels(current);
1295
1296 for label in current.iter().filter(|label| !desired.contains(*label)) {
1297 let path = format!("content/{page_id}/label?name={}", encode_query_value(label));
1298 self.delete_empty(&path).await?;
1299 }
1300
1301 let to_add = desired
1302 .iter()
1303 .filter(|label| !current.contains(*label))
1304 .cloned()
1305 .collect::<Vec<_>>();
1306 self.add_labels(page_id, &to_add).await
1307 }
1308
1309 async fn get_spaces_v1(&self) -> Result<ProviderResult<KbSpace>> {
1310 let response: ConfluenceListResponse<ConfluenceSpace> = self
1311 .get_json("space?limit=100&type=global,personal")
1312 .await?;
1313 let pagination = map_pagination(&response, Some(100));
1314 let items = response
1315 .results
1316 .into_iter()
1317 .map(|space| map_space(&self.base_url, space))
1318 .collect::<Vec<_>>();
1319
1320 Ok(ProviderResult::new(items).with_pagination(pagination))
1321 }
1322
1323 async fn list_pages_v1(&self, params: ListPagesParams) -> Result<ProviderResult<KbPage>> {
1324 let limit = params.limit.unwrap_or(25);
1325 let path = if let Some(cursor) = params.cursor.as_ref() {
1326 path_from_cursor(cursor, &self.api_path)
1327 } else if let Some(parent_id) = params.parent_id.as_ref() {
1328 let offset = params.offset.unwrap_or(0);
1329 let query = [
1330 format!("limit={limit}"),
1331 format!("start={offset}"),
1332 "expand=space,version,history.lastUpdated,body.view".to_string(),
1333 ];
1334 format!("content/{parent_id}/child/page?{}", query.join("&"))
1335 } else {
1336 let offset = params.offset.unwrap_or(0);
1337 let query = [
1338 format!("spaceKey={}", encode_query_value(¶ms.space_key)),
1339 "type=page".to_string(),
1340 format!("limit={limit}"),
1341 format!("start={offset}"),
1342 "expand=space,version,history.lastUpdated,body.view,ancestors".to_string(),
1343 ];
1344 format!("content?{}", query.join("&"))
1345 };
1346
1347 let response: ConfluenceListResponse<ConfluencePage> = self.get_json(&path).await?;
1348 let pagination = map_pagination(&response, Some(limit));
1349 let mut items = response
1350 .results
1351 .iter()
1352 .map(|page| map_page_summary(&self.base_url, page))
1353 .collect::<Vec<_>>();
1354
1355 if let Some(search) = params.search.as_ref() {
1356 let search = search.to_ascii_lowercase();
1357 items.retain(|page| {
1358 page.title.to_ascii_lowercase().contains(&search)
1359 || page
1360 .excerpt
1361 .as_ref()
1362 .map(|excerpt| excerpt.to_ascii_lowercase().contains(&search))
1363 .unwrap_or(false)
1364 });
1365 }
1366
1367 Ok(ProviderResult::new(items).with_pagination(pagination))
1368 }
1369
1370 async fn get_page_v1(&self, page_id: &str) -> Result<KbPageContent> {
1371 let path = format!(
1372 "content/{page_id}?expand=space,version,history.lastUpdated,body.storage,metadata.labels,ancestors"
1373 );
1374 let page: ConfluencePage = self.get_json(&path).await?;
1375 let summary = map_page_summary(&self.base_url, &page);
1376 let storage_content = page
1377 .body
1378 .as_ref()
1379 .and_then(|body| body.storage.as_ref())
1380 .and_then(|storage| storage.value.clone())
1381 .unwrap_or_default();
1382 let content = confluence_storage_to_markdown(&storage_content);
1383 let content_type = "markdown".to_string();
1384 let ancestors = page
1385 .ancestors
1386 .iter()
1387 .map(|ancestor| KbPage {
1388 id: ancestor.id.clone(),
1389 title: ancestor.title.clone(),
1390 space_key: None,
1391 url: join_link(
1392 &self.base_url,
1393 ancestor._links.base.as_deref(),
1394 ancestor._links.webui.as_deref(),
1395 ),
1396 version: None,
1397 last_modified: None,
1398 author: None,
1399 excerpt: None,
1400 })
1401 .collect();
1402 let labels = extract_labels(&page);
1403
1404 Ok(KbPageContent {
1405 page: summary,
1406 content,
1407 content_type,
1408 ancestors,
1409 labels,
1410 })
1411 }
1412
1413 async fn create_page_v1(&self, params: CreatePageParams) -> Result<KbPage> {
1414 let storage_content =
1415 normalize_confluence_write_content(¶ms.content, params.content_type.as_deref())?;
1416
1417 let payload = ConfluenceContentPayload {
1418 content_type: "page",
1419 title: ¶ms.title,
1420 space: ConfluenceCreateSpaceRef {
1421 key: ¶ms.space_key,
1422 },
1423 body: ConfluenceCreateBodyPayload {
1424 storage: ConfluenceContentBody {
1425 value: &storage_content,
1426 representation: "storage",
1427 },
1428 },
1429 ancestors: params
1430 .parent_id
1431 .as_deref()
1432 .map(|id| vec![ConfluenceCreateAncestorRef { id }])
1433 .unwrap_or_default(),
1434 };
1435
1436 let page: ConfluencePage = self.post_json("content", &payload).await?;
1437 self.add_labels(&page.id, ¶ms.labels).await?;
1438 Ok(map_page_summary(&self.base_url, &page))
1439 }
1440
1441 async fn update_page_v1(&self, params: UpdatePageParams) -> Result<KbPage> {
1442 let current_expand = if params.labels.is_some() {
1443 "space,version,body.storage,ancestors,metadata.labels"
1444 } else {
1445 "space,version,body.storage,ancestors"
1446 };
1447 let current_path = format!("content/{}?expand={current_expand}", params.page_id);
1448 let current: ConfluencePage = self.get_json(¤t_path).await?;
1449
1450 let current_title = current.title.clone();
1451 let current_content = current
1452 .body
1453 .as_ref()
1454 .and_then(|body| body.storage.as_ref())
1455 .and_then(|storage| storage.value.clone())
1456 .unwrap_or_default();
1457 let current_version = current
1458 .version
1459 .as_ref()
1460 .and_then(|version| version.number)
1461 .ok_or_else(|| {
1462 Error::InvalidData(format!(
1463 "confluence page {} is missing a version number",
1464 params.page_id
1465 ))
1466 })?;
1467
1468 if let Some(expected_version) = params.version
1469 && expected_version != current_version
1470 {
1471 return Err(Error::Api {
1472 status: 409,
1473 message: format!(
1474 "version conflict for page {}: expected current version {}, found {}",
1475 params.page_id, expected_version, current_version
1476 ),
1477 });
1478 }
1479
1480 let title = params.title.as_deref().unwrap_or(¤t_title);
1481 let content = match params.content.as_deref() {
1482 Some(updated) => {
1483 normalize_confluence_write_content(updated, params.content_type.as_deref())?
1484 }
1485 None => current_content,
1486 };
1487 let ancestors = params
1488 .parent_id
1489 .as_deref()
1490 .map(|id| vec![ConfluenceCreateAncestorRef { id }]);
1491
1492 let payload = ConfluenceUpdatePayload {
1493 id: ¶ms.page_id,
1494 content_type: "page",
1495 title,
1496 version: ConfluenceUpdateVersion {
1497 number: current_version.saturating_add(1),
1498 },
1499 body: ConfluenceCreateBodyPayload {
1500 storage: ConfluenceContentBody {
1501 value: &content,
1502 representation: "storage",
1503 },
1504 },
1505 ancestors,
1506 };
1507
1508 let path = format!("content/{}", params.page_id);
1509 let page: ConfluencePage = self.put_json(&path, &payload).await?;
1510 if let Some(labels) = params.labels.as_ref() {
1511 let current_labels = extract_labels(¤t);
1512 self.sync_labels(¶ms.page_id, labels, ¤t_labels)
1513 .await?;
1514 }
1515 Ok(map_page_summary(&self.base_url, &page))
1516 }
1517
1518 async fn list_pages_v2(&self, params: ListPagesParams) -> Result<ProviderResult<KbPage>> {
1519 let limit = params.limit.unwrap_or(25);
1520 let path = if let Some(cursor) = params.cursor.as_ref() {
1521 path_from_cursor(cursor, &self.page_api_path)
1522 } else {
1523 let mut query = vec![format!("limit={limit}")];
1524 if let Some(parent_id) = params.parent_id.as_ref() {
1525 format!("pages/{parent_id}/children?{}", query.join("&"))
1526 } else {
1527 let space = self.resolve_space_by_key(¶ms.space_key).await?;
1528 query.push("body-format=view".to_string());
1529 if let Some(search) = params.search.as_ref() {
1530 query.push(format!("title={}", encode_query_value(search)));
1531 }
1532 format!("spaces/{}/pages?{}", space.id, query.join("&"))
1533 }
1534 };
1535
1536 let response: ConfluenceListResponse<ConfluencePage> =
1537 self.get_json_from_api(&self.page_api_path, &path).await?;
1538 let pagination = map_pagination(&response, Some(limit));
1539 let mut items = response
1540 .results
1541 .iter()
1542 .map(|page| {
1543 let mut summary = map_page_summary(&self.base_url, page);
1544 if summary.space_key.is_none() {
1545 summary.space_key = Some(params.space_key.clone());
1546 }
1547 if summary.url.is_none() {
1548 summary.url = Some(format!("{}/pages/{}", self.base_url, page.id));
1549 }
1550 summary
1551 })
1552 .collect::<Vec<_>>();
1553
1554 if let Some(search) = params.search.as_ref() {
1555 let search = search.to_ascii_lowercase();
1556 items.retain(|page| {
1557 page.title.to_ascii_lowercase().contains(&search)
1558 || page
1559 .excerpt
1560 .as_ref()
1561 .map(|excerpt| excerpt.to_ascii_lowercase().contains(&search))
1562 .unwrap_or(false)
1563 });
1564 }
1565
1566 Ok(ProviderResult::new(items).with_pagination(pagination))
1567 }
1568
1569 async fn get_page_v2(&self, page_id: &str) -> Result<KbPageContent> {
1570 let path = format!("pages/{page_id}?body-format=storage&include-labels=true");
1571 let page: ConfluencePage = self.get_json_from_api(&self.page_api_path, &path).await?;
1572 let mut summary = map_page_summary(&self.base_url, &page);
1573 if summary.space_key.is_none()
1574 && let Some(space_id) = page.space_id.as_deref()
1575 {
1576 summary.space_key = self.resolve_space_key_by_id(space_id).await?;
1577 }
1578 if summary.url.is_none() {
1579 summary.url = Some(format!("{}/pages/{}", self.base_url, page.id));
1580 }
1581
1582 let storage_content = page.body.as_ref().and_then(body_value).unwrap_or_default();
1583 let content = confluence_storage_to_markdown(&storage_content);
1584 let content_type = "markdown".to_string();
1585 let ancestors = match self.get_page_ancestor_chain_v2(page_id).await {
1586 Ok(ancestors) => ancestors,
1587 Err(error) if should_fallback_to_rest_api(&error) => Vec::new(),
1588 Err(error) => return Err(error),
1589 };
1590 let labels = extract_labels(&page);
1591
1592 Ok(KbPageContent {
1593 page: summary,
1594 content,
1595 content_type,
1596 ancestors,
1597 labels,
1598 })
1599 }
1600
1601 async fn create_page_v2(&self, params: CreatePageParams) -> Result<KbPage> {
1602 let storage_content =
1603 normalize_confluence_write_content(¶ms.content, params.content_type.as_deref())?;
1604 let space = self.resolve_space_by_key(¶ms.space_key).await?;
1605 let payload = ConfluenceV2PagePayload {
1606 space_id: &space.id,
1607 status: "current",
1608 title: ¶ms.title,
1609 parent_id: params.parent_id.as_deref(),
1610 body: ConfluenceContentBody {
1611 value: &storage_content,
1612 representation: "storage",
1613 },
1614 };
1615
1616 let page: ConfluencePage = self
1617 .post_json_to_api(&self.page_api_path, "pages", &payload)
1618 .await?;
1619 self.add_labels(&page.id, ¶ms.labels).await?;
1620 let mut summary = map_page_summary(&self.base_url, &page);
1621 if summary.space_key.is_none() {
1622 summary.space_key = Some(params.space_key);
1623 }
1624 if summary.url.is_none() {
1625 summary.url = Some(format!("{}/pages/{}", self.base_url, page.id));
1626 }
1627 Ok(summary)
1628 }
1629
1630 async fn update_page_v2(&self, params: UpdatePageParams) -> Result<KbPage> {
1631 let current_path = if params.labels.is_some() {
1632 format!(
1633 "pages/{}?body-format=storage&include-labels=true",
1634 params.page_id
1635 )
1636 } else {
1637 format!("pages/{}?body-format=storage", params.page_id)
1638 };
1639 let current: ConfluencePage = self
1640 .get_json_from_api(&self.page_api_path, ¤t_path)
1641 .await?;
1642 let current_title = current.title.clone();
1643 let current_content = current
1644 .body
1645 .as_ref()
1646 .and_then(body_value)
1647 .unwrap_or_default();
1648 let current_version = current
1649 .version
1650 .as_ref()
1651 .and_then(|version| version.number)
1652 .ok_or_else(|| {
1653 Error::InvalidData(format!(
1654 "confluence page {} is missing a version number",
1655 params.page_id
1656 ))
1657 })?;
1658
1659 if let Some(expected_version) = params.version
1660 && expected_version != current_version
1661 {
1662 return Err(Error::Api {
1663 status: 409,
1664 message: format!(
1665 "version conflict for page {}: expected current version {}, found {}",
1666 params.page_id, expected_version, current_version
1667 ),
1668 });
1669 }
1670
1671 let title = params.title.as_deref().unwrap_or(¤t_title);
1672 let content = match params.content.as_deref() {
1673 Some(updated) => {
1674 normalize_confluence_write_content(updated, params.content_type.as_deref())?
1675 }
1676 None => current_content,
1677 };
1678 let space_id = current
1679 .space_id
1680 .as_deref()
1681 .or_else(|| current.space.as_ref().and_then(|space| space.id.as_deref()))
1682 .ok_or_else(|| {
1683 Error::InvalidData(format!(
1684 "confluence page {} is missing a space id",
1685 params.page_id
1686 ))
1687 })?;
1688 let parent_id = params.parent_id.as_deref().or(current.parent_id.as_deref());
1689 let payload = ConfluenceV2UpdatePayload {
1690 id: ¶ms.page_id,
1691 status: "current",
1692 title,
1693 space_id,
1694 parent_id,
1695 body: ConfluenceContentBody {
1696 value: &content,
1697 representation: "storage",
1698 },
1699 version: ConfluenceUpdateVersion {
1700 number: current_version.saturating_add(1),
1701 },
1702 };
1703
1704 let path = format!("pages/{}", params.page_id);
1705 let page: ConfluencePage = self
1706 .put_json_to_api(&self.page_api_path, &path, &payload)
1707 .await?;
1708 if let Some(labels) = params.labels.as_ref() {
1709 let current_labels = extract_labels(¤t);
1710 self.sync_labels(¶ms.page_id, labels, ¤t_labels)
1711 .await?;
1712 }
1713 let mut summary = map_page_summary(&self.base_url, &page);
1714 if summary.space_key.is_none() {
1715 summary.space_key = self.resolve_space_key_by_id(space_id).await?;
1716 }
1717 if summary.url.is_none() {
1718 summary.url = Some(format!("{}/pages/{}", self.base_url, page.id));
1719 }
1720 Ok(summary)
1721 }
1722}
1723
1724#[async_trait]
1725impl KnowledgeBaseProvider for ConfluenceClient {
1726 fn provider_name(&self) -> &'static str {
1727 "confluence"
1728 }
1729
1730 async fn get_spaces(&self) -> Result<ProviderResult<KbSpace>> {
1731 if uses_v2_api(&self.space_api_path) {
1732 let path = "space?limit=100&type=global,personal";
1733 let response: ConfluenceListResponse<ConfluenceSpace> =
1734 match self.get_json_from_api(&self.space_api_path, path).await {
1735 Ok(response) => response,
1736 Err(error) if should_fallback_to_rest_api(&error) => {
1737 return self.get_spaces_v1().await;
1738 }
1739 Err(error) => return Err(error),
1740 };
1741 let pagination = map_pagination(&response, Some(100));
1742 let items = response
1743 .results
1744 .into_iter()
1745 .map(|space| map_space(&self.base_url, space))
1746 .collect::<Vec<_>>();
1747
1748 Ok(ProviderResult::new(items).with_pagination(pagination))
1749 } else {
1750 self.get_spaces_v1().await
1751 }
1752 }
1753
1754 async fn list_pages(&self, params: ListPagesParams) -> Result<ProviderResult<KbPage>> {
1755 if uses_v2_api(&self.page_api_path) {
1756 match self.list_pages_v2(params.clone()).await {
1757 Ok(result) => Ok(result),
1758 Err(error) if should_fallback_to_rest_api(&error) => {
1759 self.list_pages_v1(params).await
1760 }
1761 Err(error) => Err(error),
1762 }
1763 } else {
1764 self.list_pages_v1(params).await
1765 }
1766 }
1767
1768 async fn get_page(&self, page_id: &str) -> Result<KbPageContent> {
1769 if uses_v2_api(&self.page_api_path) {
1770 match self.get_page_v2(page_id).await {
1771 Ok(result) => Ok(result),
1772 Err(error) if should_fallback_to_rest_api(&error) => {
1773 self.get_page_v1(page_id).await
1774 }
1775 Err(error) => Err(error),
1776 }
1777 } else {
1778 self.get_page_v1(page_id).await
1779 }
1780 }
1781
1782 async fn create_page(&self, params: CreatePageParams) -> Result<KbPage> {
1783 if uses_v2_api(&self.page_api_path) {
1784 match self.create_page_v2(params.clone()).await {
1785 Ok(result) => Ok(result),
1786 Err(error) if should_fallback_to_rest_api(&error) => {
1787 self.create_page_v1(params).await
1788 }
1789 Err(error) => Err(error),
1790 }
1791 } else {
1792 self.create_page_v1(params).await
1793 }
1794 }
1795
1796 async fn update_page(&self, params: UpdatePageParams) -> Result<KbPage> {
1797 if uses_v2_api(&self.page_api_path) {
1798 match self.update_page_v2(params.clone()).await {
1799 Ok(result) => Ok(result),
1800 Err(error) if should_fallback_to_rest_api(&error) => {
1801 self.update_page_v1(params).await
1802 }
1803 Err(error) => Err(error),
1804 }
1805 } else {
1806 self.update_page_v1(params).await
1807 }
1808 }
1809
1810 async fn search(&self, params: SearchKbParams) -> Result<ProviderResult<KbPage>> {
1811 let limit = params.limit.unwrap_or(25);
1812
1813 let path = if let Some(cursor) = params.cursor.as_ref() {
1814 path_from_cursor(cursor, &self.api_path)
1815 } else {
1816 let cql = build_search_cql(¶ms);
1817 format!(
1818 "content/search?cql={}&limit={limit}&expand=space,version,history.lastUpdated,body.view",
1819 encode_query_value(&cql)
1820 )
1821 };
1822
1823 let response: ConfluenceListResponse<ConfluencePage> = self.get_json(&path).await?;
1824 let pagination = map_pagination(&response, Some(limit));
1825 let items = response
1826 .results
1827 .iter()
1828 .map(|page| map_page_summary(&self.base_url, page))
1829 .collect::<Vec<_>>();
1830
1831 Ok(ProviderResult::new(items).with_pagination(pagination))
1832 }
1833}
1834
1835#[cfg(test)]
1836mod tests {
1837 use httpmock::Method::{GET, POST, PUT};
1838 use httpmock::MockServer;
1839 use serde::{Deserialize, Serialize};
1840
1841 use super::*;
1842
1843 #[derive(Debug, Deserialize)]
1844 struct EchoResponse {
1845 ok: bool,
1846 }
1847
1848 #[derive(Debug, Serialize)]
1849 struct CreatePayload {
1850 title: String,
1851 }
1852
1853 #[tokio::test]
1854 async fn rest_api_url_normalizes_base_url() {
1855 let client =
1856 ConfluenceClient::new("https://wiki.example.com/", ConfluenceAuth::bearer("token"));
1857
1858 assert_eq!(
1859 client.rest_api_url("content"),
1860 "https://wiki.example.com/rest/api/content"
1861 );
1862 }
1863
1864 #[tokio::test]
1865 async fn rest_api_url_honors_v2_api_version() {
1866 let client =
1867 ConfluenceClient::new("https://wiki.example.com/", ConfluenceAuth::bearer("token"))
1868 .with_api_version(Some("v2"));
1869
1870 assert_eq!(
1871 client.space_api_url("space"),
1872 "https://wiki.example.com/api/v2/space"
1873 );
1874 }
1875
1876 #[tokio::test]
1877 async fn get_spaces_falls_back_to_rest_api_when_v2_is_unavailable() {
1878 let server = MockServer::start();
1879 let v2_mock = server.mock(|when, then| {
1880 when.method(GET)
1881 .path("/api/v2/space")
1882 .query_param("limit", "100")
1883 .query_param("type", "global,personal");
1884 then.status(404);
1885 });
1886 let v1_mock = server.mock(|when, then| {
1887 when.method(GET)
1888 .path("/rest/api/space")
1889 .query_param("limit", "100")
1890 .query_param("type", "global,personal");
1891 then.status(200)
1892 .header("content-type", "application/json")
1893 .body(r#"{"results":[],"start":0,"limit":100,"size":0,"_links":{}}"#);
1894 });
1895
1896 let client =
1897 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"))
1898 .with_api_version(Some("v2"));
1899
1900 let response = client.get_spaces().await.unwrap();
1901
1902 assert!(response.items.is_empty());
1903 v2_mock.assert();
1904 v1_mock.assert();
1905 }
1906
1907 #[tokio::test]
1908 async fn get_json_uses_bearer_auth() {
1909 let server = MockServer::start();
1910 let mock = server.mock(|when, then| {
1911 when.method(GET)
1912 .path("/rest/api/content")
1913 .header("authorization", "Bearer secret-token");
1914 then.status(200)
1915 .header("content-type", "application/json")
1916 .body(r#"{"ok":true}"#);
1917 });
1918
1919 let client =
1920 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
1921 let response: EchoResponse = client.get_json("content").await.unwrap();
1922
1923 mock.assert();
1924 assert!(response.ok);
1925 }
1926
1927 #[tokio::test]
1928 async fn post_json_uses_basic_auth() {
1929 let server = MockServer::start();
1930 let mock = server.mock(|when, then| {
1931 when.method(POST)
1932 .path("/rest/api/content")
1933 .header(
1934 "authorization",
1935 "Basic dXNlckBleGFtcGxlLmNvbTpwYXNzd29yZA==",
1936 )
1937 .json_body_obj(&serde_json::json!({ "title": "ADR-001" }));
1938 then.status(200)
1939 .header("content-type", "application/json")
1940 .body(r#"{"ok":true}"#);
1941 });
1942
1943 let client = ConfluenceClient::new(
1944 server.base_url(),
1945 ConfluenceAuth::basic("user@example.com", "password"),
1946 );
1947 let response: EchoResponse = client
1948 .post_json(
1949 "content",
1950 &CreatePayload {
1951 title: "ADR-001".into(),
1952 },
1953 )
1954 .await
1955 .unwrap();
1956
1957 mock.assert();
1958 assert!(response.ok);
1959 }
1960
1961 #[tokio::test]
1962 async fn proxy_headers_suppress_provider_auth() {
1963 let server = MockServer::start();
1964 let mock = server.mock(|when, then| {
1965 when.method(GET)
1966 .path("/rest/api/content")
1967 .header("x-proxy-auth", "secret")
1968 .header_missing("authorization");
1969 then.status(200)
1970 .header("content-type", "application/json")
1971 .body(r#"{"ok":true}"#);
1972 });
1973
1974 let mut headers = HashMap::new();
1975 headers.insert("x-proxy-auth".into(), "secret".into());
1976
1977 let client =
1978 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"))
1979 .with_proxy(headers);
1980 let response: EchoResponse = client.get_json("content").await.unwrap();
1981
1982 mock.assert();
1983 assert!(response.ok);
1984 }
1985
1986 #[tokio::test]
1987 async fn get_spaces_maps_confluence_spaces() {
1988 let server = MockServer::start();
1989 let mock = server.mock(|when, then| {
1990 when.method(GET)
1991 .path("/rest/api/space")
1992 .query_param("limit", "100")
1993 .query_param("type", "global,personal");
1994 then.status(200)
1995 .header("content-type", "application/json")
1996 .body(
1997 r#"{
1998 "results": [
1999 {
2000 "id": "123",
2001 "key": "ENG",
2002 "name": "Engineering",
2003 "type": "global",
2004 "status": "current",
2005 "description": { "plain": { "value": "Team docs" } },
2006 "_links": { "base": "https://wiki.example.com", "webui": "/spaces/ENG/overview" }
2007 }
2008 ],
2009 "start": 0,
2010 "limit": 100,
2011 "size": 1,
2012 "totalSize": 1,
2013 "_links": {}
2014 }"#,
2015 );
2016 });
2017
2018 let client =
2019 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
2020 let result = client.get_spaces().await.unwrap();
2021
2022 mock.assert();
2023 assert_eq!(result.items.len(), 1);
2024 assert_eq!(result.items[0].key, "ENG");
2025 assert_eq!(result.items[0].name, "Engineering");
2026 assert_eq!(result.items[0].description.as_deref(), Some("Team docs"));
2027 assert_eq!(
2028 result.items[0].url.as_deref(),
2029 Some("https://wiki.example.com/spaces/ENG/overview")
2030 );
2031 assert_eq!(result.pagination.unwrap().total, Some(1));
2032 }
2033
2034 #[tokio::test]
2035 async fn list_pages_falls_back_to_rest_content_api_when_v2_pages_are_unavailable() {
2036 let server = MockServer::start();
2037 server.mock(|when, then| {
2038 when.method(GET)
2039 .path("/api/v2/space")
2040 .query_param("limit", "100")
2041 .query_param("type", "global,personal");
2042 then.status(200)
2043 .header("content-type", "application/json")
2044 .body(
2045 r#"{
2046 "results": [
2047 { "id": "123", "key": "ENG", "name": "Engineering" }
2048 ],
2049 "_links": {}
2050 }"#,
2051 );
2052 });
2053 server.mock(|when, then| {
2054 when.method(GET)
2055 .path("/api/v2/spaces/123/pages")
2056 .query_param("limit", "25")
2057 .query_param("body-format", "view");
2058 then.status(404);
2059 });
2060 let mock = server.mock(|when, then| {
2061 when.method(GET)
2062 .path("/rest/api/content")
2063 .query_param("spaceKey", "ENG")
2064 .query_param("type", "page")
2065 .query_param("limit", "25")
2066 .query_param("start", "0")
2067 .query_param(
2068 "expand",
2069 "space,version,history.lastUpdated,body.view,ancestors",
2070 );
2071 then.status(200)
2072 .header("content-type", "application/json")
2073 .body(r#"{"results":[],"start":0,"limit":25,"size":0,"_links":{}}"#);
2074 });
2075
2076 let client =
2077 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"))
2078 .with_api_version(Some("v2"));
2079 let result = client
2080 .list_pages(ListPagesParams {
2081 space_key: "ENG".into(),
2082 limit: Some(25),
2083 offset: Some(0),
2084 cursor: None,
2085 search: None,
2086 parent_id: None,
2087 })
2088 .await
2089 .unwrap();
2090
2091 mock.assert();
2092 assert!(result.items.is_empty());
2093 }
2094
2095 #[tokio::test]
2096 async fn list_pages_uses_v2_pages_when_preferred() {
2097 let server = MockServer::start();
2098 let pages_mock = server.mock(|when, then| {
2099 when.method(GET)
2100 .path("/api/v2/pages/10/children")
2101 .query_param("limit", "25");
2102 then.status(200)
2103 .header("content-type", "application/json")
2104 .body(
2105 r#"{
2106 "results": [
2107 {
2108 "id": "42",
2109 "title": "ADR-001",
2110 "spaceId": "123",
2111 "parentId": "10",
2112 "_links": { "next": "/api/v2/pages/10/children?cursor=abc" }
2113 }
2114 ],
2115 "limit": 25,
2116 "size": 1,
2117 "_links": { "next": "/api/v2/pages/10/children?cursor=abc" }
2118 }"#,
2119 );
2120 });
2121
2122 let client =
2123 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"))
2124 .with_api_version(Some("v2"));
2125 let result = client
2126 .list_pages(ListPagesParams {
2127 space_key: "ENG".into(),
2128 limit: Some(25),
2129 offset: Some(0),
2130 cursor: None,
2131 search: None,
2132 parent_id: Some("10".into()),
2133 })
2134 .await
2135 .unwrap();
2136
2137 pages_mock.assert();
2138 assert_eq!(result.items.len(), 1);
2139 assert_eq!(result.items[0].id, "42");
2140 assert_eq!(result.items[0].space_key.as_deref(), Some("ENG"));
2141 assert_eq!(
2142 result
2143 .pagination
2144 .and_then(|pagination| pagination.next_cursor),
2145 Some("/api/v2/pages/10/children?cursor=abc".into())
2146 );
2147 }
2148
2149 #[tokio::test]
2150 async fn list_pages_uses_v1_child_endpoint_when_parent_filter_is_set() {
2151 let server = MockServer::start();
2152 let mock = server.mock(|when, then| {
2153 when.method(GET)
2154 .path("/rest/api/content/10/child/page")
2155 .query_param("limit", "25")
2156 .query_param("start", "0")
2157 .query_param("expand", "space,version,history.lastUpdated,body.view");
2158 then.status(200)
2159 .header("content-type", "application/json")
2160 .body(
2161 r#"{
2162 "results": [
2163 {
2164 "id": "42",
2165 "title": "ADR-001",
2166 "space": { "key": "ENG" },
2167 "version": { "number": 7 },
2168 "body": {
2169 "view": { "value": "<p>Architecture decision record</p>" }
2170 },
2171 "_links": { "base": "https://wiki.example.com", "webui": "/pages/viewpage.action?pageId=42" }
2172 }
2173 ],
2174 "start": 0,
2175 "limit": 25,
2176 "size": 1,
2177 "_links": {}
2178 }"#,
2179 );
2180 });
2181
2182 let client =
2183 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
2184 let result = client
2185 .list_pages(ListPagesParams {
2186 space_key: "ENG".into(),
2187 limit: Some(25),
2188 offset: Some(0),
2189 cursor: None,
2190 search: None,
2191 parent_id: Some("10".into()),
2192 })
2193 .await
2194 .unwrap();
2195
2196 mock.assert();
2197 assert_eq!(result.items.len(), 1);
2198 assert_eq!(result.items[0].id, "42");
2199 assert_eq!(result.items[0].space_key.as_deref(), Some("ENG"));
2200 }
2201
2202 #[tokio::test]
2203 async fn list_pages_maps_page_summaries_and_pagination() {
2204 let server = MockServer::start();
2205 let mock = server.mock(|when, then| {
2206 when.method(GET)
2207 .path("/rest/api/content")
2208 .query_param("spaceKey", "ENG")
2209 .query_param("type", "page")
2210 .query_param("limit", "25")
2211 .query_param("start", "0")
2212 .query_param("expand", "space,version,history.lastUpdated,body.view,ancestors");
2213 then.status(200)
2214 .header("content-type", "application/json")
2215 .body(
2216 r#"{
2217 "results": [
2218 {
2219 "id": "42",
2220 "title": "ADR-001",
2221 "space": { "key": "ENG" },
2222 "version": {
2223 "number": 7,
2224 "when": "2026-04-26T10:00:00.000Z",
2225 "by": { "displayName": "Alice" }
2226 },
2227 "body": {
2228 "view": { "value": "<p>Architecture decision record</p>", "representation": "view" }
2229 },
2230 "ancestors": [],
2231 "_links": { "base": "https://wiki.example.com", "webui": "/pages/viewpage.action?pageId=42", "next": "/rest/api/content?start=25" }
2232 }
2233 ],
2234 "start": 0,
2235 "limit": 25,
2236 "size": 1,
2237 "totalSize": 30,
2238 "_links": { "next": "/rest/api/content?start=25" }
2239 }"#,
2240 );
2241 });
2242
2243 let client =
2244 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
2245 let result = client
2246 .list_pages(ListPagesParams {
2247 space_key: "ENG".into(),
2248 limit: Some(25),
2249 offset: Some(0),
2250 cursor: None,
2251 search: None,
2252 parent_id: None,
2253 })
2254 .await
2255 .unwrap();
2256
2257 mock.assert();
2258 assert_eq!(result.items.len(), 1);
2259 assert_eq!(result.items[0].id, "42");
2260 assert_eq!(result.items[0].space_key.as_deref(), Some("ENG"));
2261 assert_eq!(result.items[0].version, Some(7));
2262 assert_eq!(result.items[0].author.as_deref(), Some("Alice"));
2263 assert_eq!(
2264 result.items[0].excerpt.as_deref(),
2265 Some("Architecture decision record")
2266 );
2267 let pagination = result.pagination.unwrap();
2268 assert!(pagination.has_more);
2269 assert_eq!(
2270 pagination.next_cursor.as_deref(),
2271 Some("/rest/api/content?start=25")
2272 );
2273 assert_eq!(pagination.total, Some(30));
2274 }
2275
2276 #[tokio::test]
2277 async fn list_pages_uses_cursor_path_for_followup_requests() {
2278 let server = MockServer::start();
2279 let mock = server.mock(|when, then| {
2280 when.method(GET)
2281 .path("/rest/api/content")
2282 .query_param("limit", "25")
2283 .query_param("start", "25");
2284 then.status(200)
2285 .header("content-type", "application/json")
2286 .body(
2287 r#"{
2288 "results": [
2289 {
2290 "id": "77",
2291 "title": "Next Page",
2292 "space": { "key": "ENG" },
2293 "_links": { "base": "https://wiki.example.com", "webui": "/pages/viewpage.action?pageId=77" }
2294 }
2295 ],
2296 "start": 25,
2297 "limit": 25,
2298 "size": 1,
2299 "totalSize": 26,
2300 "_links": {}
2301 }"#,
2302 );
2303 });
2304
2305 let client =
2306 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
2307 let result = client
2308 .list_pages(ListPagesParams {
2309 space_key: "ENG".into(),
2310 limit: Some(25),
2311 offset: Some(0),
2312 cursor: Some("/rest/api/content?limit=25&start=25".into()),
2313 search: None,
2314 parent_id: None,
2315 })
2316 .await
2317 .unwrap();
2318
2319 mock.assert();
2320 assert_eq!(result.items.len(), 1);
2321 assert_eq!(result.items[0].id, "77");
2322 }
2323
2324 #[tokio::test]
2325 async fn get_page_maps_storage_content_labels_and_ancestors() {
2326 let server = MockServer::start();
2327 let mock = server.mock(|when, then| {
2328 when.method(GET)
2329 .path("/rest/api/content/42")
2330 .query_param(
2331 "expand",
2332 "space,version,history.lastUpdated,body.storage,metadata.labels,ancestors",
2333 );
2334 then.status(200)
2335 .header("content-type", "application/json")
2336 .body(
2337 r#"{
2338 "id": "42",
2339 "title": "ADR-001",
2340 "space": { "key": "ENG" },
2341 "version": {
2342 "number": 7,
2343 "when": "2026-04-26T10:00:00.000Z",
2344 "by": { "displayName": "Alice" }
2345 },
2346 "body": {
2347 "storage": {
2348 "value": "<p>Hello <strong>world</strong></p>",
2349 "representation": "storage"
2350 }
2351 },
2352 "metadata": {
2353 "labels": {
2354 "results": [
2355 { "name": "adr" },
2356 { "name": "architecture" }
2357 ]
2358 }
2359 },
2360 "ancestors": [
2361 {
2362 "id": "10",
2363 "title": "Architecture Decisions",
2364 "_links": { "base": "https://wiki.example.com", "webui": "/pages/viewpage.action?pageId=10" }
2365 }
2366 ],
2367 "_links": { "base": "https://wiki.example.com", "webui": "/pages/viewpage.action?pageId=42" }
2368 }"#,
2369 );
2370 });
2371
2372 let client =
2373 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
2374 let page = client.get_page("42").await.unwrap();
2375
2376 mock.assert();
2377 assert_eq!(page.page.id, "42");
2378 assert_eq!(page.page.title, "ADR-001");
2379 assert_eq!(page.page.version, Some(7));
2380 assert_eq!(page.content_type, "markdown");
2381 assert_eq!(page.content, "Hello **world**");
2382 assert_eq!(page.labels, vec!["adr", "architecture"]);
2383 assert_eq!(page.ancestors.len(), 1);
2384 assert_eq!(page.ancestors[0].id, "10");
2385 assert_eq!(page.ancestors[0].title, "Architecture Decisions");
2386 }
2387
2388 #[tokio::test]
2389 async fn get_page_uses_v2_page_and_ancestors_when_preferred() {
2390 let server = MockServer::start();
2391 let space_mock = server.mock(|when, then| {
2392 when.method(GET)
2393 .path("/api/v2/space")
2394 .query_param("limit", "100")
2395 .query_param("type", "global,personal");
2396 then.status(200)
2397 .header("content-type", "application/json")
2398 .body(
2399 r#"{
2400 "results": [
2401 { "id": "123", "key": "ENG", "name": "Engineering" }
2402 ],
2403 "_links": {}
2404 }"#,
2405 );
2406 });
2407 let page_mock = server.mock(|when, then| {
2408 when.method(GET)
2409 .path("/api/v2/pages/42")
2410 .query_param("body-format", "storage")
2411 .query_param("include-labels", "true");
2412 then.status(200)
2413 .header("content-type", "application/json")
2414 .body(
2415 r#"{
2416 "id": "42",
2417 "title": "ADR-001",
2418 "spaceId": "123",
2419 "parentId": "10",
2420 "version": {
2421 "number": 7,
2422 "createdAt": "2026-04-26T10:00:00.000Z"
2423 },
2424 "body": {
2425 "representation": "storage",
2426 "value": "<p>Hello <strong>world</strong></p>"
2427 },
2428 "labels": {
2429 "results": [
2430 { "label": "adr" },
2431 { "label": "architecture" }
2432 ]
2433 }
2434 }"#,
2435 );
2436 });
2437 let ancestors_mock = server.mock(|when, then| {
2438 when.method(GET)
2439 .path("/api/v2/pages/42/ancestors")
2440 .query_param("limit", "100");
2441 then.status(200)
2442 .header("content-type", "application/json")
2443 .body(r#"{ "results": [ { "id": "10", "type": "page" } ], "_links": {} }"#);
2444 });
2445 let ancestor_page_mock = server.mock(|when, then| {
2446 when.method(GET).path("/api/v2/pages/10");
2447 then.status(200)
2448 .header("content-type", "application/json")
2449 .body(
2450 r#"{
2451 "id": "10",
2452 "title": "Architecture Decisions",
2453 "spaceId": "123"
2454 }"#,
2455 );
2456 });
2457
2458 let client =
2459 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"))
2460 .with_api_version(Some("v2"));
2461 let page = client.get_page("42").await.unwrap();
2462
2463 space_mock.assert();
2464 page_mock.assert();
2465 ancestors_mock.assert();
2466 ancestor_page_mock.assert();
2467 assert_eq!(page.page.id, "42");
2468 assert_eq!(page.page.space_key.as_deref(), Some("ENG"));
2469 assert_eq!(page.page.version, Some(7));
2470 assert_eq!(page.content, "Hello **world**");
2471 assert_eq!(page.labels, vec!["adr", "architecture"]);
2472 assert_eq!(page.ancestors.len(), 1);
2473 assert_eq!(page.ancestors[0].title, "Architecture Decisions");
2474 }
2475
2476 #[tokio::test]
2477 async fn get_page_v2_propagates_non_fallback_ancestor_errors() {
2478 let server = MockServer::start();
2479 let space_mock = server.mock(|when, then| {
2480 when.method(GET)
2481 .path("/api/v2/space")
2482 .query_param("limit", "100")
2483 .query_param("type", "global,personal");
2484 then.status(200)
2485 .header("content-type", "application/json")
2486 .body(
2487 r#"{
2488 "results": [
2489 { "id": "123", "key": "ENG", "name": "Engineering" }
2490 ],
2491 "_links": {}
2492 }"#,
2493 );
2494 });
2495 let page_mock = server.mock(|when, then| {
2496 when.method(GET)
2497 .path("/api/v2/pages/42")
2498 .query_param("body-format", "storage")
2499 .query_param("include-labels", "true");
2500 then.status(200)
2501 .header("content-type", "application/json")
2502 .body(
2503 r#"{
2504 "id": "42",
2505 "title": "ADR-001",
2506 "spaceId": "123",
2507 "version": { "number": 7 },
2508 "body": {
2509 "representation": "storage",
2510 "value": "<p>Hello</p>"
2511 }
2512 }"#,
2513 );
2514 });
2515 let ancestors_mock = server.mock(|when, then| {
2516 when.method(GET)
2517 .path("/api/v2/pages/42/ancestors")
2518 .query_param("limit", "100");
2519 then.status(401).body("unauthorized");
2520 });
2521
2522 let client =
2523 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"))
2524 .with_api_version(Some("v2"));
2525 let error = client.get_page("42").await.unwrap_err();
2526
2527 space_mock.assert();
2528 page_mock.assert();
2529 ancestors_mock.assert();
2530 assert!(matches!(error, Error::Unauthorized(_)));
2531 }
2532
2533 #[tokio::test]
2534 async fn create_page_accepts_markdown_and_posts_storage_payload() {
2535 let server = MockServer::start();
2536 let mock = server.mock(|when, then| {
2537 when.method(POST)
2538 .path("/rest/api/content")
2539 .header("authorization", "Bearer secret-token")
2540 .header("content-type", "application/json")
2541 .json_body_obj(&serde_json::json!({
2542 "type": "page",
2543 "title": "ADR-002",
2544 "space": { "key": "ENG" },
2545 "body": {
2546 "storage": {
2547 "value": "<h1>Decision</h1><p>Hello <strong>world</strong></p>",
2548 "representation": "storage"
2549 }
2550 },
2551 "ancestors": [{ "id": "10" }]
2552 }));
2553 then.status(200)
2554 .header("content-type", "application/json")
2555 .body(
2556 r#"{
2557 "id": "43",
2558 "title": "ADR-002",
2559 "space": { "key": "ENG" },
2560 "version": {
2561 "number": 1,
2562 "when": "2026-04-26T10:00:00.000Z",
2563 "by": { "displayName": "Alice" }
2564 },
2565 "_links": { "base": "https://wiki.example.com", "webui": "/pages/viewpage.action?pageId=43" }
2566 }"#,
2567 );
2568 });
2569
2570 let client =
2571 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
2572 let page = client
2573 .create_page(CreatePageParams {
2574 space_key: "ENG".into(),
2575 title: "ADR-002".into(),
2576 content: "# Decision\n\nHello **world**".into(),
2577 content_type: Some("markdown".into()),
2578 parent_id: Some("10".into()),
2579 labels: vec![],
2580 })
2581 .await
2582 .unwrap();
2583
2584 mock.assert();
2585 assert_eq!(page.id, "43");
2586 assert_eq!(page.title, "ADR-002");
2587 assert_eq!(page.space_key.as_deref(), Some("ENG"));
2588 assert_eq!(page.version, Some(1));
2589 }
2590
2591 #[tokio::test]
2592 async fn create_page_posts_labels_after_create() {
2593 let server = MockServer::start();
2594 let create_mock = server.mock(|when, then| {
2595 when.method(POST)
2596 .path("/rest/api/content")
2597 .header("authorization", "Bearer secret-token")
2598 .header("content-type", "application/json")
2599 .json_body_obj(&serde_json::json!({
2600 "type": "page",
2601 "title": "ADR-002",
2602 "space": { "key": "ENG" },
2603 "body": {
2604 "storage": {
2605 "value": "<p>Hello</p>",
2606 "representation": "storage"
2607 }
2608 }
2609 }));
2610 then.status(200)
2611 .header("content-type", "application/json")
2612 .body(
2613 r#"{
2614 "id": "43",
2615 "title": "ADR-002",
2616 "space": { "key": "ENG" },
2617 "version": { "number": 1 }
2618 }"#,
2619 );
2620 });
2621 let labels_mock = server.mock(|when, then| {
2622 when.method(POST)
2623 .path("/rest/api/content/43/label")
2624 .header("authorization", "Bearer secret-token")
2625 .header("content-type", "application/json")
2626 .json_body_obj(&serde_json::json!([
2627 { "prefix": "global", "name": "adr" },
2628 { "prefix": "global", "name": "architecture" }
2629 ]));
2630 then.status(200)
2631 .header("content-type", "application/json")
2632 .body("[]");
2633 });
2634
2635 let client =
2636 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
2637 let page = client
2638 .create_page(CreatePageParams {
2639 space_key: "ENG".into(),
2640 title: "ADR-002".into(),
2641 content: "<p>Hello</p>".into(),
2642 content_type: Some("storage".into()),
2643 parent_id: None,
2644 labels: vec!["adr".into(), "architecture".into()],
2645 })
2646 .await
2647 .unwrap();
2648
2649 create_mock.assert();
2650 labels_mock.assert();
2651 assert_eq!(page.id, "43");
2652 }
2653
2654 #[tokio::test]
2655 async fn create_page_uses_v2_pages_when_preferred() {
2656 let server = MockServer::start();
2657 let space_mock = server.mock(|when, then| {
2658 when.method(GET)
2659 .path("/api/v2/space")
2660 .query_param("limit", "100")
2661 .query_param("type", "global,personal");
2662 then.status(200)
2663 .header("content-type", "application/json")
2664 .body(
2665 r#"{
2666 "results": [
2667 { "id": "123", "key": "ENG", "name": "Engineering" }
2668 ],
2669 "_links": {}
2670 }"#,
2671 );
2672 });
2673 let create_mock = server.mock(|when, then| {
2674 when.method(POST)
2675 .path("/api/v2/pages")
2676 .header("authorization", "Bearer secret-token")
2677 .header("content-type", "application/json")
2678 .json_body_obj(&serde_json::json!({
2679 "spaceId": "123",
2680 "status": "current",
2681 "title": "ADR-002",
2682 "parentId": "10",
2683 "body": {
2684 "value": "<h1>Decision</h1><p>Hello <strong>world</strong></p>",
2685 "representation": "storage"
2686 }
2687 }));
2688 then.status(200)
2689 .header("content-type", "application/json")
2690 .body(
2691 r#"{
2692 "id": "43",
2693 "title": "ADR-002",
2694 "spaceId": "123",
2695 "version": { "number": 1 }
2696 }"#,
2697 );
2698 });
2699
2700 let client =
2701 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"))
2702 .with_api_version(Some("v2"));
2703 let page = client
2704 .create_page(CreatePageParams {
2705 space_key: "ENG".into(),
2706 title: "ADR-002".into(),
2707 content: "# Decision\n\nHello **world**".into(),
2708 content_type: Some("markdown".into()),
2709 parent_id: Some("10".into()),
2710 labels: vec![],
2711 })
2712 .await
2713 .unwrap();
2714
2715 space_mock.assert();
2716 create_mock.assert();
2717 assert_eq!(page.id, "43");
2718 assert_eq!(page.space_key.as_deref(), Some("ENG"));
2719 assert_eq!(page.version, Some(1));
2720 }
2721
2722 #[tokio::test]
2723 async fn update_page_accepts_markdown_and_puts_incremented_version() {
2724 let server = MockServer::start();
2725 let get_mock = server.mock(|when, then| {
2726 when.method(GET)
2727 .path("/rest/api/content/42")
2728 .query_param("expand", "space,version,body.storage,ancestors");
2729 then.status(200)
2730 .header("content-type", "application/json")
2731 .body(
2732 r#"{
2733 "id": "42",
2734 "title": "ADR-001",
2735 "space": { "key": "ENG" },
2736 "version": { "number": 7 },
2737 "body": {
2738 "storage": {
2739 "value": "<p>Old</p>",
2740 "representation": "storage"
2741 }
2742 },
2743 "ancestors": [
2744 { "id": "10", "title": "Architecture", "_links": {} }
2745 ],
2746 "_links": { "base": "https://wiki.example.com", "webui": "/pages/viewpage.action?pageId=42" }
2747 }"#,
2748 );
2749 });
2750 let put_mock = server.mock(|when, then| {
2751 when.method(PUT)
2752 .path("/rest/api/content/42")
2753 .header("authorization", "Bearer secret-token")
2754 .header("content-type", "application/json")
2755 .json_body_obj(&serde_json::json!({
2756 "id": "42",
2757 "type": "page",
2758 "title": "ADR-001 Revised",
2759 "version": { "number": 8 },
2760 "body": {
2761 "storage": {
2762 "value": "<p>New <strong>decision</strong></p>",
2763 "representation": "storage"
2764 }
2765 },
2766 "ancestors": [{ "id": "11" }]
2767 }));
2768 then.status(200)
2769 .header("content-type", "application/json")
2770 .body(
2771 r#"{
2772 "id": "42",
2773 "title": "ADR-001 Revised",
2774 "space": { "key": "ENG" },
2775 "version": {
2776 "number": 8,
2777 "when": "2026-04-26T11:00:00.000Z",
2778 "by": { "displayName": "Bob" }
2779 },
2780 "_links": { "base": "https://wiki.example.com", "webui": "/pages/viewpage.action?pageId=42" }
2781 }"#,
2782 );
2783 });
2784
2785 let client =
2786 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
2787 let page = client
2788 .update_page(UpdatePageParams {
2789 page_id: "42".into(),
2790 title: Some("ADR-001 Revised".into()),
2791 content: Some("New **decision**".into()),
2792 content_type: Some("markdown".into()),
2793 version: Some(7),
2794 labels: None,
2795 parent_id: Some("11".into()),
2796 })
2797 .await
2798 .unwrap();
2799
2800 get_mock.assert();
2801 put_mock.assert();
2802 assert_eq!(page.id, "42");
2803 assert_eq!(page.title, "ADR-001 Revised");
2804 assert_eq!(page.version, Some(8));
2805 assert_eq!(page.author.as_deref(), Some("Bob"));
2806 }
2807
2808 #[tokio::test]
2809 async fn update_page_uses_v2_pages_when_preferred() {
2810 let server = MockServer::start();
2811 let get_mock = server.mock(|when, then| {
2812 when.method(GET)
2813 .path("/api/v2/pages/42")
2814 .query_param("body-format", "storage");
2815 then.status(200)
2816 .header("content-type", "application/json")
2817 .body(
2818 r#"{
2819 "id": "42",
2820 "title": "ADR-001",
2821 "spaceId": "123",
2822 "parentId": "10",
2823 "version": { "number": 7 },
2824 "body": {
2825 "representation": "storage",
2826 "value": "<p>Old</p>"
2827 }
2828 }"#,
2829 );
2830 });
2831 let put_mock = server.mock(|when, then| {
2832 when.method(PUT)
2833 .path("/api/v2/pages/42")
2834 .header("authorization", "Bearer secret-token")
2835 .header("content-type", "application/json")
2836 .json_body_obj(&serde_json::json!({
2837 "id": "42",
2838 "status": "current",
2839 "title": "ADR-001 Revised",
2840 "spaceId": "123",
2841 "parentId": "11",
2842 "body": {
2843 "value": "<p>New <strong>decision</strong></p>",
2844 "representation": "storage"
2845 },
2846 "version": { "number": 8 }
2847 }));
2848 then.status(200)
2849 .header("content-type", "application/json")
2850 .body(
2851 r#"{
2852 "id": "42",
2853 "title": "ADR-001 Revised",
2854 "spaceId": "123",
2855 "version": { "number": 8, "createdAt": "2026-04-26T11:00:00.000Z" }
2856 }"#,
2857 );
2858 });
2859 let space_mock = server.mock(|when, then| {
2860 when.method(GET)
2861 .path("/api/v2/space")
2862 .query_param("limit", "100")
2863 .query_param("type", "global,personal");
2864 then.status(200)
2865 .header("content-type", "application/json")
2866 .body(
2867 r#"{
2868 "results": [
2869 { "id": "123", "key": "ENG", "name": "Engineering" }
2870 ],
2871 "_links": {}
2872 }"#,
2873 );
2874 });
2875
2876 let client =
2877 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"))
2878 .with_api_version(Some("v2"));
2879 let page = client
2880 .update_page(UpdatePageParams {
2881 page_id: "42".into(),
2882 title: Some("ADR-001 Revised".into()),
2883 content: Some("New **decision**".into()),
2884 content_type: Some("markdown".into()),
2885 version: Some(7),
2886 labels: None,
2887 parent_id: Some("11".into()),
2888 })
2889 .await
2890 .unwrap();
2891
2892 get_mock.assert();
2893 put_mock.assert();
2894 space_mock.assert();
2895 assert_eq!(page.id, "42");
2896 assert_eq!(page.title, "ADR-001 Revised");
2897 assert_eq!(page.space_key.as_deref(), Some("ENG"));
2898 assert_eq!(page.version, Some(8));
2899 }
2900
2901 #[tokio::test]
2902 async fn update_page_returns_conflict_when_expected_version_is_stale() {
2903 let server = MockServer::start();
2904 let get_mock = server.mock(|when, then| {
2905 when.method(GET)
2906 .path("/rest/api/content/42")
2907 .query_param("expand", "space,version,body.storage,ancestors");
2908 then.status(200)
2909 .header("content-type", "application/json")
2910 .body(
2911 r#"{
2912 "id": "42",
2913 "title": "ADR-001",
2914 "space": { "key": "ENG" },
2915 "version": { "number": 7 },
2916 "body": {
2917 "storage": {
2918 "value": "<p>Old</p>",
2919 "representation": "storage"
2920 }
2921 },
2922 "ancestors": [],
2923 "_links": {}
2924 }"#,
2925 );
2926 });
2927
2928 let client =
2929 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
2930 let error = client
2931 .update_page(UpdatePageParams {
2932 page_id: "42".into(),
2933 title: Some("ADR-001 Revised".into()),
2934 content: Some("<p>New</p>".into()),
2935 content_type: Some("storage".into()),
2936 version: Some(6),
2937 labels: None,
2938 parent_id: None,
2939 })
2940 .await
2941 .unwrap_err();
2942
2943 get_mock.assert();
2944 match error {
2945 Error::Api { status, message } => {
2946 assert_eq!(status, 409);
2947 assert!(message.contains("expected current version 6"));
2948 assert!(message.contains("found 7"));
2949 }
2950 other => panic!("expected conflict error, got {other:?}"),
2951 }
2952 }
2953
2954 #[tokio::test]
2955 async fn update_page_replaces_labels() {
2956 let server = MockServer::start();
2957 let get_mock = server.mock(|when, then| {
2958 when.method(GET).path("/rest/api/content/42").query_param(
2959 "expand",
2960 "space,version,body.storage,ancestors,metadata.labels",
2961 );
2962 then.status(200)
2963 .header("content-type", "application/json")
2964 .body(
2965 r#"{
2966 "id": "42",
2967 "title": "ADR-001",
2968 "space": { "key": "ENG" },
2969 "version": { "number": 7 },
2970 "body": {
2971 "storage": {
2972 "value": "<p>Old</p>",
2973 "representation": "storage"
2974 }
2975 },
2976 "metadata": {
2977 "labels": {
2978 "results": [
2979 { "name": "adr" },
2980 { "name": "obsolete" }
2981 ]
2982 }
2983 },
2984 "ancestors": [],
2985 "_links": {}
2986 }"#,
2987 );
2988 });
2989 let put_mock = server.mock(|when, then| {
2990 when.method(PUT)
2991 .path("/rest/api/content/42")
2992 .header("authorization", "Bearer secret-token")
2993 .header("content-type", "application/json");
2994 then.status(200)
2995 .header("content-type", "application/json")
2996 .body(
2997 r#"{
2998 "id": "42",
2999 "title": "ADR-001 Revised",
3000 "space": { "key": "ENG" },
3001 "version": { "number": 8 }
3002 }"#,
3003 );
3004 });
3005 let delete_mock = server.mock(|when, then| {
3006 when.method(httpmock::Method::DELETE)
3007 .path("/rest/api/content/42/label")
3008 .query_param("name", "obsolete")
3009 .header("authorization", "Bearer secret-token");
3010 then.status(204);
3011 });
3012 let add_mock = server.mock(|when, then| {
3013 when.method(POST)
3014 .path("/rest/api/content/42/label")
3015 .header("authorization", "Bearer secret-token")
3016 .header("content-type", "application/json")
3017 .json_body_obj(&serde_json::json!([
3018 { "prefix": "global", "name": "architecture" }
3019 ]));
3020 then.status(200)
3021 .header("content-type", "application/json")
3022 .body("[]");
3023 });
3024
3025 let client =
3026 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
3027 let page = client
3028 .update_page(UpdatePageParams {
3029 page_id: "42".into(),
3030 title: Some("ADR-001 Revised".into()),
3031 content: Some("<p>New</p>".into()),
3032 content_type: Some("storage".into()),
3033 version: Some(7),
3034 labels: Some(vec!["adr".into(), "architecture".into()]),
3035 parent_id: None,
3036 })
3037 .await
3038 .unwrap();
3039
3040 get_mock.assert();
3041 put_mock.assert();
3042 delete_mock.assert();
3043 add_mock.assert();
3044 assert_eq!(page.version, Some(8));
3045 }
3046
3047 #[test]
3048 fn storage_and_markdown_converters_cover_basic_formatting() {
3049 let markdown = confluence_storage_to_markdown(
3050 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>"#,
3051 );
3052 assert_eq!(
3053 markdown,
3054 "## ADR\n\nHello **world** and [link](https://example.com)\n\n- One\n- Two"
3055 );
3056
3057 let storage = markdown_to_confluence_storage(
3058 "## ADR\n\nHello **world** and [link](https://example.com)\n\n- One\n- Two",
3059 );
3060 assert_eq!(
3061 storage,
3062 "<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>"
3063 );
3064 }
3065
3066 #[test]
3067 fn markdown_code_blocks_escape_cdata_terminators() {
3068 let storage = markdown_to_confluence_storage("```xml\nbefore ]]> after\n```");
3069 assert!(storage.contains("<![CDATA[before ]]]]><![CDATA[> after"));
3070 }
3071
3072 #[tokio::test]
3073 async fn search_builds_free_text_cql_and_maps_results() {
3074 let server = MockServer::start();
3075 let mock = server.mock(|when, then| {
3076 when.method(GET)
3077 .path("/rest/api/content/search")
3078 .query_param("cql", "type = page AND space = \"ENG\" AND text ~ \"architecture\"")
3079 .query_param("limit", "10")
3080 .query_param("expand", "space,version,history.lastUpdated,body.view");
3081 then.status(200)
3082 .header("content-type", "application/json")
3083 .body(
3084 r#"{
3085 "results": [
3086 {
3087 "id": "99",
3088 "title": "Architecture Overview",
3089 "space": { "key": "ENG" },
3090 "version": {
3091 "number": 3,
3092 "when": "2026-04-26T10:00:00.000Z",
3093 "by": { "displayName": "Alice" }
3094 },
3095 "body": {
3096 "view": { "value": "<p>System architecture</p>", "representation": "view" }
3097 },
3098 "_links": { "base": "https://wiki.example.com", "webui": "/pages/viewpage.action?pageId=99" }
3099 }
3100 ],
3101 "start": 0,
3102 "limit": 10,
3103 "size": 1,
3104 "totalSize": 1,
3105 "_links": {}
3106 }"#,
3107 );
3108 });
3109
3110 let client =
3111 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
3112 let result = client
3113 .search(SearchKbParams {
3114 query: "architecture".into(),
3115 space_key: Some("ENG".into()),
3116 cursor: None,
3117 limit: Some(10),
3118 raw_query: false,
3119 })
3120 .await
3121 .unwrap();
3122
3123 mock.assert();
3124 assert_eq!(result.items.len(), 1);
3125 assert_eq!(result.items[0].id, "99");
3126 assert_eq!(result.items[0].title, "Architecture Overview");
3127 assert_eq!(result.items[0].space_key.as_deref(), Some("ENG"));
3128 }
3129
3130 #[tokio::test]
3131 async fn search_uses_raw_cql_and_cursor_path() {
3132 let server = MockServer::start();
3133 let mock = server.mock(|when, then| {
3134 when.method(GET)
3135 .path("/rest/api/content/search")
3136 .query_param("cql", "label = \"adr\"")
3137 .query_param("limit", "5")
3138 .query_param("expand", "space,version,history.lastUpdated,body.view");
3139 then.status(200)
3140 .header("content-type", "application/json")
3141 .body(
3142 r#"{
3143 "results": [],
3144 "start": 0,
3145 "limit": 5,
3146 "size": 0,
3147 "totalSize": 6,
3148 "_links": { "next": "/rest/api/content/search?cql=label%20%3D%20%22adr%22&limit=5&start=5" }
3149 }"#,
3150 );
3151 });
3152 let next_mock = server.mock(|when, then| {
3153 when.method(GET)
3154 .path("/rest/api/content/search")
3155 .query_param("cql", "label = \"adr\"")
3156 .query_param("limit", "5")
3157 .query_param("start", "5");
3158 then.status(200)
3159 .header("content-type", "application/json")
3160 .body(
3161 r#"{
3162 "results": [
3163 {
3164 "id": "123",
3165 "title": "ADR-123",
3166 "space": { "key": "ENG" },
3167 "_links": { "base": "https://wiki.example.com", "webui": "/pages/viewpage.action?pageId=123" }
3168 }
3169 ],
3170 "start": 5,
3171 "limit": 5,
3172 "size": 1,
3173 "totalSize": 6,
3174 "_links": {}
3175 }"#,
3176 );
3177 });
3178
3179 let client =
3180 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
3181 let first = client
3182 .search(SearchKbParams {
3183 query: r#"label = "adr""#.into(),
3184 space_key: None,
3185 cursor: None,
3186 limit: Some(5),
3187 raw_query: true,
3188 })
3189 .await
3190 .unwrap();
3191 let next_cursor = first
3192 .pagination
3193 .as_ref()
3194 .and_then(|p| p.next_cursor.clone());
3195
3196 mock.assert();
3197 assert!(first.items.is_empty());
3198 assert_eq!(
3199 next_cursor.as_deref(),
3200 Some("/rest/api/content/search?cql=label%20%3D%20%22adr%22&limit=5&start=5")
3201 );
3202
3203 let second = client
3204 .search(SearchKbParams {
3205 query: String::new(),
3206 space_key: None,
3207 cursor: next_cursor,
3208 limit: Some(5),
3209 raw_query: true,
3210 })
3211 .await
3212 .unwrap();
3213
3214 next_mock.assert();
3215 assert_eq!(second.items.len(), 1);
3216 assert_eq!(second.items[0].id, "123");
3217 assert_eq!(second.items[0].title, "ADR-123");
3218 }
3219
3220 #[tokio::test]
3221 async fn search_percent_encodes_reserved_query_characters() {
3222 let server = MockServer::start();
3223 let mock = server.mock(|when, then| {
3224 when.method(GET)
3225 .path("/rest/api/content/search")
3226 .query_param("cql", "type = page AND text ~ \"R&D?x=y+z\"")
3227 .query_param("limit", "5")
3228 .query_param("expand", "space,version,history.lastUpdated,body.view");
3229 then.status(200)
3230 .header("content-type", "application/json")
3231 .body(r#"{"results":[],"start":0,"limit":5,"size":0,"_links":{}}"#);
3232 });
3233
3234 let client =
3235 ConfluenceClient::new(server.base_url(), ConfluenceAuth::bearer("secret-token"));
3236 let result = client
3237 .search(SearchKbParams {
3238 query: "R&D?x=y+z".into(),
3239 space_key: None,
3240 cursor: None,
3241 limit: Some(5),
3242 raw_query: false,
3243 })
3244 .await
3245 .unwrap();
3246
3247 mock.assert();
3248 assert!(result.items.is_empty());
3249 }
3250}