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