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                    // Canonicalize dev-dependency paths like regular dependencies, so the same local
407                    // library in both lists dedups instead of comparing relative against absolute.
408                    for dep in deps {
409                        let dep = canonicalize_dependency_path_relative_to(&path, dep.clone())?;
410                        let dep = if dep.location == Location::Workspace {
411                            resolve_workspace_dependency(&path, dep)?
412                        } else {
413                            dep
414                        };
415                        test_dependencies.push(dep);
416                    }
417                }
418                test_dependencies
419            } else {
420                Vec::new()
421            };
422
423            for dependency in test_dependencies.into_iter().chain(std::iter::once(first_dependency.clone())) {
424                Self::graph_build(
425                    &home_path,
426                    network,
427                    endpoint,
428                    &first_dependency,
429                    dependency,
430                    &mut map,
431                    &mut digraph,
432                    no_cache,
433                    no_local,
434                    network_retries,
435                    &declared_deps,
436                    &old_lock,
437                    &mut new_lock,
438                    offline,
439                )?;
440            }
441
442            // Workspace: carry all entries since the lock is shared. Standalone: carry only dev-git
443            // names (a plain build skips dev deps, so their pins may legitimately be unresolved).
444            if workspace_root.is_some() {
445                new_lock.carry_over(&old_lock, |_| true);
446            } else {
447                let dev_git_names: Vec<&str> = if with_tests {
448                    Vec::new()
449                } else {
450                    manifest
451                        .dev_dependencies
452                        .iter()
453                        .flatten()
454                        .filter(|dep| dep.location == Location::Git)
455                        .map(|dep| dep.name.as_str())
456                        .collect()
457                };
458                new_lock.carry_over(&old_lock, |entry| dev_git_names.contains(&entry.name.as_str()));
459            }
460            // Persist the lock (and drop a stale one when no git deps remain).
461            new_lock.write(&lock_dir)?;
462
463            let ordered_dependency_symbols =
464                digraph.post_order().map_err(|_| crate::errors::circular_dependency_error())?;
465
466            (
467                ordered_dependency_symbols.into_iter().map(|symbol| map.swap_remove(&symbol).unwrap().1).collect(),
468                digraph,
469            )
470        } else {
471            (Vec::new(), DiGraph::default())
472        };
473
474        Ok(Package { base_directory: path, workspace_root, compilation_units, manifest, dep_graph: digraph })
475    }
476
477    #[allow(clippy::too_many_arguments)]
478    fn graph_build(
479        home_path: &Path,
480        network: Option<NetworkName>,
481        endpoint: Option<&str>,
482        main_program: &Dependency,
483        new: Dependency,
484        map: &mut IndexMap<Symbol, (Dependency, CompilationUnit)>,
485        graph: &mut DiGraph<Symbol>,
486        no_cache: bool,
487        no_local: bool,
488        network_retries: u32,
489        declared_deps: &IndexMap<Symbol, Dependency>,
490        old_lock: &Lock,
491        new_lock: &mut Lock,
492        offline: bool,
493    ) -> Result<()> {
494        let name_symbol = symbol(&new.name)?;
495
496        let unit = match map.entry(name_symbol) {
497            Entry::Occupied(occupied) => {
498                // We've already visited this dependency. Just make sure it's compatible with
499                // the one we already have.
500                let existing_dep = &occupied.get().0;
501                assert_eq!(new.name, existing_dep.name);
502                if new.location != existing_dep.location
503                    || new.path != existing_dep.path
504                    || new.edition != existing_dep.edition
505                    || new.git != existing_dep.git
506                {
507                    return Err(crate::errors::conflicting_dependency(existing_dep, new).into());
508                }
509                return Ok(());
510            }
511            Entry::Vacant(vacant) => {
512                let unit = match (new.path.as_ref(), new.location) {
513                    (Some(path), Location::Local) if !no_local => {
514                        // It's a local dependency.
515                        if path.extension().and_then(|p| p.to_str()) == Some("aleo") && path.is_file() {
516                            CompilationUnit::from_aleo_path(name_symbol, path, declared_deps)?
517                        } else {
518                            CompilationUnit::from_package_path(name_symbol, path)?
519                        }
520                    }
521                    (Some(path), Location::Test) => {
522                        // It's a test dependency - the path points to the source file,
523                        // not a package.
524                        CompilationUnit::from_test_path(path, main_program.clone())?
525                    }
526                    (_, Location::Network) | (Some(_), Location::Local) => {
527                        // It's a network dependency.
528                        let Some(endpoint) = endpoint else {
529                            return Err(anyhow!("An endpoint must be provided to fetch network dependencies.").into());
530                        };
531                        let Some(network) = network else {
532                            return Err(anyhow!("A network must be provided to fetch network dependencies.").into());
533                        };
534                        CompilationUnit::fetch(
535                            name_symbol,
536                            new.edition,
537                            home_path,
538                            network,
539                            endpoint,
540                            no_cache,
541                            network_retries,
542                        )?
543                    }
544                    (_, Location::Git) => CompilationUnit::from_git(
545                        name_symbol,
546                        &new,
547                        home_path,
548                        old_lock,
549                        new_lock,
550                        offline,
551                        declared_deps,
552                    )?,
553                    (_, Location::Workspace) => {
554                        return Err(anyhow!(
555                            "Workspace dependency `{}` was not resolved before graph building. This is a compiler bug.",
556                            new.name
557                        )
558                        .into());
559                    }
560                    _ => return Err(anyhow!("Invalid dependency data for {} (path must be given).", new.name).into()),
561                };
562
563                vacant.insert((new, unit.clone()));
564
565                unit
566            }
567        };
568
569        graph.add_node(name_symbol);
570
571        // Security: a package in a git checkout may only path-reference its own checkout.
572        // Intra-checkout deps were rewritten to git deps in `from_git`; any remaining path dep is an escape.
573        let checkouts_root = crate::git::checkouts_root(home_path);
574        if let ProgramData::SourcePath { directory, .. } = &unit.data
575            && directory.starts_with(&checkouts_root)
576        {
577            // The checkout root is `<checkouts_root>/<key>/<commit>`.
578            let checkout = directory
579                .strip_prefix(&checkouts_root)
580                .ok()
581                .and_then(|rel| {
582                    let mut components = rel.components();
583                    Some((components.next()?, components.next()?))
584                })
585                .map(|(key, commit)| checkouts_root.join(key).join(commit));
586            for dependency in unit.dependencies.iter() {
587                if let Some(path) = &dependency.path
588                    && !checkout.as_ref().is_some_and(|checkout| path.starts_with(checkout))
589                {
590                    return Err(crate::errors::invalid_manifest_dependency(
591                        &dependency.name,
592                        "a git dependency may only reference paths inside its own repository checkout",
593                    )
594                    .into());
595                }
596            }
597        }
598
599        for dependency in unit.dependencies.iter() {
600            let dependency_symbol = symbol(&dependency.name)?;
601            graph.add_edge(name_symbol, dependency_symbol);
602            Self::graph_build(
603                home_path,
604                network,
605                endpoint,
606                main_program,
607                dependency.clone(),
608                map,
609                graph,
610                no_cache,
611                no_local,
612                network_retries,
613                declared_deps,
614                old_lock,
615                new_lock,
616                offline,
617            )?;
618        }
619
620        Ok(())
621    }
622}
623
624fn main_template(name: &str) -> String {
625    format!(
626        r#"// The '{name}' program.
627program {name}.aleo {{
628    // This is the constructor for the program.
629    // The constructor allows you to manage program upgrades.
630    // It is called when the program is deployed or upgraded.
631    // It is currently configured to **prevent** upgrades.
632    // Other configurations include:
633    //  - @admin(address="aleo1...")
634    //  - @checksum(mapping="credits.aleo/fixme", key="0field")
635    //  - @custom
636    // For more information, please refer to the documentation: `https://docs.leo-lang.org/guides/upgradability`
637    @noupgrade
638    constructor() {{}}
639
640    fn main(public a: u32, b: u32) -> u32 {{
641        let c: u32 = a + b;
642        return c;
643    }}
644}}
645"#
646    )
647}
648
649fn test_template(name: &str) -> String {
650    format!(
651        r#"// The 'test_{name}' test program.
652import {name}.aleo;
653program test_{name}.aleo {{
654    @test
655    @should_fail
656    fn test_main_fails() {{
657        let result: u32 = {name}.aleo::main(2u32, 3u32);
658        assert_eq(result, 3u32);
659    }}
660
661    @noupgrade
662    constructor() {{}}
663}}
664"#
665    )
666}
667
668fn lib_template(name: &str) -> String {
669    format!(
670        r#"// The '{name}' library.
671
672// Returns the identity of x.
673fn example(x: u32) -> u32 {{
674    return x;
675}}
676"#
677    )
678}
679
680fn lib_test_template(name: &str) -> String {
681    format!(
682        r#"// The 'test_{name}' test program.
683program test_{name}.aleo {{
684    @test
685    fn test_example() {{
686        assert_eq({name}::example(42u32), 42u32);
687    }}
688
689    @noupgrade
690    constructor() {{}}
691}}
692"#
693    )
694}
695
696/// Walk the manifest tree and collect all declared dependencies.
697///
698/// This gives `parse_dependencies_from_aleo` full knowledge of which programs are
699/// declared as local dependencies, regardless of the order they appear in the manifest.
700/// Without this, `.aleo` file imports are classified against a snapshot of
701/// already-processed dependencies, requiring the user to list them in topological order.
702fn collect_declared_deps(
703    root_path: &Path,
704    manifest: &Manifest,
705    with_tests: bool,
706) -> Result<IndexMap<Symbol, Dependency>> {
707    let mut declared = IndexMap::new();
708    collect_declared_deps_recursive(root_path, manifest, with_tests, &mut declared)?;
709    Ok(declared)
710}
711
712fn collect_declared_deps_recursive(
713    base_path: &Path,
714    manifest: &Manifest,
715    include_dev: bool,
716    declared: &mut IndexMap<Symbol, Dependency>,
717) -> Result<()> {
718    let deps = manifest.dependencies.iter().flatten();
719    let dev: Vec<&Dependency> =
720        if include_dev { manifest.dev_dependencies.iter().flatten().collect() } else { Vec::new() };
721    for dep in deps.chain(dev) {
722        let dep = canonicalize_dependency_path_relative_to(base_path, dep.clone())?;
723        // Resolve workspace deps early - converts to Location::Local with an absolute path.
724        let dep = if dep.location == Location::Workspace { resolve_workspace_dependency(base_path, dep)? } else { dep };
725        let sym = symbol(&dep.name)?;
726        // Only recurse into newly discovered dependencies to avoid infinite
727        // recursion on circular manifests (cycles are caught later by
728        // `DiGraph::post_order`).
729        let Entry::Vacant(e) = declared.entry(sym) else {
730            continue;
731        };
732        e.insert(dep.clone());
733        if dep.location == Location::Local
734            && let Some(path) = &dep.path
735        {
736            let manifest_path = path.join(MANIFEST_FILENAME);
737            if path.is_dir() && manifest_path.exists() {
738                let child = Manifest::read_from_file(manifest_path)?;
739                // dev_dependencies are not transitive.
740                collect_declared_deps_recursive(path, &child, false, declared)?;
741            }
742        }
743    }
744    Ok(())
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750
751    fn dummy_package(base: &str) -> Package {
752        dummy_package_with(base, None)
753    }
754
755    fn dummy_package_with(base: &str, workspace_root: Option<PathBuf>) -> Package {
756        Package {
757            base_directory: PathBuf::from(base),
758            workspace_root,
759            compilation_units: Vec::new(),
760            manifest: Manifest {
761                program: "demo.aleo".to_string(),
762                version: "0.1.0".to_string(),
763                description: String::new(),
764                license: "MIT".to_string(),
765                leo: "0.0.0".to_string(),
766                dependencies: None,
767                dev_dependencies: None,
768                no_std: false,
769            },
770            dep_graph: DiGraph::default(),
771        }
772    }
773
774    #[test]
775    fn bare_unit_name_strips_aleo_suffix() {
776        assert_eq!(crate::bare_unit_name("token.aleo"), "token");
777        assert_eq!(crate::bare_unit_name("token"), "token");
778        assert_eq!(crate::bare_unit_name("credits.aleo"), "credits");
779    }
780
781    #[test]
782    fn unit_paths_are_keyed_by_bare_name() {
783        let pkg = dummy_package("/tmp/demo");
784        // The directory key is the bare compilation unit name, accepting input
785        // with or without the `.aleo` suffix.
786        assert_eq!(pkg.unit_build_directory("token.aleo"), PathBuf::from("/tmp/demo/build/token"));
787        assert_eq!(pkg.unit_build_directory("token"), PathBuf::from("/tmp/demo/build/token"));
788        assert_eq!(pkg.unit_bytecode_path("token.aleo"), PathBuf::from("/tmp/demo/build/token/token.aleo"));
789        assert_eq!(pkg.unit_abi_path("token"), PathBuf::from("/tmp/demo/build/token/abi.json"));
790        assert_eq!(pkg.unit_interfaces_directory("token"), PathBuf::from("/tmp/demo/build/token/interfaces"));
791        assert_eq!(pkg.unit_snapshots_directory("token"), PathBuf::from("/tmp/demo/build/token/snapshots"));
792    }
793
794    #[test]
795    fn libraries_are_keyed_like_programs() {
796        // A library is keyed by its name exactly like a program: a library
797        // `my_lib` declaring interfaces gets `build/my_lib/interfaces/`.
798        let pkg = dummy_package("/tmp/demo");
799        assert_eq!(pkg.unit_build_directory("my_lib"), PathBuf::from("/tmp/demo/build/my_lib"));
800        assert_eq!(pkg.unit_interfaces_directory("my_lib"), PathBuf::from("/tmp/demo/build/my_lib/interfaces"));
801    }
802
803    #[test]
804    fn build_directory_is_the_single_root() {
805        let pkg = dummy_package("/tmp/demo");
806        assert_eq!(pkg.build_directory(), PathBuf::from("/tmp/demo/build"));
807        // Every per-unit path is rooted at `build_directory()`, the single layout seam.
808        assert!(pkg.unit_bytecode_path("x").starts_with(pkg.build_directory()));
809        assert!(pkg.unit_interfaces_directory("credits.aleo").starts_with(pkg.build_directory()));
810    }
811
812    #[test]
813    fn workspace_root_routes_build_directory_to_shared() {
814        // When inside a workspace, `build_directory()` routes to the
815        // workspace root - not the package's own directory - so every
816        // member's per-unit subdirectory collapses under one shared
817        // `<root>/build/` and deduplicates structurally on unit name.
818        let pkg = dummy_package_with("/tmp/ws/members/token", Some(PathBuf::from("/tmp/ws")));
819        assert_eq!(pkg.build_directory(), PathBuf::from("/tmp/ws/build"));
820        assert_eq!(pkg.unit_build_directory("token"), PathBuf::from("/tmp/ws/build/token"));
821        assert_eq!(pkg.unit_bytecode_path("token"), PathBuf::from("/tmp/ws/build/token/token.aleo"));
822        // The package's own base_directory is irrelevant for the per-unit path:
823        // a workspace member and a separate dependency keyed by the same unit
824        // name resolve to byte-identical paths.
825        let dep = dummy_package_with("/tmp/ws/members/swap", Some(PathBuf::from("/tmp/ws")));
826        assert_eq!(pkg.unit_bytecode_path("token"), dep.unit_bytecode_path("token"));
827    }
828
829    #[test]
830    fn standalone_package_keeps_per_base_build_directory() {
831        // The standalone path must not change: a package outside any
832        // workspace still rooots its build under its own directory.
833        let pkg = dummy_package_with("/tmp/standalone", None);
834        assert_eq!(pkg.build_directory(), PathBuf::from("/tmp/standalone/build"));
835        assert_eq!(pkg.unit_build_directory("demo"), PathBuf::from("/tmp/standalone/build/demo"));
836    }
837}