ccd-cli 1.0.0-beta.1

Bootstrap and validate Continuous Context Development repositories
// Profile parsing and validation helpers are shared across commands and tests;
// not every constructor is reached in every build shape.

use std::env;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{bail, Context, Result};

pub const DEFAULT_PROFILE: &str = "main";
pub const PROFILE_ENV_VAR: &str = "CCD_PROFILE";

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProfileName(String);

impl ProfileName {
    pub fn new(value: impl Into<String>) -> Result<Self> {
        let value = value.into();
        validate_profile_name(&value)?;
        Ok(Self(value))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl AsRef<str> for ProfileName {
    fn as_ref(&self) -> &str {
        self.as_str()
    }
}

impl fmt::Display for ProfileName {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        self.0.fmt(f)
    }
}

pub fn resolve(explicit: Option<&str>) -> Result<ProfileName> {
    let env_value = env::var_os(PROFILE_ENV_VAR).map(PathBuf::from);
    resolve_with_env(explicit, env_value.as_deref())
}

pub fn resolve_with_env(explicit: Option<&str>, env_value: Option<&Path>) -> Result<ProfileName> {
    if let Some(profile) = explicit {
        return ProfileName::new(profile)
            .with_context(|| format!("invalid explicit profile value `{profile}`"));
    }

    if let Some(profile) = env_value {
        let profile = profile.to_string_lossy();
        return ProfileName::new(profile.as_ref())
            .with_context(|| format!("invalid {PROFILE_ENV_VAR} value `{profile}`"));
    }

    ProfileName::new(DEFAULT_PROFILE)
}

pub fn ensure_profile_dir(ccd_root: &Path, profile: &ProfileName) -> Result<PathBuf> {
    let profile_root = ccd_root.join("profiles").join(profile.as_str());
    let overlay_root = profile_root.join("repos");

    fs::create_dir_all(&overlay_root)
        .with_context(|| format!("failed to create directory {}", overlay_root.display()))?;

    Ok(profile_root)
}

fn validate_profile_name(value: &str) -> Result<()> {
    if value.is_empty() {
        bail!("profile name cannot be empty");
    }

    if value == "." || value == ".." {
        bail!("profile name cannot be `.` or `..`");
    }

    if value.contains('/') || value.contains('\\') {
        bail!("profile name cannot contain path separators");
    }

    if value.as_bytes().contains(&0) {
        bail!("profile name cannot contain NUL bytes");
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use tempfile::tempdir;

    use super::*;

    #[test]
    fn resolve_prefers_explicit_profile() {
        let profile =
            resolve_with_env(Some("work"), Some(Path::new("personal"))).expect("profile resolves");

        assert_eq!(profile.as_str(), "work");
    }

    #[test]
    fn resolve_uses_environment_when_explicit_is_missing() {
        let profile =
            resolve_with_env(None, Some(Path::new("personal"))).expect("profile resolves");

        assert_eq!(profile.as_str(), "personal");
    }

    #[test]
    fn resolve_falls_back_to_main() {
        let profile = resolve_with_env(None, None).expect("profile resolves");

        assert_eq!(profile.as_str(), DEFAULT_PROFILE);
    }

    #[test]
    fn resolve_rejects_invalid_profile_names() {
        let error = resolve_with_env(Some("../bad"), None).expect_err("profile should fail");

        assert!(error.to_string().contains("invalid explicit profile value"));
    }

    #[test]
    fn ensure_profile_dir_creates_profile_kernel_skeleton() {
        let temp = tempdir().expect("tempdir");
        let ccd_root = temp.path().join(".ccd");
        let profile = ProfileName::new("main").expect("profile");

        let profile_root = ensure_profile_dir(&ccd_root, &profile).expect("profile dir");

        assert_eq!(profile_root, ccd_root.join("profiles/main"));
        assert!(profile_root.is_dir());
        assert!(profile_root.join("repos").is_dir());
    }
}