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