Skip to main content

gitcortex_store/
branch.rs

1use std::{
2    fs,
3    hash::{DefaultHasher, Hash, Hasher},
4    path::{Path, PathBuf},
5};
6
7use gitcortex_core::error::{GitCortexError, Result};
8
9// ── Branch name sanitization ──────────────────────────────────────────────────
10
11/// Sanitize a branch name so it can be used as a KuzuDB table name prefix.
12///
13/// Rules applied (in order):
14/// - `/`  → `__`  (preserves branch hierarchy visibility)
15/// - any remaining non-alphanumeric char → `_`
16/// - leading digit → prefix with `b_` (table names can't start with a digit)
17///
18/// Examples:
19/// - `main`           → `main`
20/// - `feat/auth`      → `feat__auth`
21/// - `feat/auth-v2`   → `feat__auth_v2`
22/// - `release/v1.0`   → `release__v1_0`
23pub fn sanitize(branch: &str) -> String {
24    let expanded = branch.replace('/', "__");
25    let mut s: String = expanded
26        .chars()
27        .map(|c| {
28            if c.is_alphanumeric() || c == '_' {
29                c
30            } else {
31                '_'
32            }
33        })
34        .collect();
35
36    if s.starts_with(|c: char| c.is_ascii_digit()) {
37        s.insert_str(0, "b_");
38    }
39    s
40}
41
42// ── Repository identity ───────────────────────────────────────────────────────
43
44/// Derive a stable 16-hex-char ID from the absolute repo root path.
45/// Used to namespace per-repo data directories without path encoding issues.
46pub fn repo_id(repo_root: &Path) -> String {
47    let mut hasher = DefaultHasher::new();
48    repo_root.to_string_lossy().hash(&mut hasher);
49    format!("{:016x}", hasher.finish())
50}
51
52// ── XDG data paths ────────────────────────────────────────────────────────────
53
54/// Root data directory for a repo: `$XDG_DATA_HOME/gitcortex/{repo_id}/`
55pub fn data_dir(repo_id: &str) -> PathBuf {
56    let base = std::env::var("XDG_DATA_HOME")
57        .map(PathBuf::from)
58        .unwrap_or_else(|_| home_dir().join(".local/share"));
59    base.join("gitcortex").join(repo_id)
60}
61
62fn home_dir() -> PathBuf {
63    std::env::var("HOME")
64        .map(PathBuf::from)
65        .unwrap_or_else(|_| PathBuf::from("."))
66}
67
68/// Path to the single KuzuDB file for a repo (all branches, namespaced by table prefix).
69pub fn db_path(repo_id: &str) -> PathBuf {
70    data_dir(repo_id).join("graph.kuzu")
71}
72
73/// Path to the last-indexed SHA file for a specific branch.
74pub fn last_sha_path(repo_id: &str, branch: &str) -> PathBuf {
75    data_dir(repo_id).join(format!("{}.sha", sanitize(branch)))
76}
77
78// ── last_sha persistence ──────────────────────────────────────────────────────
79
80pub fn read_last_sha(repo_id: &str, branch: &str) -> Result<Option<String>> {
81    let path = last_sha_path(repo_id, branch);
82    match fs::read_to_string(&path) {
83        Ok(s) => Ok(Some(s.trim().to_owned())),
84        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
85        Err(e) => Err(GitCortexError::Io(e)),
86    }
87}
88
89pub fn write_last_sha(repo_id: &str, branch: &str, sha: &str) -> Result<()> {
90    let path = last_sha_path(repo_id, branch);
91    if let Some(parent) = path.parent() {
92        fs::create_dir_all(parent)?;
93    }
94    fs::write(&path, sha).map_err(GitCortexError::Io)
95}
96
97// ── Tests ─────────────────────────────────────────────────────────────────────
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn sanitize_plain() {
105        assert_eq!(sanitize("main"), "main");
106    }
107
108    #[test]
109    fn sanitize_slash_becomes_double_underscore() {
110        assert_eq!(sanitize("feat/auth"), "feat__auth");
111    }
112
113    #[test]
114    fn sanitize_dash_and_dot() {
115        assert_eq!(sanitize("release/v1.0-rc"), "release__v1_0_rc");
116    }
117
118    #[test]
119    fn sanitize_leading_digit() {
120        assert_eq!(sanitize("1-hotfix"), "b_1_hotfix");
121    }
122
123    #[test]
124    fn repo_id_is_stable() {
125        let path = Path::new("/home/user/myproject");
126        assert_eq!(repo_id(path), repo_id(path));
127    }
128
129    #[test]
130    fn repo_id_differs_across_paths() {
131        let a = repo_id(Path::new("/home/user/proj-a"));
132        let b = repo_id(Path::new("/home/user/proj-b"));
133        assert_ne!(a, b);
134    }
135}