Skip to main content

kaizen/core/
workspace.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! Workspace path canonicalization + machine-local registry.
3
4use 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
15/// Resolve an existing workspace without creating registry or project state.
16pub 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(|p| {
58        if seed.as_ref() == Some(p) {
59            return true;
60        }
61        p.exists()
62            && (db_path(p).ok().is_some_and(|d| d.exists())
63                || crate::core::machine_registry::is_registered(p))
64    });
65    if roots.is_empty()
66        && let Some(path) = seed
67    {
68        roots.push(path);
69    }
70    Ok(roots)
71}
72
73pub fn db_path(workspace: &Path) -> Result<PathBuf> {
74    let path = crate::core::paths::project_data_child(workspace, Path::new("kaizen.db"))?;
75    ["kaizen.db-journal", "kaizen.db-wal", "kaizen.db-shm"]
76        .into_iter()
77        .try_for_each(|name| {
78            crate::core::paths::project_data_child(workspace, Path::new(name)).map(drop)
79        })?;
80    Ok(path)
81}
82
83fn registry_entries() -> Result<Vec<PathBuf>> {
84    crate::core::machine_registry::list_paths()
85}
86
87fn push_unique(roots: &mut Vec<PathBuf>, path: PathBuf) {
88    if !roots.iter().any(|row| row == &path) {
89        roots.push(path);
90    }
91}
92
93fn slug_match(paths: &[PathBuf], name: &str) -> Vec<PathBuf> {
94    paths
95        .iter()
96        .filter(|p| crate::core::paths::workspace_slug(p) == name)
97        .cloned()
98        .collect()
99}
100
101fn seg_match(paths: &[PathBuf], name: &str) -> Vec<PathBuf> {
102    paths
103        .iter()
104        .filter(|p| p.file_name().and_then(|n| n.to_str()) == Some(name))
105        .cloned()
106        .collect()
107}
108
109fn ambiguous_error(name: &str, matches: &[PathBuf]) -> anyhow::Error {
110    let list = matches
111        .iter()
112        .map(|p| format!("  {}", p.display()))
113        .collect::<Vec<_>>()
114        .join("\n");
115    anyhow::anyhow!(
116        "ambiguous project '{name}'. matches:\n{list}\nuse --workspace <path> or the slug."
117    )
118}
119
120/// Resolve a short project name to its registered workspace path.
121///
122/// Resolution order:
123/// 1. Exact slug match (`workspace_slug(path) == name`)
124/// 2. Last path segment match (`path.file_name() == name`)
125///
126/// Returns `Err` on zero matches (unknown) or multiple matches (ambiguous).
127pub fn resolve_project_name(name: &str) -> Result<PathBuf> {
128    let paths = crate::core::machine_registry::list_paths()?;
129    let slugs = slug_match(&paths, name);
130    if slugs.len() == 1 {
131        return Ok(slugs.into_iter().next().unwrap());
132    }
133    let segs = seg_match(&paths, name);
134    match segs.len() {
135        1 => Ok(segs.into_iter().next().unwrap()),
136        0 => anyhow::bail!(
137            "unknown project '{name}'. run 'kaizen projects' to see registered projects."
138        ),
139        _ => Err(ambiguous_error(name, &segs)),
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::core::paths::test_lock;
147    use std::path::Path;
148    use tempfile::TempDir;
149
150    fn with_home<T>(test: impl FnOnce(&Path) -> T) -> T {
151        let _guard = test_lock::global().lock().unwrap();
152        let home = TempDir::new().unwrap();
153        unsafe { std::env::set_var("KAIZEN_HOME", home.path().join(".kaizen")) };
154        let result = test(home.path());
155        unsafe { std::env::remove_var("KAIZEN_HOME") };
156        result
157    }
158
159    #[test]
160    fn registry_round_trip() {
161        with_home(|home| {
162            let ws = home.join("repo");
163            std::fs::create_dir_all(&ws).unwrap();
164            let first = resolve(Some(&ws)).unwrap();
165            assert_eq!(first, std::fs::canonicalize(ws).unwrap());
166            assert!(crate::core::machine_registry::is_registered(&first));
167        });
168    }
169
170    #[test]
171    fn resolve_rejects_non_directory_without_state() {
172        with_home(|home| {
173            let file = home.join("workspace-file");
174            std::fs::write(&file, "not a directory").unwrap();
175            let error = resolve(Some(&file)).unwrap_err().to_string();
176            assert!(error.contains("workspace is not a directory"), "{error}");
177            assert!(!home.join(".kaizen").exists());
178        });
179    }
180
181    #[cfg(unix)]
182    #[test]
183    fn resolve_preserves_non_missing_canonicalize_error() {
184        with_home(|home| {
185            let loop_path = home.join("loop");
186            std::os::unix::fs::symlink(&loop_path, &loop_path).unwrap();
187            let error = resolve(Some(&loop_path)).unwrap_err().to_string();
188            assert!(error.contains("canonicalize workspace"), "{error}");
189            assert!(!error.contains("does not exist"), "{error}");
190            assert!(!home.join(".kaizen").exists());
191        });
192    }
193
194    #[test]
195    fn resolve_project_name_no_match() {
196        with_home(|_| {
197            let err = resolve_project_name("nonexistent").unwrap_err();
198            assert!(err.to_string().contains("unknown project"));
199        });
200    }
201}