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                ..Default::default()
466            })
467            .collect();
468
469        let manifest = Manifest {
470            program: program_name,
471            version: "0.1.0".to_string(),
472            description: String::new(),
473            license: "MIT".to_string(),
474            leo: "0.0.0".to_string(),
475            dependencies: if dependencies.is_empty() { None } else { Some(dependencies) },
476            dev_dependencies: None,
477            no_std: false,
478        };
479
480        manifest.write_to_file(member_dir.join(MANIFEST_FILENAME)).unwrap();
481
482        // Write a minimal source file so the package is valid.
483        std::fs::write(
484            member_dir.join("src/main.leo"),
485            format!("program {name}.aleo {{\n    @noupgrade\n    constructor() {{}}\n}}\n"),
486        )
487        .unwrap();
488    }
489
490    fn create_workspace(dir: &Path, members: &[&str]) {
491        let manifest = WorkspaceManifest { members: members.iter().map(|s| s.to_string()).collect() };
492        manifest.write_to_file(dir.join(WORKSPACE_MANIFEST_FILENAME)).unwrap();
493    }
494
495    #[test]
496    fn workspace_manifest_round_trip() {
497        let dir = temp_dir().join("ws_test_roundtrip");
498        let _ = std::fs::remove_dir_all(&dir);
499        std::fs::create_dir_all(&dir).unwrap();
500
501        let manifest = WorkspaceManifest { members: vec!["alpha".into(), "beta".into()] };
502        let path = dir.join(WORKSPACE_MANIFEST_FILENAME);
503        manifest.write_to_file(&path).unwrap();
504
505        let loaded = WorkspaceManifest::read_from_file(&path).unwrap();
506        assert_eq!(loaded.members, vec!["alpha", "beta"]);
507
508        std::fs::remove_dir_all(&dir).unwrap();
509    }
510
511    #[test]
512    fn workspace_from_directory_valid() {
513        let dir = temp_dir().join("ws_test_valid");
514        let _ = std::fs::remove_dir_all(&dir);
515        std::fs::create_dir_all(&dir).unwrap();
516
517        create_member(&dir, "alpha", &[]);
518        create_member(&dir, "beta", &[]);
519        create_workspace(&dir, &["alpha", "beta"]);
520
521        let ws = Workspace::from_directory(&dir).unwrap().unwrap();
522        assert_eq!(ws.member_paths.len(), 2);
523        assert_eq!(ws.member_names.len(), 2);
524
525        std::fs::remove_dir_all(&dir).unwrap();
526    }
527
528    #[test]
529    fn workspace_from_directory_missing_member() {
530        let dir = temp_dir().join("ws_test_missing");
531        let _ = std::fs::remove_dir_all(&dir);
532        std::fs::create_dir_all(&dir).unwrap();
533
534        create_member(&dir, "alpha", &[]);
535        // "beta" is listed but not created.
536        create_workspace(&dir, &["alpha", "beta"]);
537
538        let result = Workspace::from_directory(&dir);
539        assert!(result.is_err());
540
541        std::fs::remove_dir_all(&dir).unwrap();
542    }
543
544    #[test]
545    fn workspace_discover_from_subdirectory() {
546        let dir = temp_dir().join("ws_test_discover");
547        let _ = std::fs::remove_dir_all(&dir);
548        std::fs::create_dir_all(&dir).unwrap();
549
550        create_member(&dir, "alpha", &[]);
551        create_workspace(&dir, &["alpha"]);
552
553        let member_dir = dir.join("alpha");
554        let ws = Workspace::discover(&member_dir).unwrap().unwrap();
555        assert_eq!(ws.root_directory, dir.canonicalize().unwrap());
556
557        std::fs::remove_dir_all(&dir).unwrap();
558    }
559
560    #[test]
561    fn workspace_discover_none() {
562        let dir = temp_dir().join("ws_test_no_workspace");
563        let _ = std::fs::remove_dir_all(&dir);
564        std::fs::create_dir_all(&dir).unwrap();
565
566        let result = Workspace::discover(&dir).unwrap();
567        assert!(result.is_none());
568
569        std::fs::remove_dir_all(&dir).unwrap();
570    }
571
572    #[test]
573    fn workspace_dependency_ordering() {
574        let dir = temp_dir().join("ws_test_ordering");
575        let _ = std::fs::remove_dir_all(&dir);
576        std::fs::create_dir_all(&dir).unwrap();
577
578        let alpha_dir = dir.join("alpha");
579
580        // alpha has no deps, beta depends on alpha.
581        create_member(&dir, "alpha", &[]);
582        create_member(&dir, "beta", &[("alpha", &alpha_dir)]);
583        create_workspace(&dir, &["beta", "alpha"]); // intentionally wrong order
584
585        let ws = Workspace::from_directory(&dir).unwrap().unwrap();
586        // alpha should come before beta regardless of manifest order.
587        let names: Vec<&str> = ws.member_names.iter().map(|s| s.as_str()).collect();
588        let alpha_pos = names.iter().position(|n| *n == "alpha.aleo").unwrap();
589        let beta_pos = names.iter().position(|n| *n == "beta.aleo").unwrap();
590        assert!(alpha_pos < beta_pos, "alpha should be ordered before beta");
591
592        std::fs::remove_dir_all(&dir).unwrap();
593    }
594
595    #[test]
596    fn workspace_find_member() {
597        let dir = temp_dir().join("ws_test_find");
598        let _ = std::fs::remove_dir_all(&dir);
599        std::fs::create_dir_all(&dir).unwrap();
600
601        create_member(&dir, "alpha", &[]);
602        create_workspace(&dir, &["alpha"]);
603
604        let ws = Workspace::from_directory(&dir).unwrap().unwrap();
605        assert!(ws.find_member("alpha").is_some());
606        assert!(ws.find_member("alpha.aleo").is_some());
607        assert!(ws.find_member("nonexistent").is_none());
608
609        std::fs::remove_dir_all(&dir).unwrap();
610    }
611
612    /// Create a member whose dependencies use `Location::Workspace` (no path).
613    fn create_member_with_workspace_deps(workspace_dir: &Path, name: &str, dep_names: &[&str]) {
614        let member_dir = workspace_dir.join(name);
615        std::fs::create_dir_all(member_dir.join("src")).unwrap();
616
617        let program_name = format!("{name}.aleo");
618        let dependencies: Vec<_> = dep_names
619            .iter()
620            .map(|dep_name| Dependency {
621                name: format!("{dep_name}.aleo"),
622                location: Location::Workspace,
623                path: None,
624                edition: None,
625                ..Default::default()
626            })
627            .collect();
628
629        let manifest = Manifest {
630            program: program_name,
631            version: "0.1.0".to_string(),
632            description: String::new(),
633            license: "MIT".to_string(),
634            leo: "0.0.0".to_string(),
635            dependencies: if dependencies.is_empty() { None } else { Some(dependencies) },
636            dev_dependencies: None,
637            no_std: false,
638        };
639
640        manifest.write_to_file(member_dir.join(MANIFEST_FILENAME)).unwrap();
641
642        std::fs::write(
643            member_dir.join("src/main.leo"),
644            format!("program {name}.aleo {{\n    @noupgrade\n    constructor() {{}}\n}}\n"),
645        )
646        .unwrap();
647    }
648
649    #[test]
650    fn workspace_resolve_workspace_dep() {
651        let dir = temp_dir().join("ws_test_resolve_ws_dep");
652        let _ = std::fs::remove_dir_all(&dir);
653        std::fs::create_dir_all(&dir).unwrap();
654
655        create_member(&dir, "alpha", &[]);
656        create_member_with_workspace_deps(&dir, "beta", &["alpha"]);
657        create_workspace(&dir, &["alpha", "beta"]);
658
659        let beta_dir = dir.join("beta");
660        let dep = Dependency { name: "alpha.aleo".to_string(), location: Location::Workspace, ..Default::default() };
661        let resolved = resolve_workspace_dependency(&beta_dir, dep).unwrap();
662        assert_eq!(resolved.location, Location::Local);
663        assert!(resolved.path.is_some());
664        assert!(resolved.path.unwrap().ends_with("alpha"));
665
666        std::fs::remove_dir_all(&dir).unwrap();
667    }
668
669    #[test]
670    fn workspace_dependency_ordering_with_workspace_location() {
671        let dir = temp_dir().join("ws_test_ordering_ws_loc");
672        let _ = std::fs::remove_dir_all(&dir);
673        std::fs::create_dir_all(&dir).unwrap();
674
675        // alpha has no deps, beta depends on alpha via Location::Workspace.
676        create_member(&dir, "alpha", &[]);
677        create_member_with_workspace_deps(&dir, "beta", &["alpha"]);
678        create_workspace(&dir, &["beta", "alpha"]); // intentionally wrong order
679
680        let ws = Workspace::from_directory(&dir).unwrap().unwrap();
681        // alpha should come before beta regardless of manifest order.
682        let names: Vec<&str> = ws.member_names.iter().map(|s| s.as_str()).collect();
683        let alpha_pos = names.iter().position(|n| *n == "alpha.aleo").unwrap();
684        let beta_pos = names.iter().position(|n| *n == "beta.aleo").unwrap();
685        assert!(alpha_pos < beta_pos, "alpha should be ordered before beta");
686
687        std::fs::remove_dir_all(&dir).unwrap();
688    }
689
690    #[test]
691    fn workspace_dep_outside_workspace_errors() {
692        let dir = temp_dir().join("ws_test_dep_no_ws");
693        let _ = std::fs::remove_dir_all(&dir);
694        std::fs::create_dir_all(&dir).unwrap();
695
696        // No workspace.json - just a standalone directory.
697        let dep = Dependency { name: "alpha.aleo".to_string(), location: Location::Workspace, ..Default::default() };
698        let result = resolve_workspace_dependency(&dir, dep);
699        assert!(result.is_err());
700
701        std::fs::remove_dir_all(&dir).unwrap();
702    }
703
704    #[test]
705    fn workspace_dep_member_not_found_errors() {
706        let dir = temp_dir().join("ws_test_dep_not_found");
707        let _ = std::fs::remove_dir_all(&dir);
708        std::fs::create_dir_all(&dir).unwrap();
709
710        create_member(&dir, "alpha", &[]);
711        create_workspace(&dir, &["alpha"]);
712
713        // Try to resolve a workspace dep on "nonexistent" which is not a member.
714        let dep = Dependency {
715            name: "nonexistent.aleo".to_string(),
716            location: Location::Workspace,
717            path: None,
718            edition: None,
719            ..Default::default()
720        };
721        let result = resolve_workspace_dependency(&dir.join("alpha"), dep);
722        assert!(result.is_err());
723
724        std::fs::remove_dir_all(&dir).unwrap();
725    }
726
727    #[test]
728    fn auto_register_appends_new_member() {
729        let dir = temp_dir().join("ws_test_auto_register_basic");
730        let _ = std::fs::remove_dir_all(&dir);
731        std::fs::create_dir_all(&dir).unwrap();
732
733        create_member(&dir, "alpha", &[]);
734        create_workspace(&dir, &["alpha"]);
735
736        create_member(&dir, "beta", &[]);
737        let beta_dir = dir.join("beta");
738        let registered = Workspace::auto_register_member(&beta_dir).unwrap();
739        assert!(registered);
740
741        let manifest = WorkspaceManifest::read_from_file(dir.join(WORKSPACE_MANIFEST_FILENAME)).unwrap();
742        assert_eq!(manifest.members, vec!["alpha".to_string(), "beta".to_string()]);
743
744        std::fs::remove_dir_all(&dir).unwrap();
745    }
746
747    #[test]
748    fn auto_register_skips_when_glob_matches() {
749        let dir = temp_dir().join("ws_test_auto_register_glob");
750        let _ = std::fs::remove_dir_all(&dir);
751        std::fs::create_dir_all(dir.join("packages")).unwrap();
752
753        create_workspace(&dir, &["packages/*"]);
754        create_member(&dir.join("packages"), "foo", &[]);
755        let foo_dir = dir.join("packages/foo");
756
757        let registered = Workspace::auto_register_member(&foo_dir).unwrap();
758        assert!(!registered, "should skip when a glob already covers the new member");
759
760        let manifest = WorkspaceManifest::read_from_file(dir.join(WORKSPACE_MANIFEST_FILENAME)).unwrap();
761        assert_eq!(manifest.members, vec!["packages/*".to_string()]);
762
763        std::fs::remove_dir_all(&dir).unwrap();
764    }
765
766    #[test]
767    fn auto_register_skips_when_already_listed() {
768        let dir = temp_dir().join("ws_test_auto_register_dup");
769        let _ = std::fs::remove_dir_all(&dir);
770        std::fs::create_dir_all(&dir).unwrap();
771
772        create_member(&dir, "foo", &[]);
773        create_workspace(&dir, &["foo"]);
774        let foo_dir = dir.join("foo");
775
776        let registered = Workspace::auto_register_member(&foo_dir).unwrap();
777        assert!(!registered);
778
779        let manifest = WorkspaceManifest::read_from_file(dir.join(WORKSPACE_MANIFEST_FILENAME)).unwrap();
780        assert_eq!(manifest.members, vec!["foo".to_string()]);
781
782        std::fs::remove_dir_all(&dir).unwrap();
783    }
784
785    #[test]
786    fn auto_register_skips_outside_workspace() {
787        let dir = temp_dir().join("ws_test_auto_register_outside");
788        let _ = std::fs::remove_dir_all(&dir);
789        std::fs::create_dir_all(&dir).unwrap();
790
791        // No workspace.json - the member directory has no enclosing workspace.
792        create_member(&dir, "foo", &[]);
793        let foo_dir = dir.join("foo");
794
795        let registered = Workspace::auto_register_member(&foo_dir).unwrap();
796        assert!(!registered, "auto-register should be a no-op when no workspace exists");
797
798        std::fs::remove_dir_all(&dir).unwrap();
799    }
800
801    #[test]
802    fn auto_register_preserves_existing_order() {
803        let dir = temp_dir().join("ws_test_auto_register_order");
804        let _ = std::fs::remove_dir_all(&dir);
805        std::fs::create_dir_all(&dir).unwrap();
806
807        create_member(&dir, "alpha", &[]);
808        create_member(&dir, "charlie", &[]);
809        create_workspace(&dir, &["alpha", "charlie"]);
810
811        create_member(&dir, "beta", &[]);
812        let beta_dir = dir.join("beta");
813        Workspace::auto_register_member(&beta_dir).unwrap();
814
815        let manifest = WorkspaceManifest::read_from_file(dir.join(WORKSPACE_MANIFEST_FILENAME)).unwrap();
816        // New entry appended at the end; existing order preserved.
817        assert_eq!(manifest.members, vec!["alpha".to_string(), "charlie".to_string(), "beta".to_string()]);
818
819        std::fs::remove_dir_all(&dir).unwrap();
820    }
821
822    #[test]
823    fn auto_register_succeeds_despite_broken_member() {
824        // A new package must register even when a sibling member listed in
825        // `workspace.json` is broken: auto-registration only needs the workspace
826        // root, not a fully resolved member list.
827        let dir = temp_dir().join("ws_test_auto_register_broken_member");
828        let _ = std::fs::remove_dir_all(&dir);
829        std::fs::create_dir_all(&dir).unwrap();
830
831        create_member(&dir, "alpha", &[]);
832        // `ghost` is listed but never created - resolving the workspace would fail.
833        create_workspace(&dir, &["alpha", "ghost"]);
834
835        create_member(&dir, "beta", &[]);
836        let beta_dir = dir.join("beta");
837        let registered = Workspace::auto_register_member(&beta_dir).unwrap();
838        assert!(registered, "a new package should register despite a broken sibling member");
839
840        let manifest = WorkspaceManifest::read_from_file(dir.join(WORKSPACE_MANIFEST_FILENAME)).unwrap();
841        assert_eq!(manifest.members, vec!["alpha".to_string(), "ghost".to_string(), "beta".to_string()]);
842
843        std::fs::remove_dir_all(&dir).unwrap();
844    }
845
846    #[test]
847    fn auto_register_registers_glob_subdir() {
848        let dir = temp_dir().join("ws_test_auto_register_glob_subdir");
849        let _ = std::fs::remove_dir_all(&dir);
850        std::fs::create_dir_all(dir.join("packages/sub")).unwrap();
851
852        create_workspace(&dir, &["packages/*"]);
853        create_member(&dir.join("packages/sub"), "foo", &[]);
854        let foo_dir = dir.join("packages/sub/foo");
855
856        let registered = Workspace::auto_register_member(&foo_dir).unwrap();
857        assert!(registered, "`packages/*` does not cover a nested package, so it should be registered");
858
859        let manifest = WorkspaceManifest::read_from_file(dir.join(WORKSPACE_MANIFEST_FILENAME)).unwrap();
860        assert_eq!(manifest.members, vec!["packages/*".to_string(), "packages/sub/foo".to_string()]);
861
862        std::fs::remove_dir_all(&dir).unwrap();
863    }
864
865    #[test]
866    fn auto_register_skips_when_recursive_glob_matches() {
867        let dir = temp_dir().join("ws_test_auto_register_glob_recursive");
868        let _ = std::fs::remove_dir_all(&dir);
869        std::fs::create_dir_all(dir.join("packages/sub")).unwrap();
870
871        create_workspace(&dir, &["packages/**"]);
872        create_member(&dir.join("packages/sub"), "foo", &[]);
873        let foo_dir = dir.join("packages/sub/foo");
874
875        let registered = Workspace::auto_register_member(&foo_dir).unwrap();
876        assert!(!registered, "`packages/**` crosses `/` and covers nested packages, so it should be skipped");
877
878        let manifest = WorkspaceManifest::read_from_file(dir.join(WORKSPACE_MANIFEST_FILENAME)).unwrap();
879        assert_eq!(manifest.members, vec!["packages/**".to_string()]);
880
881        std::fs::remove_dir_all(&dir).unwrap();
882    }
883
884    #[test]
885    fn initialize_skeleton_creates_workspace_json() {
886        let dir = temp_dir().join("ws_test_init_skeleton_basic");
887        let _ = std::fs::remove_dir_all(&dir);
888        std::fs::create_dir_all(&dir).unwrap();
889
890        let full_path = Workspace::initialize_skeleton("my_workspace", &dir).unwrap();
891        assert!(full_path.is_dir());
892        assert_eq!(full_path.file_name().and_then(|n| n.to_str()), Some("my_workspace"));
893
894        let manifest_path = full_path.join(WORKSPACE_MANIFEST_FILENAME);
895        assert!(manifest_path.exists());
896        let manifest = WorkspaceManifest::read_from_file(&manifest_path).unwrap();
897        assert!(manifest.members.is_empty());
898
899        std::fs::remove_dir_all(&dir).unwrap();
900    }
901
902    #[test]
903    fn initialize_skeleton_rejects_existing_dir() {
904        let dir = temp_dir().join("ws_test_init_skeleton_existing");
905        let _ = std::fs::remove_dir_all(&dir);
906        std::fs::create_dir_all(dir.join("my_workspace")).unwrap();
907
908        let result = Workspace::initialize_skeleton("my_workspace", &dir);
909        assert!(result.is_err());
910
911        std::fs::remove_dir_all(&dir).unwrap();
912    }
913
914    #[test]
915    fn initialize_skeleton_rejects_invalid_name() {
916        let dir = temp_dir().join("ws_test_init_skeleton_invalid_name");
917        let _ = std::fs::remove_dir_all(&dir);
918        std::fs::create_dir_all(&dir).unwrap();
919
920        // Underscore-prefixed names are rejected by `is_valid_package_name`.
921        let result = Workspace::initialize_skeleton("_oops", &dir);
922        assert!(result.is_err());
923        // Empty names are rejected.
924        let result = Workspace::initialize_skeleton("", &dir);
925        assert!(result.is_err());
926        // Names containing "aleo" are rejected.
927        let result = Workspace::initialize_skeleton("my_aleo_ws", &dir);
928        assert!(result.is_err());
929
930        std::fs::remove_dir_all(&dir).unwrap();
931    }
932
933    #[test]
934    fn initialize_skeleton_writes_gitignore() {
935        let dir = temp_dir().join("ws_test_init_skeleton_gitignore");
936        let _ = std::fs::remove_dir_all(&dir);
937        std::fs::create_dir_all(&dir).unwrap();
938
939        let full_path = Workspace::initialize_skeleton("my_workspace", &dir).unwrap();
940        let gitignore = full_path.join(".gitignore");
941        assert!(gitignore.exists(), ".gitignore should be created at the workspace root");
942        assert_eq!(std::fs::read_to_string(&gitignore).unwrap(), "build/\n");
943
944        std::fs::remove_dir_all(&dir).unwrap();
945    }
946
947    #[test]
948    fn workspace_rejects_duplicate_program_names() {
949        let dir = temp_dir().join("ws_test_dup_program_names");
950        let _ = std::fs::remove_dir_all(&dir);
951        std::fs::create_dir_all(&dir).unwrap();
952
953        // Two members in distinct directories whose `program.json` declares
954        // the same `program: "token.aleo"`. The shared `<root>/build/token/`
955        // would otherwise race - reject at workspace load time.
956        create_member(&dir, "token", &[]);
957        let other = dir.join("token-v2");
958        std::fs::create_dir_all(other.join("src")).unwrap();
959        let manifest = Manifest {
960            program: "token.aleo".to_string(),
961            version: "0.1.0".to_string(),
962            description: String::new(),
963            license: "MIT".to_string(),
964            leo: "0.0.0".to_string(),
965            no_std: false,
966            dependencies: None,
967            dev_dependencies: None,
968        };
969        manifest.write_to_file(other.join(MANIFEST_FILENAME)).unwrap();
970        std::fs::write(other.join("src/main.leo"), "program token.aleo {\n    @noupgrade\n    constructor() {}\n}\n")
971            .unwrap();
972        create_workspace(&dir, &["token", "token-v2"]);
973
974        let err = Workspace::from_directory(&dir).unwrap_err().to_string();
975        assert!(err.contains("token.aleo"), "expected error to name the duplicated program: {err}");
976
977        std::fs::remove_dir_all(&dir).unwrap();
978    }
979
980    #[test]
981    fn discover_root_returns_workspace_dir_without_resolving_members() {
982        // Verifies the cheap parent-walk: even when a *listed* member is
983        // broken (here, missing entirely), `discover_root` still finds the
984        // workspace root. The full `discover` would error.
985        let dir = temp_dir().join("ws_test_discover_root_cheap");
986        let _ = std::fs::remove_dir_all(&dir);
987        std::fs::create_dir_all(&dir).unwrap();
988        create_workspace(&dir, &["does_not_exist"]);
989
990        let canonical = dir.canonicalize().unwrap();
991        assert_eq!(Workspace::discover_root(&dir).unwrap(), Some(canonical));
992        assert!(Workspace::discover(&dir).is_err());
993
994        std::fs::remove_dir_all(&dir).unwrap();
995    }
996
997    #[test]
998    fn workspace_glob_member_basic() {
999        let dir = temp_dir().join("ws_test_glob_basic");
1000        let _ = std::fs::remove_dir_all(&dir);
1001        std::fs::create_dir_all(dir.join("programs")).unwrap();
1002
1003        let programs = dir.join("programs");
1004        create_member(&programs, "alpha", &[]);
1005        create_member(&programs, "beta", &[]);
1006        create_workspace(&dir, &["programs/*"]);
1007
1008        let ws = Workspace::from_directory(&dir).unwrap().unwrap();
1009        assert_eq!(ws.member_paths.len(), 2);
1010        let names: Vec<&str> = ws.member_names.iter().map(|s| s.as_str()).collect();
1011        assert!(names.contains(&"alpha.aleo"));
1012        assert!(names.contains(&"beta.aleo"));
1013
1014        std::fs::remove_dir_all(&dir).unwrap();
1015    }
1016
1017    #[test]
1018    fn workspace_glob_member_recursive() {
1019        let dir = temp_dir().join("ws_test_glob_recursive");
1020        let _ = std::fs::remove_dir_all(&dir);
1021        std::fs::create_dir_all(dir.join("programs/sub")).unwrap();
1022
1023        create_member(&dir.join("programs"), "alpha", &[]);
1024        create_member(&dir.join("programs/sub"), "beta", &[]);
1025        create_workspace(&dir, &["programs/**"]);
1026
1027        let ws = Workspace::from_directory(&dir).unwrap().unwrap();
1028        let names: Vec<&str> = ws.member_names.iter().map(|s| s.as_str()).collect();
1029        assert!(names.contains(&"alpha.aleo"));
1030        assert!(names.contains(&"beta.aleo"));
1031
1032        std::fs::remove_dir_all(&dir).unwrap();
1033    }
1034
1035    #[test]
1036    fn workspace_glob_member_no_match() {
1037        let dir = temp_dir().join("ws_test_glob_no_match");
1038        let _ = std::fs::remove_dir_all(&dir);
1039        std::fs::create_dir_all(&dir).unwrap();
1040
1041        create_workspace(&dir, &["programs/*"]);
1042
1043        let ws = Workspace::from_directory(&dir).unwrap().unwrap();
1044        assert!(ws.member_paths.is_empty());
1045
1046        std::fs::remove_dir_all(&dir).unwrap();
1047    }
1048
1049    #[test]
1050    fn workspace_glob_member_mixed() {
1051        let dir = temp_dir().join("ws_test_glob_mixed");
1052        let _ = std::fs::remove_dir_all(&dir);
1053        std::fs::create_dir_all(dir.join("programs")).unwrap();
1054
1055        create_member(&dir, "literal_one", &[]);
1056        create_member(&dir.join("programs"), "globbed", &[]);
1057        create_workspace(&dir, &["literal_one", "programs/*"]);
1058
1059        let ws = Workspace::from_directory(&dir).unwrap().unwrap();
1060        let names: Vec<&str> = ws.member_names.iter().map(|s| s.as_str()).collect();
1061        assert!(names.contains(&"literal_one.aleo"));
1062        assert!(names.contains(&"globbed.aleo"));
1063
1064        std::fs::remove_dir_all(&dir).unwrap();
1065    }
1066
1067    #[test]
1068    fn workspace_glob_skips_non_packages() {
1069        let dir = temp_dir().join("ws_test_glob_skip_non_pkg");
1070        let _ = std::fs::remove_dir_all(&dir);
1071        let programs = dir.join("programs");
1072        std::fs::create_dir_all(programs.join("junk")).unwrap();
1073
1074        create_member(&programs, "real", &[]);
1075        std::fs::write(programs.join("notes.txt"), "scratch").unwrap();
1076
1077        create_workspace(&dir, &["programs/*"]);
1078
1079        let ws = Workspace::from_directory(&dir).unwrap().unwrap();
1080        assert_eq!(ws.member_paths.len(), 1);
1081        assert_eq!(ws.member_names[0], "real.aleo");
1082
1083        std::fs::remove_dir_all(&dir).unwrap();
1084    }
1085
1086    #[test]
1087    fn workspace_glob_dep_ordering() {
1088        let dir = temp_dir().join("ws_test_glob_dep_order");
1089        let _ = std::fs::remove_dir_all(&dir);
1090        std::fs::create_dir_all(dir.join("programs")).unwrap();
1091
1092        let programs = dir.join("programs");
1093        create_member(&programs, "alpha", &[]);
1094        create_member_with_workspace_deps(&programs, "beta", &["alpha"]);
1095        create_workspace(&dir, &["programs/*"]);
1096
1097        let ws = Workspace::from_directory(&dir).unwrap().unwrap();
1098        let names: Vec<&str> = ws.member_names.iter().map(|s| s.as_str()).collect();
1099        let alpha_pos = names.iter().position(|n| *n == "alpha.aleo").unwrap();
1100        let beta_pos = names.iter().position(|n| *n == "beta.aleo").unwrap();
1101        assert!(alpha_pos < beta_pos, "alpha should be ordered before beta even when discovered via glob");
1102
1103        std::fs::remove_dir_all(&dir).unwrap();
1104    }
1105
1106    #[test]
1107    fn workspace_glob_dedup() {
1108        let dir = temp_dir().join("ws_test_glob_dedup");
1109        let _ = std::fs::remove_dir_all(&dir);
1110        std::fs::create_dir_all(dir.join("programs")).unwrap();
1111
1112        create_member(&dir.join("programs"), "alpha", &[]);
1113        create_workspace(&dir, &["programs/alpha", "programs/*"]);
1114
1115        let ws = Workspace::from_directory(&dir).unwrap().unwrap();
1116        assert_eq!(ws.member_paths.len(), 1, "duplicate member from literal + glob should be deduplicated");
1117
1118        std::fs::remove_dir_all(&dir).unwrap();
1119    }
1120
1121    #[test]
1122    fn workspace_glob_invalid_pattern() {
1123        let dir = temp_dir().join("ws_test_glob_invalid");
1124        let _ = std::fs::remove_dir_all(&dir);
1125        std::fs::create_dir_all(&dir).unwrap();
1126
1127        create_workspace(&dir, &["[invalid"]);
1128
1129        let result = Workspace::from_directory(&dir);
1130        assert!(result.is_err(), "malformed glob pattern should produce a structured error");
1131
1132        std::fs::remove_dir_all(&dir).unwrap();
1133    }
1134
1135    #[test]
1136    fn workspace_member_outside_root_errors() {
1137        let parent = temp_dir().join("ws_test_member_outside_root");
1138        let _ = std::fs::remove_dir_all(&parent);
1139        std::fs::create_dir_all(&parent).unwrap();
1140
1141        // A package that lives next to the workspace, not inside it.
1142        create_member(&parent, "sibling", &[]);
1143
1144        let ws_dir = parent.join("ws");
1145        std::fs::create_dir_all(&ws_dir).unwrap();
1146        create_workspace(&ws_dir, &["../sibling"]);
1147
1148        let result = Workspace::from_directory(&ws_dir);
1149        assert!(result.is_err(), "a member resolving outside the workspace root should be rejected");
1150        let err_msg = format!("{}", result.unwrap_err());
1151        assert!(err_msg.contains("outside the workspace root"), "error should be the outside-root error: {err_msg}");
1152
1153        std::fs::remove_dir_all(&parent).unwrap();
1154    }
1155
1156    #[test]
1157    fn workspace_circular_workspace_deps_error() {
1158        let dir = temp_dir().join("ws_test_circular_ws_deps");
1159        let _ = std::fs::remove_dir_all(&dir);
1160        std::fs::create_dir_all(&dir).unwrap();
1161
1162        // alpha depends on beta, beta depends on alpha, both via Location::Workspace.
1163        create_member_with_workspace_deps(&dir, "alpha", &["beta"]);
1164        create_member_with_workspace_deps(&dir, "beta", &["alpha"]);
1165        create_workspace(&dir, &["alpha", "beta"]);
1166
1167        let result = Workspace::from_directory(&dir);
1168        assert!(result.is_err(), "circular workspace deps should be detected");
1169        let err_msg = format!("{}", result.unwrap_err());
1170        assert!(err_msg.contains("circular"), "error should mention circularity: {err_msg}");
1171
1172        let _ = std::fs::remove_dir_all(&dir);
1173    }
1174}