lgtm-cli 0.4.0

Run LGTM code reviews with a bundled web UI and gRPC server.
//! General filesystem path, repository slug, and remote URL utilities.

use std::path::{Path, PathBuf};

use sha2::{Digest, Sha256};
use url::Url;

use crate::backend::error::{Error, Result};

/// Resolve the default root directory (`~/.lgtm/snapshots` or `$LGTM_HOME/snapshots`) for local snapshot repositories.
pub fn default_snapshots_root() -> PathBuf {
    if let Ok(home) = std::env::var("LGTM_HOME")
        && !home.trim().is_empty()
    {
        return PathBuf::from(home).join("snapshots");
    }
    dirs::home_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .join(".lgtm")
        .join("snapshots")
}

/// Normalize a working tree or `.git` path to the canonical `.git` directory string and worktree root.
pub fn resolve_git_paths(git_dir_or_worktree: &str) -> Result<(PathBuf, PathBuf, String)> {
    let trimmed = git_dir_or_worktree.trim();
    if trimmed.is_empty() {
        return Err(Error::Invalid("git_dir must not be empty".to_owned()));
    }
    let input_path = Path::new(trimmed);
    let canonical = std::fs::canonicalize(input_path).map_err(|err| {
        Error::Invalid(format!(
            "failed to resolve git_dir '{}': {err}",
            input_path.display()
        ))
    })?;

    let (git_dir, worktree_dir) = if canonical.file_name().is_some_and(|name| name == ".git") {
        let worktree = canonical
            .parent()
            .ok_or_else(|| Error::Invalid("git_dir has no parent worktree".to_owned()))?
            .to_path_buf();
        (canonical, worktree)
    } else if canonical.join(".git").exists() {
        let dot_git = canonical.join(".git");
        let resolved_git = if dot_git.is_file() {
            resolve_gitdir_file(&dot_git)?
        } else {
            dot_git
        };
        (resolved_git, canonical)
    } else {
        (canonical.clone(), canonical)
    };

    let normalized_git_dir = git_dir.to_string_lossy().into_owned();
    Ok((git_dir, worktree_dir, normalized_git_dir))
}

/// Compute a deterministic, filesystem-safe `$repoSlug` for a canonical `git_dir`.
pub fn compute_repo_slug(git_dir: &str) -> Result<String> {
    let (_, worktree_dir, normalized_git_dir) = resolve_git_paths(git_dir)?;
    let repo_name = worktree_dir
        .file_name()
        .and_then(|s| s.to_str())
        .unwrap_or("repo")
        .chars()
        .map(|ch| {
            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
                ch
            } else {
                '-'
            }
        })
        .collect::<String>();

    let mut hasher = Sha256::new();
    hasher.update(normalized_git_dir.as_bytes());
    let digest = hasher.finalize();
    let short_hash = digest[..6]
        .iter()
        .map(|byte| format!("{byte:02x}"))
        .collect::<String>();

    Ok(format!("{repo_name}-{short_hash}"))
}

/// Normalize a Git remote URL (SSH or HTTPS) into a canonical `https://<host>/<owner>/<repo>` URL.
pub fn normalize_repository_url(raw_remote: &str) -> Result<String> {
    let trimmed = raw_remote.trim();
    if trimmed.is_empty() {
        return Err(Error::Invalid(
            "repository_url must not be empty".to_owned(),
        ));
    }

    if let Some(rest) = trimmed.strip_prefix("git@") {
        let (host, path) = rest
            .split_once(':')
            .ok_or_else(|| Error::Invalid(format!("invalid SSH remote address '{trimmed}'")))?;
        let clean_path = path.trim_start_matches('/').trim_end_matches(".git");
        if clean_path.split('/').count() < 2 {
            return Err(Error::Invalid(format!(
                "repository_url '{trimmed}' must include owner and repository name"
            )));
        }
        return Ok(format!("https://{host}/{clean_path}"));
    }

    let parsed = Url::parse(trimmed)
        .map_err(|err| Error::Invalid(format!("invalid repository_url '{trimmed}': {err}")))?;
    let scheme = match parsed.scheme() {
        "http" | "https" => parsed.scheme(),
        "ssh" => "https",
        other => {
            return Err(Error::Invalid(format!(
                "unsupported repository_url scheme '{other}'"
            )));
        }
    };
    let host = parsed
        .host_str()
        .ok_or_else(|| Error::Invalid(format!("repository_url '{trimmed}' is missing a host")))?;
    let clean_path = parsed
        .path()
        .trim_start_matches('/')
        .trim_end_matches('/')
        .trim_end_matches(".git");
    if clean_path
        .split('/')
        .filter(|part| !part.is_empty())
        .count()
        < 2
    {
        return Err(Error::Invalid(format!(
            "repository_url '{trimmed}' must include owner and repository name"
        )));
    }
    Ok(format!("{scheme}://{host}/{clean_path}"))
}

/// Extract `(owner, repo)` from a normalized `repository_url`.
pub fn parse_github_owner_repo(repository_url: &str) -> Result<(String, String)> {
    let normalized = normalize_repository_url(repository_url)?;
    let parsed = Url::parse(&normalized)
        .map_err(|err| Error::Invalid(format!("invalid repository_url '{normalized}': {err}")))?;
    let mut segments = parsed
        .path_segments()
        .ok_or_else(|| Error::Invalid(format!("invalid repository_url '{normalized}'")))?
        .filter(|segment| !segment.is_empty());
    let owner = segments
        .next()
        .ok_or_else(|| Error::Invalid(format!("missing owner in '{normalized}'")))?
        .to_owned();
    let repo = segments
        .next()
        .ok_or_else(|| Error::Invalid(format!("missing repository name in '{normalized}'")))?
        .to_owned();
    Ok((owner, repo))
}

/// Parse an `--as` agent descriptor (`provider/model` or `model`) into `(name, provider, model)`.
pub fn parse_agent_descriptor(raw: &str) -> Result<(String, String, String)> {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        return Err(Error::Invalid(
            "agent identifier must not be empty".to_owned(),
        ));
    }
    if let Some((provider, model)) = trimmed.split_once('/') {
        let provider = provider.trim();
        let model = model.trim();
        if provider.is_empty() || model.is_empty() {
            return Err(Error::Invalid(format!(
                "invalid agent identifier '{trimmed}'; expected '<provider>/<model>' or '<model>'"
            )));
        }
        return Ok((model.to_owned(), provider.to_owned(), model.to_owned()));
    }

    let lower = trimmed.to_ascii_lowercase();
    let provider = if lower.starts_with("claude") || lower.contains("anthropic") {
        "anthropic"
    } else if lower.starts_with("gpt") || lower.starts_with("o1") || lower.starts_with("o3") {
        "openai"
    } else if lower.starts_with("gemini") {
        "google"
    } else {
        "custom"
    };
    Ok((trimmed.to_owned(), provider.to_owned(), trimmed.to_owned()))
}

fn resolve_gitdir_file(dot_git_file: &Path) -> Result<PathBuf> {
    let contents = std::fs::read_to_string(dot_git_file).map_err(|err| {
        Error::Invalid(format!(
            "failed to read .git file '{}': {err}",
            dot_git_file.display()
        ))
    })?;
    let raw_target = contents
        .trim()
        .strip_prefix("gitdir:")
        .ok_or_else(|| Error::Invalid(format!("malformed .git file '{}'", dot_git_file.display())))?
        .trim();
    let target_path = Path::new(raw_target);
    let resolved = if target_path.is_absolute() {
        target_path.to_path_buf()
    } else {
        dot_git_file
            .parent()
            .unwrap_or_else(|| Path::new("."))
            .join(target_path)
    };
    std::fs::canonicalize(&resolved).map_err(|err| {
        Error::Invalid(format!(
            "failed to canonicalize gitdir target '{}': {err}",
            resolved.display()
        ))
    })
}