flyboat 2.0.0

Container environment manager for development
Documentation
#![forbid(unsafe_code)]
#![deny(clippy::all)]

pub mod cli;
pub mod commands;
pub mod config;
pub mod docker;
pub mod environment;
pub mod error;
pub mod template;

pub use error::{Error, Result};

/// Parsed environment name with optional features (e.g., "rust+gpu+network")
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnvFeaturizedName {
    pub name: String,
    pub features: Vec<String>,
}

impl EnvFeaturizedName {
    /// Parse "name+feature1+feature2" syntax
    pub fn parse(input: &str) -> Result<Self> {
        let parts: Vec<&str> = input.split('+').collect();

        if parts.is_empty() || parts[0].is_empty() {
            return Err(Error::InvalidEnvironment(
                "Environment name cannot be empty".into(),
            ));
        }

        let name = parts[0].to_string();
        let features: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect();

        for f in &features {
            if f.is_empty() {
                return Err(Error::InvalidFeature("Feature name cannot be empty".into()));
            }
            if !is_valid_name(f) {
                return Err(Error::InvalidFeature(format!(
                    "'{}' contains invalid characters",
                    f
                )));
            }
        }

        Ok(Self { name, features })
    }
}

/// Checks if a name contains only valid characters: [a-zA-Z0-9-_]
/// Used to validate environment names, namespace parts, and aliases.
pub fn is_valid_name(name: &str) -> bool {
    !name.is_empty()
        && name
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}

/// Sanitizes a directory name for use as container name postfix.
/// Keeps only [a-zA-Z0-9-_] characters.
pub fn sanitize_name(name: &str) -> String {
    name.chars()
        .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
        .collect()
}

/// Gets the default container name from the current directory.
/// Returns sanitized directory name, or "0" if unable to determine.
pub fn default_container_name() -> String {
    std::env::current_dir()
        .ok()
        .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
        .map(|n| sanitize_name(&n))
        .filter(|n| !n.is_empty())
        .unwrap_or_else(|| "0".to_string())
}

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

    #[test]
    fn test_is_valid_name_simple() {
        assert!(is_valid_name("test"));
        assert!(is_valid_name("my-project"));
        assert!(is_valid_name("my_project"));
        assert!(is_valid_name("Test123"));
    }

    #[test]
    fn test_is_valid_name_rejects_invalid() {
        assert!(!is_valid_name(""));
        assert!(!is_valid_name("my/project"));
        assert!(!is_valid_name("my:project"));
        assert!(!is_valid_name("hello world"));
        assert!(!is_valid_name("test@123"));
        assert!(!is_valid_name("café"));
    }

    #[test]
    fn test_sanitize_name_simple() {
        assert_eq!(sanitize_name("test"), "test");
    }

    #[test]
    fn test_sanitize_name_removes_special_chars() {
        assert_eq!(sanitize_name("Spa&9 p"), "Spa9p");
    }

    #[test]
    fn test_sanitize_name_keeps_valid_chars() {
        assert_eq!(sanitize_name("my-project_123"), "my-project_123");
    }

    #[test]
    fn test_sanitize_name_empty_result() {
        assert_eq!(sanitize_name("@#$%"), "");
    }

    #[test]
    fn test_sanitize_name_mixed() {
        assert_eq!(sanitize_name("hello world!"), "helloworld");
    }

    #[test]
    fn test_sanitize_name_unicode() {
        assert_eq!(sanitize_name("café-123"), "caf-123");
    }

    #[test]
    fn test_parse_env_spec_no_features() {
        let spec = EnvFeaturizedName::parse("rust").unwrap();
        assert_eq!(spec.name, "rust");
        assert!(spec.features.is_empty());
    }

    #[test]
    fn test_parse_env_spec_single_feature() {
        let spec = EnvFeaturizedName::parse("rust+gpu").unwrap();
        assert_eq!(spec.name, "rust");
        assert_eq!(spec.features, vec!["gpu"]);
    }

    #[test]
    fn test_parse_env_spec_multiple_features() {
        let spec = EnvFeaturizedName::parse("rust+gpu+network+dev").unwrap();
        assert_eq!(spec.name, "rust");
        assert_eq!(spec.features, vec!["gpu", "network", "dev"]);
    }

    #[test]
    fn test_parse_env_spec_with_namespace() {
        let spec = EnvFeaturizedName::parse("my_collection/rust+gpu").unwrap();
        assert_eq!(spec.name, "my_collection/rust");
        assert_eq!(spec.features, vec!["gpu"]);
    }

    #[test]
    fn test_parse_env_spec_empty_name_error() {
        let result = EnvFeaturizedName::parse("+gpu");
        assert!(result.is_err());
    }

    #[test]
    fn test_parse_env_spec_empty_feature_error() {
        let result = EnvFeaturizedName::parse("rust++gpu");
        assert!(result.is_err());
    }

    #[test]
    fn test_parse_env_spec_invalid_feature_chars() {
        let result = EnvFeaturizedName::parse("rust+bad/feature");
        assert!(result.is_err());
    }
}