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