Skip to main content

omni_dev/cli/atlassian/
confluence.rs

1//! Confluence CLI subcommands.
2
3pub(crate) mod attachment;
4pub(crate) mod children;
5pub(crate) mod comment;
6pub(crate) mod compare;
7pub(crate) mod create;
8pub(crate) mod delete;
9pub(crate) mod download;
10pub(crate) mod edit;
11pub(crate) mod history;
12pub(crate) mod label;
13pub(crate) mod move_page;
14pub(crate) mod read;
15pub(crate) mod search;
16pub(crate) mod space;
17pub(crate) mod user;
18pub(crate) mod write;
19
20use anyhow::Result;
21use clap::{Parser, Subcommand};
22
23/// Confluence page management, search, and more.
24#[derive(Parser)]
25pub struct ConfluenceCommand {
26    /// The Confluence subcommand to execute.
27    #[command(subcommand)]
28    pub command: ConfluenceSubcommands,
29}
30
31/// Confluence subcommands.
32#[derive(Subcommand)]
33pub enum ConfluenceSubcommands {
34    /// Manages comments on a Confluence page.
35    Comment(comment::CommentCommand),
36    /// Fetches a Confluence page and outputs it as JFM markdown or ADF JSON (mirrors the `confluence_read` MCP tool).
37    ///
38    /// The output carries `localId` attributes (and inline-comment anchor
39    /// spans) that anchor inline comments and other stateful nodes. Preserve
40    /// them verbatim when editing so a later `write` does not drop those
41    /// comments.
42    Read(read::ReadCommand),
43    /// Pushes content to a Confluence page (mirrors the `confluence_write` MCP tool).
44    ///
45    /// This fully replaces the page body, so it can silently lose data. Inline
46    /// comments (and task-item state) are anchored to the page through the
47    /// `localId` attributes that `read` emits; if the body you push omits them,
48    /// Confluence drops the inline comments tied to those anchors. Edit the JFM
49    /// produced by `read` and keep its `localId`s intact — do not hand-author a
50    /// fresh body or push content produced with local IDs stripped (see
51    /// `atlassian convert from-adf --strip-local-ids`).
52    Write(write::WriteCommand),
53    /// Interactive fetch-edit-push cycle for a Confluence page.
54    Edit(edit::EditCommand),
55    /// Searches Confluence pages using CQL (mirrors the `confluence_search` MCP tool).
56    Search(search::SearchCommand),
57    /// Creates a new Confluence page (mirrors the `confluence_create` MCP tool).
58    Create(create::CreateCommand),
59    /// Deletes a Confluence page (mirrors the `confluence_delete` MCP tool).
60    Delete(delete::DeleteCommand),
61    /// Moves or reparents a Confluence page (same-space only) (mirrors the `confluence_move` MCP tool).
62    Move(move_page::MoveCommand),
63    /// Manages labels on Confluence pages.
64    Label(label::LabelCommand),
65    /// Manages attachments on Confluence pages.
66    Attachment(attachment::AttachmentCommand),
67    /// Recursively downloads a Confluence page tree (mirrors the `confluence_download` MCP tool).
68    Download(download::DownloadCommand),
69    /// Lists child pages of a Confluence page or top-level pages in a space (mirrors the `confluence_children` MCP tool).
70    Children(children::ChildrenCommand),
71    /// Lists version history (metadata) for a Confluence page (mirrors the `confluence_history` MCP tool).
72    History(history::HistoryCommand),
73    /// Compares two versions of a Confluence page (structural diff).
74    Compare(compare::CompareCommandGroup),
75    /// Confluence user operations.
76    User(user::UserCommand),
77    /// Confluence space operations.
78    Space(space::SpaceCommand),
79}
80
81impl ConfluenceCommand {
82    /// Executes the Confluence command.
83    pub async fn execute(self) -> Result<()> {
84        match self.command {
85            ConfluenceSubcommands::Comment(cmd) => cmd.execute().await,
86            ConfluenceSubcommands::Read(cmd) => cmd.execute().await,
87            ConfluenceSubcommands::Write(cmd) => cmd.execute().await,
88            ConfluenceSubcommands::Edit(cmd) => cmd.execute().await,
89            ConfluenceSubcommands::Search(cmd) => cmd.execute().await,
90            ConfluenceSubcommands::Create(cmd) => cmd.execute().await,
91            ConfluenceSubcommands::Label(cmd) => cmd.execute().await,
92            ConfluenceSubcommands::Attachment(cmd) => cmd.execute().await,
93            ConfluenceSubcommands::Delete(cmd) => cmd.execute().await,
94            ConfluenceSubcommands::Move(cmd) => cmd.execute().await,
95            ConfluenceSubcommands::Download(cmd) => cmd.execute().await,
96            ConfluenceSubcommands::Children(cmd) => cmd.execute().await,
97            ConfluenceSubcommands::History(cmd) => cmd.execute().await,
98            ConfluenceSubcommands::Compare(cmd) => cmd.execute().await,
99            ConfluenceSubcommands::User(cmd) => cmd.execute().await,
100            ConfluenceSubcommands::Space(cmd) => cmd.execute().await,
101        }
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use crate::cli::atlassian::format::{ContentFormat, OutputFormat};
109
110    #[test]
111    fn confluence_subcommands_comment_variant() {
112        let cmd = ConfluenceCommand {
113            command: ConfluenceSubcommands::Comment(comment::CommentCommand {
114                command: comment::CommentSubcommands::List(comment::ListCommand {
115                    id: "12345".to_string(),
116                    kind: comment::CommentKindFilter::All,
117                    limit: 25,
118                    output: OutputFormat::Table,
119                }),
120            }),
121        };
122        assert!(matches!(cmd.command, ConfluenceSubcommands::Comment(_)));
123    }
124
125    #[test]
126    fn confluence_subcommands_read_variant() {
127        let cmd = ConfluenceCommand {
128            command: ConfluenceSubcommands::Read(read::ReadCommand {
129                id: "12345".to_string(),
130                out_file: None,
131                output: None,
132                format: ContentFormat::Jfm,
133                version: None,
134            }),
135        };
136        assert!(matches!(cmd.command, ConfluenceSubcommands::Read(_)));
137    }
138
139    #[test]
140    fn confluence_subcommands_write_variant() {
141        let cmd = ConfluenceCommand {
142            command: ConfluenceSubcommands::Write(write::WriteCommand {
143                id: "12345".to_string(),
144                file: None,
145                format: ContentFormat::Adf,
146                force: false,
147                dry_run: false,
148            }),
149        };
150        assert!(matches!(cmd.command, ConfluenceSubcommands::Write(_)));
151    }
152
153    #[test]
154    fn confluence_subcommands_edit_variant() {
155        let cmd = ConfluenceCommand {
156            command: ConfluenceSubcommands::Edit(edit::EditCommand {
157                id: "12345".to_string(),
158            }),
159        };
160        assert!(matches!(cmd.command, ConfluenceSubcommands::Edit(_)));
161    }
162
163    #[test]
164    fn confluence_subcommands_search_variant() {
165        let cmd = ConfluenceCommand {
166            command: ConfluenceSubcommands::Search(search::SearchCommand {
167                cql: Some("space = ENG".to_string()),
168                space: None,
169                title: None,
170                limit: 25,
171                output: OutputFormat::Table,
172            }),
173        };
174        assert!(matches!(cmd.command, ConfluenceSubcommands::Search(_)));
175    }
176
177    #[test]
178    fn confluence_subcommands_create_variant() {
179        let cmd = ConfluenceCommand {
180            command: ConfluenceSubcommands::Create(create::CreateCommand {
181                file: None,
182                format: ContentFormat::Jfm,
183                space: Some("ENG".to_string()),
184                title: Some("Test".to_string()),
185                parent: None,
186                dry_run: false,
187            }),
188        };
189        assert!(matches!(cmd.command, ConfluenceSubcommands::Create(_)));
190    }
191
192    #[test]
193    fn confluence_subcommands_label_variant() {
194        let cmd = ConfluenceCommand {
195            command: ConfluenceSubcommands::Label(label::LabelCommand {
196                command: label::LabelSubcommands::List(label::ListCommand {
197                    id: "12345".to_string(),
198                    output: OutputFormat::Table,
199                }),
200            }),
201        };
202        assert!(matches!(cmd.command, ConfluenceSubcommands::Label(_)));
203    }
204
205    #[test]
206    fn confluence_subcommands_attachment_variant() {
207        let cmd = ConfluenceCommand {
208            command: ConfluenceSubcommands::Attachment(attachment::AttachmentCommand {
209                command: attachment::AttachmentSubcommands::List(attachment::ListCommand {
210                    page_id: "12345".to_string(),
211                    cursor: None,
212                    limit: 25,
213                    output: OutputFormat::Table,
214                }),
215            }),
216        };
217        assert!(matches!(cmd.command, ConfluenceSubcommands::Attachment(_)));
218    }
219
220    #[test]
221    fn confluence_subcommands_delete_variant() {
222        let cmd = ConfluenceCommand {
223            command: ConfluenceSubcommands::Delete(delete::DeleteCommand {
224                id: "12345".to_string(),
225                force: true,
226                dry_run: false,
227                purge: false,
228            }),
229        };
230        assert!(matches!(cmd.command, ConfluenceSubcommands::Delete(_)));
231    }
232
233    #[test]
234    fn confluence_subcommands_move_variant() {
235        let cmd = ConfluenceCommand {
236            command: ConfluenceSubcommands::Move(move_page::MoveCommand {
237                id: "12345".to_string(),
238                target: "456".to_string(),
239                position: move_page::MovePosition::Append,
240            }),
241        };
242        assert!(matches!(cmd.command, ConfluenceSubcommands::Move(_)));
243    }
244
245    /// Exercises the `Move` dispatch arm in `ConfluenceCommand::execute` with
246    /// injected fake credentials so `create_client()` succeeds; the API call
247    /// is allowed to fail.
248    #[tokio::test]
249    #[allow(clippy::await_holding_lock)]
250    async fn confluence_command_execute_move_dispatch() {
251        // Serialise on the one canonical env mutex (issue #950).
252        let _lock = crate::atlassian::auth::test_util::AUTH_ENV_MUTEX
253            .lock()
254            .unwrap_or_else(std::sync::PoisonError::into_inner);
255
256        std::env::set_var("ATLASSIAN_INSTANCE_URL", "http://127.0.0.1:1");
257        std::env::set_var("ATLASSIAN_EMAIL", "test@example.com");
258        std::env::set_var("ATLASSIAN_API_TOKEN", "fake-token");
259
260        let cmd = ConfluenceCommand {
261            command: ConfluenceSubcommands::Move(move_page::MoveCommand {
262                id: "12345".to_string(),
263                target: "456".to_string(),
264                position: move_page::MovePosition::Append,
265            }),
266        };
267        let _ = cmd.execute().await;
268
269        std::env::remove_var("ATLASSIAN_INSTANCE_URL");
270        std::env::remove_var("ATLASSIAN_EMAIL");
271        std::env::remove_var("ATLASSIAN_API_TOKEN");
272    }
273
274    #[test]
275    fn confluence_subcommands_user_variant() {
276        let cmd = ConfluenceCommand {
277            command: ConfluenceSubcommands::User(user::UserCommand {
278                command: user::UserSubcommands::Search(user::UserSearchCommand {
279                    query: "alice".to_string(),
280                    limit: 25,
281                    output: OutputFormat::Table,
282                }),
283            }),
284        };
285        assert!(matches!(cmd.command, ConfluenceSubcommands::User(_)));
286    }
287
288    #[test]
289    fn confluence_subcommands_space_variant() {
290        let cmd = ConfluenceCommand {
291            command: ConfluenceSubcommands::Space(space::SpaceCommand {
292                command: space::SpaceSubcommands::List(space::ListCommand {
293                    keys: vec![],
294                    r#type: None,
295                    status: None,
296                    cursor: None,
297                    limit: 25,
298                    output: OutputFormat::Table,
299                }),
300            }),
301        };
302        assert!(matches!(cmd.command, ConfluenceSubcommands::Space(_)));
303    }
304
305    #[test]
306    fn confluence_subcommands_space_pages_variant() {
307        let pages = ConfluenceCommand {
308            command: ConfluenceSubcommands::Space(space::SpaceCommand {
309                command: space::SpaceSubcommands::Pages(space::PagesCommand {
310                    key: "ENG".to_string(),
311                    status: None,
312                    sort: None,
313                    cursor: None,
314                    limit: 25,
315                    output: OutputFormat::Table,
316                }),
317            }),
318        };
319        let other = ConfluenceCommand {
320            command: ConfluenceSubcommands::Read(read::ReadCommand {
321                id: "1".to_string(),
322                out_file: None,
323                output: None,
324                format: ContentFormat::Jfm,
325                version: None,
326            }),
327        };
328        // Single `matches!` site exercised against both a matching and
329        // non-matching variant so both arms are covered at the same source
330        // line (avoids the partial-branch noise of two separate sites).
331        for (expected, cmd) in [(true, pages), (false, other)] {
332            assert_eq!(
333                matches!(cmd.command, ConfluenceSubcommands::Space(_)),
334                expected
335            );
336        }
337    }
338
339    /// Exercises the `Space` dispatch arm in `ConfluenceCommand::execute`
340    /// with injected fake credentials so `create_client()` succeeds and the
341    /// downstream call is reached. The subsequent API call is allowed to
342    /// fail — we only care that the dispatch line runs.
343    #[tokio::test]
344    #[allow(clippy::await_holding_lock)]
345    async fn confluence_command_execute_space_dispatch() {
346        let _lock = crate::atlassian::auth::test_util::AUTH_ENV_MUTEX
347            .lock()
348            .unwrap_or_else(std::sync::PoisonError::into_inner);
349
350        std::env::set_var("ATLASSIAN_INSTANCE_URL", "http://127.0.0.1:1");
351        std::env::set_var("ATLASSIAN_EMAIL", "test@example.com");
352        std::env::set_var("ATLASSIAN_API_TOKEN", "fake-token");
353
354        let cmd = ConfluenceCommand {
355            command: ConfluenceSubcommands::Space(space::SpaceCommand {
356                command: space::SpaceSubcommands::List(space::ListCommand {
357                    keys: vec![],
358                    r#type: None,
359                    status: None,
360                    cursor: None,
361                    limit: 25,
362                    output: OutputFormat::Table,
363                }),
364            }),
365        };
366        let _ = cmd.execute().await;
367
368        std::env::remove_var("ATLASSIAN_INSTANCE_URL");
369        std::env::remove_var("ATLASSIAN_EMAIL");
370        std::env::remove_var("ATLASSIAN_API_TOKEN");
371    }
372
373    #[test]
374    fn confluence_subcommands_children_variant() {
375        let cmd = ConfluenceCommand {
376            command: ConfluenceSubcommands::Children(children::ChildrenCommand {
377                id: Some("12345".to_string()),
378                space: None,
379                recursive: false,
380                max_depth: 0,
381                output: OutputFormat::Table,
382            }),
383        };
384        assert!(matches!(cmd.command, ConfluenceSubcommands::Children(_)));
385    }
386
387    #[test]
388    fn confluence_subcommands_history_variant() {
389        let cmd = ConfluenceCommand {
390            command: ConfluenceSubcommands::History(history::HistoryCommand {
391                id: "12345".to_string(),
392                since: None,
393                limit: 20,
394                output: OutputFormat::Table,
395            }),
396        };
397        assert!(matches!(cmd.command, ConfluenceSubcommands::History(_)));
398    }
399
400    /// Exercises the `History` dispatch arm in `ConfluenceCommand::execute`
401    /// with injected fake credentials so `create_client()` succeeds and the
402    /// downstream call is reached. The subsequent API call is allowed to
403    /// fail — we only care that the dispatch line runs.
404    #[tokio::test]
405    #[allow(clippy::await_holding_lock)]
406    async fn confluence_command_execute_history_dispatch() {
407        // Serialise on the one canonical env mutex (issue #950).
408        let _lock = crate::atlassian::auth::test_util::AUTH_ENV_MUTEX
409            .lock()
410            .unwrap_or_else(std::sync::PoisonError::into_inner);
411
412        std::env::set_var("ATLASSIAN_INSTANCE_URL", "http://127.0.0.1:1");
413        std::env::set_var("ATLASSIAN_EMAIL", "test@example.com");
414        std::env::set_var("ATLASSIAN_API_TOKEN", "fake-token");
415
416        let cmd = ConfluenceCommand {
417            command: ConfluenceSubcommands::History(history::HistoryCommand {
418                id: "12345".to_string(),
419                since: None,
420                limit: 20,
421                output: OutputFormat::Table,
422            }),
423        };
424        let _ = cmd.execute().await;
425
426        std::env::remove_var("ATLASSIAN_INSTANCE_URL");
427        std::env::remove_var("ATLASSIAN_EMAIL");
428        std::env::remove_var("ATLASSIAN_API_TOKEN");
429    }
430
431    /// Exercises the `Children` dispatch arm in `ConfluenceCommand::execute`
432    /// with injected fake credentials so `create_client()` succeeds and the
433    /// downstream call is reached. The subsequent API call is allowed to
434    /// fail — we only care that the dispatch line runs.
435    #[tokio::test]
436    #[allow(clippy::await_holding_lock)]
437    async fn confluence_command_execute_children_dispatch() {
438        // Routes through the crate-wide `AUTH_ENV_MUTEX` so the env-var
439        // mutation doesn't race against other Atlassian-touching tests.
440        let _lock = crate::atlassian::auth::test_util::AUTH_ENV_MUTEX
441            .lock()
442            .unwrap_or_else(std::sync::PoisonError::into_inner);
443
444        std::env::set_var("ATLASSIAN_INSTANCE_URL", "http://127.0.0.1:1");
445        std::env::set_var("ATLASSIAN_EMAIL", "test@example.com");
446        std::env::set_var("ATLASSIAN_API_TOKEN", "fake-token");
447
448        let cmd = ConfluenceCommand {
449            command: ConfluenceSubcommands::Children(children::ChildrenCommand {
450                id: Some("12345".to_string()),
451                space: None,
452                recursive: false,
453                max_depth: 0,
454                output: OutputFormat::Table,
455            }),
456        };
457        let _ = cmd.execute().await;
458
459        std::env::remove_var("ATLASSIAN_INSTANCE_URL");
460        std::env::remove_var("ATLASSIAN_EMAIL");
461        std::env::remove_var("ATLASSIAN_API_TOKEN");
462    }
463
464    /// Exercises the `Attachment` dispatch arm in `ConfluenceCommand::execute`.
465    #[tokio::test]
466    #[allow(clippy::await_holding_lock)]
467    async fn confluence_command_execute_attachment_dispatch() {
468        // Serialise on the one canonical env mutex (issue #950).
469        let _lock = crate::atlassian::auth::test_util::AUTH_ENV_MUTEX
470            .lock()
471            .unwrap_or_else(std::sync::PoisonError::into_inner);
472
473        std::env::set_var("ATLASSIAN_INSTANCE_URL", "http://127.0.0.1:1");
474        std::env::set_var("ATLASSIAN_EMAIL", "test@example.com");
475        std::env::set_var("ATLASSIAN_API_TOKEN", "fake-token");
476
477        let cmd = ConfluenceCommand {
478            command: ConfluenceSubcommands::Attachment(attachment::AttachmentCommand {
479                command: attachment::AttachmentSubcommands::List(attachment::ListCommand {
480                    page_id: "12345".to_string(),
481                    cursor: None,
482                    limit: 25,
483                    output: OutputFormat::Table,
484                }),
485            }),
486        };
487        let _ = cmd.execute().await;
488
489        std::env::remove_var("ATLASSIAN_INSTANCE_URL");
490        std::env::remove_var("ATLASSIAN_EMAIL");
491        std::env::remove_var("ATLASSIAN_API_TOKEN");
492    }
493
494    #[test]
495    fn confluence_subcommands_download_variant() {
496        let cmd = ConfluenceCommand {
497            command: ConfluenceSubcommands::Download(download::DownloadCommand {
498                id: Some("12345".to_string()),
499                space: None,
500                output_dir: std::path::PathBuf::from("."),
501                format: ContentFormat::Jfm,
502                concurrency: 8,
503                max_depth: 0,
504                title_filter: None,
505                resume: false,
506                include_attachments: false,
507                on_conflict: download::OnConflict::Backup,
508            }),
509        };
510        assert!(matches!(cmd.command, ConfluenceSubcommands::Download(_)));
511    }
512
513    #[test]
514    fn confluence_subcommands_download_space_variant() {
515        let cmd = ConfluenceCommand {
516            command: ConfluenceSubcommands::Download(download::DownloadCommand {
517                id: None,
518                space: Some("AD".to_string()),
519                output_dir: std::path::PathBuf::from("./AD"),
520                format: ContentFormat::Jfm,
521                concurrency: 8,
522                max_depth: 0,
523                title_filter: Some("architecture".to_string()),
524                resume: false,
525                include_attachments: false,
526                on_conflict: download::OnConflict::Backup,
527            }),
528        };
529        assert!(matches!(cmd.command, ConfluenceSubcommands::Download(_)));
530    }
531
532    /// Exercises the `Compare` dispatch arm in `ConfluenceCommand::execute`
533    /// with injected fake credentials so `create_client()` succeeds and the
534    /// downstream call is reached. The subsequent API call is allowed to
535    /// fail — we only care that the dispatch line runs.
536    #[tokio::test]
537    #[allow(clippy::await_holding_lock)]
538    async fn confluence_command_execute_compare_dispatch() {
539        // Serialise on the one canonical env mutex (issue #950).
540        let _lock = crate::atlassian::auth::test_util::AUTH_ENV_MUTEX
541            .lock()
542            .unwrap_or_else(std::sync::PoisonError::into_inner);
543
544        std::env::set_var("ATLASSIAN_INSTANCE_URL", "http://127.0.0.1:1");
545        std::env::set_var("ATLASSIAN_EMAIL", "test@example.com");
546        std::env::set_var("ATLASSIAN_API_TOKEN", "fake-token");
547
548        let cmd = ConfluenceCommand {
549            command: ConfluenceSubcommands::Compare(compare::CompareCommandGroup {
550                command: compare::CompareSubcommands::Run(compare::CompareCommand {
551                    id: "12345".to_string(),
552                    from: "previous".to_string(),
553                    to: "latest".to_string(),
554                    detail: compare::DetailArg::Outline,
555                    include: "body".to_string(),
556                    ignore_whitespace: true,
557                    min_change_chars: 0,
558                    filter_sections: Vec::new(),
559                    budget: 16384,
560                    output: OutputFormat::Yaml,
561                }),
562            }),
563        };
564        let _ = cmd.execute().await;
565
566        std::env::remove_var("ATLASSIAN_INSTANCE_URL");
567        std::env::remove_var("ATLASSIAN_EMAIL");
568        std::env::remove_var("ATLASSIAN_API_TOKEN");
569    }
570}