1use 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#[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#[derive(Debug, Clone)]
50pub struct Workspace {
51 pub root_directory: PathBuf,
53 pub member_paths: Vec<PathBuf>,
55 pub member_names: Vec<String>,
57}
58
59impl Workspace {
60 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 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 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 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 pub fn find_member(&self, name: &str) -> Option<&PathBuf> {
124 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 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 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 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 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 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 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
225fn 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
243pub 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
257fn is_glob_pattern(s: &str) -> bool {
259 s.contains(['*', '?', '['])
260}
261
262fn 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 out.push(relative_str.replace('\\', "/"));
285 }
286 Ok(out)
287}
288
289fn pattern_matches_relative(patterns: &[String], relative: &str) -> bool {
292 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
304fn 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 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
325fn order_members(members: &[(PathBuf, String)]) -> Result<Vec<(PathBuf, String)>> {
333 if members.len() <= 1 {
335 return Ok(members.to_vec());
336 }
337
338 let mut graph = DiGraph::<String>::new(Default::default());
339
340 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 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 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 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 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 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 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 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 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 create_member(&dir, "alpha", &[]);
557 create_member(&dir, "beta", &[("alpha", &alpha_dir)]);
558 create_workspace(&dir, &["beta", "alpha"]); let ws = Workspace::from_directory(&dir).unwrap().unwrap();
561 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 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 create_member(&dir, "alpha", &[]);
651 create_member_with_workspace_deps(&dir, "beta", &["alpha"]);
652 create_workspace(&dir, &["beta", "alpha"]); let ws = Workspace::from_directory(&dir).unwrap().unwrap();
655 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 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 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 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 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 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 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 let result = Workspace::initialize_skeleton("_oops", &dir);
896 assert!(result.is_err());
897 let result = Workspace::initialize_skeleton("", &dir);
899 assert!(result.is_err());
900 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 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 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}