Skip to main content

kaizen/core/
paths.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! Shared path helpers (used by `workspace` and `machine_registry` to avoid import cycles).
3
4use anyhow::Result;
5use std::path::{Path, PathBuf};
6
7/// `KAIZEN_HOME` or `~/.kaizen` (requires `HOME`), or `None` if undiscoverable.
8pub fn kaizen_dir() -> Option<PathBuf> {
9    std::env::var("KAIZEN_HOME")
10        .ok()
11        .map(PathBuf::from)
12        .or_else(|| {
13            std::env::var("HOME")
14                .ok()
15                .map(|home| PathBuf::from(home).join(".kaizen"))
16        })
17}
18
19/// `/Users/lucas/Projects/kaizen` → `Users-lucas-Projects-kaizen`
20///
21/// Used for kaizen's own data dir (`~/.kaizen/projects/<slug>/`).
22pub fn workspace_slug(path: &Path) -> String {
23    path.to_string_lossy()
24        .trim_start_matches('/')
25        .replace('/', "-")
26}
27
28/// Cursor project slug: strips leading `/`, then replaces `/` and `.` with `-`.
29///
30/// Cursor stores transcripts at `~/.cursor/projects/<cursor_slug>/agent-transcripts`.
31/// Example: `/Users/lucas.marques/Projects/kaizen` → `Users-lucas-marques-Projects-kaizen`.
32pub fn cursor_slug(path: &Path) -> String {
33    path.to_string_lossy()
34        .trim_start_matches('/')
35        .replace(['/', '.'], "-")
36}
37
38/// Claude Code project slug: leading `/` becomes `-`, then `/` and `.` → `-`.
39///
40/// Claude Code stores sessions at `~/.claude/projects/<claude_slug>/sessions`.
41/// Example: `/Users/lucas.marques/Projects/kaizen` → `-Users-lucas-marques-Projects-kaizen`.
42pub fn claude_code_slug(path: &Path) -> String {
43    let s = path.to_string_lossy();
44    let with_leading = if let Some(rest) = s.strip_prefix('/') {
45        format!("-{rest}")
46    } else {
47        s.into_owned()
48    };
49    with_leading.replace(['/', '.'], "-")
50}
51
52/// `~/.kaizen/projects/<slug>/` (or `$KAIZEN_HOME/projects/<slug>/`), created on demand.
53pub fn project_data_dir(workspace: &Path) -> Result<PathBuf> {
54    let home = kaizen_dir().ok_or_else(|| anyhow::anyhow!("KAIZEN_HOME / HOME unset"))?;
55    let canon = std::fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
56    let slug = workspace_slug(&canon);
57    let dir = home.join("projects").join(slug);
58    std::fs::create_dir_all(&dir)?;
59    Ok(dir)
60}
61
62pub fn canonical(path: &Path) -> PathBuf {
63    std::fs::canonicalize(path).unwrap_or_else(|_| absolute(path))
64}
65
66fn absolute(path: &Path) -> PathBuf {
67    if path.is_absolute() {
68        return path.to_path_buf();
69    }
70    std::env::current_dir()
71        .map(|cwd| cwd.join(path))
72        .unwrap_or_else(|_| path.to_path_buf())
73}
74
75#[cfg(test)]
76pub(crate) mod test_lock {
77    use std::sync::{Mutex, OnceLock};
78
79    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
80
81    pub fn global() -> &'static Mutex<()> {
82        LOCK.get_or_init(|| Mutex::new(()))
83    }
84}