Skip to main content

leo_package/
package.rs

1// Copyright (C) 2019-2026 Provable Inc.
2// This file is part of the Leo library.
3
4// The Leo library is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8
9// The Leo library is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12// GNU General Public License for more details.
13
14// You should have received a copy of the GNU General Public License
15// along with the Leo library. If not, see <https://www.gnu.org/licenses/>.
16
17use crate::*;
18
19use leo_ast::DiGraph;
20use leo_errors::Result;
21use leo_span::Symbol;
22
23use indexmap::{IndexMap, map::Entry};
24use snarkvm::prelude::anyhow;
25use std::path::{Path, PathBuf};
26
27/// Either the bytecode of an Aleo program (if it was a network dependency) or
28/// a path to its source (if it was local).
29#[derive(Clone, Debug)]
30pub enum ProgramData {
31    Bytecode(String),
32    /// For a local dependency, `directory` is the directory of the package
33    /// For a test dependency, `directory` is the directory of the test file.
34    SourcePath {
35        directory: PathBuf,
36        source: PathBuf,
37    },
38}
39
40/// A Leo package.
41#[derive(Clone, Debug)]
42pub struct Package {
43    /// The directory on the filesystem where the package is located, canonicalized.
44    pub base_directory: PathBuf,
45
46    /// Canonicalized workspace root, when the package lives inside a workspace
47    /// tree (an ancestor directory contains `workspace.json`). `None` for
48    /// standalone packages. When `Some`, `build_directory()` returns
49    /// `<workspace_root>/build/` so every package under the workspace root -
50    /// member or not - shares one flat, unit-keyed build root, and a unit
51    /// built once by any member is reused structurally by all the others.
52    /// Populated once in `from_directory_impl`; never mutated afterwards.
53    pub workspace_root: Option<PathBuf>,
54
55    /// A topologically sorted list of all compilation units in this package, whether
56    /// dependencies or the main program.
57    ///
58    /// Any unit's dependent unit will appear before it, so that compiling
59    /// them in order should give access to all stubs necessary to compile each
60    /// compilation unit.
61    pub compilation_units: Vec<CompilationUnit>,
62
63    /// The manifest file of this package.
64    pub manifest: Manifest,
65
66    /// The dependency graph of the package.
67    pub dep_graph: DiGraph<Symbol>,
68}
69
70impl Package {
71    /// The root of the build directory.
72    ///
73    /// This is the single place that knows where build artifacts are rooted;
74    /// every per-unit path below is composed from it. For a package inside a
75    /// workspace tree this returns `<workspace_root>/build/` so every member
76    /// shares one flat, unit-keyed build root; for a standalone package it
77    /// returns `<base_directory>/build/`.
78    pub fn build_directory(&self) -> PathBuf {
79        self.workspace_root.as_deref().unwrap_or(&self.base_directory).join(BUILD_DIRECTORY)
80    }
81
82    /// The package's own compilation unit, identified via the manifest.
83    /// Robust under `--build-tests` (unlike `compilation_units.last()`).
84    pub fn primary_unit(&self) -> Option<&CompilationUnit> {
85        let primary = bare_unit_name(&self.manifest.program);
86        self.compilation_units.iter().find(|u| !u.kind.is_test() && bare_unit_name(&u.name.to_string()) == primary)
87    }
88
89    /// The `build/<name>/` directory for a single compilation unit - a program,
90    /// library, or test - whether it is this package's own unit, a local
91    /// dependency, or a fetched network import.
92    pub fn unit_build_directory(&self, name: &str) -> PathBuf {
93        self.build_directory().join(bare_unit_name(name))
94    }
95
96    /// Path to a unit's compiled Aleo bytecode: `build/<name>/<name>.aleo`.
97    /// Only programs and tests produce bytecode; libraries do not.
98    pub fn unit_bytecode_path(&self, name: &str) -> PathBuf {
99        let bare = bare_unit_name(name);
100        self.unit_build_directory(name).join(format!("{bare}.aleo"))
101    }
102
103    /// Path to a unit's Leo ABI: `build/<name>/abi.json`.
104    pub fn unit_abi_path(&self, name: &str) -> PathBuf {
105        self.unit_build_directory(name).join(ABI_FILENAME)
106    }
107
108    /// Path to a unit's interface ABI directory: `build/<name>/interfaces/`.
109    /// Both programs and libraries can declare interfaces.
110    pub fn unit_interfaces_directory(&self, name: &str) -> PathBuf {
111        self.unit_build_directory(name).join(INTERFACES_DIRNAME)
112    }
113
114    pub fn source_directory(&self) -> PathBuf {
115        self.base_directory.join(SOURCE_DIRECTORY)
116    }
117
118    pub fn tests_directory(&self) -> PathBuf {
119        self.base_directory.join(TESTS_DIRECTORY)
120    }
121
122    /// Create a Leo package by the name `package_name` in a subdirectory of `path`.
123    pub fn initialize<P: AsRef<Path>>(package_name: &str, path: P, is_library: bool) -> Result<PathBuf> {
124        Self::initialize_impl(package_name, path.as_ref(), is_library)
125    }
126
127    fn initialize_impl(package_name: &str, path: &Path, is_library: bool) -> Result<PathBuf> {
128        let package_name = if is_library {
129            if !crate::is_valid_library_name(package_name) {
130                return Err(crate::errors::cli_invalid_package_name("library", package_name).into());
131            }
132
133            package_name.to_string()
134        } else {
135            let program_name =
136                if package_name.ends_with(".aleo") { package_name.to_string() } else { format!("{package_name}.aleo") };
137
138            if !crate::is_valid_program_name(&program_name) {
139                return Err(crate::errors::cli_invalid_package_name("program", &program_name).into());
140            }
141
142            program_name
143        };
144
145        let path = path.canonicalize().map_err(|e| crate::errors::failed_path(path.display(), e))?;
146        let full_path = path.join(package_name.strip_suffix(".aleo").unwrap_or(&package_name));
147
148        // Verify that there is no existing directory at the path.
149        if full_path.exists() {
150            return Err(
151                crate::errors::failed_to_initialize_package(package_name, &path, "Directory already exists").into()
152            );
153        }
154
155        // Create the package directory.
156        std::fs::create_dir(&full_path)
157            .map_err(|e| crate::errors::failed_to_initialize_package(&package_name, &full_path, e))?;
158
159        // Change the current working directory to the package directory.
160        std::env::set_current_dir(&full_path)
161            .map_err(|e| crate::errors::failed_to_initialize_package(&package_name, &full_path, e))?;
162
163        // Create .gitignore
164        const GITIGNORE_TEMPLATE: &str = ".env\n*.avm\n*.prover\n*.verifier\nbuild/\n";
165        const GITIGNORE_FILENAME: &str = ".gitignore";
166
167        let gitignore_path = full_path.join(GITIGNORE_FILENAME);
168        std::fs::write(gitignore_path, GITIGNORE_TEMPLATE).map_err(crate::errors::io_error_gitignore_file)?;
169
170        // Create manifest
171        let manifest = Manifest {
172            program: package_name.clone(),
173            version: "0.1.0".to_string(),
174            description: String::new(),
175            license: "MIT".to_string(),
176            leo: env!("CARGO_PKG_VERSION").to_string(),
177            dependencies: None,
178            dev_dependencies: None,
179            no_std: false,
180        };
181
182        let manifest_path = full_path.join(MANIFEST_FILENAME);
183        manifest.write_to_file(manifest_path)?;
184
185        // Create src/
186        let source_path = full_path.join(SOURCE_DIRECTORY);
187
188        std::fs::create_dir(&source_path)
189            .map_err(|e| crate::errors::failed_to_create_source_directory(source_path.display(), e))?;
190
191        let name_no_aleo = package_name.strip_suffix(".aleo").unwrap_or(&package_name);
192
193        if is_library {
194            // Create lib.leo with a placeholder function.
195            let lib_path = source_path.join("lib.leo");
196
197            std::fs::write(&lib_path, lib_template(name_no_aleo)).map_err(|e| {
198                crate::errors::util_file_io_error(format_args!("Failed to write `{}`", lib_path.display()), e)
199            })?;
200
201            // Create tests directory with a starter test file.
202            let tests_path = full_path.join(TESTS_DIRECTORY);
203
204            std::fs::create_dir(&tests_path)
205                .map_err(|e| crate::errors::failed_to_create_source_directory(tests_path.display(), e))?;
206
207            let test_file_path = tests_path.join(format!("test_{name_no_aleo}.leo"));
208
209            std::fs::write(&test_file_path, lib_test_template(name_no_aleo)).map_err(|e| {
210                crate::errors::util_file_io_error(format_args!("Failed to write `{}`", test_file_path.display()), e)
211            })?;
212        } else {
213            // Create main.leo
214            let main_path = source_path.join(MAIN_FILENAME);
215
216            std::fs::write(&main_path, main_template(name_no_aleo)).map_err(|e| {
217                crate::errors::util_file_io_error(format_args!("Failed to write `{}`", main_path.display()), e)
218            })?;
219
220            // Create tests directory
221            let tests_path = full_path.join(TESTS_DIRECTORY);
222
223            std::fs::create_dir(&tests_path)
224                .map_err(|e| crate::errors::failed_to_create_source_directory(tests_path.display(), e))?;
225
226            let test_file_path = tests_path.join(format!("test_{name_no_aleo}.leo"));
227
228            std::fs::write(&test_file_path, test_template(name_no_aleo)).map_err(|e| {
229                crate::errors::util_file_io_error(format_args!("Failed to write `{}`", test_file_path.display()), e)
230            })?;
231        }
232
233        Ok(full_path)
234    }
235
236    /// Examine the Leo package at `path` to create a `Package`, but don't find dependencies.
237    ///
238    /// This may be useful if you just need other information like the manifest file.
239    pub fn from_directory_no_graph<P: AsRef<Path>, Q: AsRef<Path>>(
240        path: P,
241        home_path: Q,
242        network: Option<NetworkName>,
243        endpoint: Option<&str>,
244        network_retries: u32,
245    ) -> Result<Self> {
246        Self::from_directory_impl(
247            path.as_ref(),
248            home_path.as_ref(),
249            /* build_graph */ false,
250            /* with_tests */ false,
251            /* no_cache */ false,
252            /* no_local */ false,
253            /* offline */ false,
254            network,
255            endpoint,
256            network_retries,
257        )
258    }
259
260    /// Examine the Leo package at `path` to create a `Package`, including all its dependencies,
261    /// obtaining dependencies from the file system or network and topologically sorting them.
262    #[allow(clippy::too_many_arguments)]
263    pub fn from_directory<P: AsRef<Path>, Q: AsRef<Path>>(
264        path: P,
265        home_path: Q,
266        no_cache: bool,
267        no_local: bool,
268        offline: bool,
269        network: Option<NetworkName>,
270        endpoint: Option<&str>,
271        network_retries: u32,
272    ) -> Result<Self> {
273        Self::from_directory_impl(
274            path.as_ref(),
275            home_path.as_ref(),
276            /* build_graph */ true,
277            /* with_tests */ false,
278            no_cache,
279            no_local,
280            offline,
281            network,
282            endpoint,
283            network_retries,
284        )
285    }
286
287    /// Examine the Leo package at `path` to create a `Package`, including all its dependencies
288    /// and its tests, obtaining dependencies from the file system or network and topologically sorting them.
289    #[allow(clippy::too_many_arguments)]
290    pub fn from_directory_with_tests<P: AsRef<Path>, Q: AsRef<Path>>(
291        path: P,
292        home_path: Q,
293        no_cache: bool,
294        no_local: bool,
295        offline: bool,
296        network: Option<NetworkName>,
297        endpoint: Option<&str>,
298        network_retries: u32,
299    ) -> Result<Self> {
300        Self::from_directory_impl(
301            path.as_ref(),
302            home_path.as_ref(),
303            /* build_graph */ true,
304            /* with_tests */ true,
305            no_cache,
306            no_local,
307            offline,
308            network,
309            endpoint,
310            network_retries,
311        )
312    }
313
314    pub fn test_files(&self) -> impl Iterator<Item = PathBuf> {
315        let path = self.tests_directory();
316        // This allocation isn't ideal but it's not performance critical and
317        // easily resolves lifetime issues.
318        let data: Vec<PathBuf> = Self::files_with_extension(&path, "leo").collect();
319        data.into_iter()
320    }
321
322    fn files_with_extension(path: &Path, extension: &'static str) -> impl Iterator<Item = PathBuf> {
323        path.read_dir()
324            .ok()
325            .into_iter()
326            .flatten()
327            .flat_map(|maybe_filename| maybe_filename.ok())
328            .filter(|entry| entry.file_type().ok().map(|filetype| filetype.is_file()).unwrap_or(false))
329            .flat_map(move |entry| {
330                let path = entry.path();
331                if path.extension().is_some_and(|e| e == extension) { Some(path) } else { None }
332            })
333    }
334
335    #[allow(clippy::too_many_arguments)]
336    fn from_directory_impl(
337        path: &Path,
338        home_path: &Path,
339        build_graph: bool,
340        with_tests: bool,
341        no_cache: bool,
342        no_local: bool,
343        offline: bool,
344        network: Option<NetworkName>,
345        endpoint: Option<&str>,
346        network_retries: u32,
347    ) -> Result<Self> {
348        let map_err = |path: &Path, err| {
349            crate::errors::util_file_io_error(format_args!("Trying to find path at {}", path.display()), err)
350        };
351
352        let path = path.canonicalize().map_err(|err| map_err(path, err))?;
353
354        // Detect an enclosing workspace so build artifacts route to a shared
355        // `<workspace_root>/build/`. The walk only checks for `workspace.json`
356        // (no manifest parsing, no member resolution), so it is cheap.
357        let workspace_root = Workspace::discover_root(&path)?;
358
359        let manifest = Manifest::read_from_file(path.join(MANIFEST_FILENAME))?;
360
361        let (compilation_units, digraph) = if build_graph {
362            let home_path = home_path.canonicalize().map_err(|err| map_err(home_path, err))?;
363
364            let mut map: IndexMap<Symbol, (Dependency, CompilationUnit)> = IndexMap::new();
365
366            let mut digraph = DiGraph::<Symbol>::new(Default::default());
367
368            // Pre-collect all declared dependencies from the manifest tree so that
369            // .aleo file import classification doesn't depend on processing order.
370            let declared_deps = collect_declared_deps(&path, &manifest, with_tests)?;
371
372            // The lock lives at the workspace root, else beside this package's `program.json`.
373            let lock_dir = workspace_root.as_deref().unwrap_or(&path).to_path_buf();
374            // New lock records only this build's resolutions; others are carried over from the old lock after.
375            let old_lock = Lock::read(&lock_dir);
376            let mut new_lock = Lock::default();
377
378            let first_dependency = Dependency {
379                name: manifest.program.clone(),
380                location: Location::Local,
381                path: Some(path.clone()),
382                edition: None,
383                ..Default::default()
384            };
385
386            let test_dependencies: Vec<Dependency> = if with_tests {
387                let tests_directory = path.join(TESTS_DIRECTORY);
388                let mut test_dependencies: Vec<Dependency> = Self::files_with_extension(&tests_directory, "leo")
389                    .map(|path| Dependency {
390                        // We just made sure it has a ".leo" extension.
391                        name: format!("{}.aleo", crate::filename_no_leo_extension(&path).unwrap()),
392                        edition: None,
393                        location: Location::Test,
394                        path: Some(path.to_path_buf()),
395                        ..Default::default()
396                    })
397                    .collect();
398                if let Some(deps) = manifest.dev_dependencies.as_ref() {
399                    // Canonicalize dev-dependency paths like regular dependencies, so the same local
400                    // library in both lists dedups instead of comparing relative against absolute.
401                    for dep in deps {
402                        let dep = canonicalize_dependency_path_relative_to(&path, dep.clone())?;
403                        let dep = if dep.location == Location::Workspace {
404                            resolve_workspace_dependency(&path, dep)?
405                        } else {
406                            dep
407                        };
408                        test_dependencies.push(dep);
409                    }
410                }
411                test_dependencies
412            } else {
413                Vec::new()
414            };
415
416            for dependency in test_dependencies.into_iter().chain(std::iter::once(first_dependency.clone())) {
417                Self::graph_build(
418                    &home_path,
419                    network,
420                    endpoint,
421                    &first_dependency,
422                    dependency,
423                    &mut map,
424                    &mut digraph,
425                    no_cache,
426                    no_local,
427                    network_retries,
428                    &declared_deps,
429                    &old_lock,
430                    &mut new_lock,
431                    offline,
432                )?;
433            }
434
435            // Workspace: carry all entries since the lock is shared. Standalone: carry only dev-git
436            // names (a plain build skips dev deps, so their pins may legitimately be unresolved).
437            if workspace_root.is_some() {
438                new_lock.carry_over(&old_lock, |_| true);
439            } else {
440                let dev_git_names: Vec<&str> = if with_tests {
441                    Vec::new()
442                } else {
443                    manifest
444                        .dev_dependencies
445                        .iter()
446                        .flatten()
447                        .filter(|dep| dep.location == Location::Git)
448                        .map(|dep| dep.name.as_str())
449                        .collect()
450                };
451                new_lock.carry_over(&old_lock, |entry| dev_git_names.contains(&entry.name.as_str()));
452            }
453            // Persist the lock (and drop a stale one when no git deps remain).
454            new_lock.write(&lock_dir)?;
455
456            let ordered_dependency_symbols =
457                digraph.post_order().map_err(|_| crate::errors::circular_dependency_error())?;
458
459            (
460                ordered_dependency_symbols.into_iter().map(|symbol| map.swap_remove(&symbol).unwrap().1).collect(),
461                digraph,
462            )
463        } else {
464            (Vec::new(), DiGraph::default())
465        };
466
467        Ok(Package { base_directory: path, workspace_root, compilation_units, manifest, dep_graph: digraph })
468    }
469
470    #[allow(clippy::too_many_arguments)]
471    fn graph_build(
472        home_path: &Path,
473        network: Option<NetworkName>,
474        endpoint: Option<&str>,
475        main_program: &Dependency,
476        new: Dependency,
477        map: &mut IndexMap<Symbol, (Dependency, CompilationUnit)>,
478        graph: &mut DiGraph<Symbol>,
479        no_cache: bool,
480        no_local: bool,
481        network_retries: u32,
482        declared_deps: &IndexMap<Symbol, Dependency>,
483        old_lock: &Lock,
484        new_lock: &mut Lock,
485        offline: bool,
486    ) -> Result<()> {
487        let name_symbol = symbol(&new.name)?;
488
489        let unit = match map.entry(name_symbol) {
490            Entry::Occupied(occupied) => {
491                // We've already visited this dependency. Just make sure it's compatible with
492                // the one we already have.
493                let existing_dep = &occupied.get().0;
494                assert_eq!(new.name, existing_dep.name);
495                if new.location != existing_dep.location
496                    || new.path != existing_dep.path
497                    || new.edition != existing_dep.edition
498                    || new.git != existing_dep.git
499                {
500                    return Err(crate::errors::conflicting_dependency(existing_dep, new).into());
501                }
502                return Ok(());
503            }
504            Entry::Vacant(vacant) => {
505                let unit = match (new.path.as_ref(), new.location) {
506                    (Some(path), Location::Local) if !no_local => {
507                        // It's a local dependency.
508                        if path.extension().and_then(|p| p.to_str()) == Some("aleo") && path.is_file() {
509                            CompilationUnit::from_aleo_path(name_symbol, path, declared_deps)?
510                        } else {
511                            CompilationUnit::from_package_path(name_symbol, path)?
512                        }
513                    }
514                    (Some(path), Location::Test) => {
515                        // It's a test dependency - the path points to the source file,
516                        // not a package.
517                        CompilationUnit::from_test_path(path, main_program.clone())?
518                    }
519                    (_, Location::Network) | (Some(_), Location::Local) => {
520                        // It's a network dependency.
521                        let Some(endpoint) = endpoint else {
522                            return Err(anyhow!("An endpoint must be provided to fetch network dependencies.").into());
523                        };
524                        let Some(network) = network else {
525                            return Err(anyhow!("A network must be provided to fetch network dependencies.").into());
526                        };
527                        CompilationUnit::fetch(
528                            name_symbol,
529                            new.edition,
530                            home_path,
531                            network,
532                            endpoint,
533                            no_cache,
534                            network_retries,
535                        )?
536                    }
537                    (_, Location::Git) => CompilationUnit::from_git(
538                        name_symbol,
539                        &new,
540                        home_path,
541                        old_lock,
542                        new_lock,
543                        offline,
544                        declared_deps,
545                    )?,
546                    (_, Location::Workspace) => {
547                        return Err(anyhow!(
548                            "Workspace dependency `{}` was not resolved before graph building. This is a compiler bug.",
549                            new.name
550                        )
551                        .into());
552                    }
553                    _ => return Err(anyhow!("Invalid dependency data for {} (path must be given).", new.name).into()),
554                };
555
556                vacant.insert((new, unit.clone()));
557
558                unit
559            }
560        };
561
562        graph.add_node(name_symbol);
563
564        // Security: a package in a git checkout may only path-reference its own checkout.
565        // Intra-checkout deps were rewritten to git deps in `from_git`; any remaining path dep is an escape.
566        let checkouts_root = crate::git::checkouts_root(home_path);
567        if let ProgramData::SourcePath { directory, .. } = &unit.data
568            && directory.starts_with(&checkouts_root)
569        {
570            // The checkout root is `<checkouts_root>/<key>/<commit>`.
571            let checkout = directory
572                .strip_prefix(&checkouts_root)
573                .ok()
574                .and_then(|rel| {
575                    let mut components = rel.components();
576                    Some((components.next()?, components.next()?))
577                })
578                .map(|(key, commit)| checkouts_root.join(key).join(commit));
579            for dependency in unit.dependencies.iter() {
580                if let Some(path) = &dependency.path
581                    && !checkout.as_ref().is_some_and(|checkout| path.starts_with(checkout))
582                {
583                    return Err(crate::errors::invalid_manifest_dependency(
584                        &dependency.name,
585                        "a git dependency may only reference paths inside its own repository checkout",
586                    )
587                    .into());
588                }
589            }
590        }
591
592        for dependency in unit.dependencies.iter() {
593            let dependency_symbol = symbol(&dependency.name)?;
594            graph.add_edge(name_symbol, dependency_symbol);
595            Self::graph_build(
596                home_path,
597                network,
598                endpoint,
599                main_program,
600                dependency.clone(),
601                map,
602                graph,
603                no_cache,
604                no_local,
605                network_retries,
606                declared_deps,
607                old_lock,
608                new_lock,
609                offline,
610            )?;
611        }
612
613        Ok(())
614    }
615}
616
617fn main_template(name: &str) -> String {
618    format!(
619        r#"// The '{name}' program.
620program {name}.aleo {{
621    // This is the constructor for the program.
622    // The constructor allows you to manage program upgrades.
623    // It is called when the program is deployed or upgraded.
624    // It is currently configured to **prevent** upgrades.
625    // Other configurations include:
626    //  - @admin(address="aleo1...")
627    //  - @checksum(mapping="credits.aleo/fixme", key="0field")
628    //  - @custom
629    // For more information, please refer to the documentation: `https://docs.leo-lang.org/guides/upgradability`
630    @noupgrade
631    constructor() {{}}
632
633    fn main(public a: u32, b: u32) -> u32 {{
634        let c: u32 = a + b;
635        return c;
636    }}
637}}
638"#
639    )
640}
641
642fn test_template(name: &str) -> String {
643    format!(
644        r#"// The 'test_{name}' test program.
645import {name}.aleo;
646program test_{name}.aleo {{
647    @test
648    @should_fail
649    fn test_main_fails() {{
650        let result: u32 = {name}.aleo::main(2u32, 3u32);
651        assert_eq(result, 3u32);
652    }}
653
654    @noupgrade
655    constructor() {{}}
656}}
657"#
658    )
659}
660
661fn lib_template(name: &str) -> String {
662    format!(
663        r#"// The '{name}' library.
664
665// Returns the identity of x.
666export fn example(x: u32) -> u32 {{
667    return x;
668}}
669"#
670    )
671}
672
673fn lib_test_template(name: &str) -> String {
674    format!(
675        r#"// The 'test_{name}' test program.
676program test_{name}.aleo {{
677    @test
678    fn test_example() {{
679        assert_eq({name}::example(42u32), 42u32);
680    }}
681
682    @noupgrade
683    constructor() {{}}
684}}
685"#
686    )
687}
688
689/// Walk the manifest tree and collect all declared dependencies.
690///
691/// This gives `parse_dependencies_from_aleo` full knowledge of which programs are
692/// declared as local dependencies, regardless of the order they appear in the manifest.
693/// Without this, `.aleo` file imports are classified against a snapshot of
694/// already-processed dependencies, requiring the user to list them in topological order.
695fn collect_declared_deps(
696    root_path: &Path,
697    manifest: &Manifest,
698    with_tests: bool,
699) -> Result<IndexMap<Symbol, Dependency>> {
700    let mut declared = IndexMap::new();
701    collect_declared_deps_recursive(root_path, manifest, with_tests, &mut declared)?;
702    Ok(declared)
703}
704
705fn collect_declared_deps_recursive(
706    base_path: &Path,
707    manifest: &Manifest,
708    include_dev: bool,
709    declared: &mut IndexMap<Symbol, Dependency>,
710) -> Result<()> {
711    let deps = manifest.dependencies.iter().flatten();
712    let dev: Vec<&Dependency> =
713        if include_dev { manifest.dev_dependencies.iter().flatten().collect() } else { Vec::new() };
714    for dep in deps.chain(dev) {
715        let dep = canonicalize_dependency_path_relative_to(base_path, dep.clone())?;
716        // Resolve workspace deps early - converts to Location::Local with an absolute path.
717        let dep = if dep.location == Location::Workspace { resolve_workspace_dependency(base_path, dep)? } else { dep };
718        let sym = symbol(&dep.name)?;
719        // Only recurse into newly discovered dependencies to avoid infinite
720        // recursion on circular manifests (cycles are caught later by
721        // `DiGraph::post_order`).
722        let Entry::Vacant(e) = declared.entry(sym) else {
723            continue;
724        };
725        e.insert(dep.clone());
726        if dep.location == Location::Local
727            && let Some(path) = &dep.path
728        {
729            let manifest_path = path.join(MANIFEST_FILENAME);
730            if path.is_dir() && manifest_path.exists() {
731                let child = Manifest::read_from_file(manifest_path)?;
732                // dev_dependencies are not transitive.
733                collect_declared_deps_recursive(path, &child, false, declared)?;
734            }
735        }
736    }
737    Ok(())
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743
744    fn dummy_package(base: &str) -> Package {
745        dummy_package_with(base, None)
746    }
747
748    fn dummy_package_with(base: &str, workspace_root: Option<PathBuf>) -> Package {
749        Package {
750            base_directory: PathBuf::from(base),
751            workspace_root,
752            compilation_units: Vec::new(),
753            manifest: Manifest {
754                program: "demo.aleo".to_string(),
755                version: "0.1.0".to_string(),
756                description: String::new(),
757                license: "MIT".to_string(),
758                leo: "0.0.0".to_string(),
759                dependencies: None,
760                dev_dependencies: None,
761                no_std: false,
762            },
763            dep_graph: DiGraph::default(),
764        }
765    }
766
767    #[test]
768    fn bare_unit_name_strips_aleo_suffix() {
769        assert_eq!(crate::bare_unit_name("token.aleo"), "token");
770        assert_eq!(crate::bare_unit_name("token"), "token");
771        assert_eq!(crate::bare_unit_name("credits.aleo"), "credits");
772    }
773
774    #[test]
775    fn unit_paths_are_keyed_by_bare_name() {
776        let pkg = dummy_package("/tmp/demo");
777        // The directory key is the bare compilation unit name, accepting input
778        // with or without the `.aleo` suffix.
779        assert_eq!(pkg.unit_build_directory("token.aleo"), PathBuf::from("/tmp/demo/build/token"));
780        assert_eq!(pkg.unit_build_directory("token"), PathBuf::from("/tmp/demo/build/token"));
781        assert_eq!(pkg.unit_bytecode_path("token.aleo"), PathBuf::from("/tmp/demo/build/token/token.aleo"));
782        assert_eq!(pkg.unit_abi_path("token"), PathBuf::from("/tmp/demo/build/token/abi.json"));
783        assert_eq!(pkg.unit_interfaces_directory("token"), PathBuf::from("/tmp/demo/build/token/interfaces"));
784    }
785
786    #[test]
787    fn libraries_are_keyed_like_programs() {
788        // A library is keyed by its name exactly like a program: a library
789        // `my_lib` declaring interfaces gets `build/my_lib/interfaces/`.
790        let pkg = dummy_package("/tmp/demo");
791        assert_eq!(pkg.unit_build_directory("my_lib"), PathBuf::from("/tmp/demo/build/my_lib"));
792        assert_eq!(pkg.unit_interfaces_directory("my_lib"), PathBuf::from("/tmp/demo/build/my_lib/interfaces"));
793    }
794
795    #[test]
796    fn build_directory_is_the_single_root() {
797        let pkg = dummy_package("/tmp/demo");
798        assert_eq!(pkg.build_directory(), PathBuf::from("/tmp/demo/build"));
799        // Every per-unit path is rooted at `build_directory()`, the single layout seam.
800        assert!(pkg.unit_bytecode_path("x").starts_with(pkg.build_directory()));
801        assert!(pkg.unit_interfaces_directory("credits.aleo").starts_with(pkg.build_directory()));
802    }
803
804    #[test]
805    fn workspace_root_routes_build_directory_to_shared() {
806        // When inside a workspace, `build_directory()` routes to the
807        // workspace root - not the package's own directory - so every
808        // member's per-unit subdirectory collapses under one shared
809        // `<root>/build/` and deduplicates structurally on unit name.
810        let pkg = dummy_package_with("/tmp/ws/members/token", Some(PathBuf::from("/tmp/ws")));
811        assert_eq!(pkg.build_directory(), PathBuf::from("/tmp/ws/build"));
812        assert_eq!(pkg.unit_build_directory("token"), PathBuf::from("/tmp/ws/build/token"));
813        assert_eq!(pkg.unit_bytecode_path("token"), PathBuf::from("/tmp/ws/build/token/token.aleo"));
814        // The package's own base_directory is irrelevant for the per-unit path:
815        // a workspace member and a separate dependency keyed by the same unit
816        // name resolve to byte-identical paths.
817        let dep = dummy_package_with("/tmp/ws/members/swap", Some(PathBuf::from("/tmp/ws")));
818        assert_eq!(pkg.unit_bytecode_path("token"), dep.unit_bytecode_path("token"));
819    }
820
821    #[test]
822    fn standalone_package_keeps_per_base_build_directory() {
823        // The standalone path must not change: a package outside any
824        // workspace still rooots its build under its own directory.
825        let pkg = dummy_package_with("/tmp/standalone", None);
826        assert_eq!(pkg.build_directory(), PathBuf::from("/tmp/standalone/build"));
827        assert_eq!(pkg.unit_build_directory("demo"), PathBuf::from("/tmp/standalone/build/demo"));
828    }
829}