onpath 0.2.0

Get your tools on the PATH — cross-shell, cross-platform, zero fuss
Documentation
use std::io;
use std::path::PathBuf;

/// All errors that can occur during PATH management operations.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
    /// The user's home directory could not be determined.
    #[error("home directory could not be determined")]
    HomeDirNotFound,

    /// Failed to read a file.
    #[error("failed to read {path}: {source}")]
    FileRead {
        /// The path that could not be read.
        path: PathBuf,
        /// The underlying I/O error.
        source: io::Error,
    },

    /// Failed to write a file.
    #[error("failed to write {path}: {source}")]
    FileWrite {
        /// The path that could not be written.
        path: PathBuf,
        /// The underlying I/O error.
        source: io::Error,
    },

    /// Failed to create a directory.
    #[error("failed to create directory {path}: {source}")]
    DirCreate {
        /// The directory path that could not be created.
        path: PathBuf,
        /// The underlying I/O error.
        source: io::Error,
    },

    /// Failed to create a backup of an RC file.
    #[error("failed to create backup of {path}: {source}")]
    BackupFailed {
        /// The file that could not be backed up.
        path: PathBuf,
        /// The underlying I/O error.
        source: io::Error,
    },

    /// The env directory could not be derived from the target directory.
    #[error(
        "cannot derive env_dir: {dir} has no parent directory (use .env_dir() to set explicitly)"
    )]
    EnvDirNotResolvable {
        /// The directory with no parent.
        dir: PathBuf,
    },

    /// The tool name is invalid.
    #[error("invalid tool name {name:?}: {reason}")]
    InvalidToolName {
        /// The invalid tool name.
        name: String,
        /// Why it's invalid.
        reason: String,
    },

    /// The directory path is relative (not absolute).
    #[error("directory must be an absolute path, got: {dir}")]
    RelativePath {
        /// The relative path that was provided.
        dir: PathBuf,
    },

    /// The directory path contains characters that are unsafe to embed in shell scripts.
    #[error("directory path contains unsafe characters for shell embedding: {dir}")]
    UnsafePath {
        /// The path with unsafe characters.
        dir: PathBuf,
    },

    /// The directory path is not valid UTF-8.
    #[error("directory path is not valid UTF-8: {}", dir.display())]
    NonUtf8Path {
        /// The non-UTF-8 path.
        dir: PathBuf,
    },

    /// Failed to acquire a file lock.
    #[cfg(unix)]
    #[error("failed to acquire lock on {path}: {source}")]
    LockFailed {
        /// The lockfile path.
        path: PathBuf,
        /// The underlying I/O error.
        source: io::Error,
    },

    /// Timed out waiting for a file lock.
    #[cfg(unix)]
    #[error("timed out waiting for lock on {path} (another process may be holding it)")]
    LockTimeout {
        /// The lockfile path.
        path: PathBuf,
    },

    /// No shells were detected on this system.
    #[error("no shells detected on this system")]
    NoShellsDetected,

    /// A Windows registry operation failed.
    #[cfg(windows)]
    #[error("Windows registry error: {0}")]
    Registry(#[from] RegistryError),
}

/// Windows-specific registry errors.
#[cfg(windows)]
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum RegistryError {
    /// Failed to open the `HKCU\Environment` registry key.
    #[error("failed to open registry key: {0}")]
    OpenKey(io::Error),

    /// Failed to read PATH from the registry.
    #[error("failed to read PATH from registry: {0}")]
    ReadPath(io::Error),

    /// Failed to write PATH to the registry.
    #[error("failed to write PATH to registry: {0}")]
    WritePath(io::Error),

    /// Failed to acquire the cross-process registry lock.
    #[error("failed to acquire registry lock: {0}")]
    LockFailed(io::Error),

    /// Timed out waiting for the cross-process registry lock.
    #[error("timed out waiting for registry lock (another process may be holding it)")]
    LockTimeout,
}

/// A `Result` type alias using [`Error`] as the error type.
pub type Result<T> = std::result::Result<T, Error>;

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

    #[test]
    fn error_display_all_variants() {
        let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "denied");

        assert_eq!(
            Error::HomeDirNotFound.to_string(),
            "home directory could not be determined"
        );
        let env_dir_err = Error::EnvDirNotResolvable {
            dir: PathBuf::from("/"),
        };
        assert!(env_dir_err.to_string().contains("cannot derive env_dir"));
        assert_eq!(
            Error::NoShellsDetected.to_string(),
            "no shells detected on this system"
        );

        let file_read = Error::FileRead {
            path: PathBuf::from("/tmp/test"),
            source: io_err,
        };
        assert!(file_read.to_string().contains("failed to read"));

        let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "denied");
        let file_write = Error::FileWrite {
            path: PathBuf::from("/tmp/test"),
            source: io_err,
        };
        assert!(file_write.to_string().contains("failed to write"));

        let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "denied");
        let dir_create = Error::DirCreate {
            path: PathBuf::from("/tmp/dir"),
            source: io_err,
        };
        assert!(dir_create
            .to_string()
            .contains("failed to create directory"));

        let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "denied");
        let backup = Error::BackupFailed {
            path: PathBuf::from("/tmp/file"),
            source: io_err,
        };
        assert!(backup.to_string().contains("failed to create backup"));

        let invalid_tool = Error::InvalidToolName {
            name: "bad name!".to_owned(),
            reason: "contains invalid chars".to_owned(),
        };
        assert!(invalid_tool.to_string().contains("invalid tool name"));

        let relative = Error::RelativePath {
            dir: PathBuf::from("relative/path"),
        };
        assert!(relative.to_string().contains("absolute path"));

        let unsafe_path = Error::UnsafePath {
            dir: PathBuf::from("/path/with\"quote"),
        };
        assert!(unsafe_path.to_string().contains("unsafe characters"));

        let non_utf8 = Error::NonUtf8Path {
            dir: PathBuf::from("/some/path"),
        };
        assert!(non_utf8.to_string().contains("not valid UTF-8"));

        #[cfg(unix)]
        {
            let io_err = io::Error::new(io::ErrorKind::Other, "lock error");
            let lock_err = Error::LockFailed {
                path: PathBuf::from("/tmp/.bashrc.onpath.lock"),
                source: io_err,
            };
            assert!(lock_err.to_string().contains("failed to acquire lock"));

            let timeout_err = Error::LockTimeout {
                path: PathBuf::from("/tmp/.bashrc.onpath.lock"),
            };
            assert!(timeout_err.to_string().contains("timed out"));
        }
    }

    #[cfg(windows)]
    #[test]
    fn registry_error_display() {
        let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "access denied");
        let open = RegistryError::OpenKey(io_err);
        assert!(open.to_string().contains("failed to open registry key"));

        let io_err = io::Error::new(io::ErrorKind::NotFound, "not found");
        let read = RegistryError::ReadPath(io_err);
        assert!(read.to_string().contains("failed to read PATH"));

        let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "denied");
        let write = RegistryError::WritePath(io_err);
        assert!(write.to_string().contains("failed to write PATH"));

        let io_err = io::Error::new(io::ErrorKind::Other, "mutex failed");
        let lock = RegistryError::LockFailed(io_err);
        assert!(lock.to_string().contains("failed to acquire registry lock"));

        let timeout = RegistryError::LockTimeout;
        assert!(timeout.to_string().contains("timed out"));
    }
}