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