ros_new 0.1.3

Cargo plugin to create new ROS2 Rust packages with package.xml
Documentation
//! A library for creating new Rust packages with ROS package.xml support.
use case_clause::case;
use clap::{Arg, ArgMatches, Command};
use std::{
    collections::HashMap,
    fs::{self, File},
    hash::BuildHasher,
    io::{ErrorKind, Write},
    path::Path,
    process::Command as ProcessCommand,
};

use toml_edit::{Array, DocumentMut, Formatted, Item, Value};

/// Converts command line arguments into a Cargo.toml file.
///
/// This function takes `ArgMatches` object and a `HashMap` of package metadata
/// to create and populate the Cargo.toml file with relevant metadata from the arguments.
///
/// # Errors
///
/// Returns `ErrorKind::NotFound` if package name is missing.
/// Returns `ErrorKind::InvalidData` if Cargo.toml parsing fails.
/// Returns other IO errors for file operations.
pub fn args_to_cargo_toml<S: BuildHasher>(
    matches: &ArgMatches,
    arguments: &HashMap<String, String, S>,
) -> Result<(), ErrorKind> {
    let package_name = matches
        .get_one::<String>("package_name")
        .ok_or(ErrorKind::NotFound)?;

    // First create the basic cargo project
    ProcessCommand::new("cargo")
        .arg("new")
        .args(
            matches
                .get_one::<String>("edition")
                .into_iter()
                .flat_map(|e| ["--edition", e]),
        )
        .args(
            matches
                .get_one::<String>("vcs")
                .into_iter()
                .flat_map(|v| ["--vcs", v]),
        )
        .arg(if matches.get_flag("lib") {
            "--lib"
        } else {
            "--bin"
        })
        .arg(package_name)
        .status()
        .map_err(|err| err.kind())?;

    // Now read the generated Cargo.toml and enhance it with additional metadata
    let cargo_toml_path = Path::new(package_name).join("Cargo.toml");
    let cargo_toml_content = std::fs::read_to_string(&cargo_toml_path).map_err(|err| err.kind())?;

    let mut doc = cargo_toml_content
        .parse::<DocumentMut>()
        .map_err(|_| ErrorKind::InvalidData)?;

    // Update package metadata if provided
    if let Some(package) = doc.get_mut("package") {
        // Add maintainer as author (ROS maintainer -> Cargo authors)
        if let Some(maintainer) = arguments.get("maintainer") {
            let mut authors_array: Array = Array::new();
            authors_array.push(arguments.get("mail").map_or_else(
                || maintainer.clone(),
                |mail| format!("{maintainer} <{mail}>"),
            ));

            package["authors"] = Item::Value(Value::Array(authors_array));
        }

        // Add license if provided and not the default placeholder
        if let Some(license) = arguments.get("license")
            && license != "TODO: License declaration"
        {
            package["license"] = Item::Value(Value::String(Formatted::new(license.clone())));
        }

        // Add description if provided and not the default placeholder
        if let Some(description) = arguments.get("description")
            && description != "TODO: Package description"
        {
            package["description"] =
                Item::Value(Value::String(Formatted::new(description.clone())));
        }
    }

    // Write the enhanced Cargo.toml back to file
    fs::write(&cargo_toml_path, doc.to_string()).map_err(|err| err.kind())?;

    Ok(())
}

/// Converts command line arguments into a ROS package.xml file.
///
/// This function takes a `HashMap` of package metadata and creates a ROS-compatible
/// package.xml file in a directory named after the package.
///
/// # Errors
///
/// Returns `ErrorKind::NotFound` if required arguments are missing.
/// Returns IO errors for file operations.
///
/// # Example
/// ```
/// use std::{collections::HashMap,io::ErrorKind};
/// use tempfile::tempdir;
///
/// let mut args = HashMap::new();
/// let temp_dir = tempdir().unwrap();
/// let package_path = temp_dir.path().join("test_package");
/// std::fs::create_dir(&package_path).unwrap();
///
/// args.insert("package_name".to_string(), package_path.to_str().unwrap().to_string());
/// args.insert("format".to_string(), "3".to_string());
/// args.insert("description".to_string(), "Test package".to_string());
/// args.insert("mail".to_string(), "test@example.com".to_string());
/// args.insert("maintainer".to_string(), "Tester".to_string());
/// args.insert("license".to_string(), "MIT".to_string());
///
/// ros_new::args_to_package_xml(&args).unwrap();
/// assert!(package_path.join("package.xml").exists());
/// ```
pub fn args_to_package_xml<S: BuildHasher>(
    arguments: &HashMap<String, String, S>,
) -> Result<(), ErrorKind> {
    File::create(
        Path::new(
            arguments.get("package_name").ok_or(ErrorKind::NotFound)?).join("package.xml")).map_err(|err| err.kind())?.write_all(
            [
                r#"<?xml version="1.0"?>"#, 
                &format!(r#"<?xml-model href="http://download.ros.org/schema/package_format{}.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>"#,arguments.get("format").ok_or(ErrorKind::NotFound)?), 
                &format!(r#"<package format="{}">"#, arguments.get("format").ok_or(ErrorKind::NotFound)?), 
                &format!("  <name>{}</name>", Path::new(arguments.get("package_name").ok_or(ErrorKind::NotFound)?).file_name().and_then(|n| n.to_str()).ok_or(ErrorKind::NotFound)?), 
                "  <version>0.1.0</version>", 
                &format!("  <description>{}</description>", arguments.get("description").ok_or(ErrorKind::NotFound)?), 
                &format!(r#"  <maintainer email="{}">{}</maintainer>"#, arguments.get("mail").ok_or(ErrorKind::NotFound)?, arguments.get("maintainer").ok_or(ErrorKind::NotFound)?), 
                &format!("  <license>{}</license>", arguments.get("license").ok_or(ErrorKind::NotFound)?), 
                "  <export>", 
                "    <build_type>ament_cargo</build_type>", 
                "  </export>", "</package>"
            ].join("\n").as_bytes()).map_err(|err| err.kind()).and_then(|()| {
            println!("Created ROS Rust package `{}` with package.xml",arguments.get("package_name").ok_or(ErrorKind::NotFound)?); Ok(())
        })
}

/// Parses environment arguments into CLI argument matches.
///
/// This function configures the command line interface for the `cargo ros-new` command
/// and processes the provided arguments. It handles both direct invocation and
/// `cargo ros-new` style invocation.
///
/// # Arguments
/// * `env_args` - A vector of strings representing the command line arguments
///
/// # Returns
/// Returns an `ArgMatches` object containing the parsed arguments.
///
/// # Notes
/// - When invoked as `cargo ros-new`, the first argument ("ros-new") is skipped
/// - Sets default values for ROS-specific fields (maintainer, license, etc.)
/// - Supports all standard `cargo new` arguments plus ROS-specific ones
///
/// # Example
/// ```
/// use ros_new::env_to_matches;
///
/// // Test with minimal required argument (package name)
/// let args = vec!["my_program".to_string(), "test_package".to_string()];
/// let matches = env_to_matches(args);
/// assert_eq!(matches.get_one::<String>("package_name").unwrap(), "test_package");
/// assert_eq!(matches.get_one::<String>("mail").unwrap(), "you@email.com"); // default value
///
/// // Test with custom arguments
/// let args = vec![
///     "my_program".to_string(),
///     "test_package".to_string(),
///     "--mail".to_string(),
///     "custom@example.com".to_string(),
///     "--license".to_string(),
///     "Apache-2.0".to_string()
/// ];
/// let matches = env_to_matches(args);
/// assert_eq!(matches.get_one::<String>("mail").unwrap(), "custom@example.com");
/// assert_eq!(matches.get_one::<String>("license").unwrap(), "Apache-2.0");
/// ```
#[must_use]
pub fn env_to_matches(env_args: Vec<String>) -> ArgMatches {
    Command::new("ros_new")
        .version("0.1.0")
        .author("GueLaKais <koroyeldiores@gmail.com>")
        .about("Creates a new Rust package with ROS package.xml")
        .arg(
            Arg::new("package_name")
                .required(true)
                .help("Name of the package to create"),
        ).arg(
            Arg::new("vcs")
                .long("vcs")
                .help("Initialize a new repository for the given version control system, overriding a global configuration. [possible values: git, hg, pijul, fossil, none]")
                .value_name("VCS").value_parser(["git", "hg", "pijul", "fossil", "none"])
        ).arg(
            Arg::new("edition")
                .long("edition")
                .help(
                    "Edition to set for the crate generated [possible values: 2015, 2018, 2021, 2024]"
                )
                .value_name("YEAR")
                .value_parser(["2015", "2018", "2021", "2024"])
        )
        .arg(
            Arg::new("bin")
                .long("bin")
                .help("Use a binary (application) template [default]")
                .action(clap::ArgAction::SetTrue),
        )
        .arg(
            Arg::new("lib")
                .long("lib")
                .help("Use a library template")
                .action(clap::ArgAction::SetTrue),
        )
        .arg(
            Arg::new("mail")
                .long("mail")
                .default_value("you@email.com")
                .help("Adds the E-mail Address of the maintainer"),
        )
        .arg(
            Arg::new("maintainer")
                .long("maintainer")
                .default_value("user")
                .help("The name of the maintainer"),
        )
        .arg(
            Arg::new("license")
                .long("license")
                .default_value("TODO: License declaration")
                .help("Declares the used License of this Package"),
        )
        .arg(
            Arg::new("format")
                .long("format")
                .default_value("3")
                .help("Declares the ROS package format"),
        )
        .arg(
            Arg::new("description")
                .long("description")
                .default_value("TODO: Package description")
                .help("Package Description"),
        )
        .get_matches_from(
                case!(
                    env_args.iter().any(|arg| arg == "ros-new")=> env_args.iter().take(1).chain(env_args.iter().skip(2)).cloned().collect::<Vec<String>>(), 
                    true => env_args
                )
            )
}