rs-rich-cli 0.0.9

The `rich` executable: a Rust port of Python `rich-cli` 1.8.1
//! Filename-template grammar and validated batch destination planning.

use std::ffi::{OsStr, OsString};
#[derive(Clone, Debug)]
enum Token {
    Literal(String),
    Stem,
    InputExt,
    OutputExt,
    Index,
}
#[derive(Clone, Debug)]
pub(super) struct FilenameTemplate(Vec<Token>);
pub(super) struct FilenameTokens<'a> {
    pub stem: &'a OsStr,
    pub input_ext: &'a OsStr,
    pub output_ext: &'a str,
    pub index: usize,
}
#[derive(Debug)]
pub(super) enum PathPlanError {
    InvalidTemplate(String),
    InvalidLeaf(String),
}
impl std::fmt::Display for PathPlanError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::InvalidTemplate(s) => write!(f, "invalid batch template: {s}"),
            Self::InvalidLeaf(s) => write!(f, "invalid batch filename: {s}"),
        }
    }
}
impl std::error::Error for PathPlanError {}
impl FilenameTemplate {
    pub fn parse(source: &str) -> Result<Self, PathPlanError> {
        let mut chars = source.chars().peekable();
        let mut tokens = Vec::new();
        let mut literal = String::new();
        while let Some(c) = chars.next() {
            if c == '{' || c == '}' {
                if chars.peek() == Some(&c) {
                    chars.next();
                    literal.push(c);
                    continue;
                }
                if c == '}' {
                    return Err(PathPlanError::InvalidTemplate(
                        "unmatched closing brace".into(),
                    ));
                }
                if !literal.is_empty() {
                    tokens.push(Token::Literal(std::mem::take(&mut literal)));
                }
                let mut name = String::new();
                loop {
                    match chars.next() {
                        Some('}') => break,
                        Some(c) => name.push(c),
                        None => {
                            return Err(PathPlanError::InvalidTemplate("unclosed token".into()))
                        }
                    }
                }
                tokens.push(match name.as_str() {
                    "stem" => Token::Stem,
                    "input_ext" => Token::InputExt,
                    "output_ext" => Token::OutputExt,
                    "index" => Token::Index,
                    _ => return Err(PathPlanError::InvalidTemplate(name)),
                });
            } else {
                literal.push(c);
            }
        }
        if !literal.is_empty() {
            tokens.push(Token::Literal(literal));
        }
        Ok(Self(tokens))
    }
    pub fn expand(&self, args: &FilenameTokens<'_>) -> Result<OsString, PathPlanError> {
        if args.index == 0 {
            return Err(PathPlanError::InvalidTemplate("index is one-based".into()));
        }
        let mut leaf = OsString::new();
        for token in &self.0 {
            match token {
                Token::Literal(s) => leaf.push(s),
                Token::Stem => leaf.push(args.stem),
                Token::InputExt => leaf.push(args.input_ext),
                Token::OutputExt => leaf.push(args.output_ext),
                Token::Index => leaf.push(args.index.to_string()),
            }
        }
        let bytes = leaf.as_encoded_bytes();
        if leaf.is_empty()
            || leaf == "."
            || leaf == ".."
            || bytes.iter().any(|b| matches!(*b, b'/' | b'\\' | b':' | 0))
        {
            return Err(PathPlanError::InvalidLeaf(leaf.to_string_lossy().into()));
        }
        #[cfg(windows)]
        {
            let text = leaf.to_string_lossy();
            let base = text.split('.').next().unwrap_or("").to_ascii_uppercase();
            if text.ends_with(['.', ' '])
                || text
                    .chars()
                    .any(|c| c < ' ' || matches!(c, '<' | '>' | '"' | '|' | '?' | '*'))
                || matches!(base.as_str(), "CON" | "PRN" | "AUX" | "NUL")
                || (base.len() == 4
                    && (base.starts_with("COM") || base.starts_with("LPT"))
                    && matches!(base.as_bytes()[3], b'1'..=b'9'))
            {
                return Err(PathPlanError::InvalidLeaf(text.into()));
            }
        }
        Ok(leaf)
    }
}

#[derive(Clone, Debug, Default)]
pub(super) struct BatchPathOptions {
    pub preserve_dirs: bool,
    pub input_root: Option<std::path::PathBuf>,
    pub template: Option<FilenameTemplate>,
}
pub(super) struct PlannedDestination {
    pub path: std::path::PathBuf,
    pub create_parents: Vec<std::path::PathBuf>,
}
fn resolved(path: &std::path::Path) -> Result<std::path::PathBuf, PathPlanError> {
    let absolute = if path.is_absolute() {
        path.to_owned()
    } else {
        std::env::current_dir()
            .map_err(|e| PathPlanError::InvalidLeaf(e.to_string()))?
            .join(path)
    };
    let mut ancestor = absolute.clone();
    let mut suffix = Vec::new();
    while !ancestor.exists() {
        if ancestor.symlink_metadata().is_ok() {
            return Err(PathPlanError::InvalidLeaf(format!(
                "dangling path: {}",
                ancestor.display()
            )));
        }
        suffix.push(
            ancestor
                .file_name()
                .ok_or_else(|| PathPlanError::InvalidLeaf(absolute.display().to_string()))?
                .to_owned(),
        );
        ancestor.pop();
    }
    if !suffix.is_empty() && !ancestor.is_dir() {
        return Err(PathPlanError::InvalidLeaf(format!(
            "output parent is not a directory: {}",
            ancestor.display()
        )));
    }
    let mut result = ancestor
        .canonicalize()
        .map_err(|e| PathPlanError::InvalidLeaf(e.to_string()))?;
    for part in suffix.into_iter().rev() {
        result.push(part);
    }
    Ok(result)
}
pub(super) fn plan_destination(
    input: &std::path::Path,
    output_root: &std::path::Path,
    output_ext: &str,
    index: usize,
    options: &BatchPathOptions,
) -> Result<PlannedDestination, PathPlanError> {
    use std::path::Path;
    let canonical_root = resolved(output_root)?;
    if output_root.exists() && !output_root.is_dir() {
        return Err(PathPlanError::InvalidLeaf(
            "output root is not a directory".into(),
        ));
    }
    let relative = if options.preserve_dirs {
        let root = options
            .input_root
            .as_ref()
            .ok_or_else(|| PathPlanError::InvalidLeaf("missing input root".into()))?
            .canonicalize()
            .map_err(|e| PathPlanError::InvalidLeaf(e.to_string()))?;
        input
            .canonicalize()
            .map_err(|e| PathPlanError::InvalidLeaf(e.to_string()))?
            .strip_prefix(&root)
            .map_err(|_| PathPlanError::InvalidLeaf("input is outside input root".into()))?
            .to_owned()
    } else {
        input
            .file_name()
            .map(std::path::PathBuf::from)
            .ok_or_else(|| PathPlanError::InvalidLeaf("missing input filename".into()))?
    };
    let default_template = FilenameTemplate::parse("{stem}.{output_ext}")?;
    let leaf = options
        .template
        .as_ref()
        .unwrap_or(&default_template)
        .expand(&FilenameTokens {
            stem: input.file_stem().unwrap_or_else(|| OsStr::new("output")),
            input_ext: input.extension().unwrap_or_else(|| OsStr::new("")),
            output_ext,
            index,
        })?;
    let parent = if options.preserve_dirs {
        relative.parent().unwrap_or_else(|| Path::new(""))
    } else {
        Path::new("")
    };
    let path = output_root.join(parent).join(leaf);
    if !resolved(&path)?.starts_with(&canonical_root) {
        return Err(PathPlanError::InvalidLeaf(
            "destination is outside output root".into(),
        ));
    }
    let mut create_parents = Vec::new();
    let mut parent = path.parent();
    while let Some(p) = parent.filter(|p| !p.as_os_str().is_empty()) {
        if p.exists() {
            if !p.is_dir() {
                return Err(PathPlanError::InvalidLeaf(
                    "output parent is not a directory".into(),
                ));
            }
            break;
        }
        create_parents.push(p.to_owned());
        parent = p.parent();
    }
    create_parents.reverse();
    Ok(PlannedDestination {
        path,
        create_parents,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::ffi::{OsStr, OsString};
    #[test]
    fn complete_leaf_tokens_escaping_and_validation() {
        let args = FilenameTokens {
            stem: OsStr::new("report"),
            input_ext: OsStr::new("txt"),
            output_ext: "html",
            index: 1,
        };
        assert_eq!(
            FilenameTemplate::parse("{index}-{stem}.{output_ext}")
                .unwrap()
                .expand(&args)
                .unwrap(),
            OsString::from("1-report.html")
        );
        assert_eq!(
            FilenameTemplate::parse("{stem}.{{draft}}.{output_ext}")
                .unwrap()
                .expand(&args)
                .unwrap(),
            OsString::from("report.{draft}.html")
        );
        for value in ["{unknown}", "{stem", "stem}"] {
            assert!(FilenameTemplate::parse(value).is_err());
        }
        for value in ["", ".", "..", "a/b", "a\\b", "C:escape", "bad\0name"] {
            assert!(
                FilenameTemplate::parse(value)
                    .and_then(|t| t.expand(&args))
                    .is_err(),
                "{value:?}"
            );
        }
    }
    #[cfg(unix)]
    #[test]
    fn os_tokens_preserve_non_utf8_bytes() {
        use std::os::unix::ffi::{OsStrExt, OsStringExt};
        let args = FilenameTokens {
            stem: OsStr::from_bytes(b"file\xff"),
            input_ext: OsStr::new(""),
            output_ext: "svg",
            index: 3,
        };
        assert_eq!(
            FilenameTemplate::parse("{stem}.{output_ext}")
                .unwrap()
                .expand(&args)
                .unwrap()
                .into_vec(),
            b"file\xff.svg"
        );
    }
}