Skip to main content

loom_core/workspace/
mod.rs

1pub mod add;
2pub mod down;
3pub mod editor;
4pub mod exec;
5pub mod list;
6pub mod new;
7pub mod remove;
8pub mod reset;
9pub mod shell;
10pub mod status;
11
12use std::path::{Path, PathBuf};
13
14use anyhow::{Context, Result};
15
16use crate::config::Config;
17use crate::manifest::WorkspaceManifest;
18
19/// The manifest filename placed at the workspace root.
20pub const MANIFEST_FILENAME: &str = ".loom.json";
21
22/// Progress event emitted by workspace operations (new, reset).
23/// The CLI layer converts these into progress bar updates.
24pub enum ProgressEvent {
25    RepoStarted {
26        name: String,
27        index: usize,
28        total: usize,
29    },
30    RepoComplete {
31        name: String,
32    },
33    RepoFailed {
34        name: String,
35        error: String,
36    },
37}
38
39/// Detect if `cwd` (or any ancestor) is inside a loom workspace.
40///
41/// Walks up from `cwd` looking for `.loom.json`. Returns the workspace
42/// root directory and the loaded manifest if found.
43pub fn detect_workspace(cwd: &Path) -> Result<Option<(PathBuf, WorkspaceManifest)>> {
44    let mut current = cwd.to_path_buf();
45    loop {
46        let manifest_path = current.join(MANIFEST_FILENAME);
47        if manifest_path.exists() {
48            let manifest = crate::manifest::read_manifest(&manifest_path).with_context(|| {
49                format!(
50                    "Failed to read workspace manifest at {}",
51                    manifest_path.display()
52                )
53            })?;
54            return Ok(Some((current, manifest)));
55        }
56
57        if !current.pop() {
58            break;
59        }
60    }
61    Ok(None)
62}
63
64/// Resolve a workspace by explicit name or by detecting from cwd.
65///
66/// - If `name` is Some, looks up `config.workspace.root/{name}/.loom.json`
67/// - If `name` is None, detects from `cwd`
68pub fn resolve_workspace(
69    name: Option<&str>,
70    cwd: &Path,
71    config: &Config,
72) -> Result<(PathBuf, WorkspaceManifest)> {
73    match name {
74        Some(name) => {
75            let ws_path = config.workspace.root.join(name);
76            let manifest_path = ws_path.join(MANIFEST_FILENAME);
77            if !manifest_path.exists() {
78                anyhow::bail!("Workspace '{}' not found at {}", name, ws_path.display());
79            }
80            let manifest = crate::manifest::read_manifest(&manifest_path)?;
81            Ok((ws_path, manifest))
82        }
83        None => detect_workspace(cwd)?.ok_or_else(|| {
84            anyhow::anyhow!("Not inside a loom workspace. Specify a workspace name or cd into one.")
85        }),
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use crate::manifest::{WorkspaceManifest, write_manifest};
93    use std::collections::BTreeMap;
94
95    fn create_test_workspace(root: &Path, name: &str) -> PathBuf {
96        let ws_path = root.join(name);
97        std::fs::create_dir_all(&ws_path).unwrap();
98
99        let manifest = WorkspaceManifest {
100            name: name.to_string(),
101            branch: None,
102            created: chrono::Utc::now(),
103            base_branch: None,
104            preset: None,
105            repos: vec![],
106        };
107
108        write_manifest(&ws_path.join(MANIFEST_FILENAME), &manifest).unwrap();
109        ws_path
110    }
111
112    #[test]
113    fn test_detect_workspace_at_root() {
114        let dir = tempfile::tempdir().unwrap();
115        let ws_path = create_test_workspace(dir.path(), "test-ws");
116
117        let result = detect_workspace(&ws_path).unwrap();
118        assert!(result.is_some());
119        let (path, manifest) = result.unwrap();
120        assert_eq!(path, ws_path);
121        assert_eq!(manifest.name, "test-ws");
122    }
123
124    #[test]
125    fn test_detect_workspace_from_subdirectory() {
126        let dir = tempfile::tempdir().unwrap();
127        let ws_path = create_test_workspace(dir.path(), "test-ws");
128
129        // Create a subdirectory
130        let sub = ws_path.join("some").join("nested").join("dir");
131        std::fs::create_dir_all(&sub).unwrap();
132
133        let result = detect_workspace(&sub).unwrap();
134        assert!(result.is_some());
135        let (path, _) = result.unwrap();
136        assert_eq!(path, ws_path);
137    }
138
139    #[test]
140    fn test_detect_workspace_not_found() {
141        let dir = tempfile::tempdir().unwrap();
142        let result = detect_workspace(dir.path()).unwrap();
143        assert!(result.is_none());
144    }
145
146    #[test]
147    fn test_resolve_workspace_by_name() {
148        let dir = tempfile::tempdir().unwrap();
149        let ws_root = dir.path().join("workspaces");
150        create_test_workspace(&ws_root, "my-feature");
151
152        let config = Config {
153            registry: crate::config::RegistryConfig {
154                scan_roots: vec![],
155                scan_depth: 2,
156            },
157            workspace: crate::config::WorkspaceConfig { root: ws_root },
158            sync: None,
159            terminal: None,
160            editor: None,
161            defaults: crate::config::DefaultsConfig::default(),
162            groups: BTreeMap::new(),
163            repos: BTreeMap::new(),
164            specs: None,
165            agents: crate::config::AgentsConfig::default(),
166            update: crate::config::UpdateConfig::default(),
167        };
168
169        let (path, manifest) = resolve_workspace(Some("my-feature"), dir.path(), &config).unwrap();
170        assert!(path.ends_with("my-feature"));
171        assert_eq!(manifest.name, "my-feature");
172    }
173
174    #[test]
175    fn test_resolve_workspace_by_name_not_found() {
176        let dir = tempfile::tempdir().unwrap();
177
178        let config = Config {
179            registry: crate::config::RegistryConfig {
180                scan_roots: vec![],
181                scan_depth: 2,
182            },
183            workspace: crate::config::WorkspaceConfig {
184                root: dir.path().to_path_buf(),
185            },
186            sync: None,
187            terminal: None,
188            editor: None,
189            defaults: crate::config::DefaultsConfig::default(),
190            groups: BTreeMap::new(),
191            repos: BTreeMap::new(),
192            specs: None,
193            agents: crate::config::AgentsConfig::default(),
194            update: crate::config::UpdateConfig::default(),
195        };
196
197        let result = resolve_workspace(Some("nonexistent"), dir.path(), &config);
198        assert!(result.is_err());
199        assert!(result.unwrap_err().to_string().contains("not found"));
200    }
201
202    #[test]
203    fn test_resolve_workspace_from_cwd() {
204        let dir = tempfile::tempdir().unwrap();
205        let ws_path = create_test_workspace(dir.path(), "detected-ws");
206
207        let config = Config {
208            registry: crate::config::RegistryConfig {
209                scan_roots: vec![],
210                scan_depth: 2,
211            },
212            workspace: crate::config::WorkspaceConfig {
213                root: dir.path().to_path_buf(),
214            },
215            sync: None,
216            terminal: None,
217            editor: None,
218            defaults: crate::config::DefaultsConfig::default(),
219            groups: BTreeMap::new(),
220            repos: BTreeMap::new(),
221            specs: None,
222            agents: crate::config::AgentsConfig::default(),
223            update: crate::config::UpdateConfig::default(),
224        };
225
226        let (_, manifest) = resolve_workspace(None, &ws_path, &config).unwrap();
227        assert_eq!(manifest.name, "detected-ws");
228    }
229}