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