Skip to main content

zenith_cli/
dispatch.rs

1use std::process::ExitCode;
2
3use clap::Parser;
4
5use crate::cli;
6use crate::cli::{Cli, Command};
7use crate::cli_helpers::{read_file, scope_from_arg, targets_from_flags};
8use crate::commands::serialize_pretty;
9use crate::{commands, history, mcp, selfupdate};
10
11mod library;
12mod render;
13mod workspace;
14
15/// Main entry point: parse CLI arguments, dispatch to the appropriate command,
16/// handle all file I/O, and return the appropriate exit code.
17///
18/// All business logic lives in `commands/`; this function is I/O only.
19pub fn run() -> ExitCode {
20    let cli = Cli::parse();
21
22    match cli.command {
23        Command::New(args) => match commands::new::run(&args.path, args.name.as_deref()) {
24            Ok(result) => {
25                if let Some(w) = &result.warning {
26                    eprintln!("warning: {w}");
27                }
28                println!(
29                    "created '{}' (doc-id: {})",
30                    result.path.display(),
31                    result.doc_id
32                );
33                ExitCode::SUCCESS
34            }
35            Err(e) => {
36                eprintln!("{}", e.message);
37                ExitCode::from(e.exit_code)
38            }
39        },
40
41        Command::Validate(args) => {
42            let src = match read_file(&args.path) {
43                Ok(s) => s,
44                Err(msg) => {
45                    eprintln!("{}", msg);
46                    return ExitCode::from(2);
47                }
48            };
49            let flags = crate::config::CliPolicyFlags {
50                allow: args.allow,
51                warn: args.warn,
52                deny: args.deny,
53            };
54            let out = commands::validate::run(&src, args.path.parent(), args.json, &flags);
55            println!("{}", out.stdout);
56            ExitCode::from(out.exit_code)
57        }
58
59        Command::Fmt(args) => {
60            let src = match read_file(&args.path) {
61                Ok(s) => s,
62                Err(msg) => {
63                    eprintln!("{}", msg);
64                    return ExitCode::from(2);
65                }
66            };
67            match commands::fmt::run(&src) {
68                Ok(result) => {
69                    // Write formatted content back to disk.
70                    if let Err(e) = std::fs::write(&args.path, &result.formatted) {
71                        eprintln!("error writing '{}': {}", args.path.display(), e);
72                        return ExitCode::from(2);
73                    }
74                    println!("{}", commands::fmt::render_stdout(&result, args.json));
75                    ExitCode::SUCCESS
76                }
77                Err(e) => {
78                    eprintln!("{}", e.message);
79                    ExitCode::from(e.exit_code)
80                }
81            }
82        }
83
84        Command::Tokens(args) => {
85            let src = match read_file(&args.path) {
86                Ok(s) => s,
87                Err(msg) => {
88                    eprintln!("{}", msg);
89                    return ExitCode::from(2);
90                }
91            };
92            match commands::tokens::list(&src, args.json) {
93                Ok(out) => {
94                    println!("{}", out);
95                    ExitCode::SUCCESS
96                }
97                Err((msg, code)) => {
98                    eprintln!("{}", msg);
99                    ExitCode::from(code)
100                }
101            }
102        }
103
104        Command::Render(args) => render::dispatch_render(args),
105
106        Command::Inspect(args) => {
107            let src = match read_file(&args.path) {
108                Ok(s) => s,
109                Err(msg) => {
110                    eprintln!("{}", msg);
111                    return ExitCode::from(2);
112                }
113            };
114            match commands::inspect::run(&src, args.node.as_deref(), args.json) {
115                Ok(out) => {
116                    println!("{}", out);
117                    ExitCode::SUCCESS
118                }
119                Err(e) => {
120                    eprintln!("{}", e.message);
121                    ExitCode::from(e.exit_code)
122                }
123            }
124        }
125
126        Command::Merge(args) => {
127            // Read the template document.
128            let doc_src = match read_file(&args.doc) {
129                Ok(s) => s,
130                Err(msg) => {
131                    eprintln!("{}", msg);
132                    return ExitCode::from(2);
133                }
134            };
135
136            // Read the CSV file.
137            let csv_src = match read_file(&args.data) {
138                Ok(s) => s,
139                Err(msg) => {
140                    eprintln!("{}", msg);
141                    return ExitCode::from(2);
142                }
143            };
144
145            let project_dir = args.doc.parent();
146
147            match commands::merge::run(
148                &doc_src,
149                &csv_src,
150                project_dir,
151                &args.out_dir,
152                args.name_by.as_deref(),
153            ) {
154                Ok(report) => {
155                    if args.json {
156                        println!(
157                            "{}",
158                            serialize_pretty(&commands::merge::to_json_output(&report))
159                        );
160                    } else {
161                        let n_written = report.rows.iter().filter(|r| r.failure.is_none()).count();
162                        println!(
163                            "wrote {} file(s) to '{}'",
164                            n_written,
165                            args.out_dir.display()
166                        );
167                        for r in report.failed() {
168                            eprintln!("row {}: {}", r.row + 1, r.failure.as_deref().unwrap_or(""));
169                        }
170                    }
171                    if let Some(manifest_path) = &args.manifest {
172                        let manifest = commands::merge::build_manifest(
173                            &doc_src,
174                            &csv_src,
175                            args.name_by.as_deref(),
176                            &report,
177                        );
178                        let manifest_json = serialize_pretty(&manifest);
179                        if let Some(parent) = manifest_path.parent()
180                            && !parent.as_os_str().is_empty()
181                            && let Err(e) = std::fs::create_dir_all(parent)
182                        {
183                            eprintln!(
184                                "error creating manifest directory '{}': {}",
185                                parent.display(),
186                                e
187                            );
188                            return ExitCode::from(2);
189                        }
190                        if let Err(e) = std::fs::write(manifest_path, manifest_json.as_bytes()) {
191                            eprintln!(
192                                "error writing manifest '{}': {}",
193                                manifest_path.display(),
194                                e
195                            );
196                            return ExitCode::from(2);
197                        }
198                    }
199                    let n_failed = report.rows.iter().filter(|r| r.failure.is_some()).count();
200                    if n_failed == 0 {
201                        ExitCode::SUCCESS
202                    } else {
203                        ExitCode::from(1u8)
204                    }
205                }
206                Err(e) => {
207                    eprintln!("{}", e.message);
208                    ExitCode::from(e.exit_code)
209                }
210            }
211        }
212
213        Command::Library(args) => library::dispatch_library(args),
214
215        Command::History(args) => {
216            match history::history_view(&args.path) {
217                Ok(view) => {
218                    if args.json {
219                        let versions_json: Vec<serde_json::Value> = view
220                            .versions
221                            .iter()
222                            .map(|v| {
223                                serde_json::json!({
224                                    "id": v.id,
225                                    "seq": v.seq,
226                                    "label": v.label,
227                                    "op_kind": v.op_kind,
228                                    "timestamp_ms": v.timestamp_ms,
229                                })
230                            })
231                            .collect();
232                        let obj = serde_json::json!({
233                            "doc_id": view.doc_id,
234                            "has_session": view.has_session,
235                            "versions": versions_json,
236                        });
237                        match serde_json::to_string_pretty(&obj) {
238                            Ok(s) => println!("{}", s),
239                            Err(_) => {
240                                // Fallback to text if JSON serialisation fails.
241                                println!("doc-id: {}", view.doc_id);
242                                for v in &view.versions {
243                                    let label = v.label.as_deref().unwrap_or("");
244                                    let op = v.op_kind.as_deref().unwrap_or("");
245                                    println!("{:>4}  {}  {} {}", v.seq, v.id, op, label);
246                                }
247                            }
248                        }
249                    } else {
250                        println!("doc-id: {}", view.doc_id);
251                        if view.versions.is_empty() {
252                            println!("(no versions recorded yet)");
253                        } else {
254                            for v in &view.versions {
255                                let label = v.label.as_deref().unwrap_or("");
256                                let op = v.op_kind.as_deref().unwrap_or("");
257                                println!("{:>4}  {}  {} {}", v.seq, v.id, op, label);
258                            }
259                        }
260                    }
261                    ExitCode::SUCCESS
262                }
263                Err(msg) => {
264                    eprintln!("{}", msg);
265                    ExitCode::from(2)
266                }
267            }
268        }
269
270        Command::Undo(args) => match history::undo_edit(&args.path) {
271            Ok(history::NavOutcome::Moved) => {
272                println!("undid last edit to '{}'", args.path.display());
273                ExitCode::SUCCESS
274            }
275            Ok(history::NavOutcome::NothingToDo) => {
276                println!("nothing to undo");
277                ExitCode::SUCCESS
278            }
279            Err(msg) => {
280                eprintln!("{}", msg);
281                ExitCode::from(2)
282            }
283        },
284
285        Command::Redo(args) => match history::redo_edit(&args.path) {
286            Ok(history::NavOutcome::Moved) => {
287                println!("redid last undone edit to '{}'", args.path.display());
288                ExitCode::SUCCESS
289            }
290            Ok(history::NavOutcome::NothingToDo) => {
291                println!("nothing to redo");
292                ExitCode::SUCCESS
293            }
294            Err(msg) => {
295                eprintln!("{}", msg);
296                ExitCode::from(2)
297            }
298        },
299
300        Command::Version(args) => match history::name_version(&args.path, &args.name) {
301            Ok(id) => {
302                println!("saved version '{}' as {}", args.name, id);
303                ExitCode::SUCCESS
304            }
305            Err(msg) => {
306                eprintln!("{msg}");
307                ExitCode::from(2)
308            }
309        },
310
311        Command::Restore(args) => match history::restore(&args.path, &args.rev) {
312            Ok(outcome) => {
313                if let Some(w) = &outcome.warning {
314                    eprintln!("warning: {w}");
315                }
316                println!(
317                    "restored '{}' to {}",
318                    args.path.display(),
319                    outcome.version_id
320                );
321                ExitCode::SUCCESS
322            }
323            Err(msg) => {
324                eprintln!("{msg}");
325                ExitCode::from(2)
326            }
327        },
328
329        Command::Sync(args) => match history::sync_external(&args.path) {
330            Ok(history::SyncOutcome::Captured { id }) => {
331                println!("captured external change as {id}");
332                ExitCode::SUCCESS
333            }
334            Ok(history::SyncOutcome::AlreadyInSync) => {
335                println!("already in sync");
336                ExitCode::SUCCESS
337            }
338            Err(msg) => {
339                eprintln!("{msg}");
340                ExitCode::from(2)
341            }
342        },
343
344        Command::Tx(args) => {
345            // Read document source.
346            let doc_src = match read_file(&args.path) {
347                Ok(s) => s,
348                Err(msg) => {
349                    eprintln!("{}", msg);
350                    return ExitCode::from(2);
351                }
352            };
353
354            // Read transaction JSON.
355            let tx_json = match read_file(&args.tx_file) {
356                Ok(s) => s,
357                Err(msg) => {
358                    eprintln!("{}", msg);
359                    return ExitCode::from(2);
360                }
361            };
362
363            // Run the pure transaction logic.
364            let outcome = match commands::tx::run(&doc_src, &tx_json) {
365                Ok(o) => o,
366                Err(e) => {
367                    eprintln!("{}", e.message);
368                    return ExitCode::from(e.exit_code);
369                }
370            };
371
372            // Print output.
373            if args.json {
374                println!("{}", outcome.json_str);
375            } else {
376                println!("{}", outcome.human);
377            }
378
379            // Apply: persist source_after if requested and not rejected.
380            if args.apply && outcome.exit_code != 1 {
381                let recorded = history::record_edit(
382                    outcome.result.source_after.as_bytes(),
383                    &args.path,
384                    "tx.apply",
385                );
386                if let Some(w) = &recorded.warning {
387                    eprintln!("warning: {w}");
388                }
389                if let Err(e) = std::fs::write(&args.path, &recorded.bytes) {
390                    eprintln!("error writing '{}': {}", args.path.display(), e);
391                    return ExitCode::from(2);
392                }
393            }
394
395            ExitCode::from(outcome.exit_code)
396        }
397
398        Command::Variant(args) => {
399            let doc_src = match read_file(&args.doc) {
400                Ok(s) => s,
401                Err(msg) => {
402                    eprintln!("{}", msg);
403                    return ExitCode::from(2);
404                }
405            };
406
407            // Derive the stem from the input filename (no extension).
408            let stem = args
409                .doc
410                .file_stem()
411                .and_then(|s| s.to_str())
412                .unwrap_or("doc");
413            let project_dir = args.doc.parent();
414
415            match commands::variant::run_variant(&doc_src, project_dir, &args.out_dir, stem) {
416                Ok(report) => {
417                    let failed = report.failed();
418                    if args.json {
419                        println!(
420                            "{}",
421                            serialize_pretty(&commands::variant::to_json_output(&report))
422                        );
423                    } else {
424                        println!(
425                            "generated {} variant(s) to '{}'",
426                            report.generated(),
427                            args.out_dir.display()
428                        );
429                        for r in &failed {
430                            eprintln!("variant {}: {}", r.id, r.failure.as_deref().unwrap_or(""));
431                        }
432                    }
433                    if let Some(manifest_path) = &args.manifest {
434                        let manifest = commands::variant::build_manifest(&doc_src, &report);
435                        let manifest_json = serialize_pretty(&manifest);
436                        if let Some(parent) = manifest_path.parent()
437                            && !parent.as_os_str().is_empty()
438                            && let Err(e) = std::fs::create_dir_all(parent)
439                        {
440                            eprintln!(
441                                "error creating manifest directory '{}': {}",
442                                parent.display(),
443                                e
444                            );
445                            return ExitCode::from(2);
446                        }
447                        if let Err(e) = std::fs::write(manifest_path, manifest_json.as_bytes()) {
448                            eprintln!(
449                                "error writing manifest '{}': {}",
450                                manifest_path.display(),
451                                e
452                            );
453                            return ExitCode::from(2);
454                        }
455                    }
456                    if failed.is_empty() {
457                        ExitCode::SUCCESS
458                    } else {
459                        ExitCode::from(1u8)
460                    }
461                }
462                Err(e) => {
463                    eprintln!("{}", e.message);
464                    ExitCode::from(e.exit_code)
465                }
466            }
467        }
468
469        Command::Update(args) => match selfupdate::run(args.pre, args.version.as_deref()) {
470            Ok(()) => ExitCode::SUCCESS,
471            Err(msg) => {
472                eprintln!("error: {msg}");
473                ExitCode::from(2)
474            }
475        },
476
477        Command::Theme(args) => match args.command {
478            cli::ThemeSub::New(a) => {
479                let scheme = match a.scheme.as_str() {
480                    "light" => zenith_core::theme::Scheme::Light,
481                    "dark" => zenith_core::theme::Scheme::Dark,
482                    other => {
483                        eprintln!("error: --scheme must be 'light' or 'dark', got '{other}'");
484                        return ExitCode::from(2);
485                    }
486                };
487                let input = commands::theme::ThemeInput {
488                    name: &a.name,
489                    scheme,
490                    primary: &a.primary,
491                    secondary: a.secondary.as_deref(),
492                    accent: a.accent.as_deref(),
493                    neutral: a.neutral.as_deref(),
494                    info: a.info.as_deref(),
495                    success: a.success.as_deref(),
496                    warning: a.warning.as_deref(),
497                    error: a.error.as_deref(),
498                    shape: commands::theme::Shape {
499                        radius_box: a.radius_box,
500                        radius_field: a.radius_field,
501                        radius_selector: a.radius_selector,
502                        border: a.border,
503                        depth: a.depth,
504                        noise: a.noise,
505                    },
506                };
507                match commands::theme::new(&input) {
508                    Ok(source) => {
509                        if let Some(path) = &a.out {
510                            if let Err(e) = std::fs::write(path, &source) {
511                                eprintln!("error writing '{}': {}", path.display(), e);
512                                return ExitCode::from(2);
513                            }
514                            println!("wrote {}", path.display());
515                        } else {
516                            print!("{source}");
517                        }
518                        ExitCode::SUCCESS
519                    }
520                    Err(e) => {
521                        eprintln!("error: {}", e.message);
522                        ExitCode::from(e.exit_code)
523                    }
524                }
525            }
526        },
527
528        Command::Plugin(args) => {
529            let project_root = std::path::Path::new(".");
530            match args.command {
531                cli::PluginSub::Install(a) => {
532                    let targets = targets_from_flags(&a.agents);
533                    let code = commands::plugin::run_install(
534                        project_root,
535                        targets,
536                        scope_from_arg(a.scope),
537                        a.force,
538                        a.dry_run,
539                    );
540                    ExitCode::from(code)
541                }
542                cli::PluginSub::Uninstall(a) => {
543                    let targets = targets_from_flags(&a.agents);
544                    let code = commands::plugin::run_uninstall(
545                        project_root,
546                        targets,
547                        scope_from_arg(a.scope),
548                        a.dry_run,
549                    );
550                    ExitCode::from(code)
551                }
552                cli::PluginSub::List => ExitCode::from(commands::plugin::run_list(project_root)),
553            }
554        }
555
556        Command::Mcp(args) => match &args.http {
557            Some(addr) => ExitCode::from(mcp::run_http(addr)),
558            None => ExitCode::from(mcp::run()),
559        },
560
561        Command::Fonts(args) => {
562            let (output, code) = commands::fonts::list(args.json);
563            println!("{}", output);
564            ExitCode::from(code)
565        }
566
567        Command::Schema(args) => {
568            let json = args.json;
569            let (output, code) = match args.command {
570                None => commands::schema::overview(json),
571                Some(cli::SchemaSub::Nodes) => commands::schema::nodes(json),
572                Some(cli::SchemaSub::Node { kind }) => commands::schema::node_detail(&kind, json),
573                Some(cli::SchemaSub::Ops) => commands::schema::ops(json),
574                Some(cli::SchemaSub::Op { name }) => commands::schema::op_detail(&name, json),
575                Some(cli::SchemaSub::Tokens) => commands::schema::tokens(json),
576                Some(cli::SchemaSub::Token { ty }) => commands::schema::token_detail(&ty, json),
577                Some(cli::SchemaSub::Page) => commands::schema::page(json),
578                Some(cli::SchemaSub::Asset) => commands::schema::asset(json),
579                Some(cli::SchemaSub::Document) => commands::schema::document(json),
580                Some(cli::SchemaSub::Variant) => commands::schema::variant(json),
581                Some(cli::SchemaSub::Diagnostics) => commands::schema::diagnostics(json),
582                Some(cli::SchemaSub::Brand) => commands::schema::brand(json),
583                Some(cli::SchemaSub::Block) => commands::schema::block(json),
584            };
585            println!("{}", output);
586            ExitCode::from(code)
587        }
588
589        Command::Workspace(args) => workspace::dispatch_workspace(args),
590    }
591}