use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use url::Url;
use crate::backend::error::{Error, Result};
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")
}
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))
}
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}"))
}
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}"))
}
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))
}
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()
))
})
}