1use std::collections::HashSet;
15use std::path::{Path, PathBuf};
16
17use crate::pep::PyProject;
18use crate::{Error, Result};
19
20#[derive(Debug, Clone)]
22pub struct Workspace {
23 pub root: PathBuf,
25 pub member_patterns: Vec<String>,
27 pub shared_venv: bool,
29 members: Vec<PathBuf>,
31}
32
33impl Workspace {
34 pub fn load(start_dir: &Path) -> Result<Self> {
36 let root = Self::find_root(start_dir)?;
37 Self::load_from_root(&root)
38 }
39
40 pub fn load_from_root(root: &Path) -> Result<Self> {
42 let pyproject = PyProject::load(root)?;
43
44 let rx_config = pyproject
45 .tool
46 .get("rx")
47 .ok_or_else(|| Error::WorkspaceNotFound)?;
48
49 let workspace_config = rx_config
50 .get("workspace")
51 .ok_or_else(|| Error::WorkspaceNotFound)?;
52
53 let members: Vec<String> = workspace_config
54 .get("members")
55 .and_then(|v| v.as_array())
56 .map(|arr| {
57 arr.iter()
58 .filter_map(|v| v.as_str().map(String::from))
59 .collect()
60 })
61 .unwrap_or_default();
62
63 let shared_venv = workspace_config
64 .get("shared-venv")
65 .and_then(|v| v.as_bool())
66 .unwrap_or(false);
67
68 let mut workspace = Self {
69 root: root.to_path_buf(),
70 member_patterns: members,
71 shared_venv,
72 members: Vec::new(),
73 };
74
75 workspace.resolve_members()?;
76
77 Ok(workspace)
78 }
79
80 pub fn find_root(start_dir: &Path) -> Result<PathBuf> {
82 let mut current = start_dir.to_path_buf();
83
84 loop {
85 let pyproject_path = current.join("pyproject.toml");
86 if pyproject_path.exists() {
87 if let Ok(pyproject) = PyProject::load(¤t) {
88 if let Some(rx_config) = pyproject.tool.get("rx") {
89 if rx_config.get("workspace").is_some() {
90 return Ok(current);
91 }
92 }
93 }
94 }
95
96 if !current.pop() {
97 return Err(Error::WorkspaceNotFound);
98 }
99 }
100 }
101
102 pub fn is_workspace_root(dir: &Path) -> bool {
104 if let Ok(pyproject) = PyProject::load(dir) {
105 if let Some(rx_config) = pyproject.tool.get("rx") {
106 return rx_config.get("workspace").is_some();
107 }
108 }
109 false
110 }
111
112 pub fn create(root: &Path, shared_venv: bool) -> Result<Self> {
114 let pyproject_path = root.join("pyproject.toml");
116
117 let content = if pyproject_path.exists() {
118 std::fs::read_to_string(&pyproject_path).map_err(Error::Io)?
119 } else {
120 r#"[project]
122name = "workspace-root"
123version = "0.0.0"
124description = "Workspace root - not a package"
125"#
126 .to_string()
127 };
128
129 let mut doc: toml_edit::DocumentMut = content
131 .parse()
132 .map_err(|e| Error::Config(format!("Failed to parse pyproject.toml: {}", e)))?;
133
134 if !doc.contains_key("tool") {
136 doc["tool"] = toml_edit::Item::Table(toml_edit::Table::new());
137 }
138 if !doc["tool"].as_table().unwrap().contains_key("rx") {
139 doc["tool"]["rx"] = toml_edit::Item::Table(toml_edit::Table::new());
140 }
141
142 let rx_table = doc["tool"]["rx"].as_table_mut().unwrap();
143 if !rx_table.contains_key("workspace") {
144 rx_table["workspace"] = toml_edit::Item::Table(toml_edit::Table::new());
145 }
146
147 let workspace_table = rx_table["workspace"].as_table_mut().unwrap();
148
149 if !workspace_table.contains_key("members") {
151 workspace_table["members"] = toml_edit::Item::Value(toml_edit::Array::new().into());
152 }
153
154 workspace_table["shared-venv"] = toml_edit::Item::Value(shared_venv.into());
156
157 std::fs::write(&pyproject_path, doc.to_string()).map_err(Error::Io)?;
159
160 Ok(Self {
161 root: root.to_path_buf(),
162 member_patterns: Vec::new(),
163 shared_venv,
164 members: Vec::new(),
165 })
166 }
167
168 pub fn add_member(&mut self, path: &str) -> Result<()> {
170 let member_path = self.root.join(path);
172 if !member_path.exists() {
173 return Err(Error::Config(format!(
174 "Member path does not exist: {}",
175 member_path.display()
176 )));
177 }
178
179 let member_pyproject = member_path.join("pyproject.toml");
180 if !member_pyproject.exists() {
181 return Err(Error::Config(format!(
182 "Member does not have pyproject.toml: {}",
183 member_path.display()
184 )));
185 }
186
187 let path_str = path.to_string();
189 if !self.member_patterns.contains(&path_str) {
190 self.member_patterns.push(path_str);
191 }
192
193 self.save()?;
195
196 self.resolve_members()?;
198
199 Ok(())
200 }
201
202 pub fn remove_member(&mut self, path: &str) -> Result<bool> {
204 let path_str = path.to_string();
205 let initial_len = self.member_patterns.len();
206
207 self.member_patterns.retain(|p| p != &path_str);
208
209 if self.member_patterns.len() < initial_len {
210 self.save()?;
211 self.resolve_members()?;
212 Ok(true)
213 } else {
214 Ok(false)
215 }
216 }
217
218 pub fn save(&self) -> Result<()> {
220 let pyproject_path = self.root.join("pyproject.toml");
221 let content = std::fs::read_to_string(&pyproject_path).map_err(Error::Io)?;
222
223 let mut doc: toml_edit::DocumentMut = content
224 .parse()
225 .map_err(|e| Error::Config(format!("Failed to parse pyproject.toml: {}", e)))?;
226
227 let members_array: toml_edit::Array = self
229 .member_patterns
230 .iter()
231 .map(|s| toml_edit::Value::from(s.as_str()))
232 .collect();
233
234 doc["tool"]["rx"]["workspace"]["members"] = toml_edit::Item::Value(members_array.into());
235 doc["tool"]["rx"]["workspace"]["shared-venv"] =
236 toml_edit::Item::Value(self.shared_venv.into());
237
238 std::fs::write(&pyproject_path, doc.to_string()).map_err(Error::Io)?;
239
240 Ok(())
241 }
242
243 fn resolve_members(&mut self) -> Result<()> {
245 let mut members = HashSet::new();
246
247 for pattern in &self.member_patterns {
248 if pattern.contains('*') {
250 let full_pattern = self.root.join(pattern);
252 let pattern_str = full_pattern.to_string_lossy();
253
254 if let Ok(paths) = glob::glob(&pattern_str) {
255 for entry in paths.flatten() {
256 if entry.is_dir() && entry.join("pyproject.toml").exists() {
258 members.insert(entry);
259 }
260 }
261 }
262 } else {
263 let member_path = self.root.join(pattern);
265 if member_path.is_dir() && member_path.join("pyproject.toml").exists() {
266 members.insert(member_path);
267 }
268 }
269 }
270
271 self.members = members.into_iter().collect();
272 self.members.sort();
273
274 Ok(())
275 }
276
277 pub fn members(&self) -> &[PathBuf] {
279 &self.members
280 }
281
282 pub fn lockfile_path(&self) -> PathBuf {
284 self.root.join("rx.lock")
285 }
286
287 pub fn venv_path(&self) -> PathBuf {
289 self.root.join(".venv")
290 }
291
292 pub fn all_dependencies(&self) -> Result<Vec<crate::pep::Requirement>> {
294 let mut all_reqs = Vec::new();
295 let mut seen_names = HashSet::new();
296
297 for member_path in &self.members {
298 let pyproject = PyProject::load(member_path)?;
299
300 for dep in pyproject.dependencies() {
301 if let Ok(req) = crate::pep::Requirement::parse(dep) {
302 let name_lower = req.name.to_lowercase();
303 if !seen_names.contains(&name_lower) {
304 seen_names.insert(name_lower);
305 all_reqs.push(req);
306 }
307 }
308 }
309
310 for dep in pyproject.dev_dependencies() {
311 if let Ok(req) = crate::pep::Requirement::parse(dep) {
312 let name_lower = req.name.to_lowercase();
313 if !seen_names.contains(&name_lower) {
314 seen_names.insert(name_lower);
315 all_reqs.push(req);
316 }
317 }
318 }
319 }
320
321 Ok(all_reqs)
322 }
323
324 pub fn member_info(&self) -> Result<Vec<MemberInfo>> {
326 let mut info = Vec::new();
327
328 for member_path in &self.members {
329 let pyproject = PyProject::load(member_path)?;
330 let relative_path = member_path
331 .strip_prefix(&self.root)
332 .unwrap_or(member_path)
333 .to_string_lossy()
334 .to_string();
335
336 info.push(MemberInfo {
337 path: relative_path,
338 name: pyproject.name().map(String::from),
339 version: pyproject.version().map(String::from),
340 dependency_count: pyproject.dependencies().len()
341 + pyproject.dev_dependencies().len(),
342 });
343 }
344
345 Ok(info)
346 }
347}
348
349#[derive(Debug, Clone)]
351pub struct MemberInfo {
352 pub path: String,
354 pub name: Option<String>,
356 pub version: Option<String>,
358 pub dependency_count: usize,
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365 use tempfile::TempDir;
366
367 #[test]
368 fn test_workspace_create() {
369 let temp = TempDir::new().unwrap();
370 let root = temp.path();
371
372 let workspace = Workspace::create(root, false).unwrap();
373 assert_eq!(workspace.members().len(), 0);
374 assert!(!workspace.shared_venv);
375
376 let content = std::fs::read_to_string(root.join("pyproject.toml")).unwrap();
378 assert!(content.contains("[tool.rx.workspace]"));
379 }
380
381 #[test]
382 fn test_is_workspace_root() {
383 let temp = TempDir::new().unwrap();
384 let root = temp.path();
385
386 assert!(!Workspace::is_workspace_root(root));
388
389 Workspace::create(root, false).unwrap();
391
392 assert!(Workspace::is_workspace_root(root));
394 }
395}