f-tree 0.4.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::runner::config::RunProfile;
use forester_rs::runner::Runner;
use forester_rs::runtime::builder::builtin::builtin_actions_file;
use forester_rs::runtime::builder::ForesterBuilder;
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("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("run")
                .about(r#"Runs the tree from the file system. Accepts an optional run profile in yaml"#)
                .arg(arg!(-p --profile <PATH> "a path to a run profile in yaml. The default profile if empty"))
                .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. The 'main' 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"))
        )
}

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 run(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.as_str())
        .unwrap_or("main.tree");
    let main_file = buf(main_file, root.clone());

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

    let profile = match matches.get_one::<String>("profile") {
        Some(p) => match RunProfile::from_file(buf(p, root)) {
            Ok(profile) => profile,
            Err(err) => {
                error!("the run profile can not be loaded due to '{:?}'", err);
                return;
            }
        },
        None => RunProfile::default(),
    };

    match Runner::build(main_file, main_tree, profile) {
        Ok(mut runner) => match runner.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 std() {
    let f = builtin_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(("run", args)) => {
            run(args);
        }
        Some(("print-std-actions", _)) => {
            std();
        }
        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", "run", "print-std-actions"] {
            assert!(names.contains(&expected), "missing subcommand '{expected}'");
        }
        assert_eq!(names.len(), 4);
    }

    #[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 run_parses_all_arguments() {
        let matches = cli().get_matches_from([
            "f-tree",
            "run",
            "-p",
            "profile.yaml",
            "-r",
            "/root",
            "-m",
            "other.tree",
            "-t",
            "main_root",
        ]);
        let (name, args) = matches.subcommand().unwrap();
        assert_eq!(name, "run");
        assert_eq!(args.get_one::<String>("profile").unwrap(), "profile.yaml");
        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 run_arguments_are_optional() {
        let matches = cli().get_matches_from(["f-tree", "run"]);
        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 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")
        );
    }
}