use std::path::Path;
use schemars::JsonSchema;
use serde::Serialize;
use sha2::{Digest, Sha256};
use crate::treesitter::finalize_hex;
pub fn hash_repo_path(path: &Path) -> String {
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let mut hasher = Sha256::new();
hasher.update(canonical.to_string_lossy().as_bytes());
finalize_hex(hasher)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum RefPattern {
#[allow(dead_code)] Worktree,
SingleCommit,
RangeDoubleDot,
RangeTripleDot,
Branch,
Sha,
}
impl RefPattern {
pub fn as_str(&self) -> &'static str {
match self {
RefPattern::Worktree => "worktree",
RefPattern::SingleCommit => "single_commit",
RefPattern::RangeDoubleDot => "range_double_dot",
RefPattern::RangeTripleDot => "range_triple_dot",
RefPattern::Branch => "branch",
RefPattern::Sha => "sha",
}
}
}
pub fn normalize_ref_pattern(ref_str: &str) -> RefPattern {
if ref_str.contains("...") {
return RefPattern::RangeTripleDot;
}
if ref_str.contains("..") {
return RefPattern::RangeDoubleDot;
}
if is_hex_sha(ref_str) {
return RefPattern::Sha;
}
if ref_str.contains('~') || ref_str.contains('^') {
return RefPattern::SingleCommit;
}
if ref_str == "HEAD" {
return RefPattern::SingleCommit;
}
RefPattern::Branch
}
fn is_hex_sha(s: &str) -> bool {
let len = s.len();
(len == 40 || len >= 12) && s.chars().all(|c| c.is_ascii_hexdigit())
}
pub fn classify_ref_mode(base_ref: &str, head_ref: Option<&str>) -> &'static str {
match head_ref {
None => "worktree",
Some(_) => normalize_ref_pattern(base_ref).as_str(),
}
}
pub fn classify_truncation_reason(reason: &str) -> &'static str {
match reason {
"paginated" => "paginated",
"max_files" => "max_files",
"max_file_size" => "max_file_size",
"token_budget" => "token_budget",
_ => "unknown",
}
}
pub fn classify_error_kind(err: &str) -> &'static str {
let lower = err.to_lowercase();
if lower.contains("resolve") || lower.contains("ref not found") || lower.contains("invalid ref")
{
"ref_not_found"
} else if lower.contains("repository")
|| lower.contains("repo not found")
|| lower.contains("not a git")
|| lower.contains("open repo")
{
"repo_not_found"
} else if lower.contains("diff") {
"diff_failed"
} else if lower.contains("parse") || lower.contains("tree-sitter") {
"parse_failed"
} else if lower.contains("i/o") || lower.contains("io error") || lower.contains("permission") {
"io_error"
} else {
"unknown"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_repo_path_deterministic() {
let path = Path::new("/tmp/some-repo");
let hash1 = hash_repo_path(path);
let hash2 = hash_repo_path(path);
assert_eq!(hash1, hash2);
assert_eq!(hash1.len(), 64);
}
#[test]
fn test_hash_repo_path_different_paths_differ() {
let hash1 = hash_repo_path(Path::new("/tmp/repo-a"));
let hash2 = hash_repo_path(Path::new("/tmp/repo-b"));
assert_ne!(hash1, hash2);
}
#[test]
fn test_normalize_double_dot_range() {
assert_eq!(
normalize_ref_pattern("main..HEAD"),
RefPattern::RangeDoubleDot
);
}
#[test]
fn test_normalize_triple_dot_range() {
assert_eq!(
normalize_ref_pattern("main...HEAD"),
RefPattern::RangeTripleDot
);
}
#[test]
fn test_normalize_sha() {
let sha40 = "a".repeat(40);
assert_eq!(normalize_ref_pattern(&sha40), RefPattern::Sha);
}
#[test]
fn test_normalize_short_sha() {
assert_eq!(normalize_ref_pattern("abc1234def56"), RefPattern::Sha);
assert_eq!(normalize_ref_pattern("abc1234"), RefPattern::Branch);
assert_eq!(normalize_ref_pattern("deadbeef"), RefPattern::Branch);
}
#[test]
fn test_normalize_head() {
assert_eq!(normalize_ref_pattern("HEAD"), RefPattern::SingleCommit);
}
#[test]
fn test_normalize_head_tilde() {
assert_eq!(normalize_ref_pattern("HEAD~3"), RefPattern::SingleCommit);
}
#[test]
fn test_normalize_head_caret() {
assert_eq!(normalize_ref_pattern("HEAD^2"), RefPattern::SingleCommit);
}
#[test]
fn test_normalize_branch() {
assert_eq!(normalize_ref_pattern("main"), RefPattern::Branch);
assert_eq!(normalize_ref_pattern("feature/foo"), RefPattern::Branch);
}
#[test]
fn test_classify_error_ref_not_found() {
assert_eq!(
classify_error_kind("could not resolve ref"),
"ref_not_found"
);
assert_eq!(classify_error_kind("ref not found"), "ref_not_found");
}
#[test]
fn test_classify_error_unknown() {
assert_eq!(classify_error_kind("something went wrong"), "unknown");
assert_eq!(classify_error_kind("totally random"), "unknown");
}
#[test]
fn test_normalize_ref_pattern_edge_cases() {
assert_eq!(normalize_ref_pattern(""), RefPattern::Branch);
assert_eq!(normalize_ref_pattern("refs/heads/main"), RefPattern::Branch);
assert_eq!(normalize_ref_pattern("origin/main"), RefPattern::Branch);
assert_eq!(normalize_ref_pattern("v1.0.0"), RefPattern::Branch);
}
#[test]
fn test_classify_error_no_false_positives() {
assert_eq!(classify_error_kind("refactoring in progress"), "unknown");
assert_eq!(classify_error_kind("could not reproduce"), "unknown");
}
#[test]
fn it_classifies_resolve_alone_as_ref_not_found() {
assert_eq!(classify_error_kind("could not resolve"), "ref_not_found");
}
#[test]
fn it_classifies_ref_not_found_alone() {
assert_eq!(
classify_error_kind("ref not found in repo"),
"ref_not_found"
);
}
#[test]
fn it_classifies_invalid_ref_alone() {
assert_eq!(
classify_error_kind("invalid ref specified"),
"ref_not_found"
);
}
#[test]
fn it_classifies_repository_alone_as_repo_not_found() {
assert_eq!(classify_error_kind("bad repository path"), "repo_not_found");
}
#[test]
fn it_classifies_repo_not_found_alone() {
assert_eq!(classify_error_kind("repo not found here"), "repo_not_found");
}
#[test]
fn it_classifies_not_a_git_alone() {
assert_eq!(classify_error_kind("not a git directory"), "repo_not_found");
}
#[test]
fn it_classifies_open_repo_alone() {
assert_eq!(classify_error_kind("failed to open repo"), "repo_not_found");
}
#[test]
fn it_classifies_parse_alone_as_parse_failed() {
assert_eq!(classify_error_kind("failed to parse file"), "parse_failed");
}
#[test]
fn it_classifies_tree_sitter_alone_as_parse_failed() {
assert_eq!(
classify_error_kind("tree-sitter error occurred"),
"parse_failed"
);
}
#[test]
fn it_classifies_io_slash_alone_as_io_error() {
assert_eq!(classify_error_kind("an i/o failure"), "io_error");
}
#[test]
fn it_classifies_io_error_alone() {
assert_eq!(classify_error_kind("io error on read"), "io_error");
}
#[test]
fn it_classifies_permission_alone_as_io_error() {
assert_eq!(classify_error_kind("permission denied"), "io_error");
}
#[test]
fn test_ref_pattern_serializes_to_expected_strings() {
let all_variants = [
RefPattern::Worktree,
RefPattern::SingleCommit,
RefPattern::RangeDoubleDot,
RefPattern::RangeTripleDot,
RefPattern::Branch,
RefPattern::Sha,
];
let expected = [
"worktree",
"single_commit",
"range_double_dot",
"range_triple_dot",
"branch",
"sha",
];
for (variant, exp) in all_variants.iter().zip(expected.iter()) {
let json = serde_json::to_string(variant).unwrap();
assert_eq!(json, format!("\"{}\"", exp));
assert_eq!(variant.as_str(), *exp);
}
}
#[test]
fn test_classify_ref_mode_worktree() {
assert_eq!(classify_ref_mode("HEAD", None), "worktree");
}
#[test]
fn test_classify_ref_mode_with_head_ref() {
assert_eq!(classify_ref_mode("main", Some("HEAD")), "branch");
assert_eq!(classify_ref_mode("HEAD~3", Some("HEAD")), "single_commit");
assert_eq!(classify_ref_mode(&"a".repeat(40), Some("HEAD")), "sha");
}
#[test]
fn it_classifies_paginated_as_paginated() {
assert_eq!(classify_truncation_reason("paginated"), "paginated");
}
#[test]
fn it_classifies_max_files_as_max_files() {
assert_eq!(classify_truncation_reason("max_files"), "max_files");
}
#[test]
fn it_classifies_max_file_size_as_max_file_size() {
assert_eq!(classify_truncation_reason("max_file_size"), "max_file_size");
}
#[test]
fn it_classifies_token_budget_as_token_budget() {
assert_eq!(classify_truncation_reason("token_budget"), "token_budget");
}
#[test]
fn it_classifies_unknown_reason_as_unknown() {
assert_eq!(classify_truncation_reason("TOKEN_BUDGET"), "unknown");
assert_eq!(classify_truncation_reason("something_else"), "unknown");
assert_eq!(classify_truncation_reason(""), "unknown");
}
}