use rust_widgets::designer::artifact::{
plan_artifacts, regenerate_into, ArtifactOutcome, GENERATED_MARKER,
};
use std::process::ExitCode;
const EXIT_DRIFT: u8 = 1;
const EXIT_USAGE: u8 = 2;
fn main() -> ExitCode {
let options = match Options::parse(std::env::args().skip(1)) {
Ok(options) => options,
Err(error) => {
eprintln!("designer_generate: {error}");
eprintln!();
eprintln!("{USAGE}");
return ExitCode::from(EXIT_USAGE);
}
};
match run(&options) {
Ok(true) => ExitCode::SUCCESS,
Ok(false) => ExitCode::from(EXIT_DRIFT),
Err(error) => {
eprintln!("designer_generate: {error}");
ExitCode::from(EXIT_USAGE)
}
}
}
const USAGE: &str = "\
usage: designer_generate --project <file> [--root <dir>] [--width <px>] [--height <px>] [--check]
--project <file> the designer's JSON project (required)
--root <dir> where `src/generated/` lives (default: the current directory)
--width <px> the client width the layout is solved against (default: 1280)
--height <px> the client height the layout is solved against (default: 800)
--check do not write; report whether the committed files are up to date
exit status:
0 every committed file matches the project (or was written)
1 --check found a committed file that does not match
2 the arguments or the project document could not be used";
struct Options {
project: String,
root: std::path::PathBuf,
width: u32,
height: u32,
check: bool,
}
impl Options {
fn parse(args: impl Iterator<Item = String>) -> Result<Self, String> {
let mut project: Option<String> = None;
let mut root = std::path::PathBuf::from(".");
let mut width = 1280u32;
let mut height = 800u32;
let mut check = false;
let mut args = args.peekable();
while let Some(arg) = args.next() {
match arg.as_str() {
"--project" => {
project = Some(args.next().ok_or("--project needs a file path")?);
}
"--root" => {
root = std::path::PathBuf::from(args.next().ok_or("--root needs a directory")?);
}
"--width" => {
width = parse_dimension("--width", args.next())?;
}
"--height" => {
height = parse_dimension("--height", args.next())?;
}
"--check" => check = true,
"--help" | "-h" => return Err(String::from("(help requested)")),
other => return Err(format!("unknown argument `{other}`")),
}
}
Ok(Self { project: project.ok_or("--project is required")?, root, width, height, check })
}
}
fn parse_dimension(flag: &str, value: Option<String>) -> Result<u32, String> {
let value = value.ok_or_else(|| format!("{flag} needs a pixel count"))?;
value
.parse::<u32>()
.map_err(|_| format!("{flag} must be a non-negative integer, got `{value}`"))
}
fn run(options: &Options) -> Result<bool, String> {
let json = std::fs::read_to_string(&options.project)
.map_err(|error| format!("could not read `{}`: {error}", options.project))?;
if options.check {
return check_committed(options, &json);
}
let outcomes = regenerate_into(&options.root, &json, options.width, options.height)?;
report(&outcomes);
Ok(true)
}
fn check_committed(options: &Options, json: &str) -> Result<bool, String> {
let artifacts = plan_artifacts(json, options.width, options.height)?;
let mut up_to_date = true;
for artifact in &artifacts {
let absolute = options.root.join(&artifact.path);
match std::fs::read_to_string(&absolute) {
Ok(committed) if committed == artifact.source => {
println!("unchanged {}", absolute.display());
}
Ok(committed) if !committed.contains(GENERATED_MARKER) => {
eprintln!(
"designer_generate: {} exists but is not a generated file (its first line is \
not the generated marker); refusing to compare it",
absolute.display()
);
up_to_date = false;
}
Ok(_) => {
println!("stale {}", absolute.display());
up_to_date = false;
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
println!("missing {}", absolute.display());
up_to_date = false;
}
Err(error) => {
return Err(format!("could not read {}: {error}", absolute.display()));
}
}
}
if !up_to_date {
eprintln!();
eprintln!(
"designer_generate: committed sources do not match `{}`. Regenerate with:\n\
\x20 cargo run --no-default-features --features desktop,designer --example designer_generate \
-- --project {} --root {}",
options.project,
options.project,
options.root.display()
);
}
Ok(up_to_date)
}
fn report(outcomes: &[ArtifactOutcome]) {
for outcome in outcomes {
let verb = match outcome {
ArtifactOutcome::Created(_) => "created",
ArtifactOutcome::Updated(_) => "updated",
ArtifactOutcome::Unchanged(_) => "unchanged",
};
println!("{verb} {}", outcome.path());
}
let changed = outcomes.iter().filter(|outcome| outcome.wrote()).count();
println!(
"{} file(s): {} written, {} already up to date",
outcomes.len(),
changed,
outcomes.len() - changed
);
}
#[cfg(test)]
mod tests {
use super::*;
fn args(items: &[&str]) -> Vec<String> {
items.iter().map(|item| String::from(*item)).collect()
}
#[test]
fn the_project_flag_is_required() {
let error = Options::parse(args(&[]).into_iter()).unwrap_err();
assert!(error.contains("--project is required"), "got: {error}");
}
#[test]
fn the_defaults_are_a_real_window_size_and_the_current_directory() {
let options = Options::parse(args(&["--project", "p.json"]).into_iter()).expect("parses");
assert_eq!(options.width, 1280);
assert_eq!(options.height, 800);
assert_eq!(options.root, std::path::PathBuf::from("."));
assert!(!options.check, "writing is the default; `--check` is the opt-in");
}
#[test]
fn every_flag_is_accepted() {
let options = Options::parse(
args(&[
"--project",
"p.json",
"--root",
"/tmp/x",
"--width",
"320",
"--height",
"240",
"--check",
])
.into_iter(),
)
.expect("parses");
assert_eq!(options.project, "p.json");
assert_eq!(options.root, std::path::PathBuf::from("/tmp/x"));
assert_eq!(options.width, 320);
assert_eq!(options.height, 240);
assert!(options.check);
}
#[test]
fn a_non_numeric_dimension_names_its_flag() {
let error =
Options::parse(args(&["--project", "p", "--width", "wide"]).into_iter()).unwrap_err();
assert!(error.contains("--width"), "got: {error}");
assert!(error.contains("wide"), "the offending value must appear: {error}");
}
#[test]
fn a_flag_missing_its_value_is_refused() {
let error = Options::parse(args(&["--project"]).into_iter()).unwrap_err();
assert!(error.contains("needs a file path"), "got: {error}");
}
#[test]
fn an_unknown_flag_is_refused_rather_than_ignored() {
let error = Options::parse(args(&["--project", "p", "--chekc"]).into_iter()).unwrap_err();
assert!(error.contains("unknown argument"), "got: {error}");
}
#[test]
fn a_negative_dimension_is_refused() {
let error =
Options::parse(args(&["--project", "p", "--width", "-1"]).into_iter()).unwrap_err();
assert!(error.contains("--width"), "got: {error}");
}
}