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::{Program as SvmProgram, TestnetV0, 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    /// Load an Aleo bytecode file as a package, including its local and network imports.
261    ///
262    /// Local imports use the same layouts as `leo abi`: `<root>/<name>/<name>.aleo` for a build bundle, or
263    /// `<imports-directory>/<name>.aleo` for a flat bundle. Imports that are not present there are fetched from the
264    /// network.
265    #[allow(clippy::too_many_arguments)]
266    pub fn from_aleo_file<P: AsRef<Path>, Q: AsRef<Path>>(
267        path: P,
268        home_path: Q,
269        imports_directory: Option<&Path>,
270        no_cache: bool,
271        no_local: bool,
272        network: Option<NetworkName>,
273        endpoint: Option<&str>,
274        network_retries: u32,
275    ) -> Result<Self> {
276        Self::from_aleo_file_impl(
277            path.as_ref(),
278            home_path.as_ref(),
279            imports_directory,
280            no_cache,
281            no_local,
282            network,
283            endpoint,
284            network_retries,
285        )
286    }
287
288    /// Examine the Leo package at `path` to create a `Package`, including all its dependencies,
289    /// obtaining dependencies from the file system or network and topologically sorting them.
290    #[allow(clippy::too_many_arguments)]
291    pub fn from_directory<P: AsRef<Path>, Q: AsRef<Path>>(
292        path: P,
293        home_path: Q,
294        no_cache: bool,
295        no_local: bool,
296        offline: bool,
297        network: Option<NetworkName>,
298        endpoint: Option<&str>,
299        network_retries: u32,
300    ) -> Result<Self> {
301        Self::from_directory_impl(
302            path.as_ref(),
303            home_path.as_ref(),
304            /* build_graph */ true,
305            /* with_tests */ false,
306            no_cache,
307            no_local,
308            offline,
309            network,
310            endpoint,
311            network_retries,
312        )
313    }
314
315    /// Examine the Leo package at `path` to create a `Package`, including all its dependencies
316    /// and its tests, obtaining dependencies from the file system or network and topologically sorting them.
317    #[allow(clippy::too_many_arguments)]
318    pub fn from_directory_with_tests<P: AsRef<Path>, Q: AsRef<Path>>(
319        path: P,
320        home_path: Q,
321        no_cache: bool,
322        no_local: bool,
323        offline: bool,
324        network: Option<NetworkName>,
325        endpoint: Option<&str>,
326        network_retries: u32,
327    ) -> Result<Self> {
328        Self::from_directory_impl(
329            path.as_ref(),
330            home_path.as_ref(),
331            /* build_graph */ true,
332            /* with_tests */ true,
333            no_cache,
334            no_local,
335            offline,
336            network,
337            endpoint,
338            network_retries,
339        )
340    }
341
342    pub fn test_files(&self) -> impl Iterator<Item = PathBuf> {
343        let path = self.tests_directory();
344        // This allocation isn't ideal but it's not performance critical and
345        // easily resolves lifetime issues.
346        let data: Vec<PathBuf> = Self::files_with_extension(&path, "leo").collect();
347        data.into_iter()
348    }
349
350    fn files_with_extension(path: &Path, extension: &'static str) -> impl Iterator<Item = PathBuf> {
351        path.read_dir()
352            .ok()
353            .into_iter()
354            .flatten()
355            .flat_map(|maybe_filename| maybe_filename.ok())
356            .filter(|entry| entry.file_type().ok().map(|filetype| filetype.is_file()).unwrap_or(false))
357            .flat_map(move |entry| {
358                let path = entry.path();
359                if path.extension().is_some_and(|e| e == extension) { Some(path) } else { None }
360            })
361    }
362
363    #[allow(clippy::too_many_arguments)]
364    fn from_aleo_file_impl(
365        path: &Path,
366        home_path: &Path,
367        imports_directory: Option<&Path>,
368        no_cache: bool,
369        no_local: bool,
370        network: Option<NetworkName>,
371        endpoint: Option<&str>,
372        network_retries: u32,
373    ) -> Result<Self> {
374        if path.extension().and_then(|extension| extension.to_str()) != Some("aleo") {
375            return Err(anyhow!("Expected an Aleo bytecode file with the `.aleo` extension: {}", path.display()).into());
376        }
377
378        let path = path.canonicalize().map_err(|error| crate::errors::failed_path(path.display(), error))?;
379        if !path.is_file() {
380            return Err(anyhow!("Expected an Aleo bytecode file: {}", path.display()).into());
381        }
382        let home_path =
383            home_path.canonicalize().map_err(|error| crate::errors::failed_path(home_path.display(), error))?;
384        let bytecode = std::fs::read_to_string(&path).map_err(|error| {
385            crate::errors::util_file_io_error(format_args!("Trying to read Aleo file at {}", path.display()), error)
386        })?;
387        let source_name = path.file_stem().and_then(|name| name.to_str()).unwrap_or("program");
388        let main_program: SvmProgram<TestnetV0> =
389            bytecode.parse().map_err(|_| crate::errors::snarkvm_parsing_error(source_name))?;
390        let program_name = main_program.id().to_string();
391        let program_symbol = symbol(&program_name)?;
392        let base_directory = path
393            .parent()
394            .ok_or_else(|| anyhow!("Aleo bytecode file has no parent directory: {}", path.display()))?
395            .to_path_buf();
396
397        let main_dependency = Dependency {
398            name: program_name.clone(),
399            location: Location::Local,
400            path: Some(path.clone()),
401            edition: None,
402            ..Default::default()
403        };
404        let imports_directory = if no_local {
405            None
406        } else {
407            imports_directory
408                .map(|imports_directory| -> Result<PathBuf> {
409                    let imports_directory = imports_directory
410                        .canonicalize()
411                        .map_err(|error| crate::errors::failed_path(imports_directory.display(), error))?;
412                    if !imports_directory.is_dir() {
413                        return Err(
414                            anyhow!("Expected an Aleo imports directory: {}", imports_directory.display()).into()
415                        );
416                    }
417                    Ok(imports_directory)
418                })
419                .transpose()?
420        };
421        let declared_deps = IndexMap::from([(program_symbol, main_dependency.clone())]);
422
423        let mut map: IndexMap<Symbol, (Dependency, CompilationUnit)> = IndexMap::new();
424        let mut digraph = DiGraph::new(Default::default());
425        let old_lock = Lock::default();
426        let mut new_lock = Lock::default();
427        Self::graph_build(
428            &home_path,
429            network,
430            endpoint,
431            &main_dependency,
432            main_dependency.clone(),
433            &mut map,
434            &mut digraph,
435            no_cache,
436            false,
437            imports_directory.as_deref(),
438            network_retries,
439            &declared_deps,
440            &old_lock,
441            &mut new_lock,
442            false,
443        )?;
444
445        let compilation_units = digraph
446            .post_order()
447            .map_err(|_| crate::errors::circular_dependency_error())?
448            .into_iter()
449            .map(|name| {
450                map.swap_remove(&name)
451                    .map(|(_, unit)| unit)
452                    .ok_or_else(|| anyhow!("Dependency graph contains an unknown program `{name}`.").into())
453            })
454            .collect::<Result<Vec<_>>>()?;
455        let manifest = Manifest {
456            program: program_name,
457            version: "0.0.0".to_string(),
458            description: String::new(),
459            license: String::new(),
460            leo: env!("CARGO_PKG_VERSION").to_string(),
461            dependencies: None,
462            dev_dependencies: None,
463            no_std: false,
464        };
465
466        Ok(Package { base_directory, workspace_root: None, compilation_units, manifest, dep_graph: digraph })
467    }
468
469    #[allow(clippy::too_many_arguments)]
470    fn from_directory_impl(
471        path: &Path,
472        home_path: &Path,
473        build_graph: bool,
474        with_tests: bool,
475        no_cache: bool,
476        no_local: bool,
477        offline: bool,
478        network: Option<NetworkName>,
479        endpoint: Option<&str>,
480        network_retries: u32,
481    ) -> Result<Self> {
482        let map_err = |path: &Path, err| {
483            crate::errors::util_file_io_error(format_args!("Trying to find path at {}", path.display()), err)
484        };
485
486        let path = path.canonicalize().map_err(|err| map_err(path, err))?;
487
488        // Detect an enclosing workspace so build artifacts route to a shared
489        // `<workspace_root>/build/`. The walk only checks for `workspace.json`
490        // (no manifest parsing, no member resolution), so it is cheap.
491        let workspace_root = Workspace::discover_root(&path)?;
492
493        let manifest = Manifest::read_from_file(path.join(MANIFEST_FILENAME))?;
494
495        let (compilation_units, digraph) = if build_graph {
496            let home_path = home_path.canonicalize().map_err(|err| map_err(home_path, err))?;
497
498            let mut map: IndexMap<Symbol, (Dependency, CompilationUnit)> = IndexMap::new();
499
500            let mut digraph = DiGraph::<Symbol>::new(Default::default());
501
502            // Pre-collect all declared dependencies from the manifest tree so that
503            // .aleo file import classification doesn't depend on processing order.
504            let declared_deps = collect_declared_deps(&path, &manifest, with_tests)?;
505
506            // The lock lives at the workspace root, else beside this package's `program.json`.
507            let lock_dir = workspace_root.as_deref().unwrap_or(&path).to_path_buf();
508            // New lock records only this build's resolutions; others are carried over from the old lock after.
509            let old_lock = Lock::read(&lock_dir);
510            let mut new_lock = Lock::default();
511
512            let first_dependency = Dependency {
513                name: manifest.program.clone(),
514                location: Location::Local,
515                path: Some(path.clone()),
516                edition: None,
517                ..Default::default()
518            };
519
520            let test_dependencies: Vec<Dependency> = if with_tests {
521                let tests_directory = path.join(TESTS_DIRECTORY);
522                let mut test_dependencies: Vec<Dependency> = Self::files_with_extension(&tests_directory, "leo")
523                    .map(|path| Dependency {
524                        // We just made sure it has a ".leo" extension.
525                        name: format!("{}.aleo", crate::filename_no_leo_extension(&path).unwrap()),
526                        edition: None,
527                        location: Location::Test,
528                        path: Some(path.to_path_buf()),
529                        ..Default::default()
530                    })
531                    .collect();
532                if let Some(deps) = manifest.dev_dependencies.as_ref() {
533                    // Canonicalize dev-dependency paths like regular dependencies, so the same local
534                    // library in both lists dedups instead of comparing relative against absolute.
535                    for dep in deps {
536                        let dep = canonicalize_dependency_path_relative_to(&path, dep.clone())?;
537                        let dep = if dep.location == Location::Workspace {
538                            resolve_workspace_dependency(&path, dep)?
539                        } else {
540                            dep
541                        };
542                        test_dependencies.push(dep);
543                    }
544                }
545                test_dependencies
546            } else {
547                Vec::new()
548            };
549
550            for dependency in test_dependencies.into_iter().chain(std::iter::once(first_dependency.clone())) {
551                Self::graph_build(
552                    &home_path,
553                    network,
554                    endpoint,
555                    &first_dependency,
556                    dependency,
557                    &mut map,
558                    &mut digraph,
559                    no_cache,
560                    no_local,
561                    None,
562                    network_retries,
563                    &declared_deps,
564                    &old_lock,
565                    &mut new_lock,
566                    offline,
567                )?;
568            }
569
570            // Workspace: carry all entries since the lock is shared. Standalone: carry only dev-git
571            // names (a plain build skips dev deps, so their pins may legitimately be unresolved).
572            if workspace_root.is_some() {
573                new_lock.carry_over(&old_lock, |_| true);
574            } else {
575                let dev_git_names: Vec<&str> = if with_tests {
576                    Vec::new()
577                } else {
578                    manifest
579                        .dev_dependencies
580                        .iter()
581                        .flatten()
582                        .filter(|dep| dep.location == Location::Git)
583                        .map(|dep| dep.name.as_str())
584                        .collect()
585                };
586                new_lock.carry_over(&old_lock, |entry| dev_git_names.contains(&entry.name.as_str()));
587            }
588            // Persist the lock (and drop a stale one when no git deps remain).
589            new_lock.write(&lock_dir)?;
590
591            let ordered_dependency_symbols =
592                digraph.post_order().map_err(|_| crate::errors::circular_dependency_error())?;
593
594            (
595                ordered_dependency_symbols.into_iter().map(|symbol| map.swap_remove(&symbol).unwrap().1).collect(),
596                digraph,
597            )
598        } else {
599            (Vec::new(), DiGraph::default())
600        };
601
602        Ok(Package { base_directory: path, workspace_root, compilation_units, manifest, dep_graph: digraph })
603    }
604
605    #[allow(clippy::too_many_arguments)]
606    fn graph_build(
607        home_path: &Path,
608        network: Option<NetworkName>,
609        endpoint: Option<&str>,
610        main_program: &Dependency,
611        new: Dependency,
612        map: &mut IndexMap<Symbol, (Dependency, CompilationUnit)>,
613        graph: &mut DiGraph<Symbol>,
614        no_cache: bool,
615        no_local: bool,
616        aleo_imports_directory: Option<&Path>,
617        network_retries: u32,
618        declared_deps: &IndexMap<Symbol, Dependency>,
619        old_lock: &Lock,
620        new_lock: &mut Lock,
621        offline: bool,
622    ) -> Result<()> {
623        let mut new = new;
624        if new.location == Location::Network
625            && let Some(imports_directory) = aleo_imports_directory
626        {
627            let path = aleo_import_path(imports_directory, &new.name);
628            if path.exists() {
629                if !path.is_file() {
630                    return Err(anyhow!("Expected Aleo import `{}` to be a file: {}", new.name, path.display()).into());
631                }
632                let bytecode = std::fs::read_to_string(&path).map_err(|error| {
633                    crate::errors::util_file_io_error(
634                        format_args!("Trying to read Aleo file at {}", path.display()),
635                        error,
636                    )
637                })?;
638                let imported: SvmProgram<TestnetV0> =
639                    bytecode.parse().map_err(|_| crate::errors::snarkvm_parsing_error(bare_unit_name(&new.name)))?;
640                if imported.id().to_string() != new.name {
641                    return Err(anyhow!(
642                        "Aleo import `{}` resolved to `{}`, but that file declares `{}`.",
643                        new.name,
644                        path.display(),
645                        imported.id()
646                    )
647                    .into());
648                }
649                new.location = Location::Local;
650                new.path = Some(path);
651                new.edition = None;
652            }
653        }
654
655        let name_symbol = symbol(&new.name)?;
656
657        let unit = match map.entry(name_symbol) {
658            Entry::Occupied(occupied) => {
659                // We've already visited this dependency. Just make sure it's compatible with
660                // the one we already have.
661                let existing_dep = &occupied.get().0;
662                assert_eq!(new.name, existing_dep.name);
663                if new.location != existing_dep.location
664                    || new.path != existing_dep.path
665                    || new.edition != existing_dep.edition
666                    || new.git != existing_dep.git
667                {
668                    return Err(crate::errors::conflicting_dependency(existing_dep, new).into());
669                }
670                return Ok(());
671            }
672            Entry::Vacant(vacant) => {
673                let unit = match (new.path.as_ref(), new.location) {
674                    (Some(path), Location::Local) if !no_local => {
675                        // It's a local dependency.
676                        if path.extension().and_then(|p| p.to_str()) == Some("aleo") && path.is_file() {
677                            CompilationUnit::from_aleo_path(name_symbol, path, declared_deps)?
678                        } else {
679                            CompilationUnit::from_package_path(name_symbol, path)?
680                        }
681                    }
682                    (Some(path), Location::Test) => {
683                        // It's a test dependency - the path points to the source file,
684                        // not a package.
685                        CompilationUnit::from_test_path(path, main_program.clone())?
686                    }
687                    (_, Location::Network) | (Some(_), Location::Local) => {
688                        // It's a network dependency.
689                        let Some(endpoint) = endpoint else {
690                            return Err(anyhow!("An endpoint must be provided to fetch network dependencies.").into());
691                        };
692                        let Some(network) = network else {
693                            return Err(anyhow!("A network must be provided to fetch network dependencies.").into());
694                        };
695                        CompilationUnit::fetch(
696                            name_symbol,
697                            new.edition,
698                            home_path,
699                            network,
700                            endpoint,
701                            no_cache,
702                            network_retries,
703                        )?
704                    }
705                    (_, Location::Git) => CompilationUnit::from_git(
706                        name_symbol,
707                        &new,
708                        home_path,
709                        old_lock,
710                        new_lock,
711                        offline,
712                        declared_deps,
713                    )?,
714                    (_, Location::Workspace) => {
715                        return Err(anyhow!(
716                            "Workspace dependency `{}` was not resolved before graph building. This is a compiler bug.",
717                            new.name
718                        )
719                        .into());
720                    }
721                    _ => return Err(anyhow!("Invalid dependency data for {} (path must be given).", new.name).into()),
722                };
723
724                vacant.insert((new, unit.clone()));
725
726                unit
727            }
728        };
729
730        graph.add_node(name_symbol);
731
732        // Security: a package in a git checkout may only path-reference its own checkout.
733        // Intra-checkout deps were rewritten to git deps in `from_git`; any remaining path dep is an escape.
734        let checkouts_root = crate::git::checkouts_root(home_path);
735        if let ProgramData::SourcePath { directory, .. } = &unit.data
736            && directory.starts_with(&checkouts_root)
737        {
738            // The checkout root is `<checkouts_root>/<key>/<commit>`.
739            let checkout = directory
740                .strip_prefix(&checkouts_root)
741                .ok()
742                .and_then(|rel| {
743                    let mut components = rel.components();
744                    Some((components.next()?, components.next()?))
745                })
746                .map(|(key, commit)| checkouts_root.join(key).join(commit));
747            for dependency in unit.dependencies.iter() {
748                if let Some(path) = &dependency.path
749                    && !checkout.as_ref().is_some_and(|checkout| path.starts_with(checkout))
750                {
751                    return Err(crate::errors::invalid_manifest_dependency(
752                        &dependency.name,
753                        "a git dependency may only reference paths inside its own repository checkout",
754                    )
755                    .into());
756                }
757            }
758        }
759
760        for dependency in unit.dependencies.iter() {
761            let dependency_symbol = symbol(&dependency.name)?;
762            graph.add_edge(name_symbol, dependency_symbol);
763            Self::graph_build(
764                home_path,
765                network,
766                endpoint,
767                main_program,
768                dependency.clone(),
769                map,
770                graph,
771                no_cache,
772                no_local,
773                aleo_imports_directory,
774                network_retries,
775                declared_deps,
776                old_lock,
777                new_lock,
778                offline,
779            )?;
780        }
781
782        Ok(())
783    }
784}
785
786/// Return the default directory for local imports of an Aleo bytecode file.
787pub fn default_aleo_imports_directory(path: &Path) -> Option<PathBuf> {
788    let parent = path.parent()?;
789    if parent.file_name() == path.file_stem() {
790        return parent.parent().map(Path::to_path_buf);
791    }
792
793    let imports = parent.join("imports");
794    imports.is_dir().then_some(imports)
795}
796
797/// Return the preferred path for an Aleo import in a flat or per-unit imports directory.
798pub fn aleo_import_path(imports_directory: &Path, program_name: &str) -> PathBuf {
799    let bare_name = bare_unit_name(program_name);
800    let per_unit_path = imports_directory.join(bare_name).join(program_name);
801    if per_unit_path.exists() { per_unit_path } else { imports_directory.join(program_name) }
802}
803
804fn main_template(name: &str) -> String {
805    format!(
806        r#"// The '{name}' program.
807program {name}.aleo {{
808    // This is the constructor for the program.
809    // The constructor allows you to manage program upgrades.
810    // It is called when the program is deployed or upgraded.
811    // It is currently configured to **prevent** upgrades.
812    // Other configurations include:
813    //  - @admin(address="aleo1...")
814    //  - @checksum(mapping="credits.aleo/fixme", key="0field")
815    //  - @custom
816    // For more information, please refer to the documentation: `https://docs.leo-lang.org/guides/upgradability`
817    @noupgrade
818    constructor() {{}}
819
820    fn main(public a: u32, b: u32) -> u32 {{
821        let c: u32 = a + b;
822        return c;
823    }}
824}}
825"#
826    )
827}
828
829fn test_template(name: &str) -> String {
830    format!(
831        r#"// The 'test_{name}' test program.
832import {name}.aleo;
833program test_{name}.aleo {{
834    @test
835    @should_fail
836    fn test_main_fails() {{
837        let result: u32 = {name}.aleo::main(2u32, 3u32);
838        assert_eq(result, 3u32);
839    }}
840
841    @noupgrade
842    constructor() {{}}
843}}
844"#
845    )
846}
847
848fn lib_template(name: &str) -> String {
849    format!(
850        r#"// The '{name}' library.
851
852// Returns the identity of x.
853export fn example(x: u32) -> u32 {{
854    return x;
855}}
856"#
857    )
858}
859
860fn lib_test_template(name: &str) -> String {
861    format!(
862        r#"// The 'test_{name}' test program.
863program test_{name}.aleo {{
864    @test
865    fn test_example() {{
866        assert_eq({name}::example(42u32), 42u32);
867    }}
868
869    @noupgrade
870    constructor() {{}}
871}}
872"#
873    )
874}
875
876/// Walk the manifest tree and collect all declared dependencies.
877///
878/// This gives `parse_dependencies_from_aleo` full knowledge of which programs are
879/// declared as local dependencies, regardless of the order they appear in the manifest.
880/// Without this, `.aleo` file imports are classified against a snapshot of
881/// already-processed dependencies, requiring the user to list them in topological order.
882fn collect_declared_deps(
883    root_path: &Path,
884    manifest: &Manifest,
885    with_tests: bool,
886) -> Result<IndexMap<Symbol, Dependency>> {
887    let mut declared = IndexMap::new();
888    collect_declared_deps_recursive(root_path, manifest, with_tests, &mut declared)?;
889    Ok(declared)
890}
891
892fn collect_declared_deps_recursive(
893    base_path: &Path,
894    manifest: &Manifest,
895    include_dev: bool,
896    declared: &mut IndexMap<Symbol, Dependency>,
897) -> Result<()> {
898    let deps = manifest.dependencies.iter().flatten();
899    let dev: Vec<&Dependency> =
900        if include_dev { manifest.dev_dependencies.iter().flatten().collect() } else { Vec::new() };
901    for dep in deps.chain(dev) {
902        let dep = canonicalize_dependency_path_relative_to(base_path, dep.clone())?;
903        // Resolve workspace deps early - converts to Location::Local with an absolute path.
904        let dep = if dep.location == Location::Workspace { resolve_workspace_dependency(base_path, dep)? } else { dep };
905        let sym = symbol(&dep.name)?;
906        // Only recurse into newly discovered dependencies to avoid infinite
907        // recursion on circular manifests (cycles are caught later by
908        // `DiGraph::post_order`).
909        let Entry::Vacant(e) = declared.entry(sym) else {
910            continue;
911        };
912        e.insert(dep.clone());
913        if dep.location == Location::Local
914            && let Some(path) = &dep.path
915        {
916            let manifest_path = path.join(MANIFEST_FILENAME);
917            if path.is_dir() && manifest_path.exists() {
918                let child = Manifest::read_from_file(manifest_path)?;
919                // dev_dependencies are not transitive.
920                collect_declared_deps_recursive(path, &child, false, declared)?;
921            }
922        }
923    }
924    Ok(())
925}
926
927#[cfg(test)]
928mod tests {
929    use super::*;
930    use leo_span::create_session_if_not_set_then;
931
932    const LEAF_PROGRAM: &str = "\
933program leaf.aleo;
934
935function identity:
936    input r0 as u32.private;
937    output r0 as u32.private;
938";
939
940    const DEPENDENCY_PROGRAM: &str = "\
941import leaf.aleo;
942
943program dependency.aleo;
944
945function times_two:
946    input r0 as u32.private;
947    call leaf.aleo/identity r0 into r1;
948    add r1 r1 into r2;
949    output r2 as u32.private;
950";
951
952    const MAIN_PROGRAM: &str = "\
953import dependency.aleo;
954
955program standalone.aleo;
956
957function main:
958    input r0 as u32.private;
959    call dependency.aleo/times_two r0 into r1;
960    output r1 as u32.private;
961";
962
963    fn dummy_package(base: &str) -> Package {
964        dummy_package_with(base, None)
965    }
966
967    fn dummy_package_with(base: &str, workspace_root: Option<PathBuf>) -> Package {
968        Package {
969            base_directory: PathBuf::from(base),
970            workspace_root,
971            compilation_units: Vec::new(),
972            manifest: Manifest {
973                program: "demo.aleo".to_string(),
974                version: "0.1.0".to_string(),
975                description: String::new(),
976                license: "MIT".to_string(),
977                leo: "0.0.0".to_string(),
978                dependencies: None,
979                dev_dependencies: None,
980                no_std: false,
981            },
982            dep_graph: DiGraph::default(),
983        }
984    }
985
986    #[test]
987    fn bare_unit_name_strips_aleo_suffix() {
988        assert_eq!(crate::bare_unit_name("token.aleo"), "token");
989        assert_eq!(crate::bare_unit_name("token"), "token");
990        assert_eq!(crate::bare_unit_name("credits.aleo"), "credits");
991    }
992
993    #[test]
994    fn unit_paths_are_keyed_by_bare_name() {
995        let pkg = dummy_package("/tmp/demo");
996        // The directory key is the bare compilation unit name, accepting input
997        // with or without the `.aleo` suffix.
998        assert_eq!(pkg.unit_build_directory("token.aleo"), PathBuf::from("/tmp/demo/build/token"));
999        assert_eq!(pkg.unit_build_directory("token"), PathBuf::from("/tmp/demo/build/token"));
1000        assert_eq!(pkg.unit_bytecode_path("token.aleo"), PathBuf::from("/tmp/demo/build/token/token.aleo"));
1001        assert_eq!(pkg.unit_abi_path("token"), PathBuf::from("/tmp/demo/build/token/abi.json"));
1002        assert_eq!(pkg.unit_interfaces_directory("token"), PathBuf::from("/tmp/demo/build/token/interfaces"));
1003    }
1004
1005    #[test]
1006    fn libraries_are_keyed_like_programs() {
1007        // A library is keyed by its name exactly like a program: a library
1008        // `my_lib` declaring interfaces gets `build/my_lib/interfaces/`.
1009        let pkg = dummy_package("/tmp/demo");
1010        assert_eq!(pkg.unit_build_directory("my_lib"), PathBuf::from("/tmp/demo/build/my_lib"));
1011        assert_eq!(pkg.unit_interfaces_directory("my_lib"), PathBuf::from("/tmp/demo/build/my_lib/interfaces"));
1012    }
1013
1014    #[test]
1015    fn build_directory_is_the_single_root() {
1016        let pkg = dummy_package("/tmp/demo");
1017        assert_eq!(pkg.build_directory(), PathBuf::from("/tmp/demo/build"));
1018        // Every per-unit path is rooted at `build_directory()`, the single layout seam.
1019        assert!(pkg.unit_bytecode_path("x").starts_with(pkg.build_directory()));
1020        assert!(pkg.unit_interfaces_directory("credits.aleo").starts_with(pkg.build_directory()));
1021    }
1022
1023    #[test]
1024    fn workspace_root_routes_build_directory_to_shared() {
1025        // When inside a workspace, `build_directory()` routes to the
1026        // workspace root - not the package's own directory - so every
1027        // member's per-unit subdirectory collapses under one shared
1028        // `<root>/build/` and deduplicates structurally on unit name.
1029        let pkg = dummy_package_with("/tmp/ws/members/token", Some(PathBuf::from("/tmp/ws")));
1030        assert_eq!(pkg.build_directory(), PathBuf::from("/tmp/ws/build"));
1031        assert_eq!(pkg.unit_build_directory("token"), PathBuf::from("/tmp/ws/build/token"));
1032        assert_eq!(pkg.unit_bytecode_path("token"), PathBuf::from("/tmp/ws/build/token/token.aleo"));
1033        // The package's own base_directory is irrelevant for the per-unit path:
1034        // a workspace member and a separate dependency keyed by the same unit
1035        // name resolve to byte-identical paths.
1036        let dep = dummy_package_with("/tmp/ws/members/swap", Some(PathBuf::from("/tmp/ws")));
1037        assert_eq!(pkg.unit_bytecode_path("token"), dep.unit_bytecode_path("token"));
1038    }
1039
1040    #[test]
1041    fn standalone_package_keeps_per_base_build_directory() {
1042        // The standalone path must not change: a package outside any
1043        // workspace still rooots its build under its own directory.
1044        let pkg = dummy_package_with("/tmp/standalone", None);
1045        assert_eq!(pkg.build_directory(), PathBuf::from("/tmp/standalone/build"));
1046        assert_eq!(pkg.unit_build_directory("demo"), PathBuf::from("/tmp/standalone/build/demo"));
1047    }
1048
1049    #[test]
1050    fn aleo_file_uses_sibling_imports_directory() {
1051        create_session_if_not_set_then(|_| {
1052            let root = crate::test_util::unique_dir("aleo-file-flat-imports");
1053            let program_path = root.join("standalone.aleo");
1054            let home = root.join("home");
1055            crate::test_util::write_file(&program_path, MAIN_PROGRAM);
1056            crate::test_util::write_file(&root.join("imports/dependency.aleo"), DEPENDENCY_PROGRAM);
1057            crate::test_util::write_file(&root.join("imports/leaf.aleo"), LEAF_PROGRAM);
1058            std::fs::create_dir_all(&home).expect("test registry directory should be created");
1059
1060            let package =
1061                Package::from_aleo_file(&program_path, &home, Some(&root.join("imports")), false, false, None, None, 0)
1062                    .expect("standalone Aleo program should load with its local import");
1063
1064            let names = package.compilation_units.iter().map(|unit| unit.name.to_string()).collect::<Vec<_>>();
1065            assert_eq!(names, ["leaf.aleo", "dependency.aleo", "standalone.aleo"]);
1066            assert!(package.compilation_units.iter().all(|unit| unit.is_local));
1067            assert_eq!(package.manifest.program, "standalone.aleo");
1068
1069            std::fs::remove_dir_all(root).expect("test directory should be removed");
1070        });
1071    }
1072
1073    #[test]
1074    fn aleo_file_uses_per_unit_build_layout() {
1075        create_session_if_not_set_then(|_| {
1076            let root = crate::test_util::unique_dir("aleo-file-per-unit-imports");
1077            let program_path = root.join("standalone/standalone.aleo");
1078            let home = root.join("home");
1079            crate::test_util::write_file(&program_path, MAIN_PROGRAM);
1080            crate::test_util::write_file(&root.join("dependency/dependency.aleo"), DEPENDENCY_PROGRAM);
1081            crate::test_util::write_file(&root.join("leaf/leaf.aleo"), LEAF_PROGRAM);
1082            std::fs::create_dir_all(&home).expect("test registry directory should be created");
1083
1084            let package = Package::from_aleo_file(&program_path, &home, Some(&root), false, false, None, None, 0)
1085                .expect("standalone Aleo build artifact should load with its local import");
1086
1087            let names = package.compilation_units.iter().map(|unit| unit.name.to_string()).collect::<Vec<_>>();
1088            assert_eq!(names, ["leaf.aleo", "dependency.aleo", "standalone.aleo"]);
1089            assert!(package.compilation_units.iter().all(|unit| unit.is_local));
1090
1091            std::fs::remove_dir_all(root).expect("test directory should be removed");
1092        });
1093    }
1094
1095    #[test]
1096    fn missing_aleo_import_is_classified_as_network() {
1097        create_session_if_not_set_then(|_| {
1098            let root = crate::test_util::unique_dir("aleo-file-network-import");
1099            let program_path = root.join("standalone.aleo");
1100            crate::test_util::write_file(&program_path, MAIN_PROGRAM);
1101
1102            let unit =
1103                CompilationUnit::from_aleo_path(Symbol::intern("standalone.aleo"), &program_path, &IndexMap::new())
1104                    .expect("test Aleo program should load");
1105
1106            let dependency = unit.dependencies.first().expect("test program should have one direct import");
1107            assert_eq!(dependency.name, "dependency.aleo");
1108            assert_eq!(dependency.location, Location::Network);
1109
1110            std::fs::remove_dir_all(root).expect("test directory should be removed");
1111        });
1112    }
1113
1114    #[test]
1115    fn aleo_file_resolves_local_import_below_network_import() {
1116        create_session_if_not_set_then(|_| {
1117            let root = crate::test_util::unique_dir("aleo-file-mixed-transitive-imports");
1118            let program_path = root.join("standalone.aleo");
1119            let imports = root.join("imports");
1120            let home = root.join("home");
1121            crate::test_util::write_file(&program_path, MAIN_PROGRAM);
1122            crate::test_util::write_file(&imports.join("leaf.aleo"), LEAF_PROGRAM);
1123            crate::test_util::write_file(
1124                &home.join("registry/testnet/dependency/0/dependency.aleo"),
1125                DEPENDENCY_PROGRAM,
1126            );
1127
1128            let package = Package::from_aleo_file(
1129                &program_path,
1130                &home,
1131                Some(&imports),
1132                false,
1133                false,
1134                Some(NetworkName::TestnetV0),
1135                Some("http://localhost:1"),
1136                0,
1137            )
1138            .expect("a local transitive import below a network import should be used");
1139
1140            let units =
1141                package.compilation_units.iter().map(|unit| (unit.name.to_string(), unit.is_local)).collect::<Vec<_>>();
1142            assert_eq!(units, [
1143                ("leaf.aleo".to_string(), true),
1144                ("dependency.aleo".to_string(), false),
1145                ("standalone.aleo".to_string(), true)
1146            ]);
1147
1148            std::fs::remove_dir_all(root).expect("test directory should be removed");
1149        });
1150    }
1151
1152    #[test]
1153    fn aleo_file_rejects_non_directory_imports_path() {
1154        create_session_if_not_set_then(|_| {
1155            let root = crate::test_util::unique_dir("aleo-file-invalid-imports-directory");
1156            let program_path = root.join("standalone.aleo");
1157            let home = root.join("home");
1158            crate::test_util::write_file(&program_path, MAIN_PROGRAM);
1159            std::fs::create_dir_all(&home).expect("test registry directory should be created");
1160
1161            let error = Package::from_aleo_file(&program_path, &home, Some(&program_path), false, false, None, None, 0)
1162                .expect_err("an imports path that is not a directory should fail");
1163            assert!(error.to_string().contains("Expected an Aleo imports directory"));
1164
1165            std::fs::remove_dir_all(root).expect("test directory should be removed");
1166        });
1167    }
1168}