autors-cli 0.1.0

CANoe-inspired terminal workbench for the autors crate family
mod a2l_lab;
mod app;
mod bus;
mod catalog;
mod document;
mod hardware;
mod lin_bus;
mod odx_lab;
mod prm_lab;
mod protocol;
mod symbol_lab;
mod ui;

use std::error::Error;
use std::io::{self, stdout};
use std::path::PathBuf;
use std::time::{Duration, Instant};

use app::App;
use catalog::CapabilityCatalog;
use crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind};
use crossterm::execute;
use crossterm::terminal::{
    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;

const HELP: &str = "autors-cli — terminal automotive engineering workbench

USAGE:
    autors-cli [OPTIONS] [FILE]

OPTIONS:
    --workspace <PATH>      autors workspace directory or Cargo.toml
    --manifest-path <PATH>  explicit workspace Cargo.toml
    -h, --help              print this help

FILES:
    A2L, DBC, LDF, ASC, BLF, LTRC, MDF, CDF/CDFX, ODX, PRM/CNF, ELF/MAP and
    common ECU program images receive native structured views. Other text and
    binary automotive files open in the universal inspector.

WORKBENCH:
    Press a for retained A2L measurement, calibration, and virtual DAQ.
    Press b for CAN/CAN FD transmit, tracing, DBC decoding, and scheduling.
    Press n for the equivalent LDF-driven LIN workbench. Virtual adapters are
    always present; cargo features enable Vector, Kvaser, and PEAK hardware.
    Press g for live and offline UDS/KWP, DoIP, CCP, and XCP workflows.
    Press d for an ODX-driven ECU variant/service diagnostic workbench.
";

fn main() -> Result<(), Box<dyn Error>> {
    let Some(options) = Options::parse(std::env::args().skip(1))? else {
        print!("{HELP}");
        return Ok(());
    };
    let catalog = CapabilityCatalog::load(&options.manifest_path).map_err(io::Error::other)?;
    let mut app = App::new(catalog);
    if let Some(path) = options.file {
        app.open_document(path);
    }
    run_terminal(&mut app)?;
    Ok(())
}

fn run_terminal(app: &mut App) -> io::Result<()> {
    enable_raw_mode()?;
    let mut output = stdout();
    execute!(output, EnterAlternateScreen, EnableMouseCapture)?;
    let backend = CrosstermBackend::new(output);
    let mut terminal = Terminal::new(backend)?;
    let result = event_loop(&mut terminal, app);
    disable_raw_mode()?;
    execute!(
        terminal.backend_mut(),
        LeaveAlternateScreen,
        DisableMouseCapture
    )?;
    terminal.show_cursor()?;
    result
}

fn event_loop(
    terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
    app: &mut App,
) -> io::Result<()> {
    let mut previous_tick = Instant::now();
    while !app.should_quit {
        terminal.draw(|frame| ui::draw(frame, app))?;
        if event::poll(Duration::from_millis(33))? {
            if let Event::Key(key) = event::read()? {
                if key.kind == KeyEventKind::Press {
                    app.handle_key(key);
                }
            }
        }
        let now = Instant::now();
        app.advance_playback(now.duration_since(previous_tick));
        previous_tick = now;
    }
    Ok(())
}

#[derive(Debug)]
struct Options {
    manifest_path: PathBuf,
    file: Option<PathBuf>,
}

impl Options {
    fn parse(arguments: impl IntoIterator<Item = String>) -> Result<Option<Self>, String> {
        let mut arguments = arguments.into_iter();
        let mut manifest_path = default_manifest_path();
        let mut file = None;
        while let Some(argument) = arguments.next() {
            match argument.as_str() {
                "-h" | "--help" => return Ok(None),
                "--workspace" => {
                    let value = arguments
                        .next()
                        .ok_or_else(|| "--workspace requires a path".to_owned())?;
                    let path = PathBuf::from(value);
                    manifest_path = if path.file_name().is_some_and(|name| name == "Cargo.toml") {
                        path
                    } else {
                        path.join("Cargo.toml")
                    };
                }
                "--manifest-path" => {
                    manifest_path = PathBuf::from(
                        arguments
                            .next()
                            .ok_or_else(|| "--manifest-path requires a path".to_owned())?,
                    );
                }
                value if value.starts_with('-') => {
                    return Err(format!("unknown option {value:?}\n\n{HELP}"));
                }
                value if file.is_none() => file = Some(PathBuf::from(value)),
                value => return Err(format!("unexpected second file argument {value:?}")),
            }
        }
        Ok(Some(Self {
            manifest_path,
            file,
        }))
    }
}

fn default_manifest_path() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("../..")
        .join("Cargo.toml")
}

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

    #[test]
    fn options_accept_workspace_and_initial_file() {
        let options = Options::parse([
            "--workspace".to_owned(),
            "repo".to_owned(),
            "trace.asc".to_owned(),
        ])
        .unwrap()
        .unwrap();
        assert_eq!(
            options.manifest_path,
            PathBuf::from("repo").join("Cargo.toml")
        );
        assert_eq!(options.file, Some(PathBuf::from("trace.asc")));
    }

    #[test]
    fn help_short_circuits_startup() {
        assert!(Options::parse(["--help".to_owned()]).unwrap().is_none());
    }
}