Skip to main content

omni_dev/atlassian/
confluence_types.rs

1//! Confluence Cloud REST API wire types (DTOs).
2//!
3//! Response and request data-transfer objects for the Confluence endpoints
4//! served by [`crate::atlassian::client::AtlassianClient`] and
5//! [`crate::atlassian::confluence_api::ConfluenceApi`]. Split out of `client.rs`
6//! and `confluence_api.rs` (see issue #1156) so those modules hold only
7//! transport logic.
8
9use anyhow::{Context, Result};
10use serde::{Deserialize, Serialize};
11
12/// A single hit from a Confluence CQL search (`GET /wiki/rest/api/content/search`).
13///
14/// See [`ConfluenceSearchResults`] for the paginated wrapper.
15#[derive(Debug, Clone, Serialize)]
16pub struct ConfluenceSearchResult {
17    /// Page ID.
18    pub id: String,
19    /// Page title.
20    pub title: String,
21    /// Space key (e.g., "ENG").
22    pub space_key: String,
23}
24
25/// Paginated wrapper around [`ConfluenceSearchResult`] hits from
26/// `GET /wiki/rest/api/content/search`.
27#[derive(Debug, Clone, Serialize)]
28pub struct ConfluenceSearchResults {
29    /// Matching pages.
30    pub results: Vec<ConfluenceSearchResult>,
31    /// Total number of matching results.
32    pub total: u32,
33}
34
35/// A single user hit from `GET /wiki/rest/api/search/user`.
36///
37/// See [`ConfluenceUserSearchResults`] for the paginated wrapper.
38#[derive(Debug, Clone, Serialize)]
39pub struct ConfluenceUserSearchResult {
40    /// Account ID (unique identifier). Absent for some user types such as
41    /// app users or deactivated users.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub account_id: Option<String>,
44    /// Display name.
45    pub display_name: String,
46    /// Email address.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub email: Option<String>,
49}
50
51/// Paginated wrapper around [`ConfluenceUserSearchResult`] hits from
52/// `GET /wiki/rest/api/search/user`.
53#[derive(Debug, Clone, Serialize)]
54pub struct ConfluenceUserSearchResults {
55    /// Matching users.
56    pub users: Vec<ConfluenceUserSearchResult>,
57    /// Total number of matching results.
58    pub total: u32,
59}
60
61/// A single user resolved by account ID via `GET /wiki/rest/api/user?accountId=`.
62///
63/// The Confluence v1 user object does not report an `active` flag, so `active`
64/// is always `None` here. Failure-tolerant in the same way as
65/// [`JiraUserRecord`](crate::atlassian::jira_types::JiraUserRecord). See [`ConfluenceUserGetResults`] for the batch wrapper.
66#[derive(Debug, Clone, Serialize)]
67pub struct ConfluenceUserRecord {
68    /// Account ID (always present — echoed back even when the lookup failed).
69    pub account_id: String,
70    /// Display name (falls back to `publicName` when `displayName` is absent).
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub display_name: Option<String>,
73    /// Email address. Absent unless the caller has permission to see it.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub email: Option<String>,
76    /// Account type, e.g. `"atlassian"`, `"app"`.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub account_type: Option<String>,
79    /// Always `None` — the Confluence v1 user endpoint does not return an
80    /// active flag. Present for parity with [`JiraUserRecord`](crate::atlassian::jira_types::JiraUserRecord).
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub active: Option<bool>,
83    /// Reason this ID could not be resolved (e.g. `"HTTP 404"`). Absent on
84    /// success.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub error: Option<String>,
87}
88
89/// Batch wrapper around [`ConfluenceUserRecord`] from resolving one or more
90/// account IDs via `GET /wiki/rest/api/user?accountId=`.
91#[derive(Debug, Clone, Serialize)]
92pub struct ConfluenceUserGetResults {
93    /// Resolved users, one per requested account ID (in request order).
94    pub users: Vec<ConfluenceUserRecord>,
95}
96
97#[derive(Deserialize)]
98#[allow(dead_code)]
99pub(crate) struct ConfluenceContentSearchResponse {
100    pub(crate) results: Vec<ConfluenceContentSearchEntry>,
101    #[serde(default)]
102    pub(crate) size: u32,
103    #[serde(rename = "_links", default)]
104    pub(crate) links: Option<ConfluenceSearchLinks>,
105}
106
107#[derive(Deserialize, Default)]
108pub(crate) struct ConfluenceSearchLinks {
109    pub(crate) next: Option<String>,
110}
111
112#[derive(Deserialize)]
113pub(crate) struct ConfluenceContentSearchEntry {
114    pub(crate) id: String,
115    pub(crate) title: String,
116    #[serde(rename = "_expandable")]
117    pub(crate) expandable: Option<ConfluenceExpandable>,
118}
119
120#[derive(Deserialize)]
121pub(crate) struct ConfluenceExpandable {
122    pub(crate) space: Option<String>,
123}
124
125// ── Confluence user search API response structs ───────────────────
126
127#[derive(Deserialize)]
128pub(crate) struct ConfluenceUserSearchResponse {
129    pub(crate) results: Vec<ConfluenceUserSearchEntry>,
130    #[serde(rename = "_links", default)]
131    pub(crate) links: Option<ConfluenceSearchLinks>,
132}
133
134#[derive(Deserialize)]
135pub(crate) struct ConfluenceUserSearchEntry {
136    #[serde(default)]
137    pub(crate) user: Option<ConfluenceSearchUser>,
138}
139
140#[derive(Deserialize)]
141pub(crate) struct ConfluenceSearchUser {
142    #[serde(rename = "accountId", default)]
143    pub(crate) account_id: Option<String>,
144    #[serde(rename = "displayName", default)]
145    pub(crate) display_name: Option<String>,
146    #[serde(default)]
147    pub(crate) email: Option<String>,
148    #[serde(rename = "publicName", default)]
149    pub(crate) public_name: Option<String>,
150}
151
152// ── Confluence user-get API response struct ───────────────────────
153
154/// Deserialization helper for `GET /wiki/rest/api/user?accountId=` — a bare
155/// user object (not wrapped in `results`). `publicName` is the fallback when
156/// `displayName` is unavailable; the v1 endpoint has no `active` flag.
157#[derive(Deserialize)]
158pub(crate) struct ConfluenceUserGetEntry {
159    #[serde(rename = "accountId", default)]
160    pub(crate) account_id: Option<String>,
161    #[serde(rename = "accountType", default)]
162    pub(crate) account_type: Option<String>,
163    #[serde(rename = "displayName", default)]
164    pub(crate) display_name: Option<String>,
165    #[serde(rename = "publicName", default)]
166    pub(crate) public_name: Option<String>,
167    #[serde(default)]
168    pub(crate) email: Option<String>,
169}
170
171// ── Internal API response structs ───────────────────────────────────
172
173#[derive(Deserialize)]
174pub(crate) struct ConfluencePageResponse {
175    pub(crate) id: String,
176    pub(crate) title: String,
177    pub(crate) status: String,
178    #[serde(rename = "spaceId")]
179    pub(crate) space_id: String,
180    pub(crate) version: Option<ConfluenceVersion>,
181    pub(crate) body: Option<ConfluenceBody>,
182    #[serde(rename = "parentId")]
183    pub(crate) parent_id: Option<String>,
184    #[serde(default)]
185    pub(crate) ancestors: Vec<ConfluenceAncestorEntry>,
186}
187
188#[derive(Deserialize)]
189pub(crate) struct ConfluenceAncestorEntry {
190    pub(crate) id: String,
191}
192
193#[derive(Deserialize)]
194pub(crate) struct ConfluenceVersion {
195    pub(crate) number: u32,
196}
197
198#[derive(Deserialize)]
199pub(crate) struct ConfluenceBody {
200    pub(crate) atlas_doc_format: Option<ConfluenceAtlasDoc>,
201}
202
203#[derive(Deserialize)]
204pub(crate) struct ConfluenceAtlasDoc {
205    pub(crate) value: String,
206}
207
208// ── Space lookup ────────────────────────────────────────────────────
209
210#[derive(Deserialize)]
211pub(crate) struct ConfluenceSpaceResponse {
212    pub(crate) key: String,
213}
214
215#[derive(Deserialize)]
216pub(crate) struct ConfluenceSpacesResponse {
217    pub(crate) results: Vec<ConfluenceSpaceEntry>,
218    #[serde(rename = "_links", default)]
219    pub(crate) links: Option<ConfluenceSpaceLinks>,
220}
221
222#[derive(Deserialize)]
223pub(crate) struct ConfluenceSpaceLinks {
224    pub(crate) next: Option<String>,
225}
226
227#[derive(Deserialize)]
228pub(crate) struct ConfluenceSpaceEntry {
229    pub(crate) id: String,
230    #[serde(default)]
231    pub(crate) key: Option<String>,
232    #[serde(default)]
233    pub(crate) name: Option<String>,
234    #[serde(rename = "type", default)]
235    pub(crate) type_: Option<String>,
236    #[serde(default)]
237    pub(crate) status: Option<String>,
238    #[serde(rename = "homepageId", default)]
239    pub(crate) homepage_id: Option<String>,
240}
241
242/// A Confluence space.
243#[derive(Debug, Clone, Serialize)]
244pub struct ConfluenceSpace {
245    /// Space ID.
246    pub id: String,
247    /// Space key (e.g. "ENG").
248    pub key: String,
249    /// Display name.
250    pub name: String,
251    /// Space type ("global", "personal", "collaboration", "knowledge_base").
252    #[serde(rename = "type")]
253    pub type_: String,
254    /// Status ("current" or "archived").
255    pub status: String,
256    /// Homepage page ID, when reported by the API.
257    #[serde(rename = "homepageId", skip_serializing_if = "Option::is_none")]
258    pub homepage_id: Option<String>,
259}
260
261impl From<ConfluenceSpaceEntry> for ConfluenceSpace {
262    fn from(e: ConfluenceSpaceEntry) -> Self {
263        Self {
264            id: e.id,
265            key: e.key.unwrap_or_default(),
266            name: e.name.unwrap_or_default(),
267            type_: e.type_.unwrap_or_default(),
268            status: e.status.unwrap_or_default(),
269            homepage_id: e.homepage_id,
270        }
271    }
272}
273
274/// A page of spaces returned by [`ConfluenceApi::list_spaces`](crate::atlassian::confluence_api::ConfluenceApi::list_spaces).
275///
276/// Pagination is *not* auto-drained: callers receive one page at a time and
277/// pass `next_cursor` back to fetch the next page. Mirrors the
278/// [`ConfluenceAttachmentPage`] shape so MCP/CLI callers can stream large
279/// space inventories without buffering everything in memory.
280#[derive(Debug, Clone, Serialize)]
281pub struct ConfluenceSpacePage {
282    /// Spaces on this page.
283    pub results: Vec<ConfluenceSpace>,
284    /// Opaque cursor for the next page, when present.
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub next_cursor: Option<String>,
287}
288
289/// A summary record for a Confluence page in a space.
290#[derive(Debug, Clone, Serialize)]
291pub struct PageSummary {
292    /// Page ID.
293    pub id: String,
294    /// Page title.
295    pub title: String,
296    /// Page status (e.g. `current`, `archived`, `draft`, `trashed`).
297    #[serde(skip_serializing_if = "String::is_empty")]
298    pub status: String,
299    /// Parent page ID, when reported by the API.
300    #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
301    pub parent_id: Option<String>,
302    /// Author account ID, when reported by the API.
303    #[serde(rename = "authorId", skip_serializing_if = "Option::is_none")]
304    pub author_id: Option<String>,
305    /// ISO 8601 creation timestamp, when reported by the API.
306    #[serde(rename = "createdAt", skip_serializing_if = "Option::is_none")]
307    pub created_at: Option<String>,
308}
309
310/// A page of [`PageSummary`] records returned by
311/// [`ConfluenceApi::list_space_pages`](crate::atlassian::confluence_api::ConfluenceApi::list_space_pages).
312///
313/// Pagination is *not* auto-drained: callers receive one page at a time and
314/// pass `next_cursor` back to fetch the next page. Spaces can contain
315/// thousands of pages, so we avoid buffering the whole inventory in memory.
316#[derive(Debug, Clone, Serialize)]
317pub struct PageSummaryPage {
318    /// Pages on this response.
319    pub results: Vec<PageSummary>,
320    /// Opaque cursor for the next page, when present.
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub next_cursor: Option<String>,
323}
324
325// ── Children response ──────────────────────────────────────────────
326
327#[derive(Deserialize)]
328pub(crate) struct ConfluenceChildrenResponse {
329    pub(crate) results: Vec<ConfluenceChildEntry>,
330    #[serde(rename = "_links", default)]
331    pub(crate) links: Option<ConfluenceChildrenLinks>,
332}
333
334#[derive(Deserialize)]
335pub(crate) struct ConfluenceChildEntry {
336    pub(crate) id: String,
337    pub(crate) title: String,
338    #[serde(default)]
339    pub(crate) status: Option<String>,
340}
341
342#[derive(Deserialize)]
343pub(crate) struct ConfluenceChildrenLinks {
344    pub(crate) next: Option<String>,
345}
346
347// V2 space-pages response (for `depth=root`).
348#[derive(Deserialize)]
349pub(crate) struct ConfluenceSpacePagesResponse {
350    pub(crate) results: Vec<ConfluenceSpacePageEntry>,
351    #[serde(rename = "_links", default)]
352    pub(crate) links: Option<ConfluenceChildrenLinks>,
353}
354
355#[derive(Deserialize)]
356pub(crate) struct ConfluenceSpacePageEntry {
357    pub(crate) id: String,
358    pub(crate) title: String,
359    #[serde(default)]
360    pub(crate) status: Option<String>,
361    #[serde(rename = "parentId", default)]
362    pub(crate) parent_id: Option<String>,
363}
364
365// V2 space-pages response carrying author/createdAt for `list_space_pages`.
366// Kept separate from `ConfluenceSpacePagesResponse` to avoid widening that
367// type's contract (used by `get_space_root_pages`).
368#[derive(Deserialize)]
369pub(crate) struct ConfluenceSpacePagesSummaryResponse {
370    pub(crate) results: Vec<ConfluenceSpacePageSummaryEntry>,
371    #[serde(rename = "_links", default)]
372    pub(crate) links: Option<ConfluenceChildrenLinks>,
373}
374
375#[derive(Deserialize)]
376pub(crate) struct ConfluenceSpacePageSummaryEntry {
377    pub(crate) id: String,
378    pub(crate) title: String,
379    #[serde(default)]
380    pub(crate) status: Option<String>,
381    #[serde(rename = "parentId", default)]
382    pub(crate) parent_id: Option<String>,
383    #[serde(rename = "authorId", default)]
384    pub(crate) author_id: Option<String>,
385    #[serde(rename = "createdAt", default)]
386    pub(crate) created_at: Option<String>,
387}
388
389/// A child page returned from the children API.
390#[derive(Debug, Clone, serde::Serialize)]
391pub struct ChildPage {
392    /// Page ID.
393    pub id: String,
394    /// Page title.
395    pub title: String,
396    /// Page status (e.g. "current", "draft"). Empty if not provided by the API.
397    #[serde(default, skip_serializing_if = "String::is_empty")]
398    pub status: String,
399    /// Parent page ID, if known.
400    #[serde(default, skip_serializing_if = "Option::is_none")]
401    pub parent_id: Option<String>,
402    /// Space key, if known.
403    #[serde(default, skip_serializing_if = "Option::is_none")]
404    pub space_key: Option<String>,
405}
406
407// ── Comment types ─────────────────────────────────────────────────
408
409/// Distinguishes the two kinds of Confluence page comments.
410///
411/// Confluence v2 exposes footer comments (page-level discussion) and inline
412/// comments (anchored to a text selection) on separate endpoints. Tracking the
413/// kind on each [`ConfluenceComment`] lets a merged listing identify which
414/// endpoint each entry came from.
415#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
416#[serde(rename_all = "lowercase")]
417pub enum CommentKind {
418    /// A page-level footer comment.
419    Footer,
420    /// A comment anchored to a text selection in the page body.
421    Inline,
422}
423
424impl CommentKind {
425    /// Returns the URL segment Confluence v2 uses for this kind
426    /// (`"footer-comments"` or `"inline-comments"`).
427    #[must_use]
428    pub fn endpoint_segment(self) -> &'static str {
429        match self {
430            Self::Footer => "footer-comments",
431            Self::Inline => "inline-comments",
432        }
433    }
434}
435
436impl std::fmt::Display for CommentKind {
437    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
438        match self {
439            Self::Footer => f.write_str("footer"),
440            Self::Inline => f.write_str("inline"),
441        }
442    }
443}
444
445/// Anchor metadata required when creating an inline comment.
446///
447/// Confluence's `inline-comment-properties` payload identifies which text
448/// selection on the page the comment attaches to. `match_index` is 0-based;
449/// `match_count` is the total number of occurrences of `text` on the page.
450#[derive(Debug, Clone)]
451pub struct InlineAnchor {
452    /// The selected text the comment anchors to.
453    pub text: String,
454    /// 0-based index of which occurrence on the page this comment anchors to.
455    pub match_index: usize,
456    /// Total number of occurrences of `text` on the page.
457    pub match_count: usize,
458}
459
460/// A comment on a Confluence page.
461#[derive(Debug, Clone, Serialize)]
462pub struct ConfluenceComment {
463    /// Comment ID.
464    pub id: String,
465    /// Author display name.
466    pub author: String,
467    /// Whether this is a footer or inline comment.
468    pub kind: CommentKind,
469    /// Comment body as raw ADF JSON.
470    pub body_adf: Option<serde_json::Value>,
471    /// ISO 8601 creation timestamp.
472    pub created: String,
473    /// For inline comments: the `id` of the `annotation` mark this comment is
474    /// anchored to inside the page ADF (Confluence's `inlineMarkerRef`). `None`
475    /// for footer comments. Used to locate — and re-anchor — the highlighted run.
476    #[serde(skip_serializing_if = "Option::is_none")]
477    pub inline_marker_ref: Option<String>,
478    /// For inline comments: the plaintext the reviewer originally highlighted
479    /// (Confluence's `inlineOriginalSelection`). This is durable — it does not
480    /// drift when the surrounding page text is edited — so it is the ground
481    /// truth for inline-comment drift auditing. `None` for footer comments.
482    #[serde(skip_serializing_if = "Option::is_none")]
483    pub inline_original_selection: Option<String>,
484}
485
486#[derive(Deserialize)]
487pub(crate) struct ConfluenceCommentsResponse {
488    pub(crate) results: Vec<ConfluenceCommentEntry>,
489    #[serde(rename = "_links", default)]
490    pub(crate) links: Option<ConfluenceCommentsLinks>,
491}
492
493#[derive(Deserialize)]
494pub(crate) struct ConfluenceCommentsLinks {
495    pub(crate) next: Option<String>,
496}
497
498#[derive(Deserialize)]
499pub(crate) struct ConfluenceCommentEntry {
500    pub(crate) id: String,
501    #[serde(default)]
502    pub(crate) version: Option<ConfluenceCommentVersion>,
503    #[serde(default)]
504    pub(crate) body: Option<ConfluenceCommentBody>,
505    /// Inline-comment anchor metadata. Present on the v2 `inline-comments`
506    /// endpoint regardless of `body-format`; absent for footer comments.
507    #[serde(default)]
508    pub(crate) properties: Option<ConfluenceInlineCommentProperties>,
509}
510
511/// The `properties` object on an inline-comment entry. Both fields are optional
512/// so we deserialize tolerantly across footer comments (no `properties`) and any
513/// future shape changes.
514#[derive(Deserialize)]
515pub(crate) struct ConfluenceInlineCommentProperties {
516    /// The `annotation`-mark `id` this comment is anchored to in the page ADF.
517    #[serde(rename = "inlineMarkerRef", default)]
518    pub(crate) inline_marker_ref: Option<String>,
519    /// The plaintext the reviewer highlighted when the comment was posted.
520    #[serde(rename = "inlineOriginalSelection", default)]
521    pub(crate) inline_original_selection: Option<String>,
522}
523
524#[derive(Deserialize)]
525pub(crate) struct ConfluenceCommentVersion {
526    #[serde(rename = "authorId", default)]
527    pub(crate) author_id: Option<String>,
528    #[serde(rename = "createdAt", default)]
529    pub(crate) created_at: Option<String>,
530}
531
532#[derive(Deserialize)]
533pub(crate) struct ConfluenceCommentBody {
534    pub(crate) atlas_doc_format: Option<ConfluenceAtlasDoc>,
535}
536
537#[derive(Serialize)]
538pub(crate) struct ConfluenceAddCommentRequest {
539    #[serde(rename = "pageId")]
540    pub(crate) page_id: String,
541    pub(crate) body: ConfluenceUpdateBody,
542}
543
544#[derive(Serialize)]
545pub(crate) struct ConfluenceAddInlineCommentRequest {
546    #[serde(rename = "pageId")]
547    pub(crate) page_id: String,
548    pub(crate) body: ConfluenceUpdateBody,
549    #[serde(rename = "inlineCommentProperties")]
550    pub(crate) inline_comment_properties: InlineCommentProperties,
551}
552
553#[derive(Serialize)]
554pub(crate) struct InlineCommentProperties {
555    #[serde(rename = "textSelection")]
556    pub(crate) text_selection: String,
557    #[serde(rename = "textSelectionMatchCount")]
558    pub(crate) text_selection_match_count: usize,
559    #[serde(rename = "textSelectionMatchIndex")]
560    pub(crate) text_selection_match_index: usize,
561}
562
563// ── Labels ─────────────────────────────────────────────────────────
564
565#[derive(Deserialize)]
566pub(crate) struct ConfluenceLabelsResponse {
567    pub(crate) results: Vec<ConfluenceLabelEntry>,
568    #[serde(rename = "_links", default)]
569    pub(crate) links: Option<ConfluenceLabelsLinks>,
570}
571
572#[derive(Deserialize)]
573pub(crate) struct ConfluenceLabelEntry {
574    pub(crate) id: String,
575    pub(crate) name: String,
576    pub(crate) prefix: String,
577}
578
579#[derive(Deserialize)]
580pub(crate) struct ConfluenceLabelsLinks {
581    pub(crate) next: Option<String>,
582}
583
584/// A label on a Confluence page.
585#[derive(Debug, Clone, Serialize)]
586pub struct ConfluenceLabel {
587    /// Label ID.
588    pub id: String,
589    /// Label name.
590    pub name: String,
591    /// Label prefix (e.g. "global").
592    pub prefix: String,
593}
594
595#[derive(Serialize)]
596pub(crate) struct ConfluenceAddLabelEntry {
597    pub(crate) prefix: String,
598    pub(crate) name: String,
599}
600
601// ── Versions ───────────────────────────────────────────────────────
602
603#[derive(Deserialize)]
604pub(crate) struct ConfluenceVersionsResponse {
605    pub(crate) results: Vec<ConfluenceVersionEntry>,
606    #[serde(rename = "_links", default)]
607    pub(crate) links: Option<ConfluenceVersionsLinks>,
608}
609
610#[derive(Deserialize)]
611pub(crate) struct ConfluenceVersionEntry {
612    pub(crate) number: u32,
613    #[serde(rename = "createdAt", default)]
614    pub(crate) created_at: Option<String>,
615    #[serde(default)]
616    pub(crate) message: Option<String>,
617    #[serde(rename = "minorEdit", default)]
618    pub(crate) minor_edit: Option<bool>,
619    #[serde(rename = "authorId", default)]
620    pub(crate) author_id: Option<String>,
621}
622
623#[derive(Deserialize)]
624pub(crate) struct ConfluenceVersionsLinks {
625    pub(crate) next: Option<String>,
626}
627
628/// A single version entry from a Confluence page's history.
629///
630/// Optional fields (`created_at`, `author_id`, `message`) are returned as
631/// empty strings when the API omits them — older pages can have null author
632/// or timestamp data, see issue #708.
633#[derive(Debug, Clone, Serialize, Deserialize)]
634pub struct PageVersion {
635    /// Version number (1-based; current version at the head of the list).
636    pub number: u32,
637    /// ISO 8601 creation timestamp; empty if the API returned null.
638    #[serde(default)]
639    pub created_at: String,
640    /// Account ID of the author; empty if the API returned null.
641    #[serde(default)]
642    pub author_id: String,
643    /// Version comment / edit message; empty if the API returned null.
644    #[serde(default)]
645    pub message: String,
646    /// Whether the edit was marked as minor.
647    #[serde(default)]
648    pub minor_edit: bool,
649}
650
651/// Filter applied to a version listing.
652#[derive(Debug, Clone, PartialEq, Eq)]
653pub enum SinceFilter {
654    /// Keep versions whose `number >= n`.
655    Version(u32),
656    /// Keep versions whose `created_at >= iso` (lexicographic compare on
657    /// ISO 8601 strings — ordering is correct as long as the timestamps
658    /// are fully qualified with offsets, which Confluence's API guarantees).
659    CreatedAt(String),
660}
661
662impl SinceFilter {
663    /// Parses a `since` parameter. A purely numeric input is interpreted as
664    /// a version number; anything containing `-` or `T` (the typical ISO 8601
665    /// markers) is treated as a date.
666    pub fn parse(raw: &str) -> Result<Self> {
667        let trimmed = raw.trim();
668        if trimmed.is_empty() {
669            anyhow::bail!("`since` must be a version number or ISO 8601 date");
670        }
671        if trimmed.chars().all(|c| c.is_ascii_digit()) {
672            let n: u32 = trimmed
673                .parse()
674                .with_context(|| format!("Invalid version number \"{trimmed}\""))?;
675            return Ok(Self::Version(n));
676        }
677        if trimmed.contains('-') || trimmed.contains('T') {
678            return Ok(Self::CreatedAt(trimmed.to_string()));
679        }
680        anyhow::bail!(
681            "`since` must be a numeric version (e.g. \"5\") or ISO 8601 date \
682             (e.g. \"2026-01-01T00:00:00Z\"); got \"{trimmed}\""
683        );
684    }
685
686    /// Whether `version` satisfies this filter (i.e. should be kept).
687    pub(crate) fn matches(&self, version: &PageVersion) -> bool {
688        match self {
689            Self::Version(min) => version.number >= *min,
690            Self::CreatedAt(min) => {
691                if version.created_at.is_empty() {
692                    // Tolerate missing timestamps: treat as too-old.
693                    false
694                } else {
695                    version.created_at.as_str() >= min.as_str()
696                }
697            }
698        }
699    }
700}
701
702// ── Page metadata ──────────────────────────────────────────────────
703
704/// Lightweight metadata about a Confluence page, returned by
705/// [`ConfluenceApi::get_page_metadata`](crate::atlassian::confluence_api::ConfluenceApi::get_page_metadata).
706#[derive(Debug, Clone, Serialize)]
707pub struct PageMetadata {
708    /// Page ID.
709    pub id: String,
710    /// Page title.
711    pub title: String,
712    /// Current version number, if known.
713    pub current_version: Option<u32>,
714}
715
716// ── Attachments ────────────────────────────────────────────────────
717
718#[derive(Deserialize)]
719pub(crate) struct ConfluenceAttachmentsResponse {
720    pub(crate) results: Vec<ConfluenceAttachmentEntry>,
721    #[serde(rename = "_links", default)]
722    pub(crate) links: Option<ConfluenceAttachmentLinks>,
723}
724
725#[derive(Deserialize)]
726pub(crate) struct ConfluenceAttachmentLinks {
727    pub(crate) next: Option<String>,
728}
729
730#[derive(Deserialize)]
731pub(crate) struct ConfluenceAttachmentEntry {
732    pub(crate) id: String,
733    pub(crate) title: String,
734    #[serde(rename = "mediaType", default)]
735    pub(crate) media_type: Option<String>,
736    #[serde(rename = "fileSize", default)]
737    pub(crate) file_size: Option<u64>,
738    #[serde(rename = "downloadLink", default)]
739    pub(crate) download_link: Option<String>,
740    #[serde(default)]
741    pub(crate) version: Option<ConfluenceAttachmentVersion>,
742    #[serde(rename = "pageId", default)]
743    pub(crate) page_id: Option<String>,
744    #[serde(rename = "fileId", default)]
745    pub(crate) file_id: Option<String>,
746}
747
748#[derive(Deserialize)]
749pub(crate) struct ConfluenceAttachmentVersion {
750    pub(crate) number: u32,
751}
752
753// ── v1 attachment-upload response ───────────────────────────────────
754//
755// Attachment *creation* is only available on the Confluence Cloud v1 REST
756// API (`POST /wiki/rest/api/content/{id}/child/attachment`); the v2 API
757// exposes no attachment-creation endpoint. The v1 response nests its
758// metadata differently from the v2 list shape above, so it needs its own
759// deserialization structs.
760#[derive(Deserialize)]
761pub(crate) struct ConfluenceV1AttachmentResponse {
762    pub(crate) results: Vec<ConfluenceV1AttachmentEntry>,
763}
764
765#[derive(Deserialize)]
766pub(crate) struct ConfluenceV1AttachmentEntry {
767    pub(crate) id: String,
768    pub(crate) title: String,
769    #[serde(default)]
770    pub(crate) extensions: Option<ConfluenceV1AttachmentExtensions>,
771    #[serde(default)]
772    pub(crate) version: Option<ConfluenceAttachmentVersion>,
773    #[serde(default)]
774    pub(crate) container: Option<ConfluenceV1AttachmentContainer>,
775    #[serde(rename = "_links", default)]
776    pub(crate) links: Option<ConfluenceV1AttachmentEntryLinks>,
777}
778
779#[derive(Deserialize)]
780pub(crate) struct ConfluenceV1AttachmentExtensions {
781    #[serde(rename = "mediaType", default)]
782    pub(crate) media_type: Option<String>,
783    #[serde(rename = "fileSize", default)]
784    pub(crate) file_size: Option<u64>,
785    #[serde(rename = "fileId", default)]
786    pub(crate) file_id: Option<String>,
787}
788
789#[derive(Deserialize)]
790pub(crate) struct ConfluenceV1AttachmentContainer {
791    #[serde(default)]
792    pub(crate) id: Option<String>,
793}
794
795#[derive(Deserialize)]
796pub(crate) struct ConfluenceV1AttachmentEntryLinks {
797    #[serde(default)]
798    pub(crate) download: Option<String>,
799}
800
801impl From<ConfluenceV1AttachmentEntry> for ConfluenceAttachment {
802    fn from(e: ConfluenceV1AttachmentEntry) -> Self {
803        let (media_type, file_size, file_id) = match e.extensions {
804            Some(x) => (x.media_type, x.file_size, x.file_id),
805            None => (None, None, None),
806        };
807        Self {
808            id: e.id,
809            title: e.title,
810            media_type,
811            file_size,
812            download_url: e.links.and_then(|l| l.download),
813            version: e.version.map(|v| v.number),
814            page_id: e.container.and_then(|c| c.id),
815            file_id,
816        }
817    }
818}
819
820/// An attachment on a Confluence page.
821#[derive(Debug, Clone, Serialize)]
822pub struct ConfluenceAttachment {
823    /// Attachment ID (used for delete and get).
824    pub id: String,
825    /// Display title (filename).
826    pub title: String,
827    /// MIME type, when reported by the API.
828    #[serde(skip_serializing_if = "Option::is_none")]
829    pub media_type: Option<String>,
830    /// File size in bytes, when reported by the API.
831    #[serde(skip_serializing_if = "Option::is_none")]
832    pub file_size: Option<u64>,
833    /// Download URL path or absolute URL, when reported by the API.
834    #[serde(skip_serializing_if = "Option::is_none")]
835    pub download_url: Option<String>,
836    /// Version number, when reported by the API.
837    #[serde(skip_serializing_if = "Option::is_none")]
838    pub version: Option<u32>,
839    /// Owning page ID, when reported by the API.
840    #[serde(skip_serializing_if = "Option::is_none")]
841    pub page_id: Option<String>,
842    /// Underlying file ID, when reported by the API.
843    #[serde(skip_serializing_if = "Option::is_none")]
844    pub file_id: Option<String>,
845}
846
847impl From<ConfluenceAttachmentEntry> for ConfluenceAttachment {
848    fn from(e: ConfluenceAttachmentEntry) -> Self {
849        Self {
850            id: e.id,
851            title: e.title,
852            media_type: e.media_type,
853            file_size: e.file_size,
854            download_url: e.download_link,
855            version: e.version.map(|v| v.number),
856            page_id: e.page_id,
857            file_id: e.file_id,
858        }
859    }
860}
861
862/// A page of attachments returned by [`ConfluenceApi::list_attachments`](crate::atlassian::confluence_api::ConfluenceApi::list_attachments).
863///
864/// Pagination is *not* auto-drained: callers receive one page at a time and
865/// pass `next_cursor` back to fetch the next page. Other v2 list helpers in
866/// this module (e.g. [`ConfluenceApi::get_labels`](crate::atlassian::confluence_api::ConfluenceApi::get_labels)) auto-drain — attachments
867/// expose the cursor explicitly so MCP/CLI callers can stream very large
868/// attachment lists without buffering everything in memory.
869#[derive(Debug, Clone, Serialize)]
870pub struct ConfluenceAttachmentPage {
871    /// Attachments on this page.
872    pub results: Vec<ConfluenceAttachment>,
873    /// Opaque cursor for the next page, when present.
874    #[serde(skip_serializing_if = "Option::is_none")]
875    pub next_cursor: Option<String>,
876}
877
878// ── Create request ─────────────────────────────────────────────────
879
880#[derive(Serialize)]
881pub(crate) struct ConfluenceCreateRequest {
882    #[serde(rename = "spaceId")]
883    pub(crate) space_id: String,
884    pub(crate) title: String,
885    pub(crate) body: ConfluenceUpdateBody,
886    #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
887    pub(crate) parent_id: Option<String>,
888    pub(crate) status: String,
889}
890
891#[derive(Deserialize)]
892pub(crate) struct ConfluenceCreateResponse {
893    pub(crate) id: String,
894}
895
896// ── Update request ──────────────────────────────────────────────────
897
898#[derive(Serialize)]
899pub(crate) struct ConfluenceUpdateRequest {
900    pub(crate) id: String,
901    pub(crate) status: String,
902    pub(crate) title: String,
903    pub(crate) body: ConfluenceUpdateBody,
904    pub(crate) version: ConfluenceUpdateVersion,
905}
906
907#[derive(Serialize)]
908pub(crate) struct ConfluenceUpdateBody {
909    pub(crate) representation: String,
910    pub(crate) value: String,
911}
912
913#[derive(Serialize)]
914pub(crate) struct ConfluenceUpdateVersion {
915    pub(crate) number: u32,
916    pub(crate) message: Option<String>,
917}
918
919// ── Move types ─────────────────────────────────────────────────────
920
921/// Position for [`ConfluenceApi::move_page`](crate::atlassian::confluence_api::ConfluenceApi::move_page). Same-space only —
922/// cross-space moves are not supported by the v2 API.
923#[derive(Debug, Clone, Copy, PartialEq, Eq)]
924pub enum MovePosition {
925    /// Place the page as the last child of the target (target becomes the new parent).
926    Append,
927    /// Place the page as a sibling immediately before the target.
928    Before,
929    /// Place the page as a sibling immediately after the target.
930    After,
931}
932
933impl MovePosition {
934    /// Returns the URL-path segment used by the Confluence move endpoint.
935    pub fn as_str(self) -> &'static str {
936        match self {
937            Self::Append => "append",
938            Self::Before => "before",
939            Self::After => "after",
940        }
941    }
942}
943
944/// Updated page metadata returned by [`ConfluenceApi::move_page`](crate::atlassian::confluence_api::ConfluenceApi::move_page).
945#[derive(Debug, Clone, Serialize)]
946pub struct MovedPage {
947    /// Page ID.
948    pub id: String,
949    /// Page title.
950    pub title: String,
951    /// New parent page ID, if the page now has a parent.
952    #[serde(skip_serializing_if = "Option::is_none")]
953    pub parent_id: Option<String>,
954    /// Ancestor page IDs from root toward the immediate parent.
955    pub ancestors: Vec<String>,
956}