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