Skip to main content

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