gitow 1.0.0

Open Git remotes and repository pages in your browser from the CLI
Documentation
use std::{fs, path::Path};

pub fn resolve_alias(path: &Path, alias: &str) -> Option<String> {
    let content = fs::read_to_string(path).ok()?;
    let mut current_match = false;
    let mut resolved = None;

    for line in content.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() || trimmed.starts_with('#') {
            continue;
        }

        let mut parts = trimmed.split_whitespace();
        let keyword = match parts.next() {
            Some(keyword) => keyword,
            None => continue,
        };

        if keyword.eq_ignore_ascii_case("Host") {
            current_match = parts.any(|pattern| wildcard_matches(pattern, alias));
            continue;
        }

        if current_match
            && keyword.eq_ignore_ascii_case("HostName")
            && let Some(hostname) = parts.next()
        {
            resolved = Some(hostname.replace("%h", alias));
        }
    }

    resolved
}

fn wildcard_matches(pattern: &str, candidate: &str) -> bool {
    let pattern = pattern.as_bytes();
    let candidate = candidate.as_bytes();
    let mut pattern_index = 0usize;
    let mut candidate_index = 0usize;
    let mut wildcard_index = None;
    let mut backtrack_index = 0usize;

    while candidate_index < candidate.len() {
        if pattern_index < pattern.len()
            && (pattern[pattern_index] == b'?'
                || pattern[pattern_index] == candidate[candidate_index])
        {
            pattern_index += 1;
            candidate_index += 1;
        } else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' {
            wildcard_index = Some(pattern_index);
            pattern_index += 1;
            backtrack_index = candidate_index;
        } else if let Some(wildcard) = wildcard_index {
            pattern_index = wildcard + 1;
            backtrack_index += 1;
            candidate_index = backtrack_index;
        } else {
            return false;
        }
    }

    while pattern_index < pattern.len() && pattern[pattern_index] == b'*' {
        pattern_index += 1;
    }

    pattern_index == pattern.len()
}

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

    use super::resolve_alias;

    fn write_ssh_config(contents: &str) -> NamedTempFile {
        let file = NamedTempFile::new().expect("temporary file");
        std::fs::write(file.path(), contents).expect("write ssh config");
        file
    }

    #[test]
    fn resolves_simple_alias_and_hostname_case_insensitively() {
        let file = write_ssh_config(
            "\
            host basic\n\
              hOsTnAmE basic.example.com\n\
            ",
        );

        assert_eq!(
            resolve_alias(file.path(), "basic"),
            Some("basic.example.com".to_string())
        );
    }

    #[test]
    fn resolves_later_rules_as_overrides() {
        let file = write_ssh_config(
            "\
            Host zero*\n\
              HostName zero.example.com\n\
            Host zero-override\n\
              HostName override.example.com\n\
            ",
        );

        assert_eq!(
            resolve_alias(file.path(), "zero-override"),
            Some("override.example.com".to_string())
        );
    }

    #[test]
    fn supports_wildcards_and_host_substitution() {
        let file = write_ssh_config(
            "\
            Host subone?\n\
              HostName %h.example.com\n\
            Host zero*\n\
              HostName zero.example.com\n\
            ",
        );

        assert_eq!(
            resolve_alias(file.path(), "subone7"),
            Some("subone7.example.com".to_string())
        );
        assert_eq!(
            resolve_alias(file.path(), "zero-dev"),
            Some("zero.example.com".to_string())
        );
        assert_eq!(resolve_alias(file.path(), "subone"), None);
    }
}