tuffite/
cli.rs

1mod config;
2mod context;
3mod dev;
4mod filesystem;
5mod framework;
6mod package;
7mod workflow;
8
9use std::path::PathBuf;
10
11use context::Context;
12
13use anyhow::Context as _;
14use clap::{Parser, Subcommand};
15
16#[derive(Parser)]
17#[command(
18    name = "tuff",
19    version,
20    about = "Build and package Tuffite desktop applications"
21)]
22pub struct Cli {
23    /// Increase logging detail (-v debug, -vv trace, -vvv trace with source locations).
24    #[arg(short = 'v', action = clap::ArgAction::Count, global = true)]
25    verbose: u8,
26    #[command(subcommand)]
27    command: Command,
28}
29
30#[derive(Debug, Subcommand)]
31enum Command {
32    /// Create a minimal typed Tuffite application.
33    Init {
34        /// Empty directory to initialize.
35        directory: PathBuf,
36        /// Cargo package name; defaults to the directory name.
37        #[arg(long)]
38        name: Option<String>,
39    },
40    /// Validate the complete application configuration without building it.
41    Check {
42        #[command(flatten)]
43        manifest: config::ManifestOptions,
44    },
45    /// Generate bindings and compile the application library.
46    Build {
47        #[command(flatten)]
48        manifest: config::ManifestOptions,
49        /// Use Cargo's release profile.
50        #[arg(long)]
51        release: bool,
52    },
53    /// Report configuration, toolchain, target, and Distribution identity.
54    Doctor {
55        #[command(flatten)]
56        manifest: config::ManifestOptions,
57    },
58    /// Refresh and launch a native package against a loopback Web development server.
59    Dev(dev::Options),
60    /// Package an application as a directory, ZIP, macOS bundle/DMG or Windows installer.
61    Package(package::Options),
62    /// Maintain the Tuffite framework and prebuilt Framework Distributions.
63    Framework(framework::Options),
64}
65
66pub fn main() {
67    if let Err(error) = Cli::parse().run() {
68        tracing::error!(error = %format_args!("{error:#}"), "command failed");
69        std::process::exit(2);
70    }
71}
72
73impl Cli {
74    pub fn run(self) -> anyhow::Result<()> {
75        Self::init_tracing(self.verbose);
76        tracing::debug!(command = ?self.command, "running Tuffite CLI command");
77        let mut context = Context::new(matches!(
78            &self.command,
79            Command::Dev(options) if options.force_download_framework
80        ));
81        match self.command {
82            Command::Init { directory, name } => workflow::init(&directory, name.as_deref())
83                .map_err(anyhow::Error::msg)
84                .with_context(|| format!("initialize {}", directory.display())),
85            Command::Check { manifest } => {
86                let manifest = manifest.resolve().map_err(anyhow::Error::msg)?;
87                config::check(&manifest)
88                    .map_err(anyhow::Error::msg)
89                    .with_context(|| format!("check {}", manifest.display()))?;
90                tracing::info!(manifest = %manifest.display(), "configuration is valid");
91                Ok(())
92            }
93            Command::Build { manifest, release } => {
94                let manifest = manifest.resolve().map_err(anyhow::Error::msg)?;
95                workflow::build(&mut context, &manifest, release)
96                    .map_err(anyhow::Error::msg)
97                    .with_context(|| format!("build {}", manifest.display()))
98            }
99            Command::Doctor { manifest } => {
100                let manifest = manifest.resolve().map_err(anyhow::Error::msg)?;
101                workflow::doctor(&mut context, &manifest)
102                    .map_err(anyhow::Error::msg)
103                    .with_context(|| format!("diagnose {}", manifest.display()))
104            }
105            Command::Dev(options) => options
106                .run(&mut context)
107                .map_err(anyhow::Error::msg)
108                .context("launch development application"),
109            Command::Package(options) => options
110                .run(&mut context)
111                .map_err(anyhow::Error::msg)
112                .context("package application"),
113            Command::Framework(options) => framework::run(&options)
114                .map_err(anyhow::Error::msg)
115                .context("run framework command"),
116        }
117    }
118
119    fn init_tracing(verbosity: u8) {
120        let filter = if verbosity == 0 {
121            tracing_subscriber::EnvFilter::try_from_default_env()
122                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"))
123        } else {
124            tracing_subscriber::EnvFilter::new(if verbosity == 1 { "debug" } else { "trace" })
125        };
126        let builder = tracing_subscriber::fmt()
127            .with_env_filter(filter)
128            .with_ansi(false)
129            .with_writer(std::io::stderr);
130        let _ = if verbosity >= 3 {
131            builder
132                .with_target(true)
133                .with_file(true)
134                .with_line_number(true)
135                .with_thread_ids(true)
136                .try_init()
137        } else {
138            builder.with_target(false).try_init()
139        };
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use clap::Parser as _;
146
147    use super::{Cli, Command};
148
149    #[test]
150    fn verbosity_is_global_and_counted() {
151        assert_eq!(
152            Cli::try_parse_from(["tuff", "-v", "check"])
153                .unwrap()
154                .verbose,
155            1
156        );
157        assert_eq!(
158            Cli::try_parse_from(["tuff", "check", "-vv"])
159                .unwrap()
160                .verbose,
161            2
162        );
163        assert_eq!(
164            Cli::try_parse_from(["tuff", "-vvv", "doctor"])
165                .unwrap()
166                .verbose,
167            3
168        );
169    }
170
171    #[test]
172    fn bindings_and_downloads_have_no_standalone_commands() {
173        for arguments in [
174            vec!["tuff", "generate"],
175            vec!["tuff", "download"],
176            vec!["tuff", "framework", "download"],
177        ] {
178            assert!(Cli::try_parse_from(arguments).is_err());
179        }
180        assert!(Cli::try_parse_from(["tuff", "dev", "--prepare-only"]).is_ok());
181    }
182
183    #[test]
184    fn dev_accepts_forced_framework_download() {
185        let cli = Cli::try_parse_from(["tuff", "dev", "--force-download-framework"]).unwrap();
186        let Command::Dev(options) = cli.command else {
187            panic!("expected dev command");
188        };
189        assert!(options.force_download_framework);
190    }
191
192    #[test]
193    fn application_commands_accept_discovery_and_manifest_overrides() {
194        for command in ["check", "build", "doctor", "dev", "package"] {
195            assert!(Cli::try_parse_from(["tuff", command]).is_ok());
196            assert!(
197                Cli::try_parse_from(["tuff", command, "--manifest", "project/tuffite.json"])
198                    .is_ok()
199            );
200        }
201        assert!(Cli::try_parse_from(["tuff", "package", "--format", "app"]).is_ok());
202        for command in ["check", "build", "doctor"] {
203            assert!(Cli::try_parse_from(["tuff", command, "project/tuffite.json"]).is_ok());
204            assert!(
205                Cli::try_parse_from([
206                    "tuff",
207                    command,
208                    "project/tuffite.json",
209                    "--manifest",
210                    "other/tuffite.json"
211                ])
212                .is_err()
213            );
214        }
215    }
216}