f-tree 0.3.0

the cli app to execute the commands from forester.
use std::path::PathBuf;

use clap::{arg, crate_version, Arg, ArgAction, ArgMatches, Command};
use forester_rs::runtime::builder::builtin::builtin_actions_file;
use forester_rs::runtime::builder::ros_nav::ros_actions_file;
use forester_rs::runtime::builder::ForesterBuilder;
use forester_rs::runtime_tree_default;
use forester_rs::simulator::builder::SimulatorBuilder;
use forester_rs::visualizer::Visualizer;
use log::LevelFilter;

#[macro_use]
extern crate log;

fn cli() -> Command {
    Command::new("f-tree")
        .about("A console utility to interact with Forester")
        .subcommand_required(true)
        .arg_required_else_help(true)
        .version(crate_version!())
        .arg(
            Arg::new("verbose")
                .short('v')
                .long("verbose")
                .help("Print verbose logs")
                .global(true)
                .action(ArgAction::SetTrue)
        )
        .subcommand(Command::new("print-std-actions").about("Print the list of std actions from 'import std::actions'"))
        .subcommand(Command::new("print-ros-nav2").about("Print the list of ros actions from 'import ros::nav2'"))
        .subcommand(
            Command::new("sim")
                .about(r#"Runs simulation. Expects a simulation profile"#)
                .arg(arg!(-p --profile <PATH> "a path to a sim profile, empty by default"))
                .arg(arg!(-r --root <ROOT> "a path to a root folder. The <PWD> folder by default"))
                .arg(arg!(-m --main <MAIN> "a path to a main file. The 'main.tree' by default"))
                .arg(arg!(-t --tree <TREE> "a root in a main file. If there is only one root it takes by default"))
        )
        .subcommand(
            Command::new("vis")
                .about(r#"Runs visualization. Output is in svg format."#)
                .arg(arg!(-o --output <OUTPUT> "a file for svg. If none, the name from the main file will be taken."))
                .arg(arg!(-r --root <ROOT> "a path to a root folder. The <PWD> folder by default"))
                .arg(arg!(-m --main <MAIN> "a path to a main file. The 'main.tree' by default"))
                .arg(arg!(-t --tree <TREE> "a root in a main file. If there is only one root it takes by default"))
        )
        .subcommand(
            Command::new("nav2")
                .about(r#"Convert to the XML-compatible format of nav ROS2."#)
                .arg(arg!(-o --output <OUTPUT> "a file for xml. If none, the name from the main file will be taken."))
                .arg(arg!(-r --root <ROOT> "a path to a root folder. The <PWD> folder by default"))
                .arg(arg!(-m --main <MAIN> "a path to a main file. The 'main.tree' by default"))
                .arg(arg!(-t --tree <TREE> "a root in a main file. If there is only one root it takes by default"))
        )
}

fn buf(val: &str, relative: PathBuf) -> PathBuf {
    let path = PathBuf::from(val);
    if path.is_relative() {
        let mut full_path = relative;
        full_path.push(path);
        full_path
    } else {
        path
    }
}

fn sim(matches: &ArgMatches) {
    let pwd = std::env::current_dir().expect("the current directory is present");

    let root = match matches.get_one::<String>("root") {
        Some(root) => buf(root.as_str(), pwd),
        None => pwd,
    };

    let main_file = matches
        .get_one::<String>("main")
        .map(|v| v.to_string())
        .unwrap_or("main.tree".to_string());
    let main_tree = matches.get_one::<String>("tree");

    let mut sb = SimulatorBuilder::new();
    if let Some(p) = matches.get_one::<String>("profile") {
        let sim = buf(p, root.clone());
        sb.profile(sim);
    }
    sb.root(root.clone());
    let mut fb = ForesterBuilder::from_fs();
    fb.main_file(main_file);
    fb.root(root);

    if let Some(tree_str) = main_tree {
        fb.main_tree(tree_str.to_string())
    }

    sb.forester_builder(fb);

    match sb.build() {
        Ok(mut s) => match s.run() {
            Ok(r) => {
                info!("the process is finished with the result: {:?}", r)
            }
            Err(err) => {
                error!("a runtime error occurred: {:?}", err)
            }
        },
        Err(err) => {
            error!("a build error occurred: {:?}", err)
        }
    }
}

fn viz(matches: &ArgMatches) {
    let pwd = std::env::current_dir().expect("the current directory is present");

    let root = match matches.get_one::<String>("root") {
        Some(root) => buf(root.as_str(), pwd),
        None => pwd,
    };

    match Visualizer::project_svg_to_file(
        root,
        matches.get_one::<String>("main"),
        matches.get_one::<String>("tree"),
        matches.get_one::<String>("output"),
    ) {
        Ok(_) => {
            info!("the result is successfully saved to the given file.")
        }
        Err(e) => {
            error!("the visualization failed due to '{:?}'", e);
        }
    }
}

fn export_to_nav(matches: &ArgMatches) {
    let pwd = std::env::current_dir().expect("the current directory is present");

    let root = match matches.get_one::<String>("root") {
        Some(root) => buf(root.as_str(), pwd),
        None => pwd,
    };

    let (rts, output) = runtime_tree_default(
        root,
        matches.get_one::<String>("main"),
        matches.get_one::<String>("tree"),
        matches.get_one::<String>("output"),
        "xml".to_string(),
    )
    .map_err(|e| {
        error!("the export failed due to '{:?}'", e);
    })
    .expect("the runtime tree is built");

    match rts.tree.to_ros_nav(output) {
        Ok(_) => {
            info!("the result is successfully saved to the given file.")
        }
        Err(e) => {
            error!("the export failed due to '{:?}'", e);
        }
    }
}

fn std() {
    let f = builtin_actions_file();
    info!("{f}");
}

fn ros_nav2() {
    let f = ros_actions_file();
    info!("{f}");
}

fn main() {
    let matches = cli().get_matches();

    let mut log_builder = env_logger::builder();

    log_builder.is_test(false);
    if matches.get_flag("verbose") {
        log_builder.filter_level(LevelFilter::max());
    }

    let _ = log_builder.try_init();

    match matches.subcommand() {
        Some(("sim", args)) => {
            sim(args);
        }
        Some(("vis", args)) => {
            viz(args);
        }
        Some(("print-std-actions", _)) => {
            std();
        }
        Some(("print-ros-nav2", _)) => {
            ros_nav2();
        }
        Some(("nav2", args)) => {
            export_to_nav(args);
        }
        Some((e, _)) => {
            error!("the command '{e}' does not match any expected command.");
        }
        None => {
            unreachable!();
        }
    }
}

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

    #[test]
    fn cli_exposes_expected_subcommands() {
        let cmd = cli();
        let names: Vec<&str> = cmd.get_subcommands().map(|s| s.get_name()).collect();
        for expected in ["sim", "vis", "nav2", "print-std-actions", "print-ros-nav2"] {
            assert!(names.contains(&expected), "missing subcommand '{expected}'");
        }
        assert_eq!(names.len(), 5);
    }

    #[test]
    fn cli_requires_a_subcommand() {
        assert!(cli().is_subcommand_required_set());
        assert!(cli().is_arg_required_else_help_set());
    }

    #[test]
    fn cli_version_matches_cargo() {
        assert_eq!(cli().get_version(), Some(env!("CARGO_PKG_VERSION")));
    }

    #[test]
    fn verbose_flag_is_parsed() {
        let matches = cli().get_matches_from(["f-tree", "-v", "print-std-actions"]);
        assert!(matches.get_flag("verbose"));
    }

    #[test]
    fn verbose_flag_works_after_subcommand() {
        let matches = cli().get_matches_from(["f-tree", "print-std-actions", "-v"]);
        assert!(matches.get_flag("verbose"));
    }

    #[test]
    fn verbose_flag_defaults_to_false() {
        let matches = cli().get_matches_from(["f-tree", "print-std-actions"]);
        assert!(!matches.get_flag("verbose"));
    }

    #[test]
    fn sim_parses_all_arguments() {
        let matches = cli().get_matches_from([
            "f-tree", "sim",
            "-p", "profile.toml",
            "-r", "/root",
            "-m", "other.tree",
            "-t", "main_root",
        ]);
        let (name, args) = matches.subcommand().unwrap();
        assert_eq!(name, "sim");
        assert_eq!(args.get_one::<String>("profile").unwrap(), "profile.toml");
        assert_eq!(args.get_one::<String>("root").unwrap(), "/root");
        assert_eq!(args.get_one::<String>("main").unwrap(), "other.tree");
        assert_eq!(args.get_one::<String>("tree").unwrap(), "main_root");
    }

    #[test]
    fn sim_arguments_are_optional() {
        let matches = cli().get_matches_from(["f-tree", "sim"]);
        let (_, args) = matches.subcommand().unwrap();
        assert!(args.get_one::<String>("profile").is_none());
        assert!(args.get_one::<String>("root").is_none());
        assert!(args.get_one::<String>("main").is_none());
        assert!(args.get_one::<String>("tree").is_none());
    }

    #[test]
    fn vis_parses_all_arguments() {
        let matches = cli().get_matches_from([
            "f-tree", "vis",
            "-o", "out.svg",
            "-r", "/root",
            "-m", "other.tree",
            "-t", "main_root",
        ]);
        let (name, args) = matches.subcommand().unwrap();
        assert_eq!(name, "vis");
        assert_eq!(args.get_one::<String>("output").unwrap(), "out.svg");
        assert_eq!(args.get_one::<String>("root").unwrap(), "/root");
        assert_eq!(args.get_one::<String>("main").unwrap(), "other.tree");
        assert_eq!(args.get_one::<String>("tree").unwrap(), "main_root");
    }

    #[test]
    fn nav2_parses_all_arguments() {
        let matches = cli().get_matches_from([
            "f-tree", "nav2",
            "-o", "out.xml",
            "-r", "/root",
            "-m", "other.tree",
            "-t", "main_root",
        ]);
        let (name, args) = matches.subcommand().unwrap();
        assert_eq!(name, "nav2");
        assert_eq!(args.get_one::<String>("output").unwrap(), "out.xml");
        assert_eq!(args.get_one::<String>("root").unwrap(), "/root");
        assert_eq!(args.get_one::<String>("main").unwrap(), "other.tree");
        assert_eq!(args.get_one::<String>("tree").unwrap(), "main_root");
    }

    #[test]
    fn print_subcommands_parse_without_arguments() {
        for sub in ["print-std-actions", "print-ros-nav2"] {
            let matches = cli().get_matches_from(["f-tree", sub]);
            let (name, _) = matches.subcommand().unwrap();
            assert_eq!(name, sub);
        }
    }

    #[test]
    fn buf_resolves_relative_path_against_base() {
        assert_eq!(
            buf("sub/dir", PathBuf::from("/base")),
            PathBuf::from("/base/sub/dir")
        );
    }

    #[test]
    fn buf_keeps_absolute_path_unchanged() {
        assert_eq!(
            buf("/abs/dir", PathBuf::from("/base")),
            PathBuf::from("/abs/dir")
        );
    }
}