lgtm-cli 0.5.0

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

use std::path::PathBuf;

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 {
    default_lgtm_home().join("snapshots")
}

/// Resolve the default database URL (`LGTM_DATABASE_URL`, `DATABASE_URL`, or `sqlite://$LGTM_HOME/lgtm.db?mode=rwc`).
pub fn default_database_url() -> Result<String> {
    for var in ["LGTM_DATABASE_URL", "DATABASE_URL"] {
        if let Ok(url) = std::env::var(var)
            && !url.trim().is_empty()
        {
            return Ok(url.trim().to_owned());
        }
    }
    let lgtm_home = default_lgtm_home();
    std::fs::create_dir_all(&lgtm_home).map_err(|err| {
        Error::Invalid(format!(
            "failed to create LGTM home directory '{}': {err}",
            lgtm_home.display()
        ))
    })?;
    let db_path = lgtm_home.join("lgtm.db");
    Ok(format!("sqlite://{}?mode=rwc", db_path.display()))
}

fn default_lgtm_home() -> PathBuf {
    if let Ok(home) = std::env::var("LGTM_HOME")
        && !home.trim().is_empty()
    {
        return PathBuf::from(home.trim());
    }
    dirs::home_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .join(".lgtm")
}

/// 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()))
}