clingwrap 0.7.0

types and functions to implement command line programs
Documentation
//! A type to represent filenames that need tilde expansion.
//!
//! [`TildePathBuf`] represents a filename, like
//! [`std::path::PathBuf`], but that gets a leading tilde expanded,
//! when de-serialized using [serde](https://serde.rs/).
//!
//! # Example
//!
//! ```
//! # use serde::Deserialize;
//! # use clingwrap::tildepathbuf::TildePathBuf;
//!
//! #[derive(Deserialize)]
//! pub struct MyThing {
//!     pub location: TildePathBuf,
//! }
//!
//! # fn main() {
//! let thing = r#"
//! location: ~/foo
//! "#;
//!
//! # if std::env::var("HOME").is_ok() {
//! let mything: MyThing = serde_norway::from_str(thing).unwrap();
//! println!("{}", mything.location.path().display());
//! # }
//! # }
//! ```

// Daniel Silverberg gave me a lot of help to get the serde
// integration to work.

use std::{
    env::var,
    ffi::OsStr,
    fmt,
    ops::Deref,
    path::{Path, PathBuf},
};

use serde::Deserialize;

/// A filename, like [`std::path::PathBuf`], but that gets a leading
/// tilde expanded, when de-serialized using
/// [serde](https://serde.rs/).
///
/// Note that only a filename consisting only of `~` or one that
/// starts with a `~/` get expanded. A `~username` prefix not
/// supported.
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct TildePathBuf {
    path: PathBuf,
}

impl TildePathBuf {
    /// Create a new `TildePathBuf`.
    pub fn new(path: PathBuf) -> Self {
        Self { path }
    }

    /// The contained filename after tilde expansion.
    pub fn path(&self) -> &Path {
        &self.path
    }
}

impl From<TildePathBuf> for PathBuf {
    fn from(value: TildePathBuf) -> Self {
        value.path.clone()
    }
}

impl From<PathBuf> for TildePathBuf {
    fn from(value: PathBuf) -> Self {
        Self::new(value)
    }
}

impl From<&Path> for TildePathBuf {
    fn from(value: &Path) -> Self {
        Self::new(value.into())
    }
}

impl AsRef<Path> for TildePathBuf {
    fn as_ref(&self) -> &Path {
        self.path()
    }
}

impl Deref for TildePathBuf {
    type Target = Path;
    fn deref(&self) -> &Self::Target {
        self.path()
    }
}

impl<'de> Deserialize<'de> for TildePathBuf {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::de::Deserializer<'de>,
    {
        struct TildeExpandedVisitor;
        impl serde::de::Visitor<'_> for TildeExpandedVisitor {
            type Value = TildePathBuf;
            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
                formatter.write_str("a path, with optional tildes to expand")
            }
            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
            where
                E: serde::de::Error,
            {
                let expander = Expander::new().map_err(|e| E::custom(e))?;
                Ok(TildePathBuf::new(expander.expand(Path::new(value))))
            }
        }

        deserializer.deserialize_str(TildeExpandedVisitor)
    }
}

struct Expander {
    home: PathBuf,
}

impl Expander {
    fn new() -> Result<Self, TildeError> {
        let home = var("HOME").map_err(TildeError::NoHome)?;
        Ok(Self {
            home: PathBuf::from(home),
        })
    }

    #[cfg(test)]
    fn with_home(home: &Path) -> Self {
        Self { home: home.into() }
    }

    fn expand(&self, path: &Path) -> PathBuf {
        let os = path.as_os_str();
        let bytes = os.as_encoded_bytes();
        let path: PathBuf = if bytes == b"~" {
            self.home.clone()
        } else if let Some(suffix) = bytes.strip_prefix(b"~/") {
            // We need to construct a Path from a slice of bytes.
            // This can only be done in an unsafe block. We know
            // the bytes are a valid part of a Path, because we
            // got them, and only took away two safe bytes, the
            // tilde and the slash.
            unsafe {
                let suffix = OsStr::from_encoded_bytes_unchecked(suffix);
                self.home.join(Path::new(suffix))
            }
        } else {
            path.to_path_buf()
        };

        path
    }
}

/// Possible errors from tilde expansion.
#[derive(Debug, thiserror::Error)]
pub enum TildeError {
    /// No `HOME` environment variable is set in the environment, or it
    /// is set to a value that can't be converted into a Unicode
    /// string.
    #[error("the HOME environment variable is not available")]
    NoHome(#[source] std::env::VarError),
}

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

    #[test]
    fn no_tilde() -> Result<(), Box<dyn std::error::Error>> {
        let expander = Expander::with_home(Path::new("/home/foo"));
        assert_eq!(expander.expand(Path::new("foo")), PathBuf::from("foo"));
        Ok(())
    }

    #[test]
    fn just_tilde() -> Result<(), Box<dyn std::error::Error>> {
        let expander = Expander::with_home(Path::new("/home/foo"));
        assert_eq!(expander.expand(Path::new("~")), PathBuf::from("/home/foo"));
        Ok(())
    }

    #[test]
    fn tilde_slash() -> Result<(), Box<dyn std::error::Error>> {
        let expander = Expander::with_home(Path::new("/home/foo"));
        assert_eq!(
            expander.expand(Path::new("~/bar")),
            PathBuf::from("/home/foo/bar")
        );
        Ok(())
    }

    #[test]
    fn tilde_username() -> Result<(), Box<dyn std::error::Error>> {
        let expander = Expander::with_home(Path::new("/home/foo"));
        assert_eq!(
            expander.expand(Path::new("~foo/bar")),
            PathBuf::from("~foo/bar")
        );
        Ok(())
    }

    #[test]
    fn tilde_slash_with_home_from_var() -> Result<(), Box<dyn std::error::Error>> {
        if var("HOME").is_ok() {
            let expander = Expander::new()?;
            let path = expander.expand(Path::new("~/foo"));
            let home = std::env::var("HOME")?;
            assert_eq!(path.display().to_string(), format!("{home}/foo"));
        }
        Ok(())
    }

    #[test]
    fn deser() -> Result<(), Box<dyn std::error::Error>> {
        #[derive(Deserialize)]
        pub struct MyThing {
            pub location: TildePathBuf,
        }

        let thing = r#"
location: ~/foo
"#;

        if var("HOME").is_ok() {
            let mything: MyThing = serde_norway::from_str(thing)?;
            let home = std::env::var("HOME")?;
            let wanted = PathBuf::from(format!("{home}/foo"));
            assert_eq!(mything.location.path(), wanted);
        }

        Ok(())
    }

    #[test]
    fn eq() {
        let foo1 = TildePathBuf::new(PathBuf::from("foo"));
        let foo2 = TildePathBuf::new(PathBuf::from("foo"));
        let bar = TildePathBuf::new(PathBuf::from("bar"));

        assert_eq!(foo1, foo2);
        assert_ne!(foo1, bar);
    }

    #[test]
    fn as_ref() {
        let foo = TildePathBuf::new(PathBuf::from("foo"));
        let bar: &Path = foo.as_ref();
        assert_eq!(foo.path(), bar);
    }

    #[test]
    fn deref() {
        let foo = TildePathBuf::new(PathBuf::from("foo"));
        let bar: &Path = foo.deref();
        assert_eq!(foo.path(), bar);
    }

    #[test]
    fn to_path_buf() {
        let foo = TildePathBuf::new(PathBuf::from("foo"));
        let bar = foo.to_path_buf();
        assert_eq!(foo.path(), bar);
    }

    #[test]
    fn pathbuf_from() {
        let foo = TildePathBuf::new(PathBuf::from("foo"));
        let bar = PathBuf::from(foo.clone());
        assert_eq!(foo.path(), bar);
    }
}