mtp-rs-cli 0.3.0

Universal MTP file transfer CLI built on mtp-rs
use super::error::{CliError, CliErrorKind};
use mtp_rs::ptp::ObjectInfo;
use mtp_rs::{ObjectHandle, Storage};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemotePath {
    raw: String,
    components: Vec<String>,
    trailing_slash: bool,
}

#[derive(Debug, Clone)]
pub enum ExistingRemote {
    Root,
    Object(ObjectInfo),
}

#[derive(Debug, Clone)]
pub struct UploadTarget {
    pub parent: Option<ObjectHandle>,
    pub filename: String,
    pub existing: Option<ObjectInfo>,
}

impl RemotePath {
    pub fn parse(path: &str) -> Result<Self, CliError> {
        if path.is_empty() || !path.starts_with('/') {
            return Err(CliError::new(
                CliErrorKind::RemotePath,
                "remote path must be absolute, for example /Music/song.mp3",
            ));
        }

        let trailing_slash = path != "/" && path.ends_with('/');
        let mut components = Vec::new();
        for component in path.split('/').filter(|part| !part.is_empty()) {
            validate_component(component)?;
            components.push(component.to_string());
        }

        Ok(Self {
            raw: path.to_string(),
            components,
            trailing_slash,
        })
    }

    #[must_use]
    pub fn is_root(&self) -> bool {
        self.components.is_empty()
    }

    #[must_use]
    pub fn raw(&self) -> &str {
        &self.raw
    }

    #[must_use]
    pub fn components(&self) -> &[String] {
        &self.components
    }

    #[must_use]
    pub fn trailing_slash(&self) -> bool {
        self.trailing_slash
    }
}

pub fn validate_component(component: &str) -> Result<(), CliError> {
    if component.is_empty() || component == "." || component == ".." {
        return Err(CliError::new(
            CliErrorKind::RemotePath,
            format!("invalid remote path component '{component}'"),
        ));
    }

    if component.contains('/') || component.contains('\\') || component.contains('\0') {
        return Err(CliError::new(
            CliErrorKind::RemotePath,
            format!("invalid remote filename '{component}'"),
        ));
    }

    Ok(())
}

pub async fn resolve_existing(
    storage: &Storage,
    path: &RemotePath,
    verbose: bool,
) -> Result<ExistingRemote, CliError> {
    if path.is_root() {
        return Ok(ExistingRemote::Root);
    }

    let mut parent = None;
    let mut found = None;
    for (index, component) in path.components().iter().enumerate() {
        let objects = storage
            .list_objects(parent)
            .await
            .map_err(|e| CliError::from_mtp("list remote folder", e, verbose))?;
        let object = objects
            .into_iter()
            .find(|object| object.filename == component.as_str())
            .ok_or_else(|| {
                CliError::new(
                    CliErrorKind::RemotePath,
                    format!("remote path not found: {}", path.raw()),
                )
            })?;

        let is_last = index + 1 == path.components().len();
        if !is_last && !object.is_folder() {
            return Err(CliError::new(
                CliErrorKind::RemotePath,
                format!("remote parent is not a folder: {}", object.filename),
            ));
        }

        parent = Some(object.handle);
        found = Some(object);
    }

    Ok(ExistingRemote::Object(
        found.expect("non-root path has an object"),
    ))
}

pub async fn resolve_upload_target(
    storage: &Storage,
    remote_path: &RemotePath,
    local_filename: &str,
    verbose: bool,
) -> Result<UploadTarget, CliError> {
    if remote_path.is_root() {
        return Ok(UploadTarget {
            parent: None,
            filename: local_filename.to_string(),
            existing: None,
        });
    }

    let mut components = remote_path.components().to_vec();
    let explicit_folder = remote_path.trailing_slash();
    let final_name = if explicit_folder {
        local_filename.to_string()
    } else {
        components.pop().expect("non-root path has final component")
    };
    validate_component(&final_name)?;

    let parent_path = RemotePath {
        raw: parent_raw_from_components(&components),
        components,
        trailing_slash: false,
    };
    let parent = match resolve_existing(storage, &parent_path, verbose).await? {
        ExistingRemote::Root => None,
        ExistingRemote::Object(object) if object.is_folder() => Some(object.handle),
        ExistingRemote::Object(_) => {
            return Err(CliError::new(
                CliErrorKind::RemotePath,
                "remote parent is not a folder",
            ));
        }
    };

    let objects = storage
        .list_objects(parent)
        .await
        .map_err(|e| CliError::from_mtp("list remote folder", e, verbose))?;
    if let Some(existing_folder) = objects
        .iter()
        .find(|object| object.filename == final_name.as_str() && object.is_folder())
    {
        return Ok(UploadTarget {
            parent: Some(existing_folder.handle),
            filename: local_filename.to_string(),
            existing: None,
        });
    }

    let existing = objects
        .into_iter()
        .find(|object| object.filename == final_name.as_str() && object.is_file());

    Ok(UploadTarget {
        parent,
        filename: final_name,
        existing,
    })
}

fn parent_raw_from_components(components: &[String]) -> String {
    if components.is_empty() {
        "/".to_string()
    } else {
        format!("/{}", components.join("/"))
    }
}

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

    #[test]
    fn parses_root() {
        let path = RemotePath::parse("/").unwrap();
        assert!(path.is_root());
        assert!(!path.trailing_slash());
    }

    #[test]
    fn rejects_relative_paths() {
        assert!(RemotePath::parse("Music/song.mp3").is_err());
    }

    #[test]
    fn tracks_trailing_slash_destination() {
        let path = RemotePath::parse("/Music/").unwrap();
        assert_eq!(path.components(), vec!["Music".to_string()].as_slice());
        assert!(path.trailing_slash());
    }

    #[test]
    fn rejects_invalid_components() {
        assert!(RemotePath::parse("/../x").is_err());
        assert!(RemotePath::parse("/bad\\name").is_err());
        assert!(RemotePath::parse("/bad\0name").is_err());
    }

    #[test]
    fn validates_upload_filename() {
        assert!(validate_component("app.prg").is_ok());
        assert!(validate_component("../app.prg").is_err());
        assert!(validate_component("bad\\app.prg").is_err());
    }
}