ros_add 0.1.8

The Purpose of the Package is to provide the `cargo ros_add` command to add dependencies to `Cargo.toml` and the `package.xml`
Documentation
//! A cargo subcommand for adding dependencies to both Cargo.toml and ROS package.xml files.
//!
//! This tool helps manage dependencies for ROS 2 packages written in Rust by
//! synchronizing dependencies between Cargo.toml and package.xml manifest files.

use case_clause::case;
use clap::{Arg, ArgAction, ArgMatches, Command as ClapCommand};
use quick_xml::{
    NsReader, Writer,
    events::{BytesText, Event},
};
use std::{
    fmt::{self, Display, Formatter},
    fs::{File, read_to_string, write},
    io::{BufReader, Cursor, ErrorKind},
    rc::Rc,
    str::Split,
};
use toml_edit::DocumentMut;

/// Corresponding to <http://download.ros.org/schema/package_format3.xsd> this should cover all
/// package format 3 specific dependency types.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, PartialOrd)]
pub enum DependencyType {
    /// A build dependency.
    BuildDepend,
    /// An exported build dependency.
    BuildExportDepend,
    /// A build tool dependency.
    BuildtoolDepend,
    /// An exported build tool dependency.
    BuildtoolExportDepend,
    /// An execution dependency.
    ExecDepend,
    /// default dependency type.
    #[default]
    Depend,
    /// A documentation dependency.
    DocDepend,
    /// A test dependency.
    TestDepend,
    /// A package conflict.
    Conflict,
    /// A package replacement.
    Replace,
}
impl DependencyType {
    /// Returns the corresponding TOML table name for a `Cargo.toml` file.
    #[must_use]
    pub fn cargo_toml_type(&self) -> &str {
        match self {
            Self::BuildDepend => "build-dependencies",
            _ => "dependencies",
        }
    }

    /// Returns the corresponding XML element name for a `package.xml` file.
    #[must_use]
    pub fn package_xml_type(&self) -> &str {
        match self {
            Self::Replace => "replace",
            Self::Conflict => "conflict",
            Self::TestDepend => "test-depend",
            Self::DocDepend => "doc-depend",
            Self::BuildtoolDepend => "buildtool-depend",
            Self::ExecDepend => "exec-depend",
            Self::BuildtoolExportDepend => "buildtool_export_depend",
            Self::BuildDepend => "build_depend",
            Self::BuildExportDepend => "build_export_depend",
            Self::Depend => "depend",
        }
    }
}

impl From<ArgMatches> for DependencyType {
    fn from(item: ArgMatches) -> Self {
        case! (
            item.get_flag("build")=> Self::BuildDepend,
            item.get_flag("build-export") => Self::BuildExportDepend,
            item.get_flag("buildtool") => Self::BuildtoolDepend,
            item.get_flag("buildtool-export") => Self::BuildtoolExportDepend,
            item.get_flag("exec") => Self::ExecDepend,
            item.get_flag("doc") => Self::DocDepend,
            item.get_flag("test") => Self::TestDepend,
            item.get_flag("conflict") => Self::Conflict,
            item.get_flag("replace") => Self::Replace,
            true => Self::Depend,
        )
    }
}

/// Converts command-line arguments into `clap::ArgMatches`.
///
/// This function defines the command-line interface for the `ros_add` tool,
/// including arguments for specifying dependencies and various dependency types.
#[must_use]
pub fn env_to_matches(env_args: Vec<String>) -> ArgMatches {
    ClapCommand::new("ros_add")
        .about("Add dependencies to a Cargo.toml and package.xml manifest file")
        .arg(
            Arg::new("dependency").value_name("DEP_ID") 
                .help(["Reference to a package to add as a dependency","You can reference a package by:","- `<name>`, like `cargo ros-add serde` (latest version will be used)","- `<name>@<version-req>`, like `cargo add serde@1` or `cargo add serde@=1.0.38`"].join("\n")).required(true)

        )
        .arg(Arg::new("color").help("Coloring\n \n").long("color").value_name("WHEN").value_parser(["auto", "always", "never"]))
        .arg(Arg::new("no_cargo_toml").action(ArgAction::SetTrue).help("Dependency will only be added to package.xml file.").long("no-cargo-toml").required(false))
        .arg(Arg::new("no_package_xml").action(ArgAction::SetTrue).help("Dependency will only be added to the Cargo.toml file.").long("no-package-xml").required(false))
        .arg(Arg::new("build").action(ArgAction::SetTrue).help(["add as build dependency","will appear in Cargo.toml as [build-dependencies] and in package.xml as build_depend element","Build-dependencies are only dependencies available for use by build scripts (`build.rs` files)."].join("\n")).long("build"))
        .arg(Arg::new("build-export").long("build-export").action(ArgAction::SetTrue).help("package.xml only. What even is that?")) 
        .arg(Arg::new("buildtool").long("buildtool").action(ArgAction::SetTrue).help("package.xml only. These are for packages that provide the build infrastructure")) 
        .arg(Arg::new("buildtool-export").long("buildtool-export").action(ArgAction::SetTrue).help("package.xml only. What even is that?"))
        .arg(Arg::new("exec").long("exec").action(ArgAction::SetTrue).help("package.xml only. Because Rust has no runtime applicable dependencies, this won't work")) 
        .arg(Arg::new("doc").long("doc").action(ArgAction::SetTrue).help("package.xml only. What even is that?")) 
        .arg(Arg::new("test").long("test").action(ArgAction::SetTrue).help("only in package.xml a different dependency type. Will appear in the Cargo.toml as standard dependency")) 
        .arg(Arg::new("conflict").long("conflict").action(ArgAction::SetTrue).help("package.xml only. What even is that?"))
        .arg(Arg::new("replace").long("replace").action(ArgAction::SetTrue).help("package.xml only. What even is that?")) 
        .get_matches_from(
            case!(
                env_args.iter().any(|arg| arg == "ros-add") => env_args.iter().take(1).chain(env_args.iter().skip(2)).cloned().collect::<Vec<String>>(),
                true => env_args
            )
        )
}

///represents the splitted and raw dependency input
#[derive(Clone, Debug)]
struct SplitRaw<'a> {
    parts: Split<'a, char>,
    raw: Rc<str>,
}
impl<'a> From<&'a str> for SplitRaw<'a> {
    fn from(item: &'a str) -> Self {
        Self {
            parts: item.split('@'),
            raw: item.into(),
        }
    }
}

/// Represents a package name with an optional version specification.
#[derive(Debug, Clone, PartialEq, PartialOrd, Eq)]
pub struct PackageName {
    /// The name of the package without version specification
    pub name: Rc<str>,
    /// Optional version requirement (e.g., "1.0.0", "=1.0.38", ">=1.2")
    pub version: Rc<str>,
}

impl Display for PackageName {
    /// ```rust
    /// use ros_add::PackageName;
    /// let package = PackageName::from("serde@=1.0.38");
    /// assert_eq!(format!("{package}"),"Package:serde, Version:=1.0.38");
    /// ```
    fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), fmt::Error> {
        write!(f, "Package:{}, Version:{}", self.name, self.version)
    }
}
impl<'a> From<SplitRaw<'a>> for PackageName {
    fn from(mut item: SplitRaw<'a>) -> Self {
        Self {
            name: item.parts.next().unwrap_or(item.raw.as_ref()).into(),
            version: item.parts.next().unwrap_or("*").into(),
        }
    }
}
impl<'a> From<&'a str> for PackageName {
    fn from(item: &'a str) -> Self {
        SplitRaw::from(item).into()
    }
}

/// Struct to manage and modify a `Cargo.toml` document.
#[derive(Debug, Clone)]
pub struct PathDoc {
    /// Path to the Cargo.toml file
    path: String,
    /// Parsed TOML document
    doc: DocumentMut,
    /// Type of dependency to add
    dependency_type: DependencyType,
}
impl PathDoc {
    /// Creates a new `PathDoc` instance by reading and parsing a `Cargo.toml` file.
    ///
    /// # Arguments
    ///
    /// * `path` - The path to the `Cargo.toml` file.
    /// * `dependency_type` - The type of dependency to add.
    ///   example:
    ///
    /// # Errors
    ///
    /// Returns `ErrorKind::InvalidData` if the file cannot be parsed as TOML,
    /// or other `ErrorKind` variants for file I/O errors.
    ///
    /// # Example:
    /// ```rust
    /// use ros_add::{PathDoc,DependencyType};
    /// use std::io::ErrorKind;
    /// let path = PathDoc::new("Cargo.toml",DependencyType::Depend).unwrap();
    /// ```
    pub fn new(path: &str, dependency_type: DependencyType) -> Result<Self, ErrorKind> {
        Ok(Self {
            path: path.to_string(),
            doc: read_to_string(path)
                .map_err(|e| e.kind())?
                .parse::<DocumentMut>()
                .map_err(|_| ErrorKind::InvalidData)?,
            dependency_type,
        })
    }

    /// Adds a dependency to the `Cargo.toml` document.
    ///
    /// # Arguments
    ///
    /// * `dependency_name` - The name of the dependency to add.
    ///
    /// # Errors
    ///
    /// Returns `ErrorKind::AlreadyExists` if the dependency already exists,
    /// or other `ErrorKind` variants for file I/O errors.
    pub fn add_dependency_to_cargo_toml(
        mut self,
        dependency: &PackageName,
    ) -> Result<(), ErrorKind> {
        // Check if the table exists. If not, create it.
        if self
            .doc
            .get(self.dependency_type.cargo_toml_type())
            .is_none()
        {
            self.doc[self.dependency_type.cargo_toml_type()] = toml_edit::table();
        }

        // Now, safely get the table and check for the dependency.
        #[allow(clippy::single_match_else)]
        match self.doc[self.dependency_type.cargo_toml_type()].get(dependency.name.as_ref()) {
            Some(some) => {
                eprintln!("{some} already added to dependencies");
                Err(ErrorKind::AlreadyExists)
            }
            None => {
                self.doc[self.dependency_type.cargo_toml_type()][dependency.name.as_ref()] =
                    toml_edit::value(dependency.version.as_ref());
                write(self.path.as_str(), self.doc.to_string()).map_err(|e| e.kind())?;
                println!("Dependency {} added to Cargo.toml", dependency.name);
                Ok(())
            }
        }
    }
}

/// A helper struct to manage and modify a `package.xml` document.
pub struct XMLHelper {
    /// Path to the package.xml file
    pub path: String,
    /// XML reader for parsing the package.xml file
    pub reader: NsReader<BufReader<File>>,
    /// XML writer for modifying the package.xml file
    pub writer: Writer<Cursor<Vec<u8>>>,
    /// Buffer for reading XML events
    pub buf: Vec<u8>,
    /// Type of dependency to add
    dependency_type: DependencyType,
}

impl XMLHelper {
    /// Creates a new `XMLHelper` instance by reading and parsing a `package.xml` file.
    ///
    /// # Arguments
    ///
    /// * `path` - The path to the `package.xml` file.
    /// * `dependency_type` - The type of dependency to add.
    ///
    /// # Errors
    ///
    /// Returns `ErrorKind::NotFound` if the file cannot be found,
    /// or other `ErrorKind` variants for file I/O errors.
    ///
    /// # Example:
    /// ```rust
    /// use ros_add::{XMLHelper,DependencyType};
    /// use std::{io::{ErrorKind,Cursor,BufReader},fs::{File, self}};
    ///
    /// // Create a temporary package.xml file for testing
    /// let xml_content = r#"<package format="3">
    ///   <name>test_pkg</name>
    ///   <version>0.0.0</version>
    /// </package>"#;
    /// let temp_dir = tempfile::tempdir().unwrap();
    /// let file_path = temp_dir.path().join("package.xml");
    /// fs::write(&file_path, xml_content).unwrap();
    ///
    /// let xml_helper = XMLHelper::new(file_path.to_str().unwrap(), DependencyType::BuildDepend).unwrap();
    /// assert_eq!(xml_helper.path, file_path.to_str().unwrap());
    ///
    /// // Clean up
    /// temp_dir.close().unwrap();
    /// ```
    pub fn new(path: &str, dependency_type: DependencyType) -> Result<Self, ErrorKind> {
        Ok(Self {
            path: path.to_string(),
            reader: NsReader::from_file(path).map_err(|_| ErrorKind::NotFound)?,
            writer: Writer::new_with_indent(Cursor::new(Vec::new()), b' ', 2),
            buf: Vec::new(),
            dependency_type,
        })
    }

    /// Adds a dependency to the `package.xml` document.
    ///
    /// This function iterates through the XML events, adding the new dependency
    /// before the `<export>` tag.
    ///
    /// # Arguments
    ///
    /// * `dependency_name` - The name of the dependency to add.
    ///
    /// # Errors
    ///
    /// Returns `ErrorKind::AlreadyExists` if the dependency already exists,
    /// `ErrorKind::WriteZero` if there are write errors,
    /// `ErrorKind::OutOfMemory` if memory allocation fails,
    /// `ErrorKind::InvalidData` if XML parsing fails,
    /// or other `ErrorKind` variants for file I/O errors.
    pub fn add_dependency_to_package_xml(
        mut self,
        dependency: &PackageName,
    ) -> Result<(), ErrorKind> {
        self.reader.config_mut().trim_text(true);

        loop {
            self.buf.clear();
            match self.reader.read_event_into(&mut self.buf) {
                Ok(Event::Start(e))
                    if e.name().as_ref() == self.dependency_type.package_xml_type().as_bytes() =>
                {
                    self.writer
                        .write_event(Event::Start(e.clone()))
                        .map_err(|_| ErrorKind::WriteZero)?;
                    match self.reader.read_event_into(&mut self.buf) {
                        Ok(Event::Text(text)) => {
                            case!(
                                    self.reader.decoder().decode(text.as_ref()).unwrap_or_default() == dependency.name.as_ref() => {eprintln!("Dependency '{}' already exists in package.xml",dependency.name);return Err(ErrorKind::AlreadyExists);},
                                    true => self.writer
                                        .write_event(Event::Text(text))
                                        .map_err(|_| ErrorKind::WriteZero)?

                            );
                        }
                        _ => {
                            println!("Everything allright");
                        }
                    }
                }
                Ok(Event::Start(e)) if e.name().as_ref() == b"export" => {
                    self.writer
                        .create_element(self.dependency_type.package_xml_type())
                        .write_text_content(BytesText::new(dependency.name.as_ref()))
                        .map_err(|_| ErrorKind::OutOfMemory)?;

                    self.writer
                        .write_event(Event::Start(e))
                        .map_err(|_| ErrorKind::WriteZero)?;
                }
                Ok(Event::Eof) => break,
                Ok(e) => self
                    .writer
                    .write_event(e)
                    .map_err(|_| ErrorKind::WriteZero)?,
                Err(e) => {
                    eprintln!("XML parsing error: {e}");
                    return Err(ErrorKind::InvalidData);
                }
            }
        }

        write(&self.path, self.writer.into_inner().into_inner()).map_err(|e| e.kind())?;
        Ok(())
    }
}