Skip to main content

devboy_confluence/
client.rs

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