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    async fn confluence_command_execute_move_dispatch() {
235        std::env::set_var("ATLASSIAN_INSTANCE_URL", "http://127.0.0.1:1");
236        std::env::set_var("ATLASSIAN_EMAIL", "test@example.com");
237        std::env::set_var("ATLASSIAN_API_TOKEN", "fake-token");
238
239        let cmd = ConfluenceCommand {
240            command: ConfluenceSubcommands::Move(move_page::MoveCommand {
241                id: "12345".to_string(),
242                target: "456".to_string(),
243                position: move_page::MovePosition::Append,
244            }),
245        };
246        let _ = cmd.execute().await;
247
248        std::env::remove_var("ATLASSIAN_INSTANCE_URL");
249        std::env::remove_var("ATLASSIAN_EMAIL");
250        std::env::remove_var("ATLASSIAN_API_TOKEN");
251    }
252
253    #[test]
254    fn confluence_subcommands_user_variant() {
255        let cmd = ConfluenceCommand {
256            command: ConfluenceSubcommands::User(user::UserCommand {
257                command: user::UserSubcommands::Search(user::UserSearchCommand {
258                    query: "alice".to_string(),
259                    limit: 25,
260                    output: OutputFormat::Table,
261                }),
262            }),
263        };
264        assert!(matches!(cmd.command, ConfluenceSubcommands::User(_)));
265    }
266
267    #[test]
268    fn confluence_subcommands_space_variant() {
269        let cmd = ConfluenceCommand {
270            command: ConfluenceSubcommands::Space(space::SpaceCommand {
271                command: space::SpaceSubcommands::List(space::ListCommand {
272                    keys: vec![],
273                    r#type: None,
274                    status: None,
275                    cursor: None,
276                    limit: 25,
277                    output: OutputFormat::Table,
278                }),
279            }),
280        };
281        assert!(matches!(cmd.command, ConfluenceSubcommands::Space(_)));
282    }
283
284    #[test]
285    fn confluence_subcommands_space_pages_variant() {
286        let pages = ConfluenceCommand {
287            command: ConfluenceSubcommands::Space(space::SpaceCommand {
288                command: space::SpaceSubcommands::Pages(space::PagesCommand {
289                    key: "ENG".to_string(),
290                    status: None,
291                    sort: None,
292                    cursor: None,
293                    limit: 25,
294                    output: OutputFormat::Table,
295                }),
296            }),
297        };
298        let other = ConfluenceCommand {
299            command: ConfluenceSubcommands::Read(read::ReadCommand {
300                id: "1".to_string(),
301                output: None,
302                format: ContentFormat::Jfm,
303            }),
304        };
305        // Single `matches!` site exercised against both a matching and
306        // non-matching variant so both arms are covered at the same source
307        // line (avoids the partial-branch noise of two separate sites).
308        for (expected, cmd) in [(true, pages), (false, other)] {
309            assert_eq!(
310                matches!(cmd.command, ConfluenceSubcommands::Space(_)),
311                expected
312            );
313        }
314    }
315
316    /// Exercises the `Space` dispatch arm in `ConfluenceCommand::execute`
317    /// with injected fake credentials so `create_client()` succeeds and the
318    /// downstream call is reached. The subsequent API call is allowed to
319    /// fail — we only care that the dispatch line runs.
320    #[tokio::test]
321    #[allow(clippy::await_holding_lock)]
322    async fn confluence_command_execute_space_dispatch() {
323        let _lock = crate::atlassian::auth::test_util::AUTH_ENV_MUTEX
324            .lock()
325            .unwrap_or_else(std::sync::PoisonError::into_inner);
326
327        std::env::set_var("ATLASSIAN_INSTANCE_URL", "http://127.0.0.1:1");
328        std::env::set_var("ATLASSIAN_EMAIL", "test@example.com");
329        std::env::set_var("ATLASSIAN_API_TOKEN", "fake-token");
330
331        let cmd = ConfluenceCommand {
332            command: ConfluenceSubcommands::Space(space::SpaceCommand {
333                command: space::SpaceSubcommands::List(space::ListCommand {
334                    keys: vec![],
335                    r#type: None,
336                    status: None,
337                    cursor: None,
338                    limit: 25,
339                    output: OutputFormat::Table,
340                }),
341            }),
342        };
343        let _ = cmd.execute().await;
344
345        std::env::remove_var("ATLASSIAN_INSTANCE_URL");
346        std::env::remove_var("ATLASSIAN_EMAIL");
347        std::env::remove_var("ATLASSIAN_API_TOKEN");
348    }
349
350    #[test]
351    fn confluence_subcommands_children_variant() {
352        let cmd = ConfluenceCommand {
353            command: ConfluenceSubcommands::Children(children::ChildrenCommand {
354                id: Some("12345".to_string()),
355                space: None,
356                recursive: false,
357                max_depth: 0,
358                output: OutputFormat::Table,
359            }),
360        };
361        assert!(matches!(cmd.command, ConfluenceSubcommands::Children(_)));
362    }
363
364    #[test]
365    fn confluence_subcommands_history_variant() {
366        let cmd = ConfluenceCommand {
367            command: ConfluenceSubcommands::History(history::HistoryCommand {
368                id: "12345".to_string(),
369                since: None,
370                limit: 20,
371                output: OutputFormat::Table,
372            }),
373        };
374        assert!(matches!(cmd.command, ConfluenceSubcommands::History(_)));
375    }
376
377    /// Exercises the `History` dispatch arm in `ConfluenceCommand::execute`
378    /// with injected fake credentials so `create_client()` succeeds and the
379    /// downstream call is reached. The subsequent API call is allowed to
380    /// fail — we only care that the dispatch line runs.
381    #[tokio::test]
382    async fn confluence_command_execute_history_dispatch() {
383        std::env::set_var("ATLASSIAN_INSTANCE_URL", "http://127.0.0.1:1");
384        std::env::set_var("ATLASSIAN_EMAIL", "test@example.com");
385        std::env::set_var("ATLASSIAN_API_TOKEN", "fake-token");
386
387        let cmd = ConfluenceCommand {
388            command: ConfluenceSubcommands::History(history::HistoryCommand {
389                id: "12345".to_string(),
390                since: None,
391                limit: 20,
392                output: OutputFormat::Table,
393            }),
394        };
395        let _ = cmd.execute().await;
396
397        std::env::remove_var("ATLASSIAN_INSTANCE_URL");
398        std::env::remove_var("ATLASSIAN_EMAIL");
399        std::env::remove_var("ATLASSIAN_API_TOKEN");
400    }
401
402    /// Exercises the `Children` dispatch arm in `ConfluenceCommand::execute`
403    /// with injected fake credentials so `create_client()` succeeds and the
404    /// downstream call is reached. The subsequent API call is allowed to
405    /// fail — we only care that the dispatch line runs.
406    #[tokio::test]
407    #[allow(clippy::await_holding_lock)]
408    async fn confluence_command_execute_children_dispatch() {
409        // Routes through the crate-wide `AUTH_ENV_MUTEX` so the env-var
410        // mutation doesn't race against other Atlassian-touching tests.
411        let _lock = crate::atlassian::auth::test_util::AUTH_ENV_MUTEX
412            .lock()
413            .unwrap_or_else(std::sync::PoisonError::into_inner);
414
415        std::env::set_var("ATLASSIAN_INSTANCE_URL", "http://127.0.0.1:1");
416        std::env::set_var("ATLASSIAN_EMAIL", "test@example.com");
417        std::env::set_var("ATLASSIAN_API_TOKEN", "fake-token");
418
419        let cmd = ConfluenceCommand {
420            command: ConfluenceSubcommands::Children(children::ChildrenCommand {
421                id: Some("12345".to_string()),
422                space: None,
423                recursive: false,
424                max_depth: 0,
425                output: OutputFormat::Table,
426            }),
427        };
428        let _ = cmd.execute().await;
429
430        std::env::remove_var("ATLASSIAN_INSTANCE_URL");
431        std::env::remove_var("ATLASSIAN_EMAIL");
432        std::env::remove_var("ATLASSIAN_API_TOKEN");
433    }
434
435    /// Exercises the `Attachment` dispatch arm in `ConfluenceCommand::execute`.
436    #[tokio::test]
437    async fn confluence_command_execute_attachment_dispatch() {
438        std::env::set_var("ATLASSIAN_INSTANCE_URL", "http://127.0.0.1:1");
439        std::env::set_var("ATLASSIAN_EMAIL", "test@example.com");
440        std::env::set_var("ATLASSIAN_API_TOKEN", "fake-token");
441
442        let cmd = ConfluenceCommand {
443            command: ConfluenceSubcommands::Attachment(attachment::AttachmentCommand {
444                command: attachment::AttachmentSubcommands::List(attachment::ListCommand {
445                    page_id: "12345".to_string(),
446                    cursor: None,
447                    limit: 25,
448                    output: OutputFormat::Table,
449                }),
450            }),
451        };
452        let _ = cmd.execute().await;
453
454        std::env::remove_var("ATLASSIAN_INSTANCE_URL");
455        std::env::remove_var("ATLASSIAN_EMAIL");
456        std::env::remove_var("ATLASSIAN_API_TOKEN");
457    }
458
459    #[test]
460    fn confluence_subcommands_download_variant() {
461        let cmd = ConfluenceCommand {
462            command: ConfluenceSubcommands::Download(download::DownloadCommand {
463                id: Some("12345".to_string()),
464                space: None,
465                output_dir: std::path::PathBuf::from("."),
466                format: ContentFormat::Jfm,
467                concurrency: 8,
468                max_depth: 0,
469                title_filter: None,
470                resume: false,
471                on_conflict: download::OnConflict::Backup,
472            }),
473        };
474        assert!(matches!(cmd.command, ConfluenceSubcommands::Download(_)));
475    }
476
477    #[test]
478    fn confluence_subcommands_download_space_variant() {
479        let cmd = ConfluenceCommand {
480            command: ConfluenceSubcommands::Download(download::DownloadCommand {
481                id: None,
482                space: Some("AD".to_string()),
483                output_dir: std::path::PathBuf::from("./AD"),
484                format: ContentFormat::Jfm,
485                concurrency: 8,
486                max_depth: 0,
487                title_filter: Some("architecture".to_string()),
488                resume: false,
489                on_conflict: download::OnConflict::Backup,
490            }),
491        };
492        assert!(matches!(cmd.command, ConfluenceSubcommands::Download(_)));
493    }
494
495    /// Exercises the `Compare` dispatch arm in `ConfluenceCommand::execute`
496    /// with injected fake credentials so `create_client()` succeeds and the
497    /// downstream call is reached. The subsequent API call is allowed to
498    /// fail — we only care that the dispatch line runs.
499    #[tokio::test]
500    async fn confluence_command_execute_compare_dispatch() {
501        std::env::set_var("ATLASSIAN_INSTANCE_URL", "http://127.0.0.1:1");
502        std::env::set_var("ATLASSIAN_EMAIL", "test@example.com");
503        std::env::set_var("ATLASSIAN_API_TOKEN", "fake-token");
504
505        let cmd = ConfluenceCommand {
506            command: ConfluenceSubcommands::Compare(compare::CompareCommandGroup {
507                command: compare::CompareSubcommands::Run(compare::CompareCommand {
508                    id: "12345".to_string(),
509                    from: "previous".to_string(),
510                    to: "latest".to_string(),
511                    detail: compare::DetailArg::Outline,
512                    include: "body".to_string(),
513                    ignore_whitespace: true,
514                    min_change_chars: 0,
515                    filter_sections: Vec::new(),
516                    budget: 16384,
517                    output: OutputFormat::Yaml,
518                }),
519            }),
520        };
521        let _ = cmd.execute().await;
522
523        std::env::remove_var("ATLASSIAN_INSTANCE_URL");
524        std::env::remove_var("ATLASSIAN_EMAIL");
525        std::env::remove_var("ATLASSIAN_API_TOKEN");
526    }
527}