Skip to main content

dsc/
cli.rs

1use clap::{ArgAction, Parser, Subcommand, ValueEnum};
2use clap_complete::Shell;
3use std::path::PathBuf;
4
5#[derive(Parser)]
6#[command(name = "dsc")]
7#[command(about = "Discourse CLI", long_about = None)]
8pub struct Cli {
9    /// Path to the config file. If omitted, dsc searches standard locations.
10    #[arg(long, short = 'c')]
11    pub config: Option<PathBuf>,
12    #[command(subcommand)]
13    pub command: Commands,
14}
15
16#[derive(Subcommand)]
17pub enum Commands {
18    /// List configured Discourses.
19    #[command(visible_alias = "ls")]
20    List {
21        /// Output format for the listing.
22        #[arg(long, short = 'f', value_enum, default_value = "text")]
23        format: OutputFormat,
24        /// Filter by tags (comma/semicolon separated, match-any).
25        #[arg(long, value_name = "tag1,tag2")]
26        tags: Option<String>,
27        /// Open each listed Discourse base URL in a browser tab/window.
28        #[arg(long, short = 'o')]
29        open: bool,
30        /// Include empty results and verbose listing details where supported.
31        #[arg(long, short = 'v')]
32        verbose: bool,
33        #[command(subcommand)]
34        command: Option<ListCommand>,
35    },
36    /// Add one or more Discourses to the config.
37    #[command(visible_alias = "a")]
38    Add {
39        /// Comma-separated discourse names to add.
40        names: String,
41        /// Prompt for additional optional fields while adding.
42        #[arg(long, short = 'i')]
43        interactive: bool,
44    },
45    /// Import Discourses from a file or stdin.
46    #[command(visible_alias = "imp")]
47    Import {
48        /// Path to import input (text/CSV). Reads stdin when omitted.
49        path: Option<PathBuf>,
50    },
51    /// Run remote OS + Discourse update workflow for one or all Discourses.
52    #[command(visible_alias = "up")]
53    Update {
54        /// Discourse name, or 'all' to update every configured Discourse.
55        name: String,
56        /// Parallel update mode for `dsc update all`.
57        #[arg(long, short = 'p')]
58        parallel: bool,
59        /// Maximum workers when parallel mode is enabled (default: 3).
60        #[arg(long, short = 'm')]
61        max: Option<usize>,
62        /// Disable changelog posting (posting prompt is on by default).
63        #[arg(long = "no-changelog", action = ArgAction::SetFalse, default_value_t = true)]
64        post_changelog: bool,
65        /// Auto-confirm changelog posting prompt (non-interactive mode).
66        #[arg(long, short = 'y')]
67        yes: bool,
68    },
69    /// Manage custom emoji.
70    #[command(visible_alias = "em")]
71    Emoji {
72        #[command(subcommand)]
73        command: EmojiCommand,
74    },
75    /// Pull/push/sync topics as local Markdown.
76    #[command(visible_alias = "t")]
77    Topic {
78        #[command(subcommand)]
79        command: TopicCommand,
80    },
81    /// List/copy/pull/push categories.
82    #[command(visible_alias = "cat")]
83    Category {
84        #[command(subcommand)]
85        command: CategoryCommand,
86    },
87    /// List/inspect/copy groups.
88    #[command(visible_alias = "grp")]
89    Group {
90        #[command(subcommand)]
91        command: GroupCommand,
92    },
93    /// Create/list/restore backups.
94    #[command(visible_alias = "bk")]
95    Backup {
96        #[command(subcommand)]
97        command: BackupCommand,
98    },
99    /// List/pull/push color palettes.
100    #[command(visible_alias = "pal")]
101    Palette {
102        #[command(subcommand)]
103        command: PaletteCommand,
104    },
105    /// List/install/remove plugins.
106    #[command(visible_alias = "plg")]
107    Plugin {
108        #[command(subcommand)]
109        command: PluginCommand,
110    },
111    /// List/install/remove/pull/push/duplicate themes.
112    #[command(visible_alias = "th")]
113    Theme {
114        #[command(subcommand)]
115        command: ThemeCommand,
116    },
117    /// Update site settings.
118    #[command(visible_alias = "set")]
119    Setting {
120        #[command(subcommand)]
121        command: SettingCommand,
122    },
123    /// Open a Discourse in the default browser.
124    #[command(visible_alias = "o")]
125    Open {
126        /// Discourse name.
127        discourse: String,
128    },
129    /// Generate shell completion scripts.
130    #[command(visible_alias = "comp")]
131    Completions {
132        /// Target shell.
133        #[arg(value_enum)]
134        shell: CompletionShell,
135        /// Output directory. Prints to stdout when omitted.
136        #[arg(long, short = 'd')]
137        dir: Option<PathBuf>,
138    },
139    /// Print the dsc version.
140    #[command(visible_alias = "ver")]
141    Version,
142}
143
144#[derive(Subcommand)]
145pub enum ListCommand {
146    /// Sort discourse entries by name and rewrite config in-place.
147    /// Also inserts placeholder values for unset template keys.
148    #[command(visible_alias = "ty")]
149    Tidy,
150}
151
152#[derive(Subcommand)]
153pub enum EmojiCommand {
154    /// Upload one emoji file, or bulk-upload from a directory.
155    #[command(visible_alias = "a")]
156    Add {
157        /// Discourse name.
158        discourse: String,
159        /// Local file or directory path.
160        emoji_path: PathBuf,
161        /// Optional emoji name (file uploads only).
162        emoji_name: Option<String>,
163    },
164
165    /// List custom emojis on a Discourse.
166    #[command(visible_alias = "ls")]
167    List {
168        /// Discourse name.
169        discourse: String,
170        /// Output format.
171        #[arg(long, short = 'f', value_enum, default_value = "text")]
172        format: ListFormat,
173        /// Include additional fields where supported.
174        #[arg(long, short = 'v')]
175        verbose: bool,
176        /// Render inline images when terminal protocol support is available.
177        #[arg(long, short = 'i')]
178        inline: bool,
179    },
180}
181
182#[derive(Subcommand)]
183pub enum TopicCommand {
184    /// Pull a topic to a local Markdown file.
185    #[command(visible_alias = "pl")]
186    Pull {
187        /// Discourse name.
188        discourse: String,
189        /// Topic ID.
190        topic_id: u64,
191        /// Destination file or directory (auto-derived when omitted).
192        local_path: Option<PathBuf>,
193    },
194    /// Push a local Markdown file to a topic.
195    #[command(visible_alias = "ps")]
196    Push {
197        /// Discourse name.
198        discourse: String,
199        /// Local Markdown file path.
200        local_path: PathBuf,
201        /// Topic ID.
202        topic_id: u64,
203    },
204    /// Sync a topic and local Markdown file using newest timestamp.
205    #[command(visible_alias = "sy")]
206    Sync {
207        /// Discourse name.
208        discourse: String,
209        /// Topic ID.
210        topic_id: u64,
211        /// Local Markdown file path.
212        local_path: PathBuf,
213        /// Skip sync confirmation prompt.
214        #[arg(long, short = 'y')]
215        yes: bool,
216    },
217}
218
219#[derive(Subcommand)]
220pub enum CategoryCommand {
221    /// List categories.
222    #[command(visible_alias = "ls")]
223    List {
224        /// Discourse name.
225        discourse: String,
226        /// Output format.
227        #[arg(long, short = 'f', value_enum, default_value = "text")]
228        format: ListFormat,
229        /// Include additional fields where supported.
230        #[arg(long, short = 'v')]
231        verbose: bool,
232        /// Show category hierarchy tree.
233        #[arg(long)]
234        tree: bool,
235    },
236    /// Copy a category to another Discourse.
237    #[command(visible_alias = "cp")]
238    Copy {
239        /// Source discourse name.
240        discourse: String,
241        /// Target discourse name (defaults to source when omitted).
242        #[arg(long, short = 't')]
243        target: Option<String>,
244        /// Category ID or slug.
245        category: String,
246    },
247    /// Pull all topics from a category into local Markdown files.
248    #[command(visible_alias = "pl")]
249    Pull {
250        /// Discourse name.
251        discourse: String,
252        /// Category ID or slug.
253        category: String,
254        /// Destination directory (auto-derived when omitted).
255        local_path: Option<PathBuf>,
256    },
257    /// Push local Markdown files into a category.
258    #[command(visible_alias = "ps")]
259    Push {
260        /// Discourse name.
261        discourse: String,
262        /// Local directory containing Markdown files.
263        local_path: PathBuf,
264        /// Category ID or slug.
265        category: String,
266    },
267}
268
269#[derive(Subcommand)]
270pub enum GroupCommand {
271    /// List groups.
272    #[command(visible_alias = "ls")]
273    List {
274        /// Discourse name.
275        discourse: String,
276        /// Output format.
277        #[arg(long, short = 'f', value_enum, default_value = "text")]
278        format: ListFormat,
279        /// Include additional fields where supported.
280        #[arg(long, short = 'v')]
281        verbose: bool,
282    },
283    /// Show group details.
284    #[command(visible_alias = "i")]
285    Info {
286        /// Discourse name.
287        discourse: String,
288        /// Group ID.
289        group: u64,
290        /// Output format.
291        #[arg(long, short = 'f', value_enum, default_value = "json")]
292        format: StructuredFormat,
293    },
294    /// List members of a group.
295    #[command(visible_alias = "m")]
296    Members {
297        /// Discourse name.
298        discourse: String,
299        /// Group ID.
300        group: u64,
301        /// Output format.
302        #[arg(long, short = 'f', value_enum, default_value = "text")]
303        format: ListFormat,
304    },
305    /// Copy a group to another Discourse.
306    #[command(visible_alias = "cp")]
307    Copy {
308        /// Source discourse name.
309        discourse: String,
310        /// Target discourse name (defaults to source when omitted).
311        #[arg(long, short = 't')]
312        target: Option<String>,
313        /// Group ID.
314        group: u64,
315    },
316}
317
318#[derive(Subcommand)]
319pub enum BackupCommand {
320    /// Create a new backup.
321    #[command(visible_alias = "cr")]
322    Create {
323        /// Discourse name.
324        discourse: String,
325    },
326    /// List backups.
327    #[command(visible_alias = "ls")]
328    List {
329        /// Discourse name.
330        discourse: String,
331        /// Output format.
332        #[arg(long, short = 'f', value_enum, default_value = "text")]
333        format: OutputFormat,
334        /// Include additional fields where supported.
335        #[arg(long, short = 'v')]
336        verbose: bool,
337    },
338    /// Restore a backup.
339    #[command(visible_alias = "rs")]
340    Restore {
341        /// Discourse name.
342        discourse: String,
343        /// Backup filename/path on the target system.
344        backup_path: String,
345    },
346}
347
348#[derive(Subcommand)]
349pub enum PaletteCommand {
350    /// List color palettes.
351    #[command(visible_alias = "ls")]
352    List {
353        /// Discourse name.
354        discourse: String,
355        /// Output format.
356        #[arg(long, short = 'f', value_enum, default_value = "text")]
357        format: ListFormat,
358        /// Include additional fields where supported.
359        #[arg(long, short = 'v')]
360        verbose: bool,
361    },
362    /// Pull a palette to local JSON.
363    #[command(visible_alias = "pl")]
364    Pull {
365        /// Discourse name.
366        discourse: String,
367        /// Palette ID.
368        palette_id: u64,
369        /// Destination file path (auto-derived when omitted).
370        local_path: Option<PathBuf>,
371    },
372    /// Push local JSON to create or update a palette.
373    #[command(visible_alias = "ps")]
374    Push {
375        /// Discourse name.
376        discourse: String,
377        /// Local JSON file path.
378        local_path: PathBuf,
379        /// Palette ID to update (creates a new palette when omitted).
380        palette_id: Option<u64>,
381    },
382}
383
384#[derive(Subcommand)]
385pub enum PluginCommand {
386    /// List installed plugins.
387    #[command(visible_alias = "ls")]
388    List {
389        /// Discourse name.
390        discourse: String,
391        /// Output format.
392        #[arg(long, short = 'f', value_enum, default_value = "text")]
393        format: ListFormat,
394        /// Include additional fields where supported.
395        #[arg(long, short = 'v')]
396        verbose: bool,
397    },
398    /// Install a plugin from URL.
399    #[command(visible_alias = "i")]
400    Install {
401        /// Discourse name.
402        discourse: String,
403        /// Plugin repository URL.
404        url: String,
405    },
406    /// Remove a plugin by name.
407    #[command(visible_alias = "rm")]
408    Remove {
409        /// Discourse name.
410        discourse: String,
411        /// Plugin name.
412        name: String,
413    },
414}
415
416#[derive(Subcommand)]
417pub enum ThemeCommand {
418    /// List installed themes.
419    #[command(visible_alias = "ls")]
420    List {
421        /// Discourse name.
422        discourse: String,
423        /// Output format.
424        #[arg(long, short = 'f', value_enum, default_value = "text")]
425        format: ListFormat,
426        /// Include additional fields where supported.
427        #[arg(long, short = 'v')]
428        verbose: bool,
429    },
430    /// Install a theme from URL.
431    #[command(visible_alias = "i")]
432    Install {
433        /// Discourse name.
434        discourse: String,
435        /// Theme repository URL.
436        url: String,
437    },
438    /// Remove a theme by name.
439    #[command(visible_alias = "rm")]
440    Remove {
441        /// Discourse name.
442        discourse: String,
443        /// Theme name.
444        name: String,
445    },
446    /// Pull a theme to a local JSON file.
447    #[command(visible_alias = "pl")]
448    Pull {
449        /// Discourse name.
450        discourse: String,
451        /// Theme ID (from `dsc theme list`).
452        theme_id: u64,
453        /// Destination file path (auto-derived from theme name when omitted).
454        local_path: Option<PathBuf>,
455    },
456    /// Push a local JSON file to create or update a theme.
457    #[command(visible_alias = "ps")]
458    Push {
459        /// Discourse name.
460        discourse: String,
461        /// Local JSON file path.
462        local_path: PathBuf,
463        /// Theme ID to update (creates a new theme when omitted).
464        theme_id: Option<u64>,
465    },
466    /// Duplicate a theme and print the new theme ID.
467    #[command(visible_alias = "dup")]
468    Duplicate {
469        /// Discourse name.
470        discourse: String,
471        /// Theme ID to duplicate (from `dsc theme list`).
472        theme_id: u64,
473    },
474}
475
476#[derive(Subcommand)]
477pub enum SettingCommand {
478    /// Set a site setting on a Discourse (or all tagged Discourses).
479    #[command(visible_alias = "s")]
480    Set {
481        /// Discourse name. Required when targeting a single discourse.
482        discourse: String,
483        /// Setting key.
484        setting: String,
485        /// Setting value.
486        value: String,
487        /// Optional tag filter (comma/semicolon separated, match-any). Ignored when discourse is specified.
488        #[arg(long, value_name = "tag1,tag2")]
489        tags: Option<String>,
490    },
491
492    /// Get the current value of a site setting.
493    #[command(visible_alias = "g")]
494    Get {
495        /// Discourse name.
496        discourse: String,
497        /// Setting key.
498        setting: String,
499    },
500
501    /// List all site settings.
502    #[command(visible_alias = "ls")]
503    List {
504        /// Discourse name.
505        discourse: String,
506        /// Output format.
507        #[arg(long, short = 'f', value_enum, default_value = "text")]
508        format: ListFormat,
509        /// Show output even when list is empty.
510        #[arg(long, short = 'v')]
511        verbose: bool,
512    },
513}
514
515#[derive(ValueEnum, Clone, Copy)]
516pub enum CompletionShell {
517    /// Bash shell.
518    Bash,
519    /// Zsh shell.
520    Zsh,
521    /// Fish shell.
522    Fish,
523}
524
525impl From<CompletionShell> for Shell {
526    fn from(value: CompletionShell) -> Self {
527        match value {
528            CompletionShell::Bash => Shell::Bash,
529            CompletionShell::Zsh => Shell::Zsh,
530            CompletionShell::Fish => Shell::Fish,
531        }
532    }
533}
534
535#[derive(ValueEnum, Clone)]
536pub enum OutputFormat {
537    /// Plain text.
538    #[value(alias = "plaintext")]
539    Text,
540    /// Markdown list.
541    Markdown,
542    /// Markdown table.
543    MarkdownTable,
544    /// Pretty JSON.
545    Json,
546    /// YAML.
547    #[value(alias = "yml")]
548    Yaml,
549    /// CSV.
550    Csv,
551    /// One base URL per line (pipe-friendly).
552    #[value(alias = "url")]
553    Urls,
554}
555
556#[derive(ValueEnum, Clone, Copy)]
557pub enum ListFormat {
558    /// Plain text.
559    Text,
560    /// Pretty JSON.
561    Json,
562    /// YAML.
563    #[value(alias = "yml")]
564    Yaml,
565}
566
567#[derive(ValueEnum, Clone, Copy)]
568pub enum StructuredFormat {
569    /// Pretty JSON.
570    Json,
571    /// YAML.
572    #[value(alias = "yml")]
573    Yaml,
574}