Skip to main content

leo_package/
workspace.rs

1// Copyright (C) 2019-2026 Provable Inc.
2// This file is part of the Leo library.
3
4// The Leo library is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8
9// The Leo library is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12// GNU General Public License for more details.
13
14// You should have received a copy of the GNU General Public License
15// along with the Leo library. If not, see <https://www.gnu.org/licenses/>.
16
17use crate::{Dependency, Location, MANIFEST_FILENAME, Manifest, errors};
18
19use leo_ast::DiGraph;
20use leo_errors::{Backtraced, Result};
21
22use serde::{Deserialize, Serialize};
23use std::path::{Path, PathBuf};
24
25pub const WORKSPACE_MANIFEST_FILENAME: &str = "workspace.json";
26
27/// The contents of a `workspace.json` manifest.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct WorkspaceManifest {
30    pub members: Vec<String>,
31}
32
33impl WorkspaceManifest {
34    pub fn read_from_file<P: AsRef<Path>>(path: P) -> std::result::Result<Self, Backtraced> {
35        let contents =
36            std::fs::read_to_string(&path).map_err(|e| errors::workspace_manifest_error(path.as_ref().display(), e))?;
37        serde_json::from_str(&contents).map_err(|e| errors::workspace_manifest_error(path.as_ref().display(), e))
38    }
39
40    pub fn write_to_file<P: AsRef<Path>>(&self, path: P) -> std::result::Result<(), Backtraced> {
41        let mut contents = serde_json::to_string_pretty(self)
42            .map_err(|e| errors::workspace_manifest_error(path.as_ref().display(), e))?;
43        contents.push('\n');
44        std::fs::write(&path, contents).map_err(|e| errors::workspace_manifest_error(path.as_ref().display(), e))
45    }
46}
47
48/// A Leo workspace - a collection of member packages under a single root.
49#[derive(Debug, Clone)]
50pub struct Workspace {
51    /// The canonicalized root directory containing `workspace.json`.
52    pub root_directory: PathBuf,
53    /// Member directories in dependency order, each an absolute path.
54    pub member_paths: Vec<PathBuf>,
55    /// Member program names (from each member's `program.json`), in the same order.
56    pub member_names: Vec<String>,
57}
58
59impl Workspace {
60    /// Read the workspace at `path`, if a `workspace.json` exists there.
61    ///
62    /// Returns `Ok(None)` if no manifest exists.
63    /// Returns `Err` if the manifest exists but is malformed, or if members are missing.
64    pub fn from_directory(path: &Path) -> Result<Option<Self>> {
65        let manifest_path = path.join(WORKSPACE_MANIFEST_FILENAME);
66        if !manifest_path.exists() {
67            return Ok(None);
68        }
69
70        let root_directory =
71            path.canonicalize().map_err(|e| errors::workspace_manifest_error(manifest_path.display(), e))?;
72
73        let manifest = WorkspaceManifest::read_from_file(&manifest_path)?;
74
75        // Resolve and validate each member entry, expanding glob patterns relative to the root.
76        let mut dir_to_name: Vec<(PathBuf, String)> = Vec::with_capacity(manifest.members.len());
77        let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
78        for member in &manifest.members {
79            if is_glob_pattern(member) {
80                let expanded = expand_member_pattern(&root_directory, member)?;
81                if expanded.is_empty() {
82                    tracing::warn!(
83                        "workspace member glob `{member}` in {} matched no packages",
84                        root_directory.display(),
85                    );
86                    continue;
87                }
88                for entry in expanded {
89                    let record = load_member_record(&root_directory, &entry)?;
90                    if seen.insert(record.0.clone()) {
91                        dir_to_name.push(record);
92                    }
93                }
94            } else {
95                let record = load_member_record(&root_directory, member)?;
96                if seen.insert(record.0.clone()) {
97                    dir_to_name.push(record);
98                }
99            }
100        }
101
102        // Build a dependency graph to determine the correct build order.
103        let ordered = order_members(&dir_to_name)?;
104
105        // Reject members that share a bare program name: they would otherwise
106        // race on the shared `<workspace_root>/build/<name>/` artifacts.
107        let mut by_bare_name: std::collections::HashMap<&str, &PathBuf> = std::collections::HashMap::new();
108        for (path, program) in &ordered {
109            let bare = crate::bare_unit_name(program);
110            if let Some(existing) = by_bare_name.insert(bare, path) {
111                return Err(
112                    errors::workspace_duplicate_program_name(program, existing.display(), path.display()).into()
113                );
114            }
115        }
116
117        let member_paths = ordered.iter().map(|(p, _)| p.clone()).collect();
118        let member_names = ordered.into_iter().map(|(_, n)| n).collect();
119
120        Ok(Some(Workspace { root_directory, member_paths, member_names }))
121    }
122
123    /// Walk up from `start_dir` looking for `workspace.json`, returning the
124    /// fully resolved workspace.
125    ///
126    /// Returns `Ok(None)` if no workspace root is found.
127    pub fn discover(start_dir: &Path) -> Result<Option<Self>> {
128        match Self::discover_root(start_dir)? {
129            Some(root) => Self::from_directory(&root),
130            None => Ok(None),
131        }
132    }
133
134    /// Walk up from `start_dir` to find the directory containing
135    /// `workspace.json`, returning its canonicalized path. Returns `Ok(None)`
136    /// if none is found.
137    ///
138    /// Unlike [`Workspace::discover`], this does not resolve or validate
139    /// members - it only checks for the manifest's presence. Use this when
140    /// you only need the root path (e.g. routing build artifacts) and want
141    /// to avoid the cost of reading every member's manifest.
142    pub fn discover_root(start_dir: &Path) -> Result<Option<PathBuf>> {
143        let start = start_dir.canonicalize().map_err(|e| errors::workspace_manifest_error(start_dir.display(), e))?;
144        let mut dir = start.as_path();
145        loop {
146            if dir.join(WORKSPACE_MANIFEST_FILENAME).exists() {
147                return Ok(Some(dir.to_path_buf()));
148            }
149            match dir.parent() {
150                Some(parent) => dir = parent,
151                None => return Ok(None),
152            }
153        }
154    }
155
156    /// Find a member by directory name or program name (with or without `.aleo` suffix).
157    pub fn find_member(&self, name: &str) -> Option<&PathBuf> {
158        // Try matching by directory basename.
159        if let Some(pos) = self.member_paths.iter().position(|p| p.file_name().and_then(|n| n.to_str()) == Some(name)) {
160            return Some(&self.member_paths[pos]);
161        }
162        // Try matching by program name (exact or with/without .aleo).
163        let name_with_aleo = if name.ends_with(".aleo") { name.to_string() } else { format!("{name}.aleo") };
164        let name_without_aleo = name.strip_suffix(".aleo").unwrap_or(name);
165        self.member_names.iter().zip(self.member_paths.iter()).find_map(|(prog_name, path)| {
166            if prog_name == name || prog_name == &name_with_aleo || prog_name == name_without_aleo {
167                Some(path)
168            } else {
169                None
170            }
171        })
172    }
173
174    /// Check whether a given canonicalized path is one of the member directories.
175    pub fn is_member(&self, path: &Path) -> bool {
176        let Ok(canonical) = path.canonicalize() else {
177            return false;
178        };
179        self.member_paths.iter().any(|p| p == &canonical)
180    }
181
182    /// If a workspace contains `member_dir`, append `member_dir` to its
183    /// `workspace.json` (unless already covered by a literal entry or a glob).
184    ///
185    /// Returns `Ok(true)` if the workspace manifest was modified. Returns
186    /// `Ok(false)` if there is no enclosing workspace, `member_dir` is outside
187    /// the workspace root, or the path is already covered.
188    pub fn auto_register_member(member_dir: &Path) -> Result<bool> {
189        let canonical_member = member_dir.canonicalize().map_err(|e| errors::failed_path(member_dir.display(), e))?;
190
191        let Some(parent) = canonical_member.parent() else {
192            return Ok(false);
193        };
194        // Only the workspace root is needed here, so locate it without resolving
195        // members; that keeps `leo new` from failing when an unrelated existing
196        // member is broken.
197        let Some(root_directory) = Self::discover_root(parent)? else {
198            return Ok(false);
199        };
200
201        let relative = match canonical_member.strip_prefix(&root_directory) {
202            Ok(rel) => rel,
203            Err(_) => {
204                tracing::warn!(
205                    "new package at `{}` is not inside the discovered workspace root `{}`; skipping auto-add",
206                    canonical_member.display(),
207                    root_directory.display(),
208                );
209                return Ok(false);
210            }
211        };
212        let Some(relative_str) = relative.to_str() else {
213            tracing::warn!("new package path `{}` is not valid UTF-8; skipping auto-add", canonical_member.display(),);
214            return Ok(false);
215        };
216        let entry = relative_str.replace('\\', "/");
217
218        // Re-read from disk in case the manifest was modified between discover and write,
219        // and check coverage against those fresh entries rather than the stale snapshot.
220        let manifest_path = root_directory.join(WORKSPACE_MANIFEST_FILENAME);
221        let mut manifest = WorkspaceManifest::read_from_file(&manifest_path)?;
222
223        if pattern_matches_relative(&manifest.members, &entry) {
224            return Ok(false);
225        }
226
227        manifest.members.push(entry);
228        manifest.write_to_file(&manifest_path)?;
229        Ok(true)
230    }
231
232    /// Create a fresh workspace skeleton named `name` inside `parent`.
233    ///
234    /// Writes a `workspace.json` with an empty `members` array and a
235    /// `.gitignore` listing the shared `build/` directory. The caller is
236    /// responsible for ensuring `parent` exists.
237    ///
238    /// Returns the absolute path of the new workspace directory.
239    pub fn initialize_skeleton(name: &str, parent: &Path) -> Result<PathBuf> {
240        if !crate::is_valid_library_name(name) {
241            return Err(errors::cli_invalid_package_name("workspace", name).into());
242        }
243
244        let parent = parent.canonicalize().map_err(|e| errors::failed_path(parent.display(), e))?;
245        let full_path = parent.join(name);
246
247        if full_path.exists() {
248            return Err(errors::failed_to_initialize_package(name, &full_path, "Directory already exists").into());
249        }
250
251        std::fs::create_dir(&full_path).map_err(|e| errors::failed_to_initialize_package(name, &full_path, e))?;
252
253        let manifest = WorkspaceManifest { members: Vec::new() };
254        manifest.write_to_file(full_path.join(WORKSPACE_MANIFEST_FILENAME))?;
255
256        // The workspace root owns the shared `build/` so its `.gitignore` is
257        // the natural place to ignore it. Member-level concerns (`.env`,
258        // `*.avm`, ...) stay in each member's own `.gitignore`.
259        std::fs::write(full_path.join(".gitignore"), "build/\n")
260            .map_err(|e| errors::failed_to_initialize_package(name, &full_path, e))?;
261
262        Ok(full_path)
263    }
264}
265
266/// Resolve a `Location::Workspace` dependency by looking up its name in the
267/// enclosing workspace, returning a new `Dependency` with `Location::Local`
268/// and the resolved absolute path.
269///
270/// `package_dir` is the directory of the package that declared the dependency.
271pub fn resolve_workspace_dependency(package_dir: &Path, dep: Dependency) -> Result<Dependency> {
272    let workspace =
273        Workspace::discover(package_dir)?.ok_or_else(|| errors::workspace_dep_outside_workspace(&dep.name))?;
274    let member_path = workspace
275        .find_member(&dep.name)
276        .ok_or_else(|| errors::workspace_dep_member_not_found(&dep.name, workspace.root_directory.display()))?;
277    Ok(Dependency { location: Location::Local, path: Some(member_path.clone()), ..dep })
278}
279
280/// Returns `true` if `s` contains any glob metacharacters (`*`, `?`, `[`).
281fn is_glob_pattern(s: &str) -> bool {
282    s.contains(['*', '?', '['])
283}
284
285/// Expand a glob pattern relative to `root` into a list of member directory
286/// entries, each a forward-slash path relative to `root`.
287///
288/// Only directories containing a `program.json` are returned. Other matches
289/// (files, directories without a manifest, non-UTF8 paths) are silently skipped.
290fn expand_member_pattern(root: &Path, pattern: &str) -> Result<Vec<String>> {
291    let absolute_pattern = root.join(pattern);
292    let pattern_str = absolute_pattern.to_string_lossy();
293    let entries = glob::glob(&pattern_str).map_err(|e| errors::workspace_manifest_error(pattern, e))?;
294
295    let mut out = Vec::new();
296    for entry in entries {
297        let Ok(path) = entry else { continue };
298        if !path.is_dir() {
299            continue;
300        }
301        if !path.join(MANIFEST_FILENAME).exists() {
302            continue;
303        }
304        let Ok(relative) = path.strip_prefix(root) else { continue };
305        let Some(relative_str) = relative.to_str() else { continue };
306        // Normalize to forward slashes so the entry round-trips cleanly on Windows.
307        out.push(relative_str.replace('\\', "/"));
308    }
309    Ok(out)
310}
311
312/// Check whether any of `patterns` matches `relative` either as a literal
313/// entry or as a glob pattern.
314fn pattern_matches_relative(patterns: &[String], relative: &str) -> bool {
315    // Match with `require_literal_separator` so `*`/`?`/`[]` stop at `/`, mirroring
316    // how `glob::glob` enumerates the filesystem (and leaving `**` free to cross).
317    let options = glob::MatchOptions { require_literal_separator: true, ..Default::default() };
318    patterns.iter().any(|p| {
319        if is_glob_pattern(p) {
320            glob::Pattern::new(p).map(|pat| pat.matches_with(relative, options)).unwrap_or(false)
321        } else {
322            p == relative
323        }
324    })
325}
326
327/// Load a single member's `(canonical_path, program_name)` pair, erroring if
328/// the directory or its `program.json` is missing.
329fn load_member_record(root: &Path, entry: &str) -> Result<(PathBuf, String)> {
330    let member_dir = root.join(entry);
331    if !member_dir.is_dir() {
332        return Err(errors::workspace_member_not_found(entry, root.display()).into());
333    }
334    let member_manifest_path = member_dir.join(MANIFEST_FILENAME);
335    if !member_manifest_path.exists() {
336        return Err(errors::workspace_member_not_found(entry, root.display()).into());
337    }
338    let member_manifest = Manifest::read_from_file(&member_manifest_path)?;
339    let canonical = member_dir.canonicalize().map_err(|e| errors::workspace_manifest_error(member_dir.display(), e))?;
340    // `root` is the canonicalized workspace root; reject members (e.g. `../sibling`)
341    // that resolve outside it.
342    if canonical.strip_prefix(root).is_err() {
343        return Err(errors::workspace_member_outside_root(entry, root.display()).into());
344    }
345    Ok((canonical, member_manifest.program.clone()))
346}
347
348/// Determine the build order for workspace members by analysing cross-member
349/// local dependencies.
350///
351/// Each member's `Manifest` is read to find `Location::Local` dependencies
352/// whose paths resolve to other workspace member directories. Edges are added
353/// to a `DiGraph` from dependent to dependency, and the graph is topologically
354/// sorted so that dependencies appear before the members that depend on them.
355fn order_members(members: &[(PathBuf, String)]) -> Result<Vec<(PathBuf, String)>> {
356    // If there are 0 or 1 members, no ordering is needed.
357    if members.len() <= 1 {
358        return Ok(members.to_vec());
359    }
360
361    let mut graph = DiGraph::<String>::new(Default::default());
362
363    // Index members by canonical path for quick lookup.
364    let path_to_dir_name: std::collections::HashMap<&Path, &str> = members
365        .iter()
366        .filter_map(|(path, _)| {
367            let dir_name = path.file_name()?.to_str()?;
368            Some((path.as_path(), dir_name))
369        })
370        .collect();
371
372    // Add all members as nodes.
373    for (path, _) in members {
374        let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default();
375        graph.add_node(dir_name.to_string());
376    }
377
378    // Also index members by program name for workspace dep lookup.
379    let name_to_dir_name: std::collections::HashMap<&str, &str> = members
380        .iter()
381        .filter_map(|(path, prog_name)| {
382            let dir_name = path.file_name()?.to_str()?;
383            Some((prog_name.as_str(), dir_name))
384        })
385        .collect();
386
387    // Scan each member's manifest for local/workspace dependencies pointing to other members.
388    for (member_path, _) in members {
389        let member_dir_name = member_path.file_name().and_then(|n| n.to_str()).unwrap_or_default();
390        let manifest_path = member_path.join(MANIFEST_FILENAME);
391        let manifest = Manifest::read_from_file(&manifest_path)?;
392
393        for dep in manifest.dependencies.iter().flatten() {
394            let dep_dir_name = match dep.location {
395                Location::Local => {
396                    let Some(dep_path) = &dep.path else { continue };
397                    let resolved = if dep_path.is_absolute() { dep_path.clone() } else { member_path.join(dep_path) };
398                    let Ok(canonical) = resolved.canonicalize() else { continue };
399                    let Some(&name) = path_to_dir_name.get(canonical.as_path()) else { continue };
400                    name
401                }
402                Location::Workspace => {
403                    // Match by directory basename or program name.
404                    if let Some(&name) = path_to_dir_name.values().find(|&&n| {
405                        n == dep.name
406                            || format!("{n}.aleo") == dep.name
407                            || dep.name.strip_suffix(".aleo").is_some_and(|s| s == n)
408                    }) {
409                        name
410                    } else if let Some(&name) = name_to_dir_name.get(dep.name.as_str()) {
411                        name
412                    } else {
413                        // Also try with/without .aleo suffix on program name.
414                        let alt = if dep.name.ends_with(".aleo") {
415                            dep.name.strip_suffix(".aleo").unwrap().to_string()
416                        } else {
417                            format!("{}.aleo", dep.name)
418                        };
419                        let Some(&name) = name_to_dir_name.get(alt.as_str()) else { continue };
420                        name
421                    }
422                }
423                _ => continue,
424            };
425            graph.add_edge(member_dir_name.to_string(), dep_dir_name.to_string());
426        }
427    }
428
429    let ordered = graph.post_order().map_err(|_| {
430        errors::workspace_manifest_error("workspace.json", "circular dependency between workspace members")
431    })?;
432
433    // Map the ordered directory names back to (path, program_name) pairs.
434    let name_to_member: std::collections::HashMap<&str, &(PathBuf, String)> = members
435        .iter()
436        .filter_map(|entry| {
437            let dir_name = entry.0.file_name()?.to_str()?;
438            Some((dir_name, entry))
439        })
440        .collect();
441
442    Ok(ordered
443        .iter()
444        .filter_map(|dir_name| name_to_member.get(dir_name.as_str()).map(|e| (e.0.clone(), e.1.clone())))
445        .collect())
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use std::env::temp_dir;
452
453    fn create_member(workspace_dir: &Path, name: &str, deps: &[(&str, &Path)]) {
454        let member_dir = workspace_dir.join(name);
455        std::fs::create_dir_all(member_dir.join("src")).unwrap();
456
457        let program_name = format!("{name}.aleo");
458        let dependencies: Vec<_> = deps
459            .iter()
460            .map(|(dep_name, dep_path)| crate::Dependency {
461                name: format!("{dep_name}.aleo"),
462                location: Location::Local,
463                path: Some(dep_path.to_path_buf()),
464                edition: None,
465            })
466            .collect();
467
468        let manifest = Manifest {
469            program: program_name,
470            version: "0.1.0".to_string(),
471            description: String::new(),
472            license: "MIT".to_string(),
473            leo: "0.0.0".to_string(),
474            dependencies: if dependencies.is_empty() { None } else { Some(dependencies) },
475            dev_dependencies: None,
476        };
477
478        manifest.write_to_file(member_dir.join(MANIFEST_FILENAME)).unwrap();
479
480        // Write a minimal source file so the package is valid.
481        std::fs::write(
482            member_dir.join("src/main.leo"),
483            format!("program {name}.aleo {{\n    @noupgrade\n    constructor() {{}}\n}}\n"),
484        )
485        .unwrap();
486    }
487
488    fn create_workspace(dir: &Path, members: &[&str]) {
489        let manifest = WorkspaceManifest { members: members.iter().map(|s| s.to_string()).collect() };
490        manifest.write_to_file(dir.join(WORKSPACE_MANIFEST_FILENAME)).unwrap();
491    }
492
493    #[test]
494    fn workspace_manifest_round_trip() {
495        let dir = temp_dir().join("ws_test_roundtrip");
496        let _ = std::fs::remove_dir_all(&dir);
497        std::fs::create_dir_all(&dir).unwrap();
498
499        let manifest = WorkspaceManifest { members: vec!["alpha".into(), "beta".into()] };
500        let path = dir.join(WORKSPACE_MANIFEST_FILENAME);
501        manifest.write_to_file(&path).unwrap();
502
503        let loaded = WorkspaceManifest::read_from_file(&path).unwrap();
504        assert_eq!(loaded.members, vec!["alpha", "beta"]);
505
506        std::fs::remove_dir_all(&dir).unwrap();
507    }
508
509    #[test]
510    fn workspace_from_directory_valid() {
511        let dir = temp_dir().join("ws_test_valid");
512        let _ = std::fs::remove_dir_all(&dir);
513        std::fs::create_dir_all(&dir).unwrap();
514
515        create_member(&dir, "alpha", &[]);
516        create_member(&dir, "beta", &[]);
517        create_workspace(&dir, &["alpha", "beta"]);
518
519        let ws = Workspace::from_directory(&dir).unwrap().unwrap();
520        assert_eq!(ws.member_paths.len(), 2);
521        assert_eq!(ws.member_names.len(), 2);
522
523        std::fs::remove_dir_all(&dir).unwrap();
524    }
525
526    #[test]
527    fn workspace_from_directory_missing_member() {
528        let dir = temp_dir().join("ws_test_missing");
529        let _ = std::fs::remove_dir_all(&dir);
530        std::fs::create_dir_all(&dir).unwrap();
531
532        create_member(&dir, "alpha", &[]);
533        // "beta" is listed but not created.
534        create_workspace(&dir, &["alpha", "beta"]);
535
536        let result = Workspace::from_directory(&dir);
537        assert!(result.is_err());
538
539        std::fs::remove_dir_all(&dir).unwrap();
540    }
541
542    #[test]
543    fn workspace_discover_from_subdirectory() {
544        let dir = temp_dir().join("ws_test_discover");
545        let _ = std::fs::remove_dir_all(&dir);
546        std::fs::create_dir_all(&dir).unwrap();
547
548        create_member(&dir, "alpha", &[]);
549        create_workspace(&dir, &["alpha"]);
550
551        let member_dir = dir.join("alpha");
552        let ws = Workspace::discover(&member_dir).unwrap().unwrap();
553        assert_eq!(ws.root_directory, dir.canonicalize().unwrap());
554
555        std::fs::remove_dir_all(&dir).unwrap();
556    }
557
558    #[test]
559    fn workspace_discover_none() {
560        let dir = temp_dir().join("ws_test_no_workspace");
561        let _ = std::fs::remove_dir_all(&dir);
562        std::fs::create_dir_all(&dir).unwrap();
563
564        let result = Workspace::discover(&dir).unwrap();
565        assert!(result.is_none());
566
567        std::fs::remove_dir_all(&dir).unwrap();
568    }
569
570    #[test]
571    fn workspace_dependency_ordering() {
572        let dir = temp_dir().join("ws_test_ordering");
573        let _ = std::fs::remove_dir_all(&dir);
574        std::fs::create_dir_all(&dir).unwrap();
575
576        let alpha_dir = dir.join("alpha");
577
578        // alpha has no deps, beta depends on alpha.
579        create_member(&dir, "alpha", &[]);
580        create_member(&dir, "beta", &[("alpha", &alpha_dir)]);
581        create_workspace(&dir, &["beta", "alpha"]); // intentionally wrong order
582
583        let ws = Workspace::from_directory(&dir).unwrap().unwrap();
584        // alpha should come before beta regardless of manifest order.
585        let names: Vec<&str> = ws.member_names.iter().map(|s| s.as_str()).collect();
586        let alpha_pos = names.iter().position(|n| *n == "alpha.aleo").unwrap();
587        let beta_pos = names.iter().position(|n| *n == "beta.aleo").unwrap();
588        assert!(alpha_pos < beta_pos, "alpha should be ordered before beta");
589
590        std::fs::remove_dir_all(&dir).unwrap();
591    }
592
593    #[test]
594    fn workspace_find_member() {
595        let dir = temp_dir().join("ws_test_find");
596        let _ = std::fs::remove_dir_all(&dir);
597        std::fs::create_dir_all(&dir).unwrap();
598
599        create_member(&dir, "alpha", &[]);
600        create_workspace(&dir, &["alpha"]);
601
602        let ws = Workspace::from_directory(&dir).unwrap().unwrap();
603        assert!(ws.find_member("alpha").is_some());
604        assert!(ws.find_member("alpha.aleo").is_some());
605        assert!(ws.find_member("nonexistent").is_none());
606
607        std::fs::remove_dir_all(&dir).unwrap();
608    }
609
610    /// Create a member whose dependencies use `Location::Workspace` (no path).
611    fn create_member_with_workspace_deps(workspace_dir: &Path, name: &str, dep_names: &[&str]) {
612        let member_dir = workspace_dir.join(name);
613        std::fs::create_dir_all(member_dir.join("src")).unwrap();
614
615        let program_name = format!("{name}.aleo");
616        let dependencies: Vec<_> = dep_names
617            .iter()
618            .map(|dep_name| Dependency {
619                name: format!("{dep_name}.aleo"),
620                location: Location::Workspace,
621                path: None,
622                edition: None,
623            })
624            .collect();
625
626        let manifest = Manifest {
627            program: program_name,
628            version: "0.1.0".to_string(),
629            description: String::new(),
630            license: "MIT".to_string(),
631            leo: "0.0.0".to_string(),
632            dependencies: if dependencies.is_empty() { None } else { Some(dependencies) },
633            dev_dependencies: None,
634        };
635
636        manifest.write_to_file(member_dir.join(MANIFEST_FILENAME)).unwrap();
637
638        std::fs::write(
639            member_dir.join("src/main.leo"),
640            format!("program {name}.aleo {{\n    @noupgrade\n    constructor() {{}}\n}}\n"),
641        )
642        .unwrap();
643    }
644
645    #[test]
646    fn workspace_resolve_workspace_dep() {
647        let dir = temp_dir().join("ws_test_resolve_ws_dep");
648        let _ = std::fs::remove_dir_all(&dir);
649        std::fs::create_dir_all(&dir).unwrap();
650
651        create_member(&dir, "alpha", &[]);
652        create_member_with_workspace_deps(&dir, "beta", &["alpha"]);
653        create_workspace(&dir, &["alpha", "beta"]);
654
655        let beta_dir = dir.join("beta");
656        let dep =
657            Dependency { name: "alpha.aleo".to_string(), location: Location::Workspace, path: None, edition: None };
658        let resolved = resolve_workspace_dependency(&beta_dir, dep).unwrap();
659        assert_eq!(resolved.location, Location::Local);
660        assert!(resolved.path.is_some());
661        assert!(resolved.path.unwrap().ends_with("alpha"));
662
663        std::fs::remove_dir_all(&dir).unwrap();
664    }
665
666    #[test]
667    fn workspace_dependency_ordering_with_workspace_location() {
668        let dir = temp_dir().join("ws_test_ordering_ws_loc");
669        let _ = std::fs::remove_dir_all(&dir);
670        std::fs::create_dir_all(&dir).unwrap();
671
672        // alpha has no deps, beta depends on alpha via Location::Workspace.
673        create_member(&dir, "alpha", &[]);
674        create_member_with_workspace_deps(&dir, "beta", &["alpha"]);
675        create_workspace(&dir, &["beta", "alpha"]); // intentionally wrong order
676
677        let ws = Workspace::from_directory(&dir).unwrap().unwrap();
678        // alpha should come before beta regardless of manifest order.
679        let names: Vec<&str> = ws.member_names.iter().map(|s| s.as_str()).collect();
680        let alpha_pos = names.iter().position(|n| *n == "alpha.aleo").unwrap();
681        let beta_pos = names.iter().position(|n| *n == "beta.aleo").unwrap();
682        assert!(alpha_pos < beta_pos, "alpha should be ordered before beta");
683
684        std::fs::remove_dir_all(&dir).unwrap();
685    }
686
687    #[test]
688    fn workspace_dep_outside_workspace_errors() {
689        let dir = temp_dir().join("ws_test_dep_no_ws");
690        let _ = std::fs::remove_dir_all(&dir);
691        std::fs::create_dir_all(&dir).unwrap();
692
693        // No workspace.json - just a standalone directory.
694        let dep =
695            Dependency { name: "alpha.aleo".to_string(), location: Location::Workspace, path: None, edition: None };
696        let result = resolve_workspace_dependency(&dir, dep);
697        assert!(result.is_err());
698
699        std::fs::remove_dir_all(&dir).unwrap();
700    }
701
702    #[test]
703    fn workspace_dep_member_not_found_errors() {
704        let dir = temp_dir().join("ws_test_dep_not_found");
705        let _ = std::fs::remove_dir_all(&dir);
706        std::fs::create_dir_all(&dir).unwrap();
707
708        create_member(&dir, "alpha", &[]);
709        create_workspace(&dir, &["alpha"]);
710
711        // Try to resolve a workspace dep on "nonexistent" which is not a member.
712        let dep = Dependency {
713            name: "nonexistent.aleo".to_string(),
714            location: Location::Workspace,
715            path: None,
716            edition: None,
717        };
718        let result = resolve_workspace_dependency(&dir.join("alpha"), dep);
719        assert!(result.is_err());
720
721        std::fs::remove_dir_all(&dir).unwrap();
722    }
723
724    #[test]
725    fn auto_register_appends_new_member() {
726        let dir = temp_dir().join("ws_test_auto_register_basic");
727        let _ = std::fs::remove_dir_all(&dir);
728        std::fs::create_dir_all(&dir).unwrap();
729
730        create_member(&dir, "alpha", &[]);
731        create_workspace(&dir, &["alpha"]);
732
733        create_member(&dir, "beta", &[]);
734        let beta_dir = dir.join("beta");
735        let registered = Workspace::auto_register_member(&beta_dir).unwrap();
736        assert!(registered);
737
738        let manifest = WorkspaceManifest::read_from_file(dir.join(WORKSPACE_MANIFEST_FILENAME)).unwrap();
739        assert_eq!(manifest.members, vec!["alpha".to_string(), "beta".to_string()]);
740
741        std::fs::remove_dir_all(&dir).unwrap();
742    }
743
744    #[test]
745    fn auto_register_skips_when_glob_matches() {
746        let dir = temp_dir().join("ws_test_auto_register_glob");
747        let _ = std::fs::remove_dir_all(&dir);
748        std::fs::create_dir_all(dir.join("packages")).unwrap();
749
750        create_workspace(&dir, &["packages/*"]);
751        create_member(&dir.join("packages"), "foo", &[]);
752        let foo_dir = dir.join("packages/foo");
753
754        let registered = Workspace::auto_register_member(&foo_dir).unwrap();
755        assert!(!registered, "should skip when a glob already covers the new member");
756
757        let manifest = WorkspaceManifest::read_from_file(dir.join(WORKSPACE_MANIFEST_FILENAME)).unwrap();
758        assert_eq!(manifest.members, vec!["packages/*".to_string()]);
759
760        std::fs::remove_dir_all(&dir).unwrap();
761    }
762
763    #[test]
764    fn auto_register_skips_when_already_listed() {
765        let dir = temp_dir().join("ws_test_auto_register_dup");
766        let _ = std::fs::remove_dir_all(&dir);
767        std::fs::create_dir_all(&dir).unwrap();
768
769        create_member(&dir, "foo", &[]);
770        create_workspace(&dir, &["foo"]);
771        let foo_dir = dir.join("foo");
772
773        let registered = Workspace::auto_register_member(&foo_dir).unwrap();
774        assert!(!registered);
775
776        let manifest = WorkspaceManifest::read_from_file(dir.join(WORKSPACE_MANIFEST_FILENAME)).unwrap();
777        assert_eq!(manifest.members, vec!["foo".to_string()]);
778
779        std::fs::remove_dir_all(&dir).unwrap();
780    }
781
782    #[test]
783    fn auto_register_skips_outside_workspace() {
784        let dir = temp_dir().join("ws_test_auto_register_outside");
785        let _ = std::fs::remove_dir_all(&dir);
786        std::fs::create_dir_all(&dir).unwrap();
787
788        // No workspace.json - the member directory has no enclosing workspace.
789        create_member(&dir, "foo", &[]);
790        let foo_dir = dir.join("foo");
791
792        let registered = Workspace::auto_register_member(&foo_dir).unwrap();
793        assert!(!registered, "auto-register should be a no-op when no workspace exists");
794
795        std::fs::remove_dir_all(&dir).unwrap();
796    }
797
798    #[test]
799    fn auto_register_preserves_existing_order() {
800        let dir = temp_dir().join("ws_test_auto_register_order");
801        let _ = std::fs::remove_dir_all(&dir);
802        std::fs::create_dir_all(&dir).unwrap();
803
804        create_member(&dir, "alpha", &[]);
805        create_member(&dir, "charlie", &[]);
806        create_workspace(&dir, &["alpha", "charlie"]);
807
808        create_member(&dir, "beta", &[]);
809        let beta_dir = dir.join("beta");
810        Workspace::auto_register_member(&beta_dir).unwrap();
811
812        let manifest = WorkspaceManifest::read_from_file(dir.join(WORKSPACE_MANIFEST_FILENAME)).unwrap();
813        // New entry appended at the end; existing order preserved.
814        assert_eq!(manifest.members, vec!["alpha".to_string(), "charlie".to_string(), "beta".to_string()]);
815
816        std::fs::remove_dir_all(&dir).unwrap();
817    }
818
819    #[test]
820    fn auto_register_succeeds_despite_broken_member() {
821        // A new package must register even when a sibling member listed in
822        // `workspace.json` is broken: auto-registration only needs the workspace
823        // root, not a fully resolved member list.
824        let dir = temp_dir().join("ws_test_auto_register_broken_member");
825        let _ = std::fs::remove_dir_all(&dir);
826        std::fs::create_dir_all(&dir).unwrap();
827
828        create_member(&dir, "alpha", &[]);
829        // `ghost` is listed but never created - resolving the workspace would fail.
830        create_workspace(&dir, &["alpha", "ghost"]);
831
832        create_member(&dir, "beta", &[]);
833        let beta_dir = dir.join("beta");
834        let registered = Workspace::auto_register_member(&beta_dir).unwrap();
835        assert!(registered, "a new package should register despite a broken sibling member");
836
837        let manifest = WorkspaceManifest::read_from_file(dir.join(WORKSPACE_MANIFEST_FILENAME)).unwrap();
838        assert_eq!(manifest.members, vec!["alpha".to_string(), "ghost".to_string(), "beta".to_string()]);
839
840        std::fs::remove_dir_all(&dir).unwrap();
841    }
842
843    #[test]
844    fn auto_register_registers_glob_subdir() {
845        let dir = temp_dir().join("ws_test_auto_register_glob_subdir");
846        let _ = std::fs::remove_dir_all(&dir);
847        std::fs::create_dir_all(dir.join("packages/sub")).unwrap();
848
849        create_workspace(&dir, &["packages/*"]);
850        create_member(&dir.join("packages/sub"), "foo", &[]);
851        let foo_dir = dir.join("packages/sub/foo");
852
853        let registered = Workspace::auto_register_member(&foo_dir).unwrap();
854        assert!(registered, "`packages/*` does not cover a nested package, so it should be registered");
855
856        let manifest = WorkspaceManifest::read_from_file(dir.join(WORKSPACE_MANIFEST_FILENAME)).unwrap();
857        assert_eq!(manifest.members, vec!["packages/*".to_string(), "packages/sub/foo".to_string()]);
858
859        std::fs::remove_dir_all(&dir).unwrap();
860    }
861
862    #[test]
863    fn auto_register_skips_when_recursive_glob_matches() {
864        let dir = temp_dir().join("ws_test_auto_register_glob_recursive");
865        let _ = std::fs::remove_dir_all(&dir);
866        std::fs::create_dir_all(dir.join("packages/sub")).unwrap();
867
868        create_workspace(&dir, &["packages/**"]);
869        create_member(&dir.join("packages/sub"), "foo", &[]);
870        let foo_dir = dir.join("packages/sub/foo");
871
872        let registered = Workspace::auto_register_member(&foo_dir).unwrap();
873        assert!(!registered, "`packages/**` crosses `/` and covers nested packages, so it should be skipped");
874
875        let manifest = WorkspaceManifest::read_from_file(dir.join(WORKSPACE_MANIFEST_FILENAME)).unwrap();
876        assert_eq!(manifest.members, vec!["packages/**".to_string()]);
877
878        std::fs::remove_dir_all(&dir).unwrap();
879    }
880
881    #[test]
882    fn initialize_skeleton_creates_workspace_json() {
883        let dir = temp_dir().join("ws_test_init_skeleton_basic");
884        let _ = std::fs::remove_dir_all(&dir);
885        std::fs::create_dir_all(&dir).unwrap();
886
887        let full_path = Workspace::initialize_skeleton("my_workspace", &dir).unwrap();
888        assert!(full_path.is_dir());
889        assert_eq!(full_path.file_name().and_then(|n| n.to_str()), Some("my_workspace"));
890
891        let manifest_path = full_path.join(WORKSPACE_MANIFEST_FILENAME);
892        assert!(manifest_path.exists());
893        let manifest = WorkspaceManifest::read_from_file(&manifest_path).unwrap();
894        assert!(manifest.members.is_empty());
895
896        std::fs::remove_dir_all(&dir).unwrap();
897    }
898
899    #[test]
900    fn initialize_skeleton_rejects_existing_dir() {
901        let dir = temp_dir().join("ws_test_init_skeleton_existing");
902        let _ = std::fs::remove_dir_all(&dir);
903        std::fs::create_dir_all(dir.join("my_workspace")).unwrap();
904
905        let result = Workspace::initialize_skeleton("my_workspace", &dir);
906        assert!(result.is_err());
907
908        std::fs::remove_dir_all(&dir).unwrap();
909    }
910
911    #[test]
912    fn initialize_skeleton_rejects_invalid_name() {
913        let dir = temp_dir().join("ws_test_init_skeleton_invalid_name");
914        let _ = std::fs::remove_dir_all(&dir);
915        std::fs::create_dir_all(&dir).unwrap();
916
917        // Underscore-prefixed names are rejected by `is_valid_package_name`.
918        let result = Workspace::initialize_skeleton("_oops", &dir);
919        assert!(result.is_err());
920        // Empty names are rejected.
921        let result = Workspace::initialize_skeleton("", &dir);
922        assert!(result.is_err());
923        // Names containing "aleo" are rejected.
924        let result = Workspace::initialize_skeleton("my_aleo_ws", &dir);
925        assert!(result.is_err());
926
927        std::fs::remove_dir_all(&dir).unwrap();
928    }
929
930    #[test]
931    fn initialize_skeleton_writes_gitignore() {
932        let dir = temp_dir().join("ws_test_init_skeleton_gitignore");
933        let _ = std::fs::remove_dir_all(&dir);
934        std::fs::create_dir_all(&dir).unwrap();
935
936        let full_path = Workspace::initialize_skeleton("my_workspace", &dir).unwrap();
937        let gitignore = full_path.join(".gitignore");
938        assert!(gitignore.exists(), ".gitignore should be created at the workspace root");
939        assert_eq!(std::fs::read_to_string(&gitignore).unwrap(), "build/\n");
940
941        std::fs::remove_dir_all(&dir).unwrap();
942    }
943
944    #[test]
945    fn workspace_rejects_duplicate_program_names() {
946        let dir = temp_dir().join("ws_test_dup_program_names");
947        let _ = std::fs::remove_dir_all(&dir);
948        std::fs::create_dir_all(&dir).unwrap();
949
950        // Two members in distinct directories whose `program.json` declares
951        // the same `program: "token.aleo"`. The shared `<root>/build/token/`
952        // would otherwise race - reject at workspace load time.
953        create_member(&dir, "token", &[]);
954        let other = dir.join("token-v2");
955        std::fs::create_dir_all(other.join("src")).unwrap();
956        let manifest = Manifest {
957            program: "token.aleo".to_string(),
958            version: "0.1.0".to_string(),
959            description: String::new(),
960            license: "MIT".to_string(),
961            leo: "0.0.0".to_string(),
962            dependencies: None,
963            dev_dependencies: None,
964        };
965        manifest.write_to_file(other.join(MANIFEST_FILENAME)).unwrap();
966        std::fs::write(other.join("src/main.leo"), "program token.aleo {\n    @noupgrade\n    constructor() {}\n}\n")
967            .unwrap();
968        create_workspace(&dir, &["token", "token-v2"]);
969
970        let err = Workspace::from_directory(&dir).unwrap_err().to_string();
971        assert!(err.contains("token.aleo"), "expected error to name the duplicated program: {err}");
972
973        std::fs::remove_dir_all(&dir).unwrap();
974    }
975
976    #[test]
977    fn discover_root_returns_workspace_dir_without_resolving_members() {
978        // Verifies the cheap parent-walk: even when a *listed* member is
979        // broken (here, missing entirely), `discover_root` still finds the
980        // workspace root. The full `discover` would error.
981        let dir = temp_dir().join("ws_test_discover_root_cheap");
982        let _ = std::fs::remove_dir_all(&dir);
983        std::fs::create_dir_all(&dir).unwrap();
984        create_workspace(&dir, &["does_not_exist"]);
985
986        let canonical = dir.canonicalize().unwrap();
987        assert_eq!(Workspace::discover_root(&dir).unwrap(), Some(canonical));
988        assert!(Workspace::discover(&dir).is_err());
989
990        std::fs::remove_dir_all(&dir).unwrap();
991    }
992
993    #[test]
994    fn workspace_glob_member_basic() {
995        let dir = temp_dir().join("ws_test_glob_basic");
996        let _ = std::fs::remove_dir_all(&dir);
997        std::fs::create_dir_all(dir.join("programs")).unwrap();
998
999        let programs = dir.join("programs");
1000        create_member(&programs, "alpha", &[]);
1001        create_member(&programs, "beta", &[]);
1002        create_workspace(&dir, &["programs/*"]);
1003
1004        let ws = Workspace::from_directory(&dir).unwrap().unwrap();
1005        assert_eq!(ws.member_paths.len(), 2);
1006        let names: Vec<&str> = ws.member_names.iter().map(|s| s.as_str()).collect();
1007        assert!(names.contains(&"alpha.aleo"));
1008        assert!(names.contains(&"beta.aleo"));
1009
1010        std::fs::remove_dir_all(&dir).unwrap();
1011    }
1012
1013    #[test]
1014    fn workspace_glob_member_recursive() {
1015        let dir = temp_dir().join("ws_test_glob_recursive");
1016        let _ = std::fs::remove_dir_all(&dir);
1017        std::fs::create_dir_all(dir.join("programs/sub")).unwrap();
1018
1019        create_member(&dir.join("programs"), "alpha", &[]);
1020        create_member(&dir.join("programs/sub"), "beta", &[]);
1021        create_workspace(&dir, &["programs/**"]);
1022
1023        let ws = Workspace::from_directory(&dir).unwrap().unwrap();
1024        let names: Vec<&str> = ws.member_names.iter().map(|s| s.as_str()).collect();
1025        assert!(names.contains(&"alpha.aleo"));
1026        assert!(names.contains(&"beta.aleo"));
1027
1028        std::fs::remove_dir_all(&dir).unwrap();
1029    }
1030
1031    #[test]
1032    fn workspace_glob_member_no_match() {
1033        let dir = temp_dir().join("ws_test_glob_no_match");
1034        let _ = std::fs::remove_dir_all(&dir);
1035        std::fs::create_dir_all(&dir).unwrap();
1036
1037        create_workspace(&dir, &["programs/*"]);
1038
1039        let ws = Workspace::from_directory(&dir).unwrap().unwrap();
1040        assert!(ws.member_paths.is_empty());
1041
1042        std::fs::remove_dir_all(&dir).unwrap();
1043    }
1044
1045    #[test]
1046    fn workspace_glob_member_mixed() {
1047        let dir = temp_dir().join("ws_test_glob_mixed");
1048        let _ = std::fs::remove_dir_all(&dir);
1049        std::fs::create_dir_all(dir.join("programs")).unwrap();
1050
1051        create_member(&dir, "literal_one", &[]);
1052        create_member(&dir.join("programs"), "globbed", &[]);
1053        create_workspace(&dir, &["literal_one", "programs/*"]);
1054
1055        let ws = Workspace::from_directory(&dir).unwrap().unwrap();
1056        let names: Vec<&str> = ws.member_names.iter().map(|s| s.as_str()).collect();
1057        assert!(names.contains(&"literal_one.aleo"));
1058        assert!(names.contains(&"globbed.aleo"));
1059
1060        std::fs::remove_dir_all(&dir).unwrap();
1061    }
1062
1063    #[test]
1064    fn workspace_glob_skips_non_packages() {
1065        let dir = temp_dir().join("ws_test_glob_skip_non_pkg");
1066        let _ = std::fs::remove_dir_all(&dir);
1067        let programs = dir.join("programs");
1068        std::fs::create_dir_all(programs.join("junk")).unwrap();
1069
1070        create_member(&programs, "real", &[]);
1071        std::fs::write(programs.join("notes.txt"), "scratch").unwrap();
1072
1073        create_workspace(&dir, &["programs/*"]);
1074
1075        let ws = Workspace::from_directory(&dir).unwrap().unwrap();
1076        assert_eq!(ws.member_paths.len(), 1);
1077        assert_eq!(ws.member_names[0], "real.aleo");
1078
1079        std::fs::remove_dir_all(&dir).unwrap();
1080    }
1081
1082    #[test]
1083    fn workspace_glob_dep_ordering() {
1084        let dir = temp_dir().join("ws_test_glob_dep_order");
1085        let _ = std::fs::remove_dir_all(&dir);
1086        std::fs::create_dir_all(dir.join("programs")).unwrap();
1087
1088        let programs = dir.join("programs");
1089        create_member(&programs, "alpha", &[]);
1090        create_member_with_workspace_deps(&programs, "beta", &["alpha"]);
1091        create_workspace(&dir, &["programs/*"]);
1092
1093        let ws = Workspace::from_directory(&dir).unwrap().unwrap();
1094        let names: Vec<&str> = ws.member_names.iter().map(|s| s.as_str()).collect();
1095        let alpha_pos = names.iter().position(|n| *n == "alpha.aleo").unwrap();
1096        let beta_pos = names.iter().position(|n| *n == "beta.aleo").unwrap();
1097        assert!(alpha_pos < beta_pos, "alpha should be ordered before beta even when discovered via glob");
1098
1099        std::fs::remove_dir_all(&dir).unwrap();
1100    }
1101
1102    #[test]
1103    fn workspace_glob_dedup() {
1104        let dir = temp_dir().join("ws_test_glob_dedup");
1105        let _ = std::fs::remove_dir_all(&dir);
1106        std::fs::create_dir_all(dir.join("programs")).unwrap();
1107
1108        create_member(&dir.join("programs"), "alpha", &[]);
1109        create_workspace(&dir, &["programs/alpha", "programs/*"]);
1110
1111        let ws = Workspace::from_directory(&dir).unwrap().unwrap();
1112        assert_eq!(ws.member_paths.len(), 1, "duplicate member from literal + glob should be deduplicated");
1113
1114        std::fs::remove_dir_all(&dir).unwrap();
1115    }
1116
1117    #[test]
1118    fn workspace_glob_invalid_pattern() {
1119        let dir = temp_dir().join("ws_test_glob_invalid");
1120        let _ = std::fs::remove_dir_all(&dir);
1121        std::fs::create_dir_all(&dir).unwrap();
1122
1123        create_workspace(&dir, &["[invalid"]);
1124
1125        let result = Workspace::from_directory(&dir);
1126        assert!(result.is_err(), "malformed glob pattern should produce a structured error");
1127
1128        std::fs::remove_dir_all(&dir).unwrap();
1129    }
1130
1131    #[test]
1132    fn workspace_member_outside_root_errors() {
1133        let parent = temp_dir().join("ws_test_member_outside_root");
1134        let _ = std::fs::remove_dir_all(&parent);
1135        std::fs::create_dir_all(&parent).unwrap();
1136
1137        // A package that lives next to the workspace, not inside it.
1138        create_member(&parent, "sibling", &[]);
1139
1140        let ws_dir = parent.join("ws");
1141        std::fs::create_dir_all(&ws_dir).unwrap();
1142        create_workspace(&ws_dir, &["../sibling"]);
1143
1144        let result = Workspace::from_directory(&ws_dir);
1145        assert!(result.is_err(), "a member resolving outside the workspace root should be rejected");
1146        let err_msg = format!("{}", result.unwrap_err());
1147        assert!(err_msg.contains("outside the workspace root"), "error should be the outside-root error: {err_msg}");
1148
1149        std::fs::remove_dir_all(&parent).unwrap();
1150    }
1151
1152    #[test]
1153    fn workspace_circular_workspace_deps_error() {
1154        let dir = temp_dir().join("ws_test_circular_ws_deps");
1155        let _ = std::fs::remove_dir_all(&dir);
1156        std::fs::create_dir_all(&dir).unwrap();
1157
1158        // alpha depends on beta, beta depends on alpha, both via Location::Workspace.
1159        create_member_with_workspace_deps(&dir, "alpha", &["beta"]);
1160        create_member_with_workspace_deps(&dir, "beta", &["alpha"]);
1161        create_workspace(&dir, &["alpha", "beta"]);
1162
1163        let result = Workspace::from_directory(&dir);
1164        assert!(result.is_err(), "circular workspace deps should be detected");
1165        let err_msg = format!("{}", result.unwrap_err());
1166        assert!(err_msg.contains("circular"), "error should mention circularity: {err_msg}");
1167
1168        let _ = std::fs::remove_dir_all(&dir);
1169    }
1170}