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 root = path
11        .map(Path::to_path_buf)
12        .map(Ok)
13        .unwrap_or_else(std::env::current_dir)?;
14    let canonical = canonical(&root);
15    let _ = crate::core::machine_registry::upsert_from_resolve(&canonical);
16    if let Ok(data_dir) = project_data_dir(&canonical)
17        && let Err(e) = crate::core::migrate_home::migrate_legacy_in_repo(&canonical, &data_dir)
18    {
19        tracing::warn!("legacy migration failed: {e}");
20    }
21    Ok(canonical)
22}
23
24pub fn machine_workspaces(seed: Option<&Path>) -> Result<Vec<PathBuf>> {
25    let seed = seed.map(canonical);
26    let mut roots = registry_entries()?;
27    if let Some(path) = seed.as_ref() {
28        push_unique(&mut roots, path.clone());
29    }
30    roots.retain(|p| {
31        if seed.as_ref() == Some(p) {
32            return true;
33        }
34        p.exists()
35            && (db_path(p).ok().is_some_and(|d| d.exists())
36                || crate::core::machine_registry::is_registered(p))
37    });
38    if roots.is_empty()
39        && let Some(path) = seed
40    {
41        roots.push(path);
42    }
43    Ok(roots)
44}
45
46pub fn db_path(workspace: &Path) -> Result<PathBuf> {
47    Ok(project_data_dir(workspace)?.join("kaizen.db"))
48}
49
50fn registry_entries() -> Result<Vec<PathBuf>> {
51    crate::core::machine_registry::list_paths()
52}
53
54fn push_unique(roots: &mut Vec<PathBuf>, path: PathBuf) {
55    if !roots.iter().any(|row| row == &path) {
56        roots.push(path);
57    }
58}
59
60fn slug_match(paths: &[PathBuf], name: &str) -> Vec<PathBuf> {
61    paths
62        .iter()
63        .filter(|p| crate::core::paths::workspace_slug(p) == name)
64        .cloned()
65        .collect()
66}
67
68fn seg_match(paths: &[PathBuf], name: &str) -> Vec<PathBuf> {
69    paths
70        .iter()
71        .filter(|p| p.file_name().and_then(|n| n.to_str()) == Some(name))
72        .cloned()
73        .collect()
74}
75
76fn ambiguous_error(name: &str, matches: &[PathBuf]) -> anyhow::Error {
77    let list = matches
78        .iter()
79        .map(|p| format!("  {}", p.display()))
80        .collect::<Vec<_>>()
81        .join("\n");
82    anyhow::anyhow!(
83        "ambiguous project '{name}'. matches:\n{list}\nuse --workspace <path> or the slug."
84    )
85}
86
87/// Resolve a short project name to its registered workspace path.
88///
89/// Resolution order:
90/// 1. Exact slug match (`workspace_slug(path) == name`)
91/// 2. Last path segment match (`path.file_name() == name`)
92///
93/// Returns `Err` on zero matches (unknown) or multiple matches (ambiguous).
94pub fn resolve_project_name(name: &str) -> Result<PathBuf> {
95    let paths = crate::core::machine_registry::list_paths()?;
96    let slugs = slug_match(&paths, name);
97    if slugs.len() == 1 {
98        return Ok(slugs.into_iter().next().unwrap());
99    }
100    let segs = seg_match(&paths, name);
101    match segs.len() {
102        1 => Ok(segs.into_iter().next().unwrap()),
103        0 => anyhow::bail!(
104            "unknown project '{name}'. run 'kaizen projects list' to see registered projects."
105        ),
106        _ => Err(ambiguous_error(name, &segs)),
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::core::paths::test_lock;
114    use tempfile::TempDir;
115
116    #[test]
117    fn registry_round_trip() {
118        let _guard = test_lock::global().lock().unwrap();
119        let home = TempDir::new().unwrap();
120        let ws = home.path().join("repo");
121        std::fs::create_dir_all(&ws).unwrap();
122        unsafe { std::env::set_var("KAIZEN_HOME", home.path().join(".kaizen")) };
123        let first = resolve(Some(&ws)).unwrap();
124        let rows = machine_workspaces(Some(&first)).unwrap();
125        assert_eq!(rows, vec![first]);
126        unsafe { std::env::remove_var("KAIZEN_HOME") };
127    }
128
129    #[test]
130    fn resolve_project_name_no_match() {
131        let _guard = test_lock::global().lock().unwrap();
132        let home = TempDir::new().unwrap();
133        unsafe { std::env::set_var("KAIZEN_HOME", home.path().join(".kaizen")) };
134        let err = resolve_project_name("nonexistent").unwrap_err();
135        assert!(err.to_string().contains("unknown project"));
136        unsafe { std::env::remove_var("KAIZEN_HOME") };
137    }
138}