diff --git a/Cargo.lock b/Cargo.lock index 328a0b9887e..267721cca92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3050,6 +3050,7 @@ dependencies = [ "tar", "tempfile", "toml 0.8.20", + "toml_edit", "tracing", "url", "vec1", diff --git a/Cargo.toml b/Cargo.toml index 8a97be60867..395a2bed1f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -238,7 +238,7 @@ thiserror = "1.0" tikv-jemallocator = "0.6" tokio = "1.12" toml = "0.8" -toml_edit = "0.22" +toml_edit = { version = "0.22", features = ["serde"] } tower = { version = "0.5", default-features = false } tower-lsp = "0.20" tracing = "0.1" diff --git a/docs/book/src/SUMMARY.md b/docs/book/src/SUMMARY.md index b89f3d04fb4..dbef9f03f59 100644 --- a/docs/book/src/SUMMARY.md +++ b/docs/book/src/SUMMARY.md @@ -80,6 +80,8 @@ - [Workspaces](./forc/workspaces.md) - [Dependencies](./forc/dependencies.md) - [Commands](./forc/commands/index.md) + - [forc add](./forc/commands/forc_add.md) + - [forc remove](./forc/commands/forc_remove.md) - [forc addr2line](./forc/commands/forc_addr2line.md) - [forc build](./forc/commands/forc_build.md) - [forc check](./forc/commands/forc_check.md) diff --git a/docs/book/src/forc/commands/forc_add.md b/docs/book/src/forc/commands/forc_add.md new file mode 100644 index 00000000000..87747390519 --- /dev/null +++ b/docs/book/src/forc/commands/forc_add.md @@ -0,0 +1,32 @@ +# forc add + +Adds one or more dependencies to a `Forc.toml` manifest. + +## **Usage** + +```bash +forc add [OPTIONS] ... +``` + +## **Arguments** + +* ``: List of dependencies in the format `name[@version]` (e.g., `custom_lib@0.1.0`, `custom_contract`) + +## **Options** + +* `--path `: Add a local path dependency. +* `--git `: Add a Git-based dependency. + + * Can be combined with one of: + + * `--branch ` + * `--tag ` + * `--rev ` +* `--ipfs `: Add a dependency sourced from IPFS. +* `--contract-dep`: Add to `[contract-dependencies]` instead of `[dependencies]`. +* `--salt `: Salt to use for contract deployment (only applies to contract dependencies). +* `--package `: Apply change to a specific package in a workspace. +* `--manifest-path `: Path to the `Forc.toml`. +* `--dry-run`: Show what would be changed without writing to the file. +* `--offline`: Do not fetch any remote dependencies. +* `--ipfs-node `: IPFS node to use for IPFS-sourced dependencies. diff --git a/docs/book/src/forc/commands/forc_remove.md b/docs/book/src/forc/commands/forc_remove.md new file mode 100644 index 00000000000..df5e72964ac --- /dev/null +++ b/docs/book/src/forc/commands/forc_remove.md @@ -0,0 +1,22 @@ +# forc remove + +Removes one or more dependencies from a `Forc.toml` manifest. + +## **Usage** + +```bash +forc remove [OPTIONS] ... +``` + +## **Arguments** + +* ``: List of dependencies to remove by name (e.g., `custom_lib`, `custom_contract`) + +## **Options** + +* `--contract-dep`: Remove from `[contract-dependencies]` instead of `[dependencies]`. +* `--package `: Target a specific package in a workspace. +* `--manifest-path `: Path to the `Forc.toml`. +* `--dry-run`: Preview what would be removed without making any changes. +* `--offline`: Prevent forc from fetching metadata or resolving versions remotely. +* `--ipfs-node `: IPFS node to use for reference. diff --git a/docs/book/src/forc/dependencies.md b/docs/book/src/forc/dependencies.md index c3721dbbbc5..673aedcd1a8 100644 --- a/docs/book/src/forc/dependencies.md +++ b/docs/book/src/forc/dependencies.md @@ -1,55 +1,122 @@ # Dependencies -Forc has a dependency management system which can pull packages using `git`, `ipfs` or `registry`. This allows users to build and share Forc libraries. +Forc has a dependency management system which can pull packages using `git`, `ipfs`, `path`, or the community `registry`. This allows users to build and share Forc libraries. -## Adding a dependency +## Adding Dependencies -If your `Forc.toml` doesn't already have a `[dependencies]` table, add one. Below, list the package name alongside its source. Currently, `forc` supports `git`, `ipfs`, `path` and `registry` sources. +You can add dependencies manually in your `Forc.toml`, or by using the `forc add` command. -If a `git` source is specified, `forc` will fetch the git repository at the given URL and then search for a `Forc.toml` for a package with the given name anywhere inside the git repository. +### Using `forc add` -The following example adds a library dependency named `custom_lib`. For git dependencies you may optionally specify a `branch`, `tag`, or `rev` (i.e. commit hash) reference. +The `forc add` CLI supports various sources and optional flags: -```toml -[dependencies] -custom_lib = { git = "https://github.com/FuelLabs/custom_lib", branch = "master" } -# custom_lib = { git = "https://github.com/FuelLabs/custom_lib", tag = "v0.0.1" } -# custom_lib = { git = "https://github.com/FuelLabs/custom_lib", rev = "87f80bdf323e2d64e213895d0a639ad468f4deff" } +```bash +forc add [--path ] [--git --tag ] [--ipfs ] [--contract-dep] ``` -Depending on a local library using `path`: +#### Add Examples + +* From a Git branch: + + ```bash + forc add custom_lib --git https://github.com/FuelLabs/custom_lib --branch master + ``` + +* From a local path: + + ```bash + forc add custom_lib --path ../custom_lib + ``` + +* From IPFS: + + ```bash + forc add custom_lib --ipfs QmYwAPJzv5CZsnA... + ``` + +* From registry (forc.pub): + + ```bash + forc add custom_lib@0.0.1 + ``` + +* Add as a contract dependency: + + ```bash + forc add my_contract --git https://github.com/example/contract --contract-dep + ``` + +Optional: + +* `--salt ` for custom contract salt. +* `--package ` to target a specific package in a workspace. +* `--manifest-path ` to specify a manifest file. + +> ⚠️ **Note:** +> We do not currently support offline mode for projects that use **registry** sources. +> Also wildcard declarations `(ex: custom_lib = *)` to get the latest version available for that package or caret declarations `(ex: custom_lib = ^0.1)` to get `SemVer` compatible latest available option for a given dependency is not supported yet. + +Once the package is added, running `forc build` will automatically fetch and resolve the dependencies. + +### Manually Editing `Forc.toml` + +If your `Forc.toml` doesn't already have a `[dependencies]` or `[contract-dependencies]` table, add one. Below, list the package name and its source. + +#### Local Path ```toml [dependencies] custom_lib = { path = "../custom_lib" } ``` -For `ipfs` sources, `forc` will fetch the specified `cid` using either a local `ipfs` node or a public gateway. `forc` automatically tries to connect to local `ipfs` node. If it fails, it defaults to using `https://ipfs.io/` as a gateway. - -The following example adds a dependency with an `ipfs` source. +#### IPFS Source ```toml [dependencies] -custom_lib = { ipfs = "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG" } +custom_lib = { ipfs = "QmYwAPJzv5CZsnA..." } ``` -For `registry` sources, `forc` will first resolve the source declared by its name and version. This is done using the forc.pub-index repo (located at `https://github.com/FuelLabs/forc.pub-index`). The package name and version is used to convert the declaration to an IPFS CID. The resolved IPFS CID is then used by either a local IPFS node or an IPFS gateway that fuel operates (`https://ipfs.forc.pub/`) to actually fetch the package. - -Available packages can be found at `https://forc.pub`. - -The following example adds a dependency with a `registry` source. +#### Registry Source (forc.pub) ```toml [dependencies] custom_lib = "0.0.1" ``` -In the example above the package `custom_lib v0.0.1` will be fetched from `forc.pub` using IPFS. +## Removing Dependencies + +You can remove one or more dependencies using the `forc remove` command: + +```bash +forc remove [--contract-dep] [--package ] [--manifest-path ] +``` + +### Remove Examples + +* Remove from `[dependencies]`: + + ```bash + forc remove custom_lib + ``` + +* Remove from `[contract-dependencies]`: -We do not currently support `offline` mode of operation for project that uses `registry` sources. Also wildcard declarations (ex: `custom_lib = *`) to get the latest version available for that package or caret declarations (ex: `custom_lib = ^0.1`) to get `SemVer` compatible latest available option for a given dependency is not supported yet. + ```bash + forc remove my_contract --contract-dep + ``` -Once the package is added, running `forc build` will automatically download added dependencies. +* Target a specific package in a workspace: -## Updating dependencies + ```bash + forc remove custom_lib --package my_project + ``` + +## Updating Dependencies + +To update dependencies in your Forc directory you can run: + +```bash +forc update +``` -To update dependencies in your Forc directory you can run `forc update`. For `path` and `ipfs` dependencies this will have no effect. For `git` dependencies with a `branch` reference, this will update the project to use the latest commit for the given branch. +For path and ipfs dependencies this will have no effect. For git dependencies with a branch reference, this will update the project to use the latest commit for the given branch. diff --git a/forc-pkg/Cargo.toml b/forc-pkg/Cargo.toml index 4aea3ba4132..f19e157c594 100644 --- a/forc-pkg/Cargo.toml +++ b/forc-pkg/Cargo.toml @@ -37,6 +37,7 @@ sway-types.workspace = true sway-utils.workspace = true tar.workspace = true toml = { workspace = true, features = ["parse"] } +toml_edit.workspace = true tracing.workspace = true url = { workspace = true, features = ["serde"] } vec1.workspace = true @@ -48,4 +49,3 @@ tempfile.workspace = true [target.'cfg(not(target_os = "macos"))'.dependencies] sysinfo.workspace = true - diff --git a/forc-pkg/src/manifest/dep_modifier.rs b/forc-pkg/src/manifest/dep_modifier.rs new file mode 100644 index 00000000000..60c00e5e261 --- /dev/null +++ b/forc-pkg/src/manifest/dep_modifier.rs @@ -0,0 +1,1285 @@ +use crate::manifest::{ + ContractDependency, Dependency, DependencyDetails, GenericManifestFile, HexSalt, +}; +use crate::source::IPFSNode; +use crate::{self as pkg, Lock, PackageManifestFile}; +use anyhow::{anyhow, bail, Result}; +use pkg::manifest::ManifestFile; +use std::collections::BTreeMap; +use std::fmt; +use std::path::Path; +use std::path::PathBuf; +use std::str::FromStr; +use sway_core::fuel_prelude::fuel_tx; +use toml_edit::{DocumentMut, InlineTable, Item, Table, Value}; +use tracing::info; + +#[derive(Clone, Debug, Default)] +pub enum Action { + #[default] + Add, + Remove, +} + +#[derive(Clone, Debug, Default)] +pub struct ModifyOpts { + // === Manifest Options === + pub manifest_path: Option, + // === Package Selection === + pub package: Option, + // === Source (Add only) === + pub source_path: Option, + pub git: Option, + pub branch: Option, + pub tag: Option, + pub rev: Option, + pub ipfs: Option, + // === Section === + pub contract_deps: bool, + pub salt: Option, + // === IPFS Node === + pub ipfs_node: Option, + // === Dependencies & Flags === + pub dependencies: Vec, + pub dry_run: bool, + pub offline: bool, + pub action: Action, +} + +pub fn modify_dependencies(opts: ModifyOpts) -> Result<()> { + let manifest_file = if let Some(p) = &opts.manifest_path { + let path = &PathBuf::from(p); + ManifestFile::from_file(path)? + } else { + let cwd = std::env::current_dir()?; + ManifestFile::from_dir(cwd)? + }; + + let root_dir = manifest_file.root_dir(); + let member_manifests = manifest_file.member_manifests()?; + + let package_manifest_dir = + resolve_package_path(&manifest_file, &opts.package, &root_dir, &member_manifests)?; + + let content = std::fs::read_to_string(&package_manifest_dir)?; + let mut toml_doc = content.parse::()?; + let backup_doc = toml_doc.clone(); + + let old_package_manifest = PackageManifestFile::from_file(&package_manifest_dir)?; + let lock_path = old_package_manifest.lock_path()?; + let old_lock = Lock::from_path(&lock_path).ok().unwrap_or_default(); + + let section = if opts.contract_deps { + Section::ContractDeps + } else { + Section::Deps + }; + + match opts.action { + Action::Add => { + for dependency in &opts.dependencies { + let (dep_name, dependency_data) = resolve_dependency( + dependency, + &opts, + &member_manifests, + &old_package_manifest.dir().to_path_buf(), + )?; + + section.add_deps_manifest_table( + &mut toml_doc, + dep_name, + dependency_data, + opts.salt.clone(), + )?; + } + } + Action::Remove => { + let dep_refs: Vec<&str> = opts.dependencies.iter().map(String::as_str).collect(); + + section.remove_deps_manifest_table(&mut toml_doc, &dep_refs)?; + } + } + + // write updates to toml doc + std::fs::write(&package_manifest_dir, toml_doc.to_string())?; + + let updated_package_manifest = PackageManifestFile::from_file(&package_manifest_dir)?; + + let member_manifests = updated_package_manifest.member_manifests()?; + + let new_plan = pkg::BuildPlan::from_lock_and_manifests( + &lock_path, + &member_manifests, + false, + opts.offline, + &opts.ipfs_node.clone().unwrap_or_default(), + ); + + new_plan.or_else(|e| { + std::fs::write(&package_manifest_dir, backup_doc.to_string()) + .map_err(|write_err| anyhow!("failed to write toml file: {}", write_err))?; + Err(e) + })?; + + if opts.dry_run { + info!("Dry run enabled. toml file not modified."); + std::fs::write(&package_manifest_dir, backup_doc.to_string())?; + + let string = toml::ser::to_string_pretty(&old_lock)?; + std::fs::write(&lock_path, string)?; + + return Ok(()); + } + + Ok(()) +} + +fn resolve_package_path( + manifest_file: &ManifestFile, + package: &Option, + root_dir: &Path, + member_manifests: &BTreeMap, +) -> Result { + if manifest_file.is_workspace() { + let Some(package_name) = package else { + let packages = member_manifests + .keys() + .cloned() + .collect::>() + .join(", "); + bail!("`forc add` could not determine which package to modify. Use --package.\nAvailable: {}", packages); + }; + + resolve_workspace_path_inner(member_manifests, package_name, root_dir) + } else if let Some(package_name) = package { + resolve_workspace_path_inner(member_manifests, package_name, root_dir) + } else { + Ok(manifest_file.path().to_path_buf()) + } +} + +fn resolve_workspace_path_inner( + member_manifests: &BTreeMap, + package_name: &str, + root_dir: &Path, +) -> Result { + if let Some(dir) = member_manifests.get(package_name) { + Ok(dir.path().to_path_buf()) + } else { + bail!( + "package(s) {} not found in workspace {}", + package_name, + root_dir.to_string_lossy() + ) + } +} + +fn resolve_dependency( + raw: &str, + opts: &ModifyOpts, + member_manifests: &BTreeMap, + package_dir: &PathBuf, +) -> Result<(String, Dependency)> { + let dep_spec: DepSpec = raw.parse()?; + let dep_name = dep_spec.name; + + let mut details = DependencyDetails { + version: dep_spec.version_req.clone(), + namespace: None, + path: opts.source_path.clone(), + git: opts.git.clone(), + branch: opts.branch.clone(), + tag: opts.tag.clone(), + package: None, + rev: opts.rev.clone(), + ipfs: opts.ipfs.clone(), + }; + + details.validate()?; + + let dependency_data = if let Some(version) = dep_spec.version_req { + Dependency::Simple(version) + } else if details.is_source_empty() { + if let Some(member) = member_manifests.get(&dep_name) { + if member.dir() == package_dir { + bail!("cannot add `{}` as a dependency to itself", dep_name); + } + + let sibling_parent = package_dir.parent().unwrap(); + let rel_path = member + .dir() + .strip_prefix(sibling_parent) + .map(|p| PathBuf::from("..").join(p)) + .unwrap_or_else(|_| member.dir().to_path_buf()); + + details.path = Some(rel_path.to_string_lossy().to_string()); + Dependency::Detailed(details) + } else { + // Fallback: no explicit source & not a sibling package. + // TODO: Integrate registry support (e.g., forc.pub) here. + bail!( + "dependency `{}` source not specified. Please specify a source (e.g., git, path) or version.", + dep_name + ); + } + } else { + Dependency::Detailed(details) + }; + + Ok((dep_name, dependency_data)) +} + +/// Reference to a package to be added as a dependency. +/// +/// See `forc add` help for more info. +#[derive(Clone, Debug, Default)] +pub struct DepSpec { + pub name: String, + pub version_req: Option, +} + +impl FromStr for DepSpec { + type Err = anyhow::Error; + + fn from_str(s: &str) -> anyhow::Result { + if s.trim().is_empty() { + bail!("Dependency spec cannot be empty"); + } + + let mut s = s.trim().split('@'); + + let name = s + .next() + .ok_or_else(|| anyhow::anyhow!("missing dependency name"))?; + + let version_req = s.next().map(|s| s.to_string()); + + if let Some(ref v) = version_req { + semver::VersionReq::parse(v) + .map_err(|_| anyhow::anyhow!("invalid version requirement `{v}`"))?; + } + + Ok(Self { + name: name.to_string(), + version_req, + }) + } +} + +impl fmt::Display for DepSpec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.version_req { + Some(version) => write!(f, "{}@{}", self.name, version), + None => write!(f, "{}", self.name), + } + } +} + +#[derive(Clone)] +pub enum Section { + Deps, + ContractDeps, +} + +impl fmt::Display for Section { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let section = match self { + Section::Deps => "dependencies", + Section::ContractDeps => "contract-dependencies", + }; + write!(f, "{}", section) + } +} + +impl Section { + pub fn add_deps_manifest_table( + &self, + doc: &mut DocumentMut, + dep_name: String, + dep_data: Dependency, + salt: Option, + ) -> Result<()> { + let section_name = self.to_string(); + + if !doc.as_table().contains_key(§ion_name) { + doc[§ion_name] = Item::Table(Table::new()); + } + + let table = doc[section_name.as_str()].as_table_mut().unwrap(); + + match self { + Section::Deps => { + let item = match dep_data { + Dependency::Simple(ver) => ver.to_string().into(), + Dependency::Detailed(details) => { + Item::Value(toml_edit::Value::InlineTable(generate_table(&details))) + } + }; + table.insert(&dep_name, item); + } + Section::ContractDeps => { + let resolved_salt = match salt.as_ref().or(salt.as_ref()) { + Some(s) => { + HexSalt::from_str(s).map_err(|e| anyhow!("Invalid salt format: {}", e))? + } + None => HexSalt(fuel_tx::Salt::default()), + }; + let contract_dep = ContractDependency { + dependency: dep_data, + salt: resolved_salt.clone(), + }; + + let dep = &contract_dep.dependency; + let salt: &HexSalt = &contract_dep.salt; + let item = match dep { + Dependency::Simple(ver) => { + let mut inline = InlineTable::default(); + inline.insert("version", Value::from(ver.to_string())); + inline.insert("salt", Value::from(format!("0x{}", salt))); + Item::Value(toml_edit::Value::InlineTable(inline)) + } + Dependency::Detailed(details) => { + let mut inline = generate_table(details); + inline.insert("salt", Value::from(format!("0x{}", salt))); + Item::Value(toml_edit::Value::InlineTable(inline)) + } + }; + table.insert(&dep_name, item); + } + }; + + Ok(()) + } + + pub fn remove_deps_manifest_table(self, doc: &mut DocumentMut, deps: &[&str]) -> Result<()> { + let section_name = self.to_string(); + + let section_table = doc[section_name.as_str()].as_table_mut().ok_or_else(|| { + anyhow!( + "the dependency `{}` could not be found in `{}`", + deps.join(", "), + section_name, + ) + })?; + + match self { + Section::Deps => { + for dep in deps { + if !section_table.contains_key(dep) { + bail!( + "the dependency `{}` could not be found in `{}`", + dep, + section_name + ); + } + section_table.remove(dep); + } + } + Section::ContractDeps => { + for dep in deps { + if !section_table.contains_key(dep) { + bail!( + "the dependency `{}` could not be found in `{}`", + dep, + section_name + ); + } + section_table.remove(dep); + } + } + } + Ok(()) + } +} + +fn generate_table(details: &DependencyDetails) -> InlineTable { + let mut inline = InlineTable::default(); + + if let Some(version) = &details.version { + inline.insert("version", Value::from(version.to_string())); + } + if let Some(git) = &details.git { + inline.insert("git", Value::from(git.to_string())); + } + if let Some(branch) = &details.branch { + inline.insert("branch", Value::from(branch.to_string())); + } + if let Some(tag) = &details.tag { + inline.insert("tag", Value::from(tag.to_string())); + } + if let Some(rev) = &details.rev { + inline.insert("rev", Value::from(rev.to_string())); + } + if let Some(path) = &details.path { + inline.insert("path", Value::from(path.to_string())); + } + if let Some(ipfs) = &details.ipfs { + inline.insert("cid", Value::from(ipfs.to_string())); + } + + inline +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::WorkspaceManifestFile; + use std::fs; + use std::str::FromStr; + use tempfile::{tempdir, TempDir}; + + fn create_test_package( + name: &str, + source_files: Vec<(&str, &str)>, + ) -> Result<(TempDir, PackageManifestFile)> { + let temp_dir = tempdir()?; + let base_path = temp_dir.path(); + + // Create package structure + fs::create_dir_all(base_path.join("src"))?; + + // Create Forc.toml + let forc_toml = format!( + r#" + [project] + authors = ["Test"] + entry = "main.sw" + license = "MIT" + name = "{}" + + [dependencies] + "#, + name + ); + fs::write(base_path.join("Forc.toml"), forc_toml)?; + + // Create source files + for (file_name, content) in source_files { + // Handle nested directories in the file path + let file_path = base_path.join("src").join(file_name); + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(file_path, content)?; + } + + // Create the manifest file + let manifest_file = PackageManifestFile::from_file(base_path.join("Forc.toml"))?; + + Ok((temp_dir, manifest_file)) + } + + fn create_test_workspace( + members: Vec<(&str, Vec<(&str, &str)>)>, + ) -> Result<(TempDir, WorkspaceManifestFile)> { + let temp_dir = tempdir()?; + let base_path = temp_dir.path(); + + // Create workspace Forc.toml + let mut workspace_toml = "[workspace]\nmembers = [".to_string(); + + for (i, (name, _)) in members.iter().enumerate() { + if i > 0 { + workspace_toml.push_str(", "); + } + workspace_toml.push_str(&format!("\"{name}\"")); + } + workspace_toml.push_str("]\n"); + + fs::write(base_path.join("Forc.toml"), workspace_toml)?; + + // Create each member + for (name, source_files) in members { + let member_path = base_path.join(name); + fs::create_dir_all(member_path.join("src"))?; + + // Create member Forc.toml + let forc_toml = format!( + r#" + [project] + authors = ["Test"] + entry = "main.sw" + license = "MIT" + name = "{}" + + [dependencies] + "#, + name + ); + fs::write(member_path.join("Forc.toml"), forc_toml)?; + + // Create source files + for (file_name, content) in source_files { + // Handle nested directories in the file path + let file_path = member_path.join("src").join(file_name); + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(file_path, content)?; + } + } + + // Create the workspace manifest file + let manifest_file = WorkspaceManifestFile::from_file(base_path.join("Forc.toml"))?; + + Ok((temp_dir, manifest_file)) + } + + #[test] + fn test_dep_from_str_name_only() { + let dep: DepSpec = "abc".parse().expect("parsing dep spec failed"); + assert_eq!(dep.name, "abc".to_string()); + assert_eq!(dep.version_req, None); + } + + #[test] + fn test_dep_from_str_name_and_version() { + let dep: DepSpec = "abc@1".parse().expect("parsing dep spec failed"); + assert_eq!(dep.name, "abc".to_string()); + assert_eq!(dep.version_req, Some("1".to_string())); + } + + #[test] + fn test_dep_spec_invalid_version_req() { + let input = "foo@not-a-version"; + let result = DepSpec::from_str(input); + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("invalid version requirement"), + "Expected version requirement parse failure" + ); + } + + #[test] + fn test_dep_from_str_invalid() { + assert!(DepSpec::from_str("").is_err()); + } + + #[test] + fn test_resolve_package_path_single_package_mode() { + let (temp_dir, pkg_manifest) = + create_test_package("test_pkg", vec![("main.sw", "fn main() -> u64 { 42 }")]).unwrap(); + + let package_spec_dir = temp_dir.path().to_path_buf(); + let expected_path = pkg_manifest.path; + + let manifest_file = ManifestFile::from_dir(&package_spec_dir).unwrap(); + + let members = manifest_file.member_manifests().unwrap(); + let root_dir = manifest_file.root_dir(); + let result = resolve_package_path(&manifest_file, &None, &root_dir, &members).unwrap(); + + assert_eq!(result, expected_path); + } + + #[test] + fn test_resolve_package_path_workspace_with_package_found() { + let (temp_dir, _) = create_test_workspace(vec![ + ("pkg1", vec![("main.sw", "fn main() -> u64 { 1 }")]), + ("pkg2", vec![("main.sw", "fn main() -> u64 { 2 }")]), + ]) + .unwrap(); + + let base_path = temp_dir.path(); + + let expected_path = base_path.join("pkg1/Forc.toml"); + + let manifest_file = ManifestFile::from_dir(base_path).unwrap(); + let members = manifest_file.member_manifests().unwrap(); + let root_dir = manifest_file.root_dir(); + + let package = "pkg1".to_string(); + let result = + resolve_package_path(&manifest_file, &Some(package), &root_dir, &members).unwrap(); + + assert_eq!(result, expected_path); + } + + #[test] + fn test_resolve_package_path_workspace_package_not_found() { + let (temp_dir, _) = create_test_workspace(vec![ + ("pkg1", vec![("main.sw", "fn main() -> u64 { 1 }")]), + ("pkg2", vec![("main.sw", "fn main() -> u64 { 2 }")]), + ]) + .unwrap(); + + let base_path = temp_dir.path(); + + let manifest_file = ManifestFile::from_dir(base_path).unwrap(); + let members = manifest_file.member_manifests().unwrap(); + let root_dir = manifest_file.root_dir(); + + let err = resolve_package_path( + &manifest_file, + &Some("missing_pkg".into()), + &root_dir, + &members, + ) + .unwrap_err(); + + assert!( + err.to_string().contains("package(s) missing_pkg not found"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_resolve_package_path_workspace_package_not_set() { + let (temp_dir, _) = create_test_workspace(vec![ + ("pkg1", vec![("main.sw", "fn main() -> u64 { 1 }")]), + ("pkg2", vec![("main.sw", "fn main() -> u64 { 2 }")]), + ]) + .unwrap(); + + let base_path = temp_dir.path(); + + let manifest_file = ManifestFile::from_dir(base_path).unwrap(); + let members = manifest_file.member_manifests().unwrap(); + let root_dir = manifest_file.root_dir(); + + let err = resolve_package_path(&manifest_file, &None, &root_dir, &members).unwrap_err(); + + let resp = "`forc add` could not determine which package to modify. Use --package.\nAvailable: pkg1, pkg2".to_string(); + assert!(err.to_string().contains(&resp), "unexpected error: {err}"); + } + + #[test] + fn test_resolve_dependency_simple_version() { + let opts = ModifyOpts { + dependencies: vec!["dep@1.0.0".to_string()], + ..Default::default() + }; + + let (temp_dir, _) = + create_test_package("test_pkg", vec![("main.sw", "fn main() -> u64 { 42 }")]).unwrap(); + + let package_spec_dir = temp_dir.path().to_path_buf(); + + let manifest_file = ManifestFile::from_dir(&package_spec_dir).unwrap(); + let members = manifest_file.member_manifests().unwrap(); + + let (name, data) = + resolve_dependency("dep@1.0.0", &opts, &members, &package_spec_dir).unwrap(); + + assert_eq!(name, "dep"); + match data { + Dependency::Simple(v) => assert_eq!(v, "1.0.0"), + _ => panic!("Expected simple dependency"), + } + } + + #[test] + fn test_resolve_dependency_detailed_variants() { + let base_opts = ModifyOpts { + ..Default::default() + }; + + let (temp_dir, _) = + create_test_package("test_pkg", vec![("main.sw", "fn main() -> u64 { 42 }")]).unwrap(); + + let package_spec_dir = temp_dir.path().to_path_buf(); + + let manifest_file = ManifestFile::from_dir(&package_spec_dir).unwrap(); + let members = manifest_file.member_manifests().unwrap(); + let dep = "dummy_dep"; + let git = "https://github.com/example/repo.git"; + + // Git alone + { + let mut opts = base_opts.clone(); + opts.git = Some(git.to_string()); + + let (name, data) = resolve_dependency(dep, &opts, &members, &package_spec_dir).unwrap(); + assert_eq!(name, dep); + match data { + Dependency::Detailed(details) => { + assert_eq!(details.git.as_deref(), Some(git)); + } + _ => panic!("Expected detailed dependency with git"), + } + } + + // Git + branch + { + let mut opts = base_opts.clone(); + opts.git = Some(git.to_string()); + opts.branch = Some("main".to_string()); + + let (name, data) = resolve_dependency(dep, &opts, &members, &package_spec_dir).unwrap(); + assert_eq!(name, dep); + match data { + Dependency::Detailed(details) => { + assert_eq!(details.git.as_deref(), Some(git)); + assert_eq!(details.branch.as_deref(), Some("main")); + } + _ => panic!("Expected detailed dependency with git+branch"), + } + } + + // Git + rev + { + let mut opts = base_opts.clone(); + opts.git = Some(git.to_string()); + opts.rev = Some("deadbeef".to_string()); + + let (name, data) = resolve_dependency(dep, &opts, &members, &package_spec_dir).unwrap(); + assert_eq!(name, dep); + match data { + Dependency::Detailed(details) => { + assert_eq!(details.git.as_deref(), Some(git)); + assert_eq!(details.rev.as_deref(), Some("deadbeef")); + } + _ => panic!("Expected detailed dependency with git+rev"), + } + } + + // Git + tag + { + let mut opts = base_opts.clone(); + opts.git = Some(git.to_string()); + opts.tag = Some("v1.2.3".to_string()); + + let (name, data) = resolve_dependency(dep, &opts, &members, &package_spec_dir).unwrap(); + assert_eq!(name, dep); + match data { + Dependency::Detailed(details) => { + assert_eq!(details.git.as_deref(), Some(git)); + assert_eq!(details.tag.as_deref(), Some("v1.2.3")); + } + _ => panic!("Expected detailed dependency with git+tag"), + } + } + + // dep + ipfs + { + let mut opts = base_opts.clone(); + opts.ipfs = Some("QmYwAPJzv5CZsnA".to_string()); + + let (name, data) = resolve_dependency(dep, &opts, &members, &package_spec_dir).unwrap(); + assert_eq!(name, dep); + match data { + Dependency::Detailed(details) => { + assert_eq!(details.ipfs.as_deref(), Some("QmYwAPJzv5CZsnA")); + } + _ => panic!("Expected detailed dependency with git+tag"), + } + } + } + + #[test] + fn test_resolve_dependency_detailed_variant_failure() { + let base_opts = ModifyOpts { + ..Default::default() + }; + + let (temp_dir, _) = + create_test_package("test_pkg", vec![("main.sw", "fn main() -> u64 { 42 }")]).unwrap(); + + let package_spec_dir = temp_dir.path().to_path_buf(); + let manifest_file = ManifestFile::from_dir(&package_spec_dir).unwrap(); + let members = manifest_file.member_manifests().unwrap(); + let dep = "dummy_dep"; + let git = "https://github.com/example/repo.git"; + + // no Git + branch + { + let mut opts = base_opts.clone(); + opts.branch = Some("main".to_string()); + let result = resolve_dependency(dep, &opts, &members, &package_spec_dir); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Details reserved for git sources used without a git field")); + } + + // no Git + rev + { + let mut opts = base_opts.clone(); + opts.rev = Some("deadbeef".to_string()); + + let result = resolve_dependency(dep, &opts, &members, &package_spec_dir); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Details reserved for git sources used without a git field")); + } + + // no Git + tag + { + let mut opts = base_opts.clone(); + opts.tag = Some("v1.2.3".to_string()); + + let result = resolve_dependency(dep, &opts, &members, &package_spec_dir); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Details reserved for git sources used without a git field")); + } + + // git + tag + rev + branch + { + let mut opts = base_opts.clone(); + opts.git = Some(git.to_string()); + opts.tag = Some("v1.2.3".to_string()); + opts.rev = Some("deadbeef".to_string()); + opts.branch = Some("main".to_string()); + + let result = resolve_dependency(dep, &opts, &members, &package_spec_dir); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Cannot specify `branch`, `tag`, and `rev` together for dependency with a Git source")); + } + + // git + branch + tag + { + let mut opts = base_opts.clone(); + opts.git = Some(git.to_string()); + opts.tag = Some("v1.2.3".to_string()); + opts.branch = Some("main".to_string()); + + let result = resolve_dependency(dep, &opts, &members, &package_spec_dir); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains( + "Cannot specify both `branch` and `tag` for dependency with a Git source" + )); + } + + // git + tag + rev + { + let mut opts = base_opts.clone(); + opts.git = Some(git.to_string()); + opts.tag = Some("v1.2.3".to_string()); + opts.rev = Some("deadbeef".to_string()); + + let result = resolve_dependency(dep, &opts, &members, &package_spec_dir); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Cannot specify both `rev` and `tag` for dependency with a Git source")); + } + + // git + branch + rev + { + let mut opts = base_opts.clone(); + opts.git = Some(git.to_string()); + opts.rev = Some("deadbeef".to_string()); + opts.branch = Some("main".to_string()); + + let result = resolve_dependency(dep, &opts, &members, &package_spec_dir); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains( + "Cannot specify both `branch` and `rev` for dependency with a Git source" + )); + } + + // no source provided + { + let opts = base_opts.clone(); + let result = resolve_dependency(dep, &opts, &members, &package_spec_dir); + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains( + "dependency `dummy_dep` source not specified. Please specify a source (e.g., git, path) or version" + )); + } + } + + #[test] + fn test_resolve_dependency_from_workspace_sibling() { + let (temp_dir, _) = create_test_workspace(vec![ + ("pkg1", vec![("main.sw", "fn main() -> u64 { 1 }")]), + ("pkg2", vec![("main.sw", "fn main() -> u64 { 2 }")]), + ]) + .unwrap(); + + let base_path = temp_dir.path(); + let package_dir = base_path.join("pkg2"); + + let dep = "pkg1"; + + let manifest_file = ManifestFile::from_dir(base_path).unwrap(); + let members = manifest_file.member_manifests().unwrap(); + + let opts = ModifyOpts { + source_path: None, + dependencies: vec![dep.to_string()], + package: Some("pkg2".to_string()), + ..Default::default() + }; + + let (name, data) = + resolve_dependency(dep, &opts, &members, &package_dir).expect("should resolve"); + + assert_eq!(name, dep); + match data { + Dependency::Detailed(details) => { + assert!(details.path.is_some()); + let actual_path = details.path.as_ref().unwrap(); + assert_eq!(actual_path, "../pkg1"); + } + _ => panic!("Expected detailed dependency with fallback path"), + } + } + + #[test] + fn test_resolve_dependency_self_dependency_error() { + let (temp_dir, _) = create_test_workspace(vec![ + ("pkg1", vec![("main.sw", "fn main() -> u64 { 1 }")]), + ("pkg2", vec![("main.sw", "fn main() -> u64 { 2 }")]), + ]) + .unwrap(); + + let base_path = temp_dir.path(); + let package_dir = base_path.join("pkg1"); + let dep = "pkg1"; + let resp = format!("cannot add `{}` as a dependency to itself", dep); + + let manifest_file = ManifestFile::from_dir(base_path).unwrap(); + let members = manifest_file.member_manifests().unwrap(); + + let opts = ModifyOpts { + dependencies: vec![dep.to_string()], + package: Some("package-1".to_string()), + ..Default::default() + }; + + let error = resolve_dependency(dep, &opts, &members, &package_dir).unwrap_err(); + assert!(error.to_string().contains(&resp)); + } + + #[test] + fn test_resolve_dependency_invalid_string() { + let opts = ModifyOpts { + dependencies: vec!["".to_string()], + ..Default::default() + }; + + let result = resolve_dependency("", &opts, &BTreeMap::new(), &PathBuf::new()); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Dependency spec cannot be empty")); + } + + #[test] + fn test_dep_section_add_to_toml_regular_dependency_success() { + let toml_str = r#" + [project] + name = "package" + entry = "main.sw" + license = "Apache-2.0" + authors = ["Fuel Labs"] + "#; + let mut doc: DocumentMut = toml_str.parse().unwrap(); + + let dep_data = Dependency::Simple("1.0.0".into()); + + let section = Section::Deps; + + section + .add_deps_manifest_table(&mut doc, "dep1".into(), dep_data, None) + .unwrap(); + + assert_eq!(doc["dependencies"]["dep1"].as_str(), Some("1.0.0")); + } + + #[test] + fn test_dep_section_add_to_toml_regular_detailed_dependency_success() { + let toml_str = r#" + [project] + name = "package" + entry = "main.sw" + license = "Apache-2.0" + authors = ["Fuel Labs"] + "#; + let mut doc: DocumentMut = toml_str.parse().unwrap(); + + let dep_data = Dependency::Detailed(DependencyDetails { + git: Some("https://github.com/example/repo".to_string()), + tag: Some("v1.2.3".to_string()), + ..Default::default() + }); + + let section = Section::Deps; + + section + .add_deps_manifest_table(&mut doc, "dep2".into(), dep_data, None) + .unwrap(); + + let table = doc["dependencies"]["dep2"].as_inline_table().unwrap(); + assert_eq!( + table.get("git").unwrap().as_str(), + Some("https://github.com/example/repo") + ); + assert_eq!(table.get("tag").unwrap().as_str(), Some("v1.2.3")); + } + + #[test] + fn test_dep_section_add_contract_dependency_with_salt() { + let toml_str = r#" + [project] + name = "contract_pkg" + entry = "main.sw" + license = "Apache-2.0" + authors = ["Fuel Labs"] + "#; + + let mut doc: DocumentMut = toml_str.parse().unwrap(); + + let section = Section::ContractDeps; + let dep_name = "custom_dep"; + let dep_data = Dependency::Simple("1.0.0".to_string()); + let salt_str = "0x2222222222222222222222222222222222222222222222222222222222222222"; + let hex_salt = HexSalt::from_str(salt_str).unwrap(); + + section + .add_deps_manifest_table( + &mut doc, + dep_name.to_string(), + dep_data, + Some(salt_str.to_string()), + ) + .unwrap(); + + let contract_table = doc["contract-dependencies"][dep_name] + .as_inline_table() + .expect("inline table not found"); + + assert_eq!( + contract_table.get("version").unwrap().as_str(), + Some("1.0.0") + ); + assert_eq!( + contract_table.get("salt").unwrap().as_str(), + Some(format!("0x{}", hex_salt).as_str()) + ); + } + + #[test] + fn test_dep_section_add_contract_dependency_with_default_salt() { + let toml_str = r#" + [project] + name = "contract_pkg" + entry = "main.sw" + license = "Apache-2.0" + authors = ["Fuel Labs"] + "#; + + let mut doc: DocumentMut = toml_str.parse().unwrap(); + + let section = Section::ContractDeps; + let dep_name = "custom_dep"; + let dep_data = Dependency::Simple("1.0.0".to_string()); + + section + .add_deps_manifest_table(&mut doc, dep_name.to_string(), dep_data, None) + .unwrap(); + + let contract_table = doc["contract-dependencies"][dep_name] + .as_inline_table() + .expect("inline table not found"); + + assert_eq!( + contract_table.get("version").unwrap().as_str(), + Some("1.0.0") + ); + assert_eq!( + contract_table.get("salt").unwrap().as_str(), + Some(format!("0x{}", fuel_tx::Salt::default()).as_str()) + ); + } + + #[test] + fn test_dep_section_add_contract_dependency_with_invalid_salt() { + let toml_str = r#" + [project] + name = "contract_pkg" + entry = "main.sw" + license = "Apache-2.0" + authors = ["Fuel Labs"] + "#; + + let mut doc: DocumentMut = toml_str.parse().unwrap(); + + let section = Section::ContractDeps; + let dep_name = "custom_dep"; + let dep_data = Dependency::Simple("1.0.0".to_string()); + + let result = section.add_deps_manifest_table( + &mut doc, + dep_name.to_string(), + dep_data, + Some("not_hex".to_string()), + ); + + assert!(result.is_err()); + assert!(format!("{}", result.unwrap_err()).contains("Invalid salt format")); + } + + #[test] + fn test_dep_section_remove_regular_dependency_success() { + let toml_str = r#" + [project] + name = "package" + entry = "main.sw" + license = "Apache-2.0" + authors = ["Fuel Labs"] + + [dependencies] + foo = "1.0.0" + bar = "2.0.0" + "#; + + let mut doc: DocumentMut = toml_str.parse().unwrap(); + + let section = Section::Deps; + section + .remove_deps_manifest_table(&mut doc, &["foo"]) + .unwrap(); + + assert!(doc["dependencies"].as_table().unwrap().get("foo").is_none()); + assert!(doc["dependencies"].as_table().unwrap().get("bar").is_some()); + } + + #[test] + fn test_dep_section_remove_regular_dependency_not_found() { + let toml_str = r#" + [project] + name = "package" + entry = "main.sw" + license = "Apache-2.0" + authors = ["Fuel Labs"] + + [dependencies] + bar = "2.0.0" + "#; + + let mut doc: DocumentMut = toml_str.parse().unwrap(); + + let section = Section::Deps; + + let err = section + .remove_deps_manifest_table(&mut doc, &["notfound"]) + .unwrap_err() + .to_string(); + + assert!(err.contains("the dependency `notfound` could not be found in `dependencies`")); + } + + #[test] + fn test_dep_section_remove_contract_dependency_success() { + let toml_str = r#" + [project] + name = "package" + entry = "main.sw" + license = "Apache-2.0" + authors = ["Fuel Labs"] + + [contract-dependencies] + baz = { path = "../baz", salt = "0x1111111111111111111111111111111111111111111111111111111111111111" } + "#; + + let mut doc: DocumentMut = toml_str.parse().unwrap(); + + let section = Section::ContractDeps; + section + .remove_deps_manifest_table(&mut doc, &["baz"]) + .unwrap(); + + assert!(doc["contract-dependencies"] + .as_table() + .unwrap() + .get("baz") + .is_none()); + } + + #[test] + fn test_dep_section_remove_contract_dependency_not_found() { + let toml_str = r#" + [project] + name = "package" + entry = "main.sw" + license = "Apache-2.0" + authors = ["Fuel Labs"] + + [contract-dependencies] + baz = { path = "../baz", salt = "0x1111111111111111111111111111111111111111111111111111111111111111" } + "#; + + let mut doc: DocumentMut = toml_str.parse().unwrap(); + + let section = Section::ContractDeps; + + let result = section.remove_deps_manifest_table(&mut doc, &["ghost"]); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("the dependency `ghost` could not be found in `contract-dependencies`")); + } + + #[test] + fn test_dep_section_remove_from_missing_section() { + let toml_str = r#" + [project] + authors = ["Fuel Labs "] + entry = "main.sw" + license = "Apache-2.0" + name = "package-1" + + [dependencies] + foo = "1.0.0" + "#; + + let mut doc: DocumentMut = toml_str.parse().unwrap(); + + let section = Section::ContractDeps; + + let result = section.remove_deps_manifest_table(&mut doc, &["ghost"]); + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("the dependency `ghost` could not be found in `contract-dependencies`")); + } + + #[test] + fn test_generate_table_basic_fields() { + let details = DependencyDetails { + version: Some("1.2.3".to_string()), + git: Some("https://github.com/example/repo".to_string()), + branch: Some("main".to_string()), + tag: Some("v1.0.0".to_string()), + rev: Some("deadbeef".to_string()), + path: Some("./lib".to_string()), + ipfs: Some("QmYw...".to_string()), + namespace: None, + package: None, + }; + + let table = generate_table(&details); + + assert_eq!(table.get("version").unwrap().as_str(), Some("1.2.3")); + assert_eq!( + table.get("git").unwrap().as_str(), + Some("https://github.com/example/repo") + ); + assert_eq!(table.get("branch").unwrap().as_str(), Some("main")); + assert_eq!(table.get("tag").unwrap().as_str(), Some("v1.0.0")); + assert_eq!(table.get("rev").unwrap().as_str(), Some("deadbeef")); + assert_eq!(table.get("path").unwrap().as_str(), Some("./lib")); + assert_eq!(table.get("cid").unwrap().as_str(), Some("QmYw...")); + } +} diff --git a/forc-pkg/src/manifest/mod.rs b/forc-pkg/src/manifest/mod.rs index 05844618265..b1d99c6682f 100644 --- a/forc-pkg/src/manifest/mod.rs +++ b/forc-pkg/src/manifest/mod.rs @@ -1,4 +1,5 @@ pub mod build_profile; +pub mod dep_modifier; use crate::pkg::{manifest_file_missing, parsing_failed, wrong_program_type}; use anyhow::{anyhow, bail, Context, Result}; @@ -64,6 +65,26 @@ pub enum ManifestFile { Workspace(WorkspaceManifestFile), } +impl ManifestFile { + pub fn is_workspace(&self) -> bool { + matches!(self, ManifestFile::Workspace(_)) + } + + pub fn root_dir(&self) -> PathBuf { + match self { + ManifestFile::Package(pkg_manifest_file) => pkg_manifest_file + .workspace() + .ok() + .flatten() + .map(|ws| ws.dir().to_path_buf()) + .unwrap_or_else(|| pkg_manifest_file.dir().to_path_buf()), + ManifestFile::Workspace(workspace_manifest_file) => { + workspace_manifest_file.dir().to_path_buf() + } + } + } +} + impl GenericManifestFile for ManifestFile { /// Returns a `PackageManifestFile` if the path is within a package directory, otherwise /// returns a `WorkspaceManifestFile` if within a workspace directory. @@ -327,6 +348,7 @@ impl DependencyDetails { version, ipfs, namespace, + path, .. } = self; @@ -334,6 +356,22 @@ impl DependencyDetails { bail!("Details reserved for git sources used without a git field"); } + if git.is_some() && branch.is_some() && tag.is_some() && rev.is_some() { + bail!("Cannot specify `branch`, `tag`, and `rev` together for dependency with a Git source"); + } + + if git.is_some() && branch.is_some() && tag.is_some() { + bail!("Cannot specify both `branch` and `tag` for dependency with a Git source"); + } + + if git.is_some() && rev.is_some() && tag.is_some() { + bail!("Cannot specify both `rev` and `tag` for dependency with a Git source"); + } + + if git.is_some() && branch.is_some() && rev.is_some() { + bail!("Cannot specify both `branch` and `rev` for dependency with a Git source"); + } + if version.is_some() && git.is_some() { bail!("Both version and git details provided for same dependency"); } @@ -345,8 +383,17 @@ impl DependencyDetails { if version.is_none() && namespace.is_some() { bail!("Namespace can only be specified for sources with version"); } + + if version.is_some() && path.is_some() { + bail!("Both version and path details provided for same dependency"); + } + Ok(()) } + + pub fn is_source_empty(&self) -> bool { + self.git.is_none() && self.path.is_none() && self.ipfs.is_none() + } } impl Dependency { @@ -1404,7 +1451,7 @@ mod tests { version: None, path: None, git: Some(git_source_string), - branch: Some("test_branch".to_string()), + branch: None, tag: None, package: None, rev: Some("9f35b8e".to_string()), diff --git a/forc/src/cli/commands/add.rs b/forc/src/cli/commands/add.rs new file mode 100644 index 00000000000..e8033dc3280 --- /dev/null +++ b/forc/src/cli/commands/add.rs @@ -0,0 +1,80 @@ +use crate::cli::shared::{ManifestArgs, PackagesSelectionArgs, SectionArgs, SourceArgs}; +use clap::Parser; +use forc_pkg::{ + manifest::dep_modifier::{self, Action, ModifyOpts}, + source::IPFSNode, +}; +use forc_util::ForcResult; + +forc_util::cli_examples! { + crate::cli::Opt { + [Add a dependencies => "forc add [@] "] + [Add a contract dependency => "forc add [@] --contract-dep"] + [Dry run => "forc add [@] --dry-run"] + } +} + +// Add dependencies to Forc toml +#[derive(Debug, Parser)] +#[clap(bin_name = "forc add", version, after_help = help())] +pub struct Command { + /// List of dependencies to add in the format "name[@version]" + #[clap(value_enum, value_name = "DEP_SPEC", required = true, num_args = 1..,)] + pub dependencies: Vec, + + /// Print the changes that would be made without actually making them + #[arg(long)] + pub dry_run: bool, + + #[clap(flatten, next_help_heading = "Manifest Options")] + pub manifest: ManifestArgs, + + #[clap(flatten, next_help_heading = "Package Selection")] + pub package: PackagesSelectionArgs, + + #[clap(flatten, next_help_heading = "Source")] + pub source: SourceArgs, + + #[clap(flatten, next_help_heading = "Section")] + pub section: SectionArgs, + + /// Offline mode. + /// + /// Prevents Forc from using the network when managing dependencies. + #[clap(long)] + pub offline: bool, + + /// The IPFS Node to use for fetching IPFS sources. + /// + /// Possible values: FUEL, PUBLIC, LOCAL, + #[clap(long)] + pub ipfs_node: Option, +} + +pub(crate) fn exec(command: Command) -> ForcResult<()> { + dep_modifier::modify_dependencies(command.into()) + .map_err(|e| format!("failed to add dependencies: {}", e)) + .map_err(|msg| msg.as_str().into()) +} + +impl From for ModifyOpts { + fn from(cmd: Command) -> Self { + ModifyOpts { + action: Action::Add, + manifest_path: cmd.manifest.manisfest_path, + package: cmd.package.package, + source_path: cmd.source.path, + git: cmd.source.git, + branch: cmd.source.git_ref.branch, + tag: cmd.source.git_ref.tag, + rev: cmd.source.git_ref.rev, + ipfs: cmd.source.ipfs, + contract_deps: cmd.section.contract_deps, + salt: cmd.section.salt, + ipfs_node: cmd.ipfs_node, + dependencies: cmd.dependencies, + dry_run: cmd.dry_run, + offline: cmd.offline, + } + } +} diff --git a/forc/src/cli/commands/mod.rs b/forc/src/cli/commands/mod.rs index d660f323b69..d6fef6a2a51 100644 --- a/forc/src/cli/commands/mod.rs +++ b/forc/src/cli/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod add; pub mod addr2line; pub mod build; pub mod check; @@ -9,6 +10,7 @@ pub mod new; pub mod parse_bytecode; pub mod plugins; pub mod predicate_root; +pub mod remove; pub mod template; pub mod test; pub mod update; diff --git a/forc/src/cli/commands/remove.rs b/forc/src/cli/commands/remove.rs new file mode 100644 index 00000000000..e07d4916639 --- /dev/null +++ b/forc/src/cli/commands/remove.rs @@ -0,0 +1,77 @@ +use crate::cli::shared::{ManifestArgs, PackagesSelectionArgs, SectionArgs}; +use clap::Parser; +use forc_pkg::{ + manifest::dep_modifier::{self, Action, ModifyOpts}, + source::IPFSNode, +}; +use forc_util::ForcResult; + +forc_util::cli_examples! { +crate::cli::Opt { + [Add a dependencies => "forc remove "] + [Add a contract dependency => "forc remove --contract-dep"] + [Dry run => "forc remove --dry-run"] +} +} + +// Add dependencies to Forc toml +#[derive(Debug, Parser)] +#[clap(bin_name = "forc remove", version, after_help = help())] +pub struct Command { + /// List of dependencies to remove in the format "name[@version]" + #[clap(value_enum, value_name = "DEP_SPEC", required = true)] + pub dependencies: Vec, + + /// Print the changes that would be made without actually making them + #[arg(long)] + pub dry_run: bool, + + #[clap(flatten, next_help_heading = "Manifest Options")] + pub manifest: ManifestArgs, + + #[clap(flatten, next_help_heading = "Package Selection")] + pub package: PackagesSelectionArgs, + + #[clap(flatten, next_help_heading = "Section")] + pub section: SectionArgs, + + /// Offline mode. + /// + /// Prevents Forc from using the network when managing dependencies. + #[clap(long)] + pub offline: bool, + + /// The IPFS Node to use for fetching IPFS sources. + /// + /// Possible values: FUEL, PUBLIC, LOCAL, + #[clap(long)] + pub ipfs_node: Option, +} + +pub(crate) fn exec(command: Command) -> ForcResult<()> { + dep_modifier::modify_dependencies(command.into()) + .map_err(|e| format!("failed to remove dependencies: {}", e)) + .map_err(|msg| msg.as_str().into()) +} + +impl From for ModifyOpts { + fn from(cmd: Command) -> Self { + ModifyOpts { + action: Action::Remove, + manifest_path: cmd.manifest.manisfest_path, + package: cmd.package.package, + source_path: None, + git: None, + branch: None, + tag: None, + rev: None, + ipfs: None, + contract_deps: cmd.section.contract_deps, + salt: cmd.section.salt, + ipfs_node: cmd.ipfs_node, + dependencies: cmd.dependencies, + dry_run: cmd.dry_run, + offline: cmd.offline, + } + } +} diff --git a/forc/src/cli/mod.rs b/forc/src/cli/mod.rs index e84768550c0..bb8bb98f086 100644 --- a/forc/src/cli/mod.rs +++ b/forc/src/cli/mod.rs @@ -1,7 +1,8 @@ use self::commands::{ - addr2line, build, check, clean, completions, contract_id, init, new, parse_bytecode, plugins, - predicate_root, template, test, update, + add, addr2line, build, check, clean, completions, contract_id, init, new, parse_bytecode, + plugins, predicate_root, remove, template, test, update, }; +pub use add::Command as AddCommand; use addr2line::Command as Addr2LineCommand; use anyhow::anyhow; pub use build::Command as BuildCommand; @@ -17,6 +18,7 @@ pub use new::Command as NewCommand; use parse_bytecode::Command as ParseBytecodeCommand; pub use plugins::Command as PluginsCommand; pub(crate) use predicate_root::Command as PredicateRootCommand; +pub use remove::Command as RemoveCommand; use std::str::FromStr; pub use template::Command as TemplateCommand; pub use test::Command as TestCommand; @@ -64,6 +66,7 @@ struct Opt { #[derive(Subcommand, Debug)] enum Forc { + Add(AddCommand), #[clap(name = "addr2line")] Addr2Line(Addr2LineCommand), #[clap(visible_alias = "b")] @@ -76,6 +79,7 @@ enum Forc { ParseBytecode(ParseBytecodeCommand), #[clap(visible_alias = "t")] Test(TestCommand), + Remove(RemoveCommand), Update(UpdateCommand), Plugins(PluginsCommand), Template(TemplateCommand), @@ -97,6 +101,7 @@ impl Forc { #[allow(dead_code)] pub fn possible_values() -> Vec<&'static str> { vec![ + "add", "addr2line", "build", "check", @@ -109,6 +114,7 @@ impl Forc { "test", "update", "template", + "remove", "contract-id", "predicate-root", ] @@ -127,6 +133,7 @@ pub async fn run_cli() -> ForcResult<()> { init_tracing_subscriber(tracing_options); match opt.command { + Forc::Add(command) => add::exec(command), Forc::Addr2Line(command) => addr2line::exec(command), Forc::Build(command) => build::exec(command), Forc::Check(command) => check::exec(command), @@ -138,6 +145,7 @@ pub async fn run_cli() -> ForcResult<()> { Forc::Plugins(command) => plugins::exec(command), Forc::Test(command) => test::exec(command), Forc::Update(command) => update::exec(command), + Forc::Remove(command) => remove::exec(command), Forc::Template(command) => template::exec(command), Forc::ContractId(command) => contract_id::exec(command), Forc::PredicateRoot(command) => predicate_root::exec(command), diff --git a/forc/src/cli/shared.rs b/forc/src/cli/shared.rs index a6b187b8058..c34f7b95450 100644 --- a/forc/src/cli/shared.rs +++ b/forc/src/cli/shared.rs @@ -1,9 +1,79 @@ //! Sets of arguments that are shared between commands. -use clap::{Args, Parser}; +use clap::{ArgGroup, Args, Parser}; use forc_pkg::source::IPFSNode; use sway_core::{BuildTarget, PrintAsm, PrintIr}; use sway_ir::PassManager; +#[derive(Debug, Args)] +#[command(group( + ArgGroup::new("source") + .required(false) + .args(["path", "git", "ipfs"]), +))] +pub struct SourceArgs { + /// Local path to the package. + #[arg(long)] + pub path: Option, + + /// Git URI for the package. + #[arg(long, value_name = "URI")] + pub git: Option, + + /// Git reference options like `branch`, `rev`, etc. + #[clap(flatten)] + pub git_ref: GitRef, + + /// IPFS CID for the package. + #[arg(long, value_name = "CID")] + pub ipfs: Option, +} + +#[derive(Args, Debug, Default)] +#[command(group( + ArgGroup::new("git_ref") + .args(["branch", "tag", "rev"]) + .multiple(false) + .requires("git") +))] +pub struct GitRef { + /// The branch to use. + #[arg(long)] + pub branch: Option, + + /// The tag to use. + #[arg(long)] + pub tag: Option, + + /// The specific revision to use. + #[arg(long)] + pub rev: Option, +} + +#[derive(Args, Debug, Default)] +pub struct SectionArgs { + /// Treats dependency as contract dependencies. + #[arg(long = "contract-dep")] + pub contract_deps: bool, + + /// Salt value for contract deployment. + #[arg(long = "salt")] + pub salt: Option, +} + +#[derive(Args, Debug, Default)] +pub struct ManifestArgs { + /// Path to the manifest file. + #[arg(long, value_name = "PATH")] + pub manisfest_path: Option, +} + +#[derive(Args, Debug, Default)] +pub struct PackagesSelectionArgs { + /// Package to perform action on. + #[arg(long, short = 'p', value_name = "SPEC")] + pub package: Option, +} + /// Args that can be shared between all commands that `build` a package. E.g. `build`, `test`, /// `deploy`. #[derive(Debug, Default, Parser)]