fp-dotfiles-manager 0.2.5

Minimal, zero-dependency Chezmoi-based dotfiles manager
pub fn concat_paths(a: &str, b: &str) -> String {
    let mut s = String::with_capacity(a.len() + b.len() + 1);
    s.push_str(a);
    if !a.ends_with('/') && !b.starts_with('/') {
        s.push('/');
    }
    s.push_str(b);
    s
}

pub fn canonical_path(path: &str) -> String {
    std::fs::canonicalize(path)
        .map(|p| p.to_string_lossy().into_owned())
        .unwrap_or_else(|_| path.to_string())
}

pub fn expand_pattern_paths(pattern: &str) -> Vec<String> {
    if !pattern.contains('*') && !pattern.contains('?') && !pattern.contains('[') {
        return vec![pattern.to_string()];
    }

    let bash_cmd = format!(
        "shopt -s nullglob dotglob; for p in {}; do echo \"$$p\"; done",
        pattern
    );
    if let Ok(output) = std::process::Command::new("bash")
        .arg("-c")
        .arg(&bash_cmd)
        .output()
    {
        if output.status.success() {
            let stdout_str = String::from_utf8_lossy(&output.stdout);
            let paths: Vec<String> = stdout_str
                .lines()
                .map(|line| line.trim().to_string())
                .filter(|line| !line.is_empty())
                .collect();
            if !paths.is_empty() {
                return paths;
            }
        }
    }

    vec![pattern.to_string()]
}

pub fn wildcard_match(s: &str, pat: &str) -> bool {
    let s_bytes = s.as_bytes();
    let pat_bytes = pat.as_bytes();

    let mut s_idx = 0;
    let mut pat_idx = 0;
    let mut star_idx = None;
    let mut s_temp_idx = 0;

    while s_idx < s_bytes.len() {
        if pat_idx < pat_bytes.len()
            && (pat_bytes[pat_idx] == b'?' || pat_bytes[pat_idx] == s_bytes[s_idx])
        {
            s_idx += 1;
            pat_idx += 1;
        } else if pat_idx < pat_bytes.len() && pat_bytes[pat_idx] == b'*' {
            star_idx = Some(pat_idx);
            s_temp_idx = s_idx;
            pat_idx += 1;
        } else if let Some(star) = star_idx {
            pat_idx = star + 1;
            s_temp_idx += 1;
            s_idx = s_temp_idx;
        } else {
            return false;
        }
    }

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

    pat_idx == pat_bytes.len()
}

pub fn path_matches_pattern(full_path: &str, pattern: &str) -> bool {
    let clean_pattern = pattern.replace(".*", "*");

    if wildcard_match(full_path, &clean_pattern) {
        return true;
    }

    if clean_pattern.ends_with('/') {
        let child_pat = format!("{}*", clean_pattern);
        if wildcard_match(full_path, &child_pat) {
            return true;
        }
    } else {
        let child_pat = format!("{}/*", clean_pattern);
        if wildcard_match(full_path, &child_pat) {
            return true;
        }
    }

    false
}

pub fn path_is_excluded(full_path: &str, excludes: &[String]) -> bool {
    excludes
        .iter()
        .any(|ex| path_matches_pattern(full_path, ex))
}

pub fn path_is_encrypted(full_path: &str, enc_list: &[String]) -> bool {
    enc_list
        .iter()
        .any(|pat| path_matches_pattern(full_path, pat))
}

pub fn path_has_valid_prefix(full_path: &str, valid_prefixes: &[String]) -> bool {
    valid_prefixes
        .iter()
        .any(|prefix| path_matches_pattern(full_path, prefix))
}

pub fn normalize_path(path: &str) -> String {
    let mut parts = Vec::new();
    for component in path.split('/') {
        if component == "." || component.is_empty() {
            continue;
        }
        if component == ".." {
            parts.pop();
        } else {
            parts.push(component);
        }
    }
    let mut normalized = String::new();
    if path.starts_with('/') {
        normalized.push('/');
    }
    normalized.push_str(&parts.join("/"));
    normalized
}