clync 0.1.1

Encrypted sync for Claude Code across machines
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;

pub fn build_remote_map(claude_projects_dir: &Path) -> HashMap<String, String> {
    let mut map = HashMap::new();

    let entries = match std::fs::read_dir(claude_projects_dir) {
        Ok(e) => e,
        Err(_) => return map,
    };

    for entry in entries.flatten() {
        if !entry.path().is_dir() {
            continue;
        }
        let dir_name = entry.file_name().to_string_lossy().to_string();
        let real_path = decode_project_dir(&dir_name);
        if let Some(remote) = git_remote_url(&real_path) {
            let normalized = normalize_remote(&remote);
            map.insert(normalized, dir_name);
        }
    }

    map
}

pub fn resolve_project_dir(
    normalized_path: &str,
    remote_map: &HashMap<String, String>,
    claude_projects_dir: &Path,
) -> Option<String> {
    let candidate = crate::manifest::denormalize_project_path(normalized_path);
    let candidate_dir = claude_projects_dir.join(&candidate);
    if candidate_dir.exists() {
        return Some(candidate);
    }

    let candidate_real = decode_project_dir(&candidate);
    if let Some(remote) = git_remote_url(&candidate_real) {
        let normalized = normalize_remote(&remote);
        if let Some(local_dir) = remote_map.get(&normalized) {
            return Some(local_dir.clone());
        }
    }

    if let Ok(entries) = std::fs::read_dir(claude_projects_dir) {
        let suffix = format!("-{normalized_path}");
        let mut matches: Vec<String> = Vec::new();
        for entry in entries.flatten() {
            if !entry.path().is_dir() {
                continue;
            }
            let dir_name = entry.file_name().to_string_lossy().to_string();
            if dir_name.ends_with(&suffix) || dir_name == normalized_path {
                matches.push(dir_name);
            }
        }
        if matches.len() == 1 {
            return Some(matches.into_iter().next().unwrap());
        }
    }

    let decoded_remote_path = decode_project_dir(&format!("-placeholder-{}", normalized_path));
    let repo_name = Path::new(&decoded_remote_path)
        .file_name()
        .map(|n| n.to_string_lossy().to_string());

    if let Some(name) = repo_name {
        for local_dir in remote_map.values() {
            if local_dir.ends_with(&format!("-{name}")) {
                return Some(local_dir.clone());
            }
        }
    }

    Some(candidate)
}

fn decode_project_dir(encoded: &str) -> String {
    let path = encoded.replace('-', "/");
    format!("/{}", path.trim_start_matches('/'))
}

fn git_remote_url(path: &str) -> Option<String> {
    let path = Path::new(path);
    if !path.exists() {
        return None;
    }
    let output = Command::new("git")
        .args(["remote", "get-url", "origin"])
        .current_dir(path)
        .output()
        .ok()?;
    if output.status.success() {
        Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
    } else {
        None
    }
}

fn normalize_remote(url: &str) -> String {
    let s = url.trim_end_matches('/').trim_end_matches(".git");

    if let Some(rest) = s.strip_prefix("git@") {
        return rest.replace(':', "/").to_lowercase();
    }

    let s = s
        .strip_prefix("https://")
        .or_else(|| s.strip_prefix("http://"))
        .or_else(|| s.strip_prefix("ssh://"))
        .unwrap_or(s);

    s.to_lowercase()
}