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 user;
17pub(crate) mod write;
18
19use anyhow::Result;
20use clap::{Parser, Subcommand};
21
22/// Confluence page management, search, and more.
23#[derive(Parser)]
24pub struct ConfluenceCommand {
25    /// The Confluence subcommand to execute.
26    #[command(subcommand)]
27    pub command: ConfluenceSubcommands,
28}
29
30/// Confluence subcommands.
31#[derive(Subcommand)]
32pub enum ConfluenceSubcommands {
33    /// Manages comments on a Confluence page.
34    Comment(comment::CommentCommand),
35    /// Fetches a Confluence page and outputs it as JFM markdown or ADF JSON.
36    Read(read::ReadCommand),
37    /// Pushes content to a Confluence page.
38    Write(write::WriteCommand),
39    /// Interactive fetch-edit-push cycle for a Confluence page.
40    Edit(edit::EditCommand),
41    /// Searches Confluence pages using CQL.
42    Search(search::SearchCommand),
43    /// Creates a new Confluence page.
44    Create(create::CreateCommand),
45    /// Deletes a Confluence page.
46    Delete(delete::DeleteCommand),
47    /// Moves or reparents a Confluence page (same-space only).
48    Move(move_page::MoveCommand),
49    /// Manages labels on Confluence pages.
50    Label(label::LabelCommand),
51    /// Manages attachments on Confluence pages.
52    Attachment(attachment::AttachmentCommand),
53    /// Recursively downloads a Confluence page tree.
54    Download(download::DownloadCommand),
55    /// Lists child pages of a Confluence page or top-level pages in a space.
56    Children(children::ChildrenCommand),
57    /// Lists version history (metadata) for a Confluence page.
58    History(history::HistoryCommand),
59    /// Compares two versions of a Confluence page (structural diff).
60    Compare(compare::CompareCommandGroup),
61    /// Confluence user operations.
62    User(user::UserCommand),
63}
64
65impl ConfluenceCommand {
66    /// Executes the Confluence command.
67    pub async fn execute(self) -> Result<()> {
68        match self.command {
69            ConfluenceSubcommands::Comment(cmd) => cmd.execute().await,
70            ConfluenceSubcommands::Read(cmd) => cmd.execute().await,
71            ConfluenceSubcommands::Write(cmd) => cmd.execute().await,
72            ConfluenceSubcommands::Edit(cmd) => cmd.execute().await,
73            ConfluenceSubcommands::Search(cmd) => cmd.execute().await,
74            ConfluenceSubcommands::Create(cmd) => cmd.execute().await,
75            ConfluenceSubcommands::Label(cmd) => cmd.execute().await,
76            ConfluenceSubcommands::Attachment(cmd) => cmd.execute().await,
77            ConfluenceSubcommands::Delete(cmd) => cmd.execute().await,
78            ConfluenceSubcommands::Move(cmd) => cmd.execute().await,
79            ConfluenceSubcommands::Download(cmd) => cmd.execute().await,
80            ConfluenceSubcommands::Children(cmd) => cmd.execute().await,
81            ConfluenceSubcommands::History(cmd) => cmd.execute().await,
82            ConfluenceSubcommands::Compare(cmd) => cmd.execute().await,
83            ConfluenceSubcommands::User(cmd) => cmd.execute().await,
84        }
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::cli::atlassian::format::{ContentFormat, OutputFormat};
92
93    #[test]
94    fn confluence_subcommands_comment_variant() {
95        let cmd = ConfluenceCommand {
96            command: ConfluenceSubcommands::Comment(comment::CommentCommand {
97                command: comment::CommentSubcommands::List(comment::ListCommand {
98                    id: "12345".to_string(),
99                    limit: 25,
100                    output: OutputFormat::Table,
101                }),
102            }),
103        };
104        assert!(matches!(cmd.command, ConfluenceSubcommands::Comment(_)));
105    }
106
107    #[test]
108    fn confluence_subcommands_read_variant() {
109        let cmd = ConfluenceCommand {
110            command: ConfluenceSubcommands::Read(read::ReadCommand {
111                id: "12345".to_string(),
112                output: None,
113                format: ContentFormat::Jfm,
114            }),
115        };
116        assert!(matches!(cmd.command, ConfluenceSubcommands::Read(_)));
117    }
118
119    #[test]
120    fn confluence_subcommands_write_variant() {
121        let cmd = ConfluenceCommand {
122            command: ConfluenceSubcommands::Write(write::WriteCommand {
123                id: "12345".to_string(),
124                file: None,
125                format: ContentFormat::Adf,
126                force: false,
127                dry_run: false,
128            }),
129        };
130        assert!(matches!(cmd.command, ConfluenceSubcommands::Write(_)));
131    }
132
133    #[test]
134    fn confluence_subcommands_edit_variant() {
135        let cmd = ConfluenceCommand {
136            command: ConfluenceSubcommands::Edit(edit::EditCommand {
137                id: "12345".to_string(),
138            }),
139        };
140        assert!(matches!(cmd.command, ConfluenceSubcommands::Edit(_)));
141    }
142
143    #[test]
144    fn confluence_subcommands_search_variant() {
145        let cmd = ConfluenceCommand {
146            command: ConfluenceSubcommands::Search(search::SearchCommand {
147                cql: Some("space = ENG".to_string()),
148                space: None,
149                title: None,
150                limit: 25,
151                output: OutputFormat::Table,
152            }),
153        };
154        assert!(matches!(cmd.command, ConfluenceSubcommands::Search(_)));
155    }
156
157    #[test]
158    fn confluence_subcommands_create_variant() {
159        let cmd = ConfluenceCommand {
160            command: ConfluenceSubcommands::Create(create::CreateCommand {
161                file: None,
162                format: ContentFormat::Jfm,
163                space: Some("ENG".to_string()),
164                title: Some("Test".to_string()),
165                parent: None,
166                dry_run: false,
167            }),
168        };
169        assert!(matches!(cmd.command, ConfluenceSubcommands::Create(_)));
170    }
171
172    #[test]
173    fn confluence_subcommands_label_variant() {
174        let cmd = ConfluenceCommand {
175            command: ConfluenceSubcommands::Label(label::LabelCommand {
176                command: label::LabelSubcommands::List(label::ListCommand {
177                    id: "12345".to_string(),
178                    output: OutputFormat::Table,
179                }),
180            }),
181        };
182        assert!(matches!(cmd.command, ConfluenceSubcommands::Label(_)));
183    }
184
185    #[test]
186    fn confluence_subcommands_attachment_variant() {
187        let cmd = ConfluenceCommand {
188            command: ConfluenceSubcommands::Attachment(attachment::AttachmentCommand {
189                command: attachment::AttachmentSubcommands::List(attachment::ListCommand {
190                    page_id: "12345".to_string(),
191                    cursor: None,
192                    limit: 25,
193                    output: OutputFormat::Table,
194                }),
195            }),
196        };
197        assert!(matches!(cmd.command, ConfluenceSubcommands::Attachment(_)));
198    }
199
200    #[test]
201    fn confluence_subcommands_delete_variant() {
202        let cmd = ConfluenceCommand {
203            command: ConfluenceSubcommands::Delete(delete::DeleteCommand {
204                id: "12345".to_string(),
205                force: true,
206                dry_run: false,
207                purge: false,
208            }),
209        };
210        assert!(matches!(cmd.command, ConfluenceSubcommands::Delete(_)));
211    }
212
213    #[test]
214    fn confluence_subcommands_move_variant() {
215        let cmd = ConfluenceCommand {
216            command: ConfluenceSubcommands::Move(move_page::MoveCommand {
217                id: "12345".to_string(),
218                target: "456".to_string(),
219                position: move_page::MovePosition::Append,
220            }),
221        };
222        assert!(matches!(cmd.command, ConfluenceSubcommands::Move(_)));
223    }
224
225    /// Exercises the `Move` dispatch arm in `ConfluenceCommand::execute` with
226    /// injected fake credentials so `create_client()` succeeds; the API call
227    /// is allowed to fail.
228    #[tokio::test]
229    async fn confluence_command_execute_move_dispatch() {
230        std::env::set_var("ATLASSIAN_INSTANCE_URL", "http://127.0.0.1:1");
231        std::env::set_var("ATLASSIAN_EMAIL", "test@example.com");
232        std::env::set_var("ATLASSIAN_API_TOKEN", "fake-token");
233
234        let cmd = ConfluenceCommand {
235            command: ConfluenceSubcommands::Move(move_page::MoveCommand {
236                id: "12345".to_string(),
237                target: "456".to_string(),
238                position: move_page::MovePosition::Append,
239            }),
240        };
241        let _ = cmd.execute().await;
242
243        std::env::remove_var("ATLASSIAN_INSTANCE_URL");
244        std::env::remove_var("ATLASSIAN_EMAIL");
245        std::env::remove_var("ATLASSIAN_API_TOKEN");
246    }
247
248    #[test]
249    fn confluence_subcommands_user_variant() {
250        let cmd = ConfluenceCommand {
251            command: ConfluenceSubcommands::User(user::UserCommand {
252                command: user::UserSubcommands::Search(user::UserSearchCommand {
253                    query: "alice".to_string(),
254                    limit: 25,
255                    output: OutputFormat::Table,
256                }),
257            }),
258        };
259        assert!(matches!(cmd.command, ConfluenceSubcommands::User(_)));
260    }
261
262    #[test]
263    fn confluence_subcommands_children_variant() {
264        let cmd = ConfluenceCommand {
265            command: ConfluenceSubcommands::Children(children::ChildrenCommand {
266                id: Some("12345".to_string()),
267                space: None,
268                recursive: false,
269                max_depth: 0,
270                output: OutputFormat::Table,
271            }),
272        };
273        assert!(matches!(cmd.command, ConfluenceSubcommands::Children(_)));
274    }
275
276    #[test]
277    fn confluence_subcommands_history_variant() {
278        let cmd = ConfluenceCommand {
279            command: ConfluenceSubcommands::History(history::HistoryCommand {
280                id: "12345".to_string(),
281                since: None,
282                limit: 20,
283                output: OutputFormat::Table,
284            }),
285        };
286        assert!(matches!(cmd.command, ConfluenceSubcommands::History(_)));
287    }
288
289    /// Exercises the `History` dispatch arm in `ConfluenceCommand::execute`
290    /// with injected fake credentials so `create_client()` succeeds and the
291    /// downstream call is reached. The subsequent API call is allowed to
292    /// fail — we only care that the dispatch line runs.
293    #[tokio::test]
294    async fn confluence_command_execute_history_dispatch() {
295        std::env::set_var("ATLASSIAN_INSTANCE_URL", "http://127.0.0.1:1");
296        std::env::set_var("ATLASSIAN_EMAIL", "test@example.com");
297        std::env::set_var("ATLASSIAN_API_TOKEN", "fake-token");
298
299        let cmd = ConfluenceCommand {
300            command: ConfluenceSubcommands::History(history::HistoryCommand {
301                id: "12345".to_string(),
302                since: None,
303                limit: 20,
304                output: OutputFormat::Table,
305            }),
306        };
307        let _ = cmd.execute().await;
308
309        std::env::remove_var("ATLASSIAN_INSTANCE_URL");
310        std::env::remove_var("ATLASSIAN_EMAIL");
311        std::env::remove_var("ATLASSIAN_API_TOKEN");
312    }
313
314    /// Exercises the `Children` dispatch arm in `ConfluenceCommand::execute`
315    /// with injected fake credentials so `create_client()` succeeds and the
316    /// downstream call is reached. The subsequent API call is allowed to
317    /// fail — we only care that the dispatch line runs.
318    #[tokio::test]
319    #[allow(clippy::await_holding_lock)]
320    async fn confluence_command_execute_children_dispatch() {
321        // Routes through the crate-wide `AUTH_ENV_MUTEX` so the env-var
322        // mutation doesn't race against other Atlassian-touching tests.
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::Children(children::ChildrenCommand {
333                id: Some("12345".to_string()),
334                space: None,
335                recursive: false,
336                max_depth: 0,
337                output: OutputFormat::Table,
338            }),
339        };
340        let _ = cmd.execute().await;
341
342        std::env::remove_var("ATLASSIAN_INSTANCE_URL");
343        std::env::remove_var("ATLASSIAN_EMAIL");
344        std::env::remove_var("ATLASSIAN_API_TOKEN");
345    }
346
347    /// Exercises the `Attachment` dispatch arm in `ConfluenceCommand::execute`.
348    #[tokio::test]
349    async fn confluence_command_execute_attachment_dispatch() {
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::Attachment(attachment::AttachmentCommand {
356                command: attachment::AttachmentSubcommands::List(attachment::ListCommand {
357                    page_id: "12345".to_string(),
358                    cursor: None,
359                    limit: 25,
360                    output: OutputFormat::Table,
361                }),
362            }),
363        };
364        let _ = cmd.execute().await;
365
366        std::env::remove_var("ATLASSIAN_INSTANCE_URL");
367        std::env::remove_var("ATLASSIAN_EMAIL");
368        std::env::remove_var("ATLASSIAN_API_TOKEN");
369    }
370
371    #[test]
372    fn confluence_subcommands_download_variant() {
373        let cmd = ConfluenceCommand {
374            command: ConfluenceSubcommands::Download(download::DownloadCommand {
375                id: Some("12345".to_string()),
376                space: None,
377                output_dir: std::path::PathBuf::from("."),
378                format: ContentFormat::Jfm,
379                concurrency: 8,
380                max_depth: 0,
381                title_filter: None,
382                resume: false,
383                on_conflict: download::OnConflict::Backup,
384            }),
385        };
386        assert!(matches!(cmd.command, ConfluenceSubcommands::Download(_)));
387    }
388
389    #[test]
390    fn confluence_subcommands_download_space_variant() {
391        let cmd = ConfluenceCommand {
392            command: ConfluenceSubcommands::Download(download::DownloadCommand {
393                id: None,
394                space: Some("AD".to_string()),
395                output_dir: std::path::PathBuf::from("./AD"),
396                format: ContentFormat::Jfm,
397                concurrency: 8,
398                max_depth: 0,
399                title_filter: Some("architecture".to_string()),
400                resume: false,
401                on_conflict: download::OnConflict::Backup,
402            }),
403        };
404        assert!(matches!(cmd.command, ConfluenceSubcommands::Download(_)));
405    }
406
407    /// Exercises the `Compare` dispatch arm in `ConfluenceCommand::execute`
408    /// with injected fake credentials so `create_client()` succeeds and the
409    /// downstream call is reached. The subsequent API call is allowed to
410    /// fail — we only care that the dispatch line runs.
411    #[tokio::test]
412    async fn confluence_command_execute_compare_dispatch() {
413        std::env::set_var("ATLASSIAN_INSTANCE_URL", "http://127.0.0.1:1");
414        std::env::set_var("ATLASSIAN_EMAIL", "test@example.com");
415        std::env::set_var("ATLASSIAN_API_TOKEN", "fake-token");
416
417        let cmd = ConfluenceCommand {
418            command: ConfluenceSubcommands::Compare(compare::CompareCommandGroup {
419                command: compare::CompareSubcommands::Run(compare::CompareCommand {
420                    id: "12345".to_string(),
421                    from: "previous".to_string(),
422                    to: "latest".to_string(),
423                    detail: compare::DetailArg::Outline,
424                    include: "body".to_string(),
425                    ignore_whitespace: true,
426                    min_change_chars: 0,
427                    filter_sections: Vec::new(),
428                    budget: 16384,
429                    output: OutputFormat::Yaml,
430                }),
431            }),
432        };
433        let _ = cmd.execute().await;
434
435        std::env::remove_var("ATLASSIAN_INSTANCE_URL");
436        std::env::remove_var("ATLASSIAN_EMAIL");
437        std::env::remove_var("ATLASSIAN_API_TOKEN");
438    }
439}