use blake3;
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct UniqueProjectId {
pub base_name: String,
pub path_hash: String,
pub instance: u32,
}
impl UniqueProjectId {
const HASH_LEN: usize = 8;
#[must_use]
pub fn new(base_name: String, path_hash: String, instance: u32) -> Self {
Self {
base_name,
path_hash,
instance,
}
}
#[must_use]
pub fn generate(project_path: &Path, existing_ids: &[UniqueProjectId]) -> Self {
let base_name = project_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let canonical_path = project_path
.canonicalize()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| project_path.to_string_lossy().to_string());
let path_hash = Self::hash_path(&canonical_path);
let instance = Self::find_next_instance(&base_name, existing_ids);
Self {
base_name,
path_hash,
instance,
}
}
#[must_use]
fn hash_path(path: &str) -> String {
let hash = blake3::hash(path.as_bytes());
hash.to_hex()[..Self::HASH_LEN].to_string()
}
#[must_use]
fn find_next_instance(base_name: &str, existing_ids: &[UniqueProjectId]) -> u32 {
existing_ids
.iter()
.filter(|id| id.base_name == base_name)
.map(|id| id.instance)
.max()
.map(|max| max + 1)
.unwrap_or(0)
}
#[must_use]
pub fn as_unique_string(&self) -> String {
format!("{}_{}_{}", self.base_name, self.path_hash, self.instance)
}
#[must_use]
pub fn display(&self) -> String {
if self.instance == 0 {
self.base_name.clone()
} else {
format!("{} (clone #{})", self.base_name, self.instance)
}
}
#[must_use]
pub fn parse_id(s: &str) -> Option<Self> {
let parts: Vec<&str> = s.rsplitn(3, '_').collect();
if parts.len() != 3 {
return None;
}
let instance = parts[0].parse().ok()?;
let path_hash = parts[1].to_string();
let base_name = parts[2].to_string();
if path_hash.len() != Self::HASH_LEN || !path_hash.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
Some(Self {
base_name,
path_hash,
instance,
})
}
#[must_use]
#[inline(always)]
pub fn from_str_compat(s: &str) -> Option<Self> {
Self::parse_id(s)
}
#[deprecated(since = "1.6.0", note = "Use `parse_id` instead")]
#[must_use]
#[inline(always)]
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Option<Self> {
Self::parse_id(s)
}
#[must_use]
pub fn is_clone(&self) -> bool {
self.instance > 0
}
#[must_use]
pub fn as_unique_id(&self) -> String {
self.to_string()
}
}
impl std::fmt::Display for UniqueProjectId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_unique_string())
}
}
impl From<&UniqueProjectId> for String {
fn from(id: &UniqueProjectId) -> Self {
id.as_unique_string()
}
}
impl From<UniqueProjectId> for String {
fn from(id: UniqueProjectId) -> Self {
id.as_unique_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_creates_valid_id() {
let path = Path::new("/home/user/projects/leindex");
let id = UniqueProjectId::generate(path, &[]);
assert_eq!(id.base_name, "leindex");
assert_eq!(id.path_hash.len(), 8);
assert_eq!(id.instance, 0);
}
#[test]
fn test_hash_path_returns_8_chars() {
let hash = UniqueProjectId::hash_path("/test/path");
assert_eq!(hash.len(), 8);
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_hash_path_is_deterministic() {
let path = "/test/path/to/project";
let hash1 = UniqueProjectId::hash_path(path);
let hash2 = UniqueProjectId::hash_path(path);
assert_eq!(hash1, hash2);
}
#[test]
fn test_hash_path_is_different_for_different_paths() {
let hash1 = UniqueProjectId::hash_path("/path/one");
let hash2 = UniqueProjectId::hash_path("/path/two");
assert_ne!(hash1, hash2);
}
#[test]
fn test_instance_starts_at_zero() {
let path = Path::new("/home/user/projects/leindex");
let id = UniqueProjectId::generate(path, &[]);
assert_eq!(id.instance, 0);
}
#[test]
fn test_instance_increments_for_same_base_name() {
let path1 = Path::new("/home/user/projects/leindex");
let id1 = UniqueProjectId::generate(path1, &[]);
let path2 = Path::new("/different/path/leindex");
let id2 = UniqueProjectId::generate(path2, &[id1.clone()]);
assert_eq!(id1.instance, 0);
assert_eq!(id2.instance, 1);
}
#[test]
fn test_instance_counts_correctly() {
let base_name = "myproject";
let id1 = UniqueProjectId::new(base_name.to_string(), "hash1".to_string(), 0);
let id2 = UniqueProjectId::new(base_name.to_string(), "hash2".to_string(), 1);
let id3 = UniqueProjectId::new(base_name.to_string(), "hash3".to_string(), 2);
let next = UniqueProjectId::find_next_instance(base_name, &[id1, id2, id3]);
assert_eq!(next, 3);
}
#[test]
fn test_to_string_format() {
let id = UniqueProjectId::new("leindex".to_string(), "a3f7d9e2".to_string(), 0);
assert_eq!(id.as_unique_string(), "leindex_a3f7d9e2_0");
}
#[test]
fn test_to_string_with_instance() {
let id = UniqueProjectId::new("leindex".to_string(), "a3f7d9e2".to_string(), 2);
assert_eq!(id.as_unique_string(), "leindex_a3f7d9e2_2");
}
#[test]
fn test_display_original() {
let id = UniqueProjectId::new("leindex".to_string(), "a3f7d9e2".to_string(), 0);
assert_eq!(id.display(), "leindex");
}
#[test]
fn test_display_clone() {
let id = UniqueProjectId::new("leindex".to_string(), "a3f7d9e2".to_string(), 1);
assert_eq!(id.display(), "leindex (clone #1)");
let id2 = UniqueProjectId::new("leindex".to_string(), "a3f7d9e2".to_string(), 5);
assert_eq!(id2.display(), "leindex (clone #5)");
}
#[test]
fn test_parse_id_valid() {
let id = UniqueProjectId::parse_id("leindex_a3f7d9e2_0");
assert!(id.is_some());
let id = id.unwrap();
assert_eq!(id.base_name, "leindex");
assert_eq!(id.path_hash, "a3f7d9e2");
assert_eq!(id.instance, 0);
}
#[test]
fn test_parse_id_invalid_format() {
assert!(UniqueProjectId::parse_id("invalid").is_none());
assert!(UniqueProjectId::parse_id("only_two_parts").is_none());
}
#[test]
fn test_parse_id_invalid_hash() {
assert!(UniqueProjectId::parse_id("leindex_a3f7_0").is_none());
assert!(UniqueProjectId::parse_id("leindex_xyzxyz9_0").is_none());
}
#[test]
fn test_parse_id_roundtrip() {
let original = UniqueProjectId::new("myproject".to_string(), "b4e8f1a3".to_string(), 3);
let s = original.as_unique_string();
let parsed = UniqueProjectId::parse_id(&s).unwrap();
assert_eq!(parsed, original);
}
#[test]
fn test_is_clone() {
let original = UniqueProjectId::new("leindex".to_string(), "hash1".to_string(), 0);
assert!(!original.is_clone());
let clone = UniqueProjectId::new("leindex".to_string(), "hash2".to_string(), 1);
assert!(clone.is_clone());
}
#[test]
fn test_as_unique_id() {
let id = UniqueProjectId::new("test".to_string(), "abcd1234".to_string(), 0);
assert_eq!(id.as_unique_id(), "test_abcd1234_0");
}
#[test]
fn test_display_trait() {
let id = UniqueProjectId::new("leindex".to_string(), "a3f7d9e2".to_string(), 0);
assert_eq!(format!("{}", id), "leindex_a3f7d9e2_0");
}
#[test]
fn test_from_string_trait() {
let id = UniqueProjectId::new("leindex".to_string(), "a3f7d9e2".to_string(), 0);
let s: String = (&id).into();
assert_eq!(s, "leindex_a3f7d9e2_0");
let s2: String = id.clone().into();
assert_eq!(s2, "leindex_a3f7d9e2_0");
}
#[test]
fn test_handle_unicode_directory_name() {
let path = Path::new("/home/user/projects/été");
let id = UniqueProjectId::generate(path, &[]);
assert_eq!(id.base_name, "été");
assert_eq!(id.path_hash.len(), 8);
}
#[test]
fn test_handle_special_characters() {
let path = Path::new("/home/user/projects/my-project_v2.0");
let id = UniqueProjectId::generate(path, &[]);
assert_eq!(id.base_name, "my-project_v2.0");
assert_eq!(id.path_hash.len(), 8);
}
#[test]
fn test_unknown_fallback_for_invalid_path() {
let path = Path::new(".");
let id = UniqueProjectId::generate(path, &[]);
assert!(!id.base_name.is_empty());
}
#[test]
fn test_serialization() {
let id = UniqueProjectId::new("leindex".to_string(), "a3f7d9e2".to_string(), 2);
let json = serde_json::to_string(&id).unwrap();
let deserialized: UniqueProjectId = serde_json::from_str(&json).unwrap();
assert_eq!(id, deserialized);
}
#[test]
fn test_generate_with_conflicting_names() {
let path1 = Path::new("/path/to/project");
let id1 = UniqueProjectId::generate(path1, &[]);
let path2 = Path::new("/different/path/project");
let id2 = UniqueProjectId::generate(path2, &[id1.clone()]);
let path3 = Path::new("/another/path/project");
let id3 = UniqueProjectId::generate(path3, &[id1.clone(), id2.clone()]);
assert_eq!(id1.instance, 0);
assert_eq!(id2.instance, 1);
assert_eq!(id3.instance, 2);
}
}