1use anyhow::Result;
5use std::path::{Path, PathBuf};
6
7pub use crate::core::paths::{canonical, kaizen_dir, project_data_dir};
8
9pub fn resolve(path: Option<&Path>) -> Result<PathBuf> {
10 let canonical = resolve_read(path)?;
11 register_workspace(&canonical);
12 Ok(canonical)
13}
14
15pub fn resolve_read(path: Option<&Path>) -> Result<PathBuf> {
17 let root = path
18 .map(Path::to_path_buf)
19 .map(Ok)
20 .unwrap_or_else(std::env::current_dir)?;
21 canonical_directory(&root)
22}
23
24fn canonical_directory(path: &Path) -> Result<PathBuf> {
25 let canonical = std::fs::canonicalize(path).map_err(|error| canonicalize_error(path, error))?;
26 anyhow::ensure!(
27 canonical.is_dir(),
28 "workspace is not a directory: {}",
29 path.display()
30 );
31 Ok(canonical)
32}
33
34fn canonicalize_error(path: &Path, error: std::io::Error) -> anyhow::Error {
35 if error.kind() == std::io::ErrorKind::NotFound {
36 return anyhow::anyhow!("workspace does not exist: {}", path.display());
37 }
38 anyhow::Error::new(error).context(format!("canonicalize workspace: {}", path.display()))
39}
40
41fn register_workspace(workspace: &Path) {
42 let Ok(data_dir) = project_data_dir(workspace) else {
43 return;
44 };
45 let _ = crate::core::machine_registry::upsert_from_resolve(workspace);
46 if let Err(e) = crate::core::legacy_import::import_legacy(workspace, &data_dir) {
47 tracing::warn!("legacy import failed: {e}");
48 }
49}
50
51pub fn machine_workspaces(seed: Option<&Path>) -> Result<Vec<PathBuf>> {
52 let seed = seed.map(canonical);
53 let mut roots = registry_entries()?;
54 if let Some(path) = seed.as_ref() {
55 push_unique(&mut roots, path.clone());
56 }
57 roots.retain(|path| seed.as_ref() == Some(path) || usable_registered_workspace(path));
58 if roots.is_empty()
59 && let Some(path) = seed
60 {
61 roots.push(path);
62 }
63 Ok(roots)
64}
65
66fn usable_registered_workspace(path: &Path) -> bool {
67 path.exists()
68 && db_path(path)
69 .is_ok_and(|db| db.exists() || crate::core::machine_registry::is_registered(path))
70}
71
72pub fn db_path(workspace: &Path) -> Result<PathBuf> {
73 let path = crate::core::paths::project_data_child(workspace, Path::new("kaizen.db"))?;
74 ["kaizen.db-journal", "kaizen.db-wal", "kaizen.db-shm"]
75 .into_iter()
76 .try_for_each(|name| {
77 crate::core::paths::project_data_child(workspace, Path::new(name)).map(drop)
78 })?;
79 Ok(path)
80}
81
82fn registry_entries() -> Result<Vec<PathBuf>> {
83 crate::core::machine_registry::list_paths()
84}
85
86fn push_unique(roots: &mut Vec<PathBuf>, path: PathBuf) {
87 if !roots.iter().any(|row| row == &path) {
88 roots.push(path);
89 }
90}
91
92fn slug_match(paths: &[PathBuf], name: &str) -> Vec<PathBuf> {
93 paths
94 .iter()
95 .filter(|p| crate::core::paths::workspace_slug(p) == name)
96 .cloned()
97 .collect()
98}
99
100fn seg_match(paths: &[PathBuf], name: &str) -> Vec<PathBuf> {
101 paths
102 .iter()
103 .filter(|p| p.file_name().and_then(|n| n.to_str()) == Some(name))
104 .cloned()
105 .collect()
106}
107
108fn ambiguous_error(name: &str, matches: &[PathBuf]) -> anyhow::Error {
109 let list = matches
110 .iter()
111 .map(|p| format!(" {}", p.display()))
112 .collect::<Vec<_>>()
113 .join("\n");
114 anyhow::anyhow!(
115 "ambiguous project '{name}'. matches:\n{list}\nuse --workspace <path> or the slug."
116 )
117}
118
119pub fn resolve_project_name(name: &str) -> Result<PathBuf> {
127 let paths = crate::core::machine_registry::list_paths()?;
128 let slugs = slug_match(&paths, name);
129 if slugs.len() == 1 {
130 return Ok(slugs.into_iter().next().unwrap());
131 }
132 let segs = seg_match(&paths, name);
133 match segs.len() {
134 1 => Ok(segs.into_iter().next().unwrap()),
135 0 => anyhow::bail!(
136 "unknown project '{name}'. run 'kaizen projects' to see registered projects."
137 ),
138 _ => Err(ambiguous_error(name, &segs)),
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145 use crate::core::paths::test_lock;
146 use std::path::Path;
147 use tempfile::TempDir;
148
149 fn with_home<T>(test: impl FnOnce(&Path) -> T) -> T {
150 let _guard = test_lock::global().lock().unwrap();
151 let home = TempDir::new().unwrap();
152 unsafe { std::env::set_var("KAIZEN_HOME", home.path().join(".kaizen")) };
153 let result = test(home.path());
154 unsafe { std::env::remove_var("KAIZEN_HOME") };
155 result
156 }
157
158 #[test]
159 fn registry_round_trip() {
160 with_home(|home| {
161 let ws = home.join("repo");
162 std::fs::create_dir_all(&ws).unwrap();
163 let first = resolve(Some(&ws)).unwrap();
164 assert_eq!(first, std::fs::canonicalize(ws).unwrap());
165 assert!(crate::core::machine_registry::is_registered(&first));
166 });
167 }
168
169 #[test]
170 fn resolve_rejects_non_directory_without_state() {
171 with_home(|home| {
172 let file = home.join("workspace-file");
173 std::fs::write(&file, "not a directory").unwrap();
174 let error = resolve(Some(&file)).unwrap_err().to_string();
175 assert!(error.contains("workspace is not a directory"), "{error}");
176 assert!(!home.join(".kaizen").exists());
177 });
178 }
179
180 #[cfg(unix)]
181 #[test]
182 fn resolve_preserves_non_missing_canonicalize_error() {
183 with_home(|home| {
184 let loop_path = home.join("loop");
185 std::os::unix::fs::symlink(&loop_path, &loop_path).unwrap();
186 let error = resolve(Some(&loop_path)).unwrap_err().to_string();
187 assert!(error.contains("canonicalize workspace"), "{error}");
188 assert!(!error.contains("does not exist"), "{error}");
189 assert!(!home.join(".kaizen").exists());
190 });
191 }
192
193 #[test]
194 fn resolve_project_name_no_match() {
195 with_home(|_| {
196 let err = resolve_project_name("nonexistent").unwrap_err();
197 assert!(err.to_string().contains("unknown project"));
198 });
199 }
200}