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