1use anyhow::Result;
5use std::path::{Component, Path, PathBuf};
6
7pub fn kaizen_dir() -> Option<PathBuf> {
9 let configured = 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 configured.map(|path| absolute(&path))
18}
19
20pub fn workspace_slug(path: &Path) -> String {
24 path.to_string_lossy()
25 .trim_start_matches('/')
26 .replace('/', "-")
27}
28
29pub fn cursor_slug(path: &Path) -> String {
34 path.to_string_lossy()
35 .trim_start_matches('/')
36 .replace(['/', '.'], "-")
37}
38
39pub fn claude_code_slug(path: &Path) -> String {
44 let s = path.to_string_lossy();
45 let with_leading = if let Some(rest) = s.strip_prefix('/') {
46 format!("-{rest}")
47 } else {
48 s.into_owned()
49 };
50 with_leading.replace(['/', '.'], "-")
51}
52
53pub fn project_data_path(workspace: &Path) -> Result<PathBuf> {
55 let home = crate::core::home_paths::root(workspace)?;
56 let canon = std::fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
57 let slug = workspace_slug(&canon);
58 let data = home.join("projects").join(slug);
59 ensure_project_data_outside_workspace(&data, &canon)?;
60 Ok(data)
61}
62
63fn ensure_project_data_outside_workspace(data: &Path, workspace: &Path) -> Result<()> {
64 ensure_outside_workspace(data, workspace, "Kaizen project data")
65}
66
67fn ensure_outside_workspace(path: &Path, workspace: &Path, label: &str) -> Result<()> {
68 anyhow::ensure!(
69 !path_is_within(path, workspace),
70 "{label} must be outside target repository"
71 );
72 Ok(())
73}
74
75pub(crate) fn path_is_within(path: &Path, root: &Path) -> bool {
76 let root = canonical(root);
77 path.starts_with(&root)
78 || path
79 .ancestors()
80 .find_map(|ancestor| ancestor.canonicalize().ok())
81 .is_some_and(|ancestor| ancestor.starts_with(root))
82}
83
84pub fn project_data_dir(workspace: &Path) -> Result<PathBuf> {
86 let dir = project_data_path(workspace)?;
87 std::fs::create_dir_all(&dir)?;
88 ensure_project_data_outside_workspace(&dir, &canonical(workspace))?;
89 Ok(dir)
90}
91
92pub fn project_data_child(workspace: &Path, relative: &Path) -> Result<PathBuf> {
94 descendant_path(&project_data_path(workspace)?, relative)
95}
96
97pub fn project_dir_for_write(workspace: &Path, relative: &Path) -> Result<PathBuf> {
99 descendant_dir_for_write(&project_data_dir(workspace)?, relative)
100}
101
102pub fn project_file_for_write(workspace: &Path, relative: &Path) -> Result<PathBuf> {
104 descendant_file_for_write(&project_data_dir(workspace)?, relative)
105}
106
107pub fn descendant_path(root: &Path, relative: &Path) -> Result<PathBuf> {
108 ensure_relative(relative)?;
109 let path = root.join(relative);
110 ensure_no_symlinks(root, &path)?;
111 Ok(path)
112}
113
114pub fn descendant_dir_for_write(root: &Path, relative: &Path) -> Result<PathBuf> {
115 let path = descendant_path(root, relative)?;
116 std::fs::create_dir_all(&path)?;
117 ensure_no_symlinks(root, &path)?;
118 Ok(path)
119}
120
121pub fn descendant_file_for_write(root: &Path, relative: &Path) -> Result<PathBuf> {
122 let path = descendant_path(root, relative)?;
123 let parent = path
124 .parent()
125 .ok_or_else(|| anyhow::anyhow!("file path has no parent"))?;
126 let relative_parent = parent.strip_prefix(root)?;
127 if !relative_parent.as_os_str().is_empty() {
128 descendant_dir_for_write(root, relative_parent)?;
129 }
130 ensure_no_symlinks(root, &path)?;
131 Ok(path)
132}
133
134fn ensure_relative(path: &Path) -> Result<()> {
135 let invalid = path
136 .components()
137 .any(|part| !matches!(part, Component::Normal(_)));
138 anyhow::ensure!(
139 !path.as_os_str().is_empty() && !invalid,
140 "project path must be relative without traversal"
141 );
142 Ok(())
143}
144
145fn ensure_no_symlinks(root: &Path, path: &Path) -> Result<()> {
146 let relative = path.strip_prefix(root)?;
147 let mut current = root.to_path_buf();
148 for component in relative.components() {
149 current.push(component);
150 validate_component(¤t)?;
151 }
152 Ok(())
153}
154
155fn validate_component(path: &Path) -> Result<()> {
156 match std::fs::symlink_metadata(path) {
157 Ok(metadata) => {
158 anyhow::ensure!(
159 !metadata.file_type().is_symlink(),
160 "project data rejects symlink: {}",
161 path.display()
162 );
163 crate::core::safe_fs::reject_hardlink(path)?;
164 }
165 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
166 Err(error) => return Err(error.into()),
167 }
168 Ok(())
169}
170
171pub fn canonical(path: &Path) -> PathBuf {
172 std::fs::canonicalize(path).unwrap_or_else(|_| absolute(path))
173}
174
175fn absolute(path: &Path) -> PathBuf {
176 if path.is_absolute() {
177 return path.to_path_buf();
178 }
179 std::env::current_dir()
180 .map(|cwd| cwd.join(path))
181 .unwrap_or_else(|_| path.to_path_buf())
182}
183
184#[cfg(test)]
185pub(crate) mod test_lock {
186 use std::sync::{Mutex, OnceLock};
187
188 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
189
190 pub fn global() -> &'static Mutex<()> {
191 LOCK.get_or_init(|| Mutex::new(()))
192 }
193}