waos-cli 0.2.0

cargo waos: writes a new program, builds a program as a component, runs it in the WaOS on this machine, packages it as a signed .wapk, and takes screenshots of its windows and gives them input while it runs.
//! `cargo waos`: writes a new program, builds a program as a component, runs it in the WaOS on
//! this machine, and packages it as a signed `.wapk` for a package source; and, while it runs
//! there, takes a screenshot of its windows and gives them input, so an agent building the program
//! sees it and uses it (the design's decision 60).
//!
//! The invariant: every command is about a program and the WaOS it runs in. A run goes to the
//! WaOS already running, starting the installed app when none answers, never to an engine of
//! this process's own (the design's decision 29); a screenshot or an input goes to the WaOS
//! running the program, never starting one; and nothing is ever installed on a device: a
//! phone runs programs in the WaOS app already on it, and the WaOS app itself is built with
//! `cargo waft`.

mod asking;
mod build;
mod input;
mod metadata;
mod new;
mod package;
mod run;
mod screenshot;
mod signing_key;
mod unreached;
mod waos_app;
mod workspace;

use std::path::Path;
use std::process::ExitCode;

use anyhow::{Context as _, Result};
use clap::{Args, Parser, Subcommand};
use waos_dev_protocol::Input;
use waos_package::ProgramId;

use crate::build::{BuiltProgram, Profile};
use crate::input::Pair;
use crate::metadata::WaosMetadata;
use crate::new::Template;

/// What cargo hands this binary: its own name, then `waos`, then the arguments.
#[derive(Parser)]
#[command(name = "cargo", bin_name = "cargo")]
enum Cargo {
    #[command(
        name = "waos",
        about = "cargo waos: writes a new program, and builds a program as a component, runs it in WaOS and packages it, and takes screenshots of its windows and gives them input while it runs",
        version
    )]
    Waos(WaosArgs),
}

#[derive(Args)]
struct WaosArgs {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Writes a new program in a folder of its name: a Waft app that `cargo waos run` runs and
    /// `cargo waos package` packages as it is.
    New(NewArgs),
    /// Builds the program as a component, for wasm32-wasip2, and puts it at
    /// target/waos/<profile>/<package>.wasm.
    Build(BuildArgs),
    /// Builds the program and runs it in the WaOS on this machine, starting WaOS.app when none
    /// answers, as the program the id in its [package.metadata.waos] names, with that program's
    /// settings and folders. What it writes comes here as it writes it; Ctrl-C stops it; the exit
    /// status is 0 for a run that returned success and 1 otherwise.
    Run(BuildArgs),
    /// Builds the program's release component and writes it as a package signed with your
    /// signing key, at target/waos/<id>-<version>.wapk, listed in target/waos/index.json.
    /// The id, name and icon are the package's [package.metadata.waos]; the version is its own.
    Package(Program),
    /// Writes the last picture of each window of the program `cargo waos run` runs, as
    /// target/waos/screenshots/<package>/window-<n>.png, and prints each with its window's
    /// number, title and size in pixels. Windows are numbered from 1 in the order they opened.
    Screenshot(Program),
    /// Taps a window of the program `cargo waos run` runs: a press and release of the mouse there.
    /// Returns once the window has shown its next picture.
    Tap(TapArgs),
    /// Types text into what a window of the program `cargo waos run` runs edits, as an input
    /// method enters it. Returns once the window has shown its next picture.
    Type(TypeArgs),
    /// Presses a key, or a chord, in a window of the program `cargo waos run` runs: enter,
    /// escape, tab, a, cmd+s, shift+tab. Returns once the window has shown its next picture.
    Key(KeyArgs),
    /// Scrolls a window of the program `cargo waos run` runs at a point, as a mouse wheel does.
    /// Returns once the window has shown its next picture.
    Scroll(ScrollArgs),
}

/// What a new program is called, and the widget kit it starts with.
#[derive(Args)]
struct NewArgs {
    /// The program's package name, and its folder's: lowercase letters, digits, `-` and `_`,
    /// starting with a letter.
    name: String,
    /// The widget kit the program starts with.
    #[arg(long, value_enum, default_value_t)]
    template: Template,
}

/// Which program.
#[derive(Args)]
struct Program {
    /// The program's package; without it, the package of this directory.
    #[arg(short, long, value_name = "PACKAGE")]
    package: Option<String>,
}

/// Which program, and which build of it.
#[derive(Args)]
struct BuildArgs {
    #[command(flatten)]
    program: Program,
    /// An optimized build.
    #[arg(long)]
    release: bool,
}

/// Which program, and which of its windows.
#[derive(Args)]
struct WindowArgs {
    #[command(flatten)]
    program: Program,
    /// The window, numbered from 1 as `cargo waos screenshot` lists them.
    #[arg(long, default_value_t = 1)]
    window: u32,
}

#[derive(Args)]
struct TapArgs {
    #[command(flatten)]
    window: WindowArgs,
    /// Where, in the window's pixels from its top-left, as a screenshot has them: <x>,<y>.
    #[arg(long, value_name = "X,Y", allow_hyphen_values = true)]
    at: Pair,
}

#[derive(Args)]
struct TypeArgs {
    #[command(flatten)]
    window: WindowArgs,
    /// The text.
    #[arg(allow_hyphen_values = true)]
    text: String,
}

#[derive(Args)]
struct KeyArgs {
    #[command(flatten)]
    window: WindowArgs,
    /// The key, or modifiers and a key joined by +: cmd, ctrl, alt and shift, then a letter, a
    /// digit, a symbol, enter, escape, tab, space, backspace, delete, up, down, left, right,
    /// home, end, pageup, pagedown or f1 to f12.
    #[arg(allow_hyphen_values = true)]
    chord: String,
}

#[derive(Args)]
struct ScrollArgs {
    #[command(flatten)]
    window: WindowArgs,
    /// Where, in the window's pixels from its top-left, as a screenshot has them: <x>,<y>.
    #[arg(long, value_name = "X,Y", allow_hyphen_values = true)]
    at: Pair,
    /// How far, in pixels: <dx>,<dy>, a positive dy scrolling down, towards the content's end.
    #[arg(long, value_name = "DX,DY", allow_hyphen_values = true)]
    by: Pair,
}

impl BuildArgs {
    fn profile(&self) -> Profile {
        if self.release {
            Profile::Release
        } else {
            Profile::Debug
        }
    }
}

fn main() -> ExitCode {
    let Cargo::Waos(args) = Cargo::parse();
    let done = match args.command {
        Command::New(new) => new_program(&new),
        Command::Build(build) => build_only(&build),
        Command::Run(build) => build_and_run(&build),
        Command::Package(program) => package(&program),
        Command::Screenshot(program) => screenshot(&program),
        Command::Tap(tap) => give(
            &tap.window,
            Input::Tap {
                x: tap.at.0,
                y: tap.at.1,
            },
        ),
        Command::Type(typed) => give(&typed.window, Input::Text(typed.text)),
        Command::Key(key) => give(&key.window, Input::Key(key.chord)),
        Command::Scroll(scroll) => give(
            &scroll.window,
            Input::Scroll {
                x: scroll.at.0,
                y: scroll.at.1,
                dx: scroll.by.0,
                dy: scroll.by.1,
            },
        ),
    };
    done.unwrap_or_else(|error| {
        eprintln!("cargo waos: {error:#}");
        ExitCode::FAILURE
    })
}

/// Writes a new program in a folder of this directory's, and says how to run it.
fn new_program(args: &NewArgs) -> Result<ExitCode> {
    let folder = new::create(&args.name, args.template, Path::new("."))?;
    println!("created {}", folder.display());
    println!("  cd {} && cargo waos run", args.name);
    Ok(ExitCode::SUCCESS)
}

/// Builds the program and says where its component is.
fn build_only(args: &BuildArgs) -> Result<ExitCode> {
    let built = build_chosen(args)?;
    println!("{}", built.component.display());
    Ok(ExitCode::SUCCESS)
}

/// Builds the program and runs it in WaOS as the program its id names until it ends, on a
/// runtime of this thread's. A package with no id is refused before anything is built.
fn build_and_run(args: &BuildArgs) -> Result<ExitCode> {
    let metadata = workspace::metadata()?;
    let package = workspace::chosen_package(&metadata, args.program.package.as_deref())?;
    let id = WaosMetadata::id_of(package)?;
    let built = build::build(&metadata, package, args.profile())?;
    on_this_thread(run::run(id, &built))
}

/// Writes the pictures of the program's windows under target/waos/screenshots/<package>, as the
/// WaOS running it answers.
fn screenshot(program: &Program) -> Result<ExitCode> {
    let metadata = workspace::metadata()?;
    let package = workspace::chosen_package(&metadata, program.package.as_deref())?;
    let id = WaosMetadata::id_of(package)?;
    let folder = workspace::waos_folder(&metadata)
        .join("screenshots")
        .join(package.name.as_str());
    on_this_thread(screenshot::take(id, &folder))
}

/// Gives `input` to the window the arguments choose, and waits for WaOS's answer.
fn give(window: &WindowArgs, input: Input) -> Result<ExitCode> {
    let id = program_id(&window.program)?;
    on_this_thread(input::give(id, window.window, input))
}

/// The id in the chosen package's [package.metadata.waos], read without building anything.
fn program_id(program: &Program) -> Result<ProgramId> {
    let metadata = workspace::metadata()?;
    let package = workspace::chosen_package(&metadata, program.package.as_deref())?;
    WaosMetadata::id_of(package)
}

/// What `connection` answers, on a runtime of this thread's, for a command's connection to WaOS.
fn on_this_thread(connection: impl Future<Output = Result<ExitCode>>) -> Result<ExitCode> {
    let runtime = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .context("a runtime for the connection")?;
    runtime.block_on(connection)
}

/// Packages the program and says where its package is.
fn package(program: &Program) -> Result<ExitCode> {
    let packaged = package::package(program.package.as_deref())?;
    eprintln!("cargo waos: listed in {}", packaged.index.display());
    println!("{}", packaged.package.display());
    Ok(ExitCode::SUCCESS)
}

/// Builds the program the arguments choose.
fn build_chosen(args: &BuildArgs) -> Result<BuiltProgram> {
    let metadata = workspace::metadata()?;
    let package = workspace::chosen_package(&metadata, args.program.package.as_deref())?;
    build::build(&metadata, package, args.profile())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn parsed(arguments: &[&str]) -> Command {
        let Cargo::Waos(args) = Cargo::try_parse_from(["cargo", "waos"].iter().chain(arguments))
            .unwrap_or_else(|error| panic!("{arguments:?}: {error}"));
        args.command
    }

    fn refused(arguments: &[&str]) -> bool {
        Cargo::try_parse_from(["cargo", "waos"].iter().chain(arguments)).is_err()
    }

    #[test]
    fn a_screenshot_names_its_package_or_none() {
        let Command::Screenshot(program) = parsed(&["screenshot"]) else {
            panic!("a screenshot");
        };
        assert_eq!(program.package, None);
        let Command::Screenshot(program) = parsed(&["screenshot", "-p", "notes"]) else {
            panic!("a screenshot");
        };
        assert_eq!(program.package.as_deref(), Some("notes"));
    }

    #[test]
    fn a_tap_is_at_a_point_in_the_first_window_unless_another_is_named() {
        let Command::Tap(tap) = parsed(&["tap", "--at", "120,48"]) else {
            panic!("a tap");
        };
        assert_eq!((tap.at, tap.window.window), (Pair(120.0, 48.0), 1));
        let Command::Tap(tap) = parsed(&["tap", "--at", "1.5,2", "--window", "2", "-p", "notes"])
        else {
            panic!("a tap");
        };
        assert_eq!((tap.at, tap.window.window), (Pair(1.5, 2.0), 2));
        assert_eq!(tap.window.program.package.as_deref(), Some("notes"));
        assert!(refused(&["tap"]), "a tap needs a point");
        assert!(refused(&["tap", "--at", "120"]));
    }

    #[test]
    fn a_scroll_is_at_a_point_by_an_amount_either_signed() {
        let Command::Scroll(scroll) = parsed(&["scroll", "--at", "10,20", "--by", "-5,-200"])
        else {
            panic!("a scroll");
        };
        assert_eq!(
            (scroll.at, scroll.by),
            (Pair(10.0, 20.0), Pair(-5.0, -200.0))
        );
        assert!(
            refused(&["scroll", "--at", "10,20"]),
            "a scroll needs an amount"
        );
    }

    #[test]
    fn text_and_a_chord_are_taken_as_written() {
        let Command::Type(typed) = parsed(&["type", "-5 eggs, milk"]) else {
            panic!("text");
        };
        assert_eq!(typed.text, "-5 eggs, milk");
        let Command::Key(key) = parsed(&["key", "cmd+s", "--window", "3"]) else {
            panic!("a key");
        };
        assert_eq!((key.chord.as_str(), key.window.window), ("cmd+s", 3));
        let Command::Key(key) = parsed(&["key", "-"]) else {
            panic!("a key");
        };
        assert_eq!(key.chord, "-");
    }
}