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