Skip to main content

leo_lang/cli/commands/
add.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 super::*;
18use leo_ast::NetworkName;
19use leo_package::{CompilationUnit, Dependency, GitReference, GitSource, Location, Lock, Manifest, Workspace};
20use leo_span::Symbol;
21use std::path::{Path, PathBuf};
22
23/// Add a new on-chain, local, or git dependency to the current package.
24#[derive(Parser, Debug)]
25#[clap(name = "leo", author = "The Leo Team <leo@provable.com>", version)]
26pub struct LeoAdd {
27    #[clap(name = "NAME", help = "The dependency name. Ex: `credits.aleo` or `credits`.")]
28    pub(crate) name: String,
29
30    #[clap(flatten)]
31    pub(crate) source: DependencySource,
32
33    #[clap(flatten)]
34    pub(crate) git_ref: GitRef,
35
36    #[clap(
37        long,
38        help = "Endpoint used to verify a network dependency exists. Overrides the `ENDPOINT` environment variable."
39    )]
40    pub(crate) endpoint: Option<String>,
41
42    #[clap(
43        long,
44        env = "NETWORK_RETRIES",
45        help = "Number of times to retry a failed network request when verifying a network dependency.",
46        default_value = "2"
47    )]
48    pub(crate) network_retries: u32,
49
50    #[clap(long, help = "This is a development dependency.", default_value = "false")]
51    pub(crate) dev: bool,
52}
53
54#[derive(Parser, Debug)]
55#[group(required = true, multiple = false)]
56pub struct DependencySource {
57    #[clap(
58        short = 'l',
59        long,
60        help = "Local path for the dependency (program or library, auto-detected).",
61        group = "source"
62    )]
63    pub(crate) local: Option<PathBuf>,
64
65    #[clap(short = 'n', long, help = "Whether the dependency is on a live network.", group = "source")]
66    pub(crate) network: bool,
67
68    #[clap(
69        short = 'e',
70        long,
71        help = "The expected edition of the program. DO NOT USE THIS UNLESS YOU KNOW WHAT YOU ARE DOING.",
72        group = "source"
73    )]
74    pub(crate) edition: Option<u16>,
75
76    #[clap(short = 'w', long, help = "Depend on another member of the enclosing workspace.", group = "source")]
77    pub(crate) workspace: bool,
78
79    #[clap(
80        short = 'g',
81        long,
82        help = "Git repository URL for the dependency (program or library, auto-detected).",
83        group = "source"
84    )]
85    pub(crate) git: Option<String>,
86}
87
88/// The git reference to track. Only meaningful with `--git`; at most one may be set.
89#[derive(Parser, Debug)]
90#[group(required = false, multiple = false)]
91pub struct GitRef {
92    #[clap(long, help = "Track a git branch (requires `--git`).", group = "gitref", requires = "git")]
93    pub(crate) branch: Option<String>,
94
95    #[clap(long, help = "Pin to a git tag (requires `--git`).", group = "gitref", requires = "git")]
96    pub(crate) tag: Option<String>,
97
98    #[clap(long, help = "Pin to a git revision (requires `--git`).", group = "gitref", requires = "git")]
99    pub(crate) rev: Option<String>,
100}
101
102/// Normalize a program dep name to always carry the `.aleo` suffix, and validate it.
103fn normalize_program_name(raw: &str) -> Result<String> {
104    let name = if raw.ends_with(".aleo") { raw.to_string() } else { format!("{raw}.aleo") };
105    if !leo_package::is_valid_program_name(&name) {
106        return Err(crate::errors::invalid_package_name("program", name).into());
107    }
108    Ok(name)
109}
110
111/// Detect whether `located` is a library or program, validate `raw_name`, and return the canonical
112/// dep name (programs carry `.aleo`, libraries don't). `origin` is used in error messages.
113fn classify_and_validate_name(
114    raw_name: &str,
115    current_is_library: bool,
116    located: &Path,
117    origin: impl std::fmt::Display,
118) -> Result<String> {
119    let dep_is_library = if located.extension().and_then(|e| e.to_str()) == Some("aleo") && located.is_file() {
120        false
121    } else {
122        let dep_manifest_path = located.join(leo_package::MANIFEST_FILENAME);
123        let dep_manifest = Manifest::read_from_file(&dep_manifest_path).map_err(|_| {
124            crate::errors::custom(format!(
125                "Could not read `{}` — is `{origin}` a valid Leo package?",
126                dep_manifest_path.display()
127            ))
128        })?;
129        !dep_manifest.program.ends_with(".aleo")
130    };
131
132    if current_is_library && !dep_is_library {
133        return Err(crate::errors::custom("A library package can only depend on other libraries.").into());
134    }
135
136    if !dep_is_library {
137        return normalize_program_name(raw_name);
138    }
139
140    if raw_name.ends_with(".aleo") {
141        return Err(crate::errors::custom(format!(
142            "`{raw_name}` ends with `.aleo` but the package at `{origin}` is a library, not a program.",
143        ))
144        .into());
145    }
146    if !leo_package::is_valid_library_name(raw_name) {
147        return Err(crate::errors::invalid_package_name("library", raw_name).into());
148    }
149    // A library manifest without src/lib.leo is an incomplete package.
150    let lib_leo = located.join("src").join(leo_package::LIB_FILENAME);
151    if !lib_leo.exists() {
152        return Err(crate::errors::custom(format!(
153            "The package at `{origin}` has a library manifest but is missing `src/{}`.",
154            leo_package::LIB_FILENAME,
155        ))
156        .into());
157    }
158    Ok(raw_name.to_string())
159}
160
161impl Command for LeoAdd {
162    type Input = ();
163    type Output = ();
164
165    fn log_span(&self) -> Span {
166        tracing::span!(tracing::Level::INFO, "Leo")
167    }
168
169    fn prelude(&self, _: Context) -> Result<Self::Input> {
170        Ok(())
171    }
172
173    fn apply(self, context: Context, _: Self::Input) -> Result<Self::Output> {
174        let path = context.dir()?;
175
176        let manifest_path = path.join(leo_package::MANIFEST_FILENAME);
177        let mut manifest = Manifest::read_from_file(&manifest_path)?;
178
179        let current_is_library = !manifest.program.ends_with(".aleo");
180
181        // Determine dependency name, location, and path.
182        let (name, location, dep_path) = if let Some(local_path) = &self.source.local {
183            let name = classify_and_validate_name(&self.name, current_is_library, local_path, local_path.display())?;
184            (name, Location::Local, Some(local_path.clone()))
185        } else if self.source.workspace {
186            // Workspace dependency - validate that an enclosing workspace exists and the member is listed.
187            let ws = leo_package::Workspace::discover(&path)?.ok_or_else(|| {
188                crate::errors::custom(
189                    "Cannot add a workspace dependency: no `workspace.json` found in any parent directory.",
190                )
191            })?;
192            let name = normalize_program_name(&self.name)?;
193            if ws.find_member(&name).is_none() {
194                return Err(crate::errors::custom(format!(
195                    "No workspace member named `{name}` found. Check the `members` list in `workspace.json`.",
196                ))
197                .into());
198            }
199            (name, Location::Workspace, None)
200        } else if let Some(url) = &self.source.git {
201            // Validate the name first, since it becomes part of the checkout path.
202            if !leo_package::is_valid_package_name(leo_package::bare_unit_name(&self.name)) {
203                return Err(crate::errors::custom(format!("`{}` is not a valid dependency name.", self.name)).into());
204            }
205
206            // Same derivation/validation the build uses, so `leo add` and the build agree.
207            let reference = match GitReference::from_opts(&self.git_ref.branch, &self.git_ref.tag, &self.git_ref.rev) {
208                Ok(reference) => reference,
209                Err(reason) => return Err(crate::errors::custom(reason).into()),
210            };
211
212            let home = context.home()?;
213            let (checkout, commit) = leo_package::git::resolve(&home, &self.name, url, &reference, None, false)?;
214
215            let located = leo_package::find_in_checkout(&checkout, &self.name).map_err(|_| {
216                crate::errors::custom(format!(
217                    "Could not find a Leo package named `{}` in `{url}`. Check the name and the git reference.",
218                    self.name
219                ))
220            })?;
221            let name = classify_and_validate_name(&self.name, current_is_library, &located, url)?;
222
223            // Pin the resolved commit so the next build reuses this checkout instead of re-fetching.
224            let lock_dir = Workspace::discover_root(&path)?.unwrap_or_else(|| path.clone());
225            let mut lock = Lock::read(&lock_dir);
226            lock.record(name.clone(), url.clone(), reference.lock_string(), commit);
227            lock.write(&lock_dir)?;
228
229            (name, Location::Git, None)
230        } else {
231            // Network or edition dependency - must be a program, not a library.
232            if current_is_library {
233                return Err(crate::errors::custom(
234                    "A library package can only depend on other libraries. Use `--local <path>` to add a library dependency.",
235                )
236                .into());
237            }
238            let name = normalize_program_name(&self.name)?;
239
240            // Verify the program exists before recording it, reusing the build's fetch path.
241            // Network/endpoint default like `build`; the name comes from the env because
242            // `--network` here selects the source rather than a network name.
243            let network = get_network(&None).unwrap_or_else(|_| {
244                tracing::warn!("⚠️ No network specified, defaulting to 'testnet'.");
245                NetworkName::TestnetV0
246            });
247            let endpoint = get_endpoint(&self.endpoint).unwrap_or_else(|_| {
248                tracing::warn!("⚠️ No endpoint specified, defaulting to '{DEFAULT_ENDPOINT}'.");
249                DEFAULT_ENDPOINT.to_string()
250            });
251            let home = context.home()?;
252            CompilationUnit::fetch(
253                Symbol::intern(&name),
254                self.source.edition,
255                &home,
256                network,
257                &endpoint,
258                false,
259                self.network_retries,
260            )
261            .map_err(|err| {
262                crate::errors::custom(format!(
263                    "Could not find program `{name}` on network `{network}` at `{endpoint}`: {err}",
264                ))
265            })?;
266
267            (name, Location::Network, None)
268        };
269
270        let new_dependency = Dependency {
271            name: name.clone(),
272            location,
273            path: dep_path.clone(),
274            edition: self.source.edition,
275            git: self.source.git.as_ref().map(|url| GitSource {
276                url: url.clone(),
277                branch: self.git_ref.branch.clone(),
278                tag: self.git_ref.tag.clone(),
279                rev: self.git_ref.rev.clone(),
280            }),
281        };
282
283        // Choose dev or normal dependencies.
284        let deps = if self.dev { &mut manifest.dev_dependencies } else { &mut manifest.dependencies };
285
286        if let Some(existing) = deps.get_or_insert_default().iter_mut().find(|dep| dep.name == new_dependency.name) {
287            match existing.location {
288                Location::Local => tracing::warn!(
289                    "⚠️ Dependency `{name}` already exists as a local dependency at `{}`. Overwriting.",
290                    existing.path.as_ref().map(|p| p.display().to_string()).unwrap_or_default()
291                ),
292                Location::Workspace => {
293                    tracing::warn!("⚠️ Dependency `{name}` already exists as a workspace dependency. Overwriting.")
294                }
295                Location::Git => {
296                    tracing::warn!("⚠️ Dependency `{name}` already exists as a git dependency. Overwriting.")
297                }
298                _ => tracing::warn!("⚠️ Dependency `{name}` already exists as a network dependency. Overwriting."),
299            }
300            *existing = new_dependency;
301        } else {
302            deps.as_mut().unwrap().push(new_dependency);
303
304            match location {
305                Location::Local => tracing::info!(
306                    "✅ Added local dependency `{name}` at path `{}`.",
307                    dep_path.as_ref().map(|p| p.display().to_string()).unwrap_or_default()
308                ),
309                Location::Workspace => tracing::info!("✅ Added workspace dependency `{name}`."),
310                Location::Git => tracing::info!("✅ Added git dependency `{name}`."),
311                _ => tracing::info!("✅ Added network dependency `{name}`."),
312            }
313        }
314
315        manifest.write_to_file(manifest_path)?;
316
317        Ok(())
318    }
319}