repoverse 0.1.7

Multi-repo workspace tool: keep many git repos in sync and roll changes up across dependency boundaries
//! Remote scheme resolution (ssh/https) and URL derivation.
//!
//! Precedence (highest wins): explicit flag → `RV_REMOTE_SCHEME` env →
//! CI auto-detect (`CI`/`GITHUB_ACTIONS` → https + token) → config default →
//! built-in ssh.

use crate::config::Scheme;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SchemeFlag {
    Ssh,
    Https,
    Unset,
}

#[derive(Debug, Clone)]
pub struct ResolvedScheme {
    pub scheme: Scheme,
    /// Token injected for CI https (best-effort; see TODO note on leakage).
    pub token: Option<String>,
    #[allow(dead_code)]
    pub source: &'static str,
}

pub trait Env {
    fn get(&self, key: &str) -> Option<String>;
}

pub struct SystemEnv;
impl Env for SystemEnv {
    fn get(&self, key: &str) -> Option<String> {
        std::env::var(key).ok()
    }
}

pub fn resolve(flag: SchemeFlag, config_default: Scheme, env: &dyn Env) -> ResolvedScheme {
    match flag {
        SchemeFlag::Ssh => {
            return ResolvedScheme {
                scheme: Scheme::Ssh,
                token: None,
                source: "flag",
            }
        }
        SchemeFlag::Https => {
            return ResolvedScheme {
                scheme: Scheme::Https,
                token: ci_token(env),
                source: "flag",
            }
        }
        SchemeFlag::Unset => {}
    }

    if let Some(v) = env.get("RV_REMOTE_SCHEME") {
        match v.to_ascii_lowercase().as_str() {
            "ssh" => {
                return ResolvedScheme {
                    scheme: Scheme::Ssh,
                    token: None,
                    source: "env",
                }
            }
            "https" => {
                return ResolvedScheme {
                    scheme: Scheme::Https,
                    token: ci_token(env),
                    source: "env",
                }
            }
            _ => {}
        }
    }

    if is_ci(env) {
        return ResolvedScheme {
            scheme: Scheme::Https,
            token: ci_token(env),
            source: "ci",
        };
    }

    ResolvedScheme {
        scheme: config_default,
        token: None,
        source: "config",
    }
}

fn is_ci(env: &dyn Env) -> bool {
    env.get("GITHUB_ACTIONS").is_some()
        || env
            .get("CI")
            .map(|v| v != "false" && !v.is_empty())
            .unwrap_or(false)
}

fn ci_token(env: &dyn Env) -> Option<String> {
    env.get("RV_GIT_TOKEN")
        .or_else(|| env.get("GH_TOKEN"))
        .or_else(|| env.get("GITHUB_TOKEN"))
}

/// Build the clone/remote URL for `owner/repo` on `host`.
pub fn url_for(host: &str, name: &str, resolved: &ResolvedScheme) -> String {
    let name = name.trim_end_matches(".git");
    match resolved.scheme {
        Scheme::Ssh => format!("git@{host}:{name}.git"),
        Scheme::Https => match &resolved.token {
            Some(t) => format!("https://x-access-token:{t}@{host}/{name}.git"),
            None => format!("https://{host}/{name}.git"),
        },
    }
}

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

    struct Map(HashMap<String, String>);
    impl Env for Map {
        fn get(&self, k: &str) -> Option<String> {
            self.0.get(k).cloned()
        }
    }
    fn env(pairs: &[(&str, &str)]) -> Map {
        Map(pairs
            .iter()
            .map(|(k, v)| (k.to_string(), v.to_string()))
            .collect())
    }

    #[test]
    fn flag_wins() {
        let r = resolve(SchemeFlag::Https, Scheme::Ssh, &env(&[]));
        assert_eq!(r.scheme, Scheme::Https);
        assert_eq!(r.source, "flag");
    }

    #[test]
    fn ci_forces_https_with_token() {
        let r = resolve(
            SchemeFlag::Unset,
            Scheme::Ssh,
            &env(&[("GITHUB_ACTIONS", "true"), ("GITHUB_TOKEN", "tok")]),
        );
        assert_eq!(r.scheme, Scheme::Https);
        assert_eq!(r.source, "ci");
        assert_eq!(r.token.as_deref(), Some("tok"));
    }

    #[test]
    fn falls_back_to_config() {
        let r = resolve(SchemeFlag::Unset, Scheme::Ssh, &env(&[]));
        assert_eq!(r.scheme, Scheme::Ssh);
        assert_eq!(r.source, "config");
    }

    #[test]
    fn url_forms() {
        let ssh = ResolvedScheme {
            scheme: Scheme::Ssh,
            token: None,
            source: "x",
        };
        assert_eq!(
            url_for("github.com", "acme/foo", &ssh),
            "git@github.com:acme/foo.git"
        );
        let https = ResolvedScheme {
            scheme: Scheme::Https,
            token: None,
            source: "x",
        };
        assert_eq!(
            url_for("github.com", "acme/foo", &https),
            "https://github.com/acme/foo.git"
        );
        let tok = ResolvedScheme {
            scheme: Scheme::Https,
            token: Some("T".into()),
            source: "x",
        };
        assert_eq!(
            url_for("github.com", "acme/foo", &tok),
            "https://x-access-token:T@github.com/acme/foo.git"
        );
    }

    #[test]
    fn env_scheme_override() {
        let r = resolve(
            SchemeFlag::Unset,
            Scheme::Ssh,
            &env(&[("RV_REMOTE_SCHEME", "https")]),
        );
        assert_eq!(r.scheme, Scheme::Https);
        assert_eq!(r.source, "env");
    }
}