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
19pub const MANIFEST_FILENAME: &str = ".loom.json";
21
22pub 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
39pub 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
64pub 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 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}