taktwerk 0.8.0

Fixed-step model execution engine for Linux with its own OPC UA server
//! Command-line arguments and dispatch.

use std::path::{Path, PathBuf};
use std::process::ExitCode;

use anyhow::{Context as _, bail};
use clap::{Parser, Subcommand};

use taktwerk_core::clock::Pace;
use taktwerk_core::overrides::Override;

use crate::kinds::ModelKind;
use crate::{campaign, fmu_wrap, import, inspect, record, run, scaffold, tui};

/// Fixed-step model execution engine for Linux with its own OPC UA server.
#[derive(Debug, Parser)]
#[command(name = "taktwerk", version, about)]
pub struct Cli {
    /// What to do.
    #[command(subcommand)]
    pub command: Command,
}

/// The subcommands.
#[derive(Debug, Subcommand)]
pub enum Command {
    /// Load a project, its models and connectors, resolve and bind it, print the plan.
    Check {
        /// Project file.
        project: PathBuf,
        /// Print the plan, or the problems, as one JSON object.
        #[arg(long)]
        json: bool,
    },
    /// Run a project until SIGINT or SIGTERM.
    Run {
        /// Project file.
        project: PathBuf,
        /// Stop after this many ticks.
        #[arg(long)]
        cycles: Option<u64>,
        /// How fast ticks follow the wall clock: `1` real time, `<factor>` that many times
        /// faster, `max` as fast as the models run. Off real time, ages count engine time.
        #[arg(long, default_value = "1", value_parser = parse_speed)]
        speed: Pace,
        /// Start no connector: the run serves nothing and needs no port.
        #[arg(long)]
        no_connectors: bool,
        /// Stop before the first tick at or after this engine time, seconds.
        #[arg(long, value_name = "SECONDS")]
        until_time: Option<f64>,
        /// Stop after the first tick at which this holds: `<signal> <op> <number>`, the op one of
        /// `>`, `>=`, `<`, `<=`, `==`, `!=`, or a bare signal for "not zero"; `name[i]` for an
        /// element. Repeatable; the first that holds ends the run.
        #[arg(long, value_name = "CONDITION")]
        stop_when: Vec<String>,
        /// Override before the run: `<instance>.<parameter|tunable|input>=<value>`,
        /// `<instance>.dims.<dim>=<n>`, `<instance>.every=<n>`, `engine.<tick_ms|start_time|
        /// input_max_age_ms>=<value>` or `<signal>=<value>` for a tunable's or external input's
        /// signal. The value is TOML (`0.5`, `true`, `[1, 2]`, `"text"`), else text. Repeatable,
        /// applied in order; each is recorded in run.json.
        #[arg(long = "set", value_name = "KEY=VALUE", value_parser = parse_override)]
        set: Vec<Override>,
        /// Use this package or FMU for model `ID`, of the same kind. Repeatable.
        #[arg(long = "model", value_name = "ID=PATH", value_parser = parse_model)]
        models: Vec<(String, PathBuf)>,
        /// Write every file recorder under this directory instead of its `dir`.
        #[arg(long, value_name = "DIR")]
        record_dir: Option<PathBuf>,
        /// Run seed: every instance whose model has a `seed` parameter or tunable (or the one its
        /// `seed_var` names) gets its own seed derived from it; `--set` still wins.
        #[arg(long)]
        seed: Option<u64>,
        /// A scenario file: timed events (set, add, ramp, fault, clear), overrides, stops and a
        /// seed. Options given here win over the file's.
        #[arg(long, value_name = "FILE")]
        scenario: Option<PathBuf>,
        /// Serve the HTTP API and web UI on this address (`127.0.0.1:8480`), as an `http`
        /// connector added to the project's; also with --no-connectors. Needs a build with the
        /// `http` feature.
        #[arg(long, value_name = "ADDR")]
        http: Option<String>,
        /// Let the --http connector accept writes and stop, with its token.
        #[arg(long, requires = "http")]
        http_writes: bool,
    },
    /// Plan a campaign of runs from one file and print the plan; with --write run it, resuming
    /// where it stopped.
    Campaign {
        /// Campaign file.
        file: PathBuf,
        /// Run the campaign; without it, only the plan is printed.
        #[arg(long)]
        write: bool,
        /// When the output holds another plan, or to run every run again: start over.
        #[arg(long)]
        force: bool,
        /// Runs at a time, instead of the file's `jobs`.
        #[arg(long)]
        jobs: Option<usize>,
    },
    /// Print a model's interface: dimensions, variables, shapes.
    Inspect {
        /// Model path: an .fmu, an extracted FMU or a raw package directory.
        model: PathBuf,
        /// Model kind.
        #[arg(long, value_enum)]
        kind: ModelKind,
    },
    /// Scaffold a project file for one model; prints it unless asked to write.
    New {
        /// Model path.
        model: PathBuf,
        /// Model kind.
        #[arg(long, value_enum)]
        kind: ModelKind,
        /// Write the project to this file instead of printing it.
        #[arg(short, long)]
        output: Option<PathBuf>,
        /// Write the project to `taktwerk.toml` (or `--output`).
        #[arg(long)]
        write: bool,
        /// Overwrite an existing file.
        #[arg(long)]
        force: bool,
        /// Base tick, milliseconds.
        #[arg(long, default_value_t = 10.0)]
        tick_ms: f64,
        /// Port of the own OPC UA server.
        #[arg(long, default_value_t = 4840)]
        port: u16,
    },
    /// Read a raw model descriptor from a C header, or propose one; prints it unless asked to
    /// write.
    ImportHeader(import::Args),
    /// Wrap a confirmed raw model package as an FMI 3 co-simulation FMU; a dry run unless
    /// asked to write.
    FmuWrap(fmu_wrap::Args),
    /// Monitor a running engine over OPC UA and edit its inputs and tunables.
    Tui {
        /// The engine's server, e.g. `opc.tcp://127.0.0.1:4840`.
        endpoint: String,
        /// Namespace URI of the signal nodes.
        #[arg(long, default_value = "urn:taktwerk")]
        namespace: String,
        /// The engine's `system_prefix`.
        #[arg(long, default_value = "taktwerk")]
        prefix: String,
    },
    /// Pull a recorder's rows and events from a running engine and write them as run files,
    /// until SIGINT or SIGTERM.
    Record(record::Args),
    /// Serve the workbench over a project folder: its files, plans, records and campaigns, runs
    /// started and watched, and the web UI. Loopback only; needs a build with the `http`
    /// feature.
    Serve {
        /// The project folder.
        #[arg(default_value = ".")]
        dir: PathBuf,
        /// Address and port to listen on.
        #[arg(long, default_value = "127.0.0.1:8470")]
        listen: String,
    },
}

/// Run `command`.
///
/// # Errors
/// Anything that stops the command before it can report on its own.
pub fn dispatch(command: Command) -> anyhow::Result<ExitCode> {
    match command {
        Command::Check { project, json } => run::check(&project, json),
        Command::Run {
            project,
            cycles,
            speed,
            no_connectors,
            until_time,
            stop_when,
            set,
            models,
            record_dir,
            seed,
            scenario,
            http,
            http_writes,
        } => run::run(
            &project,
            &run::RunOptions {
                http,
                http_writes,
                cycles,
                pace: speed,
                connectors: !no_connectors,
                until_time,
                stop_when,
                set,
                models,
                record_dir,
                seed,
                scenario,
            },
        ),
        Command::Campaign {
            file,
            write,
            force,
            jobs,
        } => campaign::campaign(&file, &campaign::Options { write, force, jobs }),
        Command::Inspect { model, kind } => {
            let adapter = kind.load(&model).map_err(anyhow::Error::msg)?;
            print!("{}", inspect::interface(kind.as_str(), adapter.interface()));
            Ok(ExitCode::SUCCESS)
        }
        Command::New {
            model,
            kind,
            output,
            write,
            force,
            tick_ms,
            port,
        } => new(&model, kind, output, write, force, tick_ms, port),
        Command::ImportHeader(args) => import::run(&args),
        Command::FmuWrap(args) => fmu_wrap::run(&args),
        Command::Tui {
            endpoint,
            namespace,
            prefix,
        } => tui::run(&endpoint, &namespace, &prefix),
        Command::Record(args) => record::run(&args),
        #[cfg(feature = "http")]
        Command::Serve { dir, listen } => crate::serve::serve(&dir, &listen),
        #[cfg(not(feature = "http"))]
        Command::Serve { .. } => {
            anyhow::bail!("serve: this taktwerk is built without the `http` feature")
        }
    }
}

fn new(
    model: &Path,
    kind: ModelKind,
    output: Option<PathBuf>,
    write: bool,
    force: bool,
    tick_ms: f64,
    port: u16,
) -> anyhow::Result<ExitCode> {
    if !(tick_ms.is_finite() && tick_ms > 0.0) {
        bail!("--tick-ms must be a positive number");
    }
    let adapter = kind.load(model).map_err(anyhow::Error::msg)?;
    let iface = adapter.interface();
    let target = match (output, write) {
        (Some(path), _) => Some(path),
        (None, true) => Some(PathBuf::from("taktwerk.toml")),
        (None, false) => None,
    };
    let base = target
        .as_deref()
        .and_then(Path::parent)
        .filter(|p| !p.as_os_str().is_empty())
        .unwrap_or_else(|| Path::new("."));
    let path = scaffold::relative(&std::path::absolute(model)?, &std::path::absolute(base)?);
    let stem = model
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or_default();
    let id = scaffold::id_from(if iface.name.is_empty() {
        stem
    } else {
        &iface.name
    });
    let text = scaffold::project(&scaffold::Scaffold {
        id,
        kind: kind.as_str(),
        path: path.to_string_lossy().into_owned(),
        interface: iface,
        tick_ms,
        port,
    });
    match target {
        None => print!("{text}"),
        Some(file) => {
            if file.exists() && !force {
                bail!("{} exists; pass --force to overwrite", file.display());
            }
            std::fs::write(&file, text).with_context(|| format!("write {}", file.display()))?;
            eprintln!("wrote {}", file.display());
        }
    }
    Ok(ExitCode::SUCCESS)
}

/// `--speed`: `max`, or a positive factor of real time (`1` is real time).
pub(crate) fn parse_speed(s: &str) -> Result<Pace, String> {
    if s == "max" {
        return Ok(Pace::Unpaced);
    }
    match s.parse::<f64>() {
        Ok(1.0) => Ok(Pace::Realtime),
        Ok(f) if f.is_finite() && f > 0.0 => Ok(Pace::Scaled(f)),
        _ => Err(format!("`{s}` is neither `max` nor a positive factor")),
    }
}

/// `--set KEY=VALUE`.
fn parse_override(s: &str) -> Result<Override, String> {
    Override::parse(s)
}

/// `--model ID=PATH`.
fn parse_model(s: &str) -> Result<(String, PathBuf), String> {
    match s.split_once('=') {
        Some((id, path)) if !id.is_empty() && !path.is_empty() => {
            Ok((id.to_owned(), PathBuf::from(path)))
        }
        _ => Err(format!("`{s}`: expected ID=PATH")),
    }
}