Skip to main content

pro_core/
workspace.rs

1//! Workspace support for monorepo management
2//!
3//! A workspace is a collection of related Python projects that share:
4//! - A unified lockfile (rx.lock) at the workspace root
5//! - Optionally, a shared virtual environment
6//!
7//! Configuration is stored in pyproject.toml:
8//! ```toml
9//! [tool.rx.workspace]
10//! members = ["packages/*", "apps/myapp"]
11//! shared-venv = true  # optional, default false
12//! ```
13
14use std::collections::HashSet;
15use std::path::{Path, PathBuf};
16
17use crate::pep::PyProject;
18use crate::{Error, Result};
19
20/// Workspace configuration
21#[derive(Debug, Clone)]
22pub struct Workspace {
23    /// Root directory of the workspace
24    pub root: PathBuf,
25    /// Member patterns (glob patterns or paths)
26    pub member_patterns: Vec<String>,
27    /// Whether to use a shared venv for all members
28    pub shared_venv: bool,
29    /// Resolved member paths
30    members: Vec<PathBuf>,
31}
32
33impl Workspace {
34    /// Load workspace from a directory (searches upward for workspace root)
35    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    /// Load workspace from a known root directory
41    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    /// Find workspace root by searching upward for [tool.rx.workspace]
81    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(&current) {
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    /// Check if a directory is a workspace root
103    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    /// Create a new workspace
113    pub fn create(root: &Path, shared_venv: bool) -> Result<Self> {
114        // Load or create pyproject.toml
115        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            // Create minimal pyproject.toml
121            r#"[project]
122name = "workspace-root"
123version = "0.0.0"
124description = "Workspace root - not a package"
125"#
126            .to_string()
127        };
128
129        // Parse and update
130        let mut doc: toml_edit::DocumentMut = content
131            .parse()
132            .map_err(|e| Error::Config(format!("Failed to parse pyproject.toml: {}", e)))?;
133
134        // Ensure [tool.rx.workspace] exists
135        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        // Set members array if not exists
150        if !workspace_table.contains_key("members") {
151            workspace_table["members"] = toml_edit::Item::Value(toml_edit::Array::new().into());
152        }
153
154        // Set shared-venv
155        workspace_table["shared-venv"] = toml_edit::Item::Value(shared_venv.into());
156
157        // Write back
158        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    /// Add a member to the workspace
169    pub fn add_member(&mut self, path: &str) -> Result<()> {
170        // Verify the path exists and has a pyproject.toml
171        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        // Add to member patterns if not already present
188        let path_str = path.to_string();
189        if !self.member_patterns.contains(&path_str) {
190            self.member_patterns.push(path_str);
191        }
192
193        // Update pyproject.toml
194        self.save()?;
195
196        // Re-resolve members
197        self.resolve_members()?;
198
199        Ok(())
200    }
201
202    /// Remove a member from the workspace
203    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    /// Save workspace configuration to pyproject.toml
219    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        // Update members array
228        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    /// Resolve member patterns to actual paths
244    fn resolve_members(&mut self) -> Result<()> {
245        let mut members = HashSet::new();
246
247        for pattern in &self.member_patterns {
248            // Check if it's a glob pattern
249            if pattern.contains('*') {
250                // Use glob to expand pattern
251                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                        // Only include directories with pyproject.toml
257                        if entry.is_dir() && entry.join("pyproject.toml").exists() {
258                            members.insert(entry);
259                        }
260                    }
261                }
262            } else {
263                // Direct path
264                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    /// Get resolved member paths
278    pub fn members(&self) -> &[PathBuf] {
279        &self.members
280    }
281
282    /// Get lockfile path (at workspace root)
283    pub fn lockfile_path(&self) -> PathBuf {
284        self.root.join("rx.lock")
285    }
286
287    /// Get venv path
288    pub fn venv_path(&self) -> PathBuf {
289        self.root.join(".venv")
290    }
291
292    /// Collect all dependencies from all members
293    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    /// Get member info for display
325    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/// Information about a workspace member
350#[derive(Debug, Clone)]
351pub struct MemberInfo {
352    /// Relative path from workspace root
353    pub path: String,
354    /// Project name
355    pub name: Option<String>,
356    /// Project version
357    pub version: Option<String>,
358    /// Total number of dependencies
359    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        // Verify pyproject.toml was created
377        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        // Not a workspace initially
387        assert!(!Workspace::is_workspace_root(root));
388
389        // Create workspace
390        Workspace::create(root, false).unwrap();
391
392        // Now it should be detected
393        assert!(Workspace::is_workspace_root(root));
394    }
395}