forc_pkg/manifest/
dep_modifier.rs

1use crate::manifest::{
2    ContractDependency, Dependency, DependencyDetails, GenericManifestFile, HexSalt,
3};
4use crate::source::IPFSNode;
5use crate::{self as pkg, Lock, PackageManifestFile};
6use anyhow::{anyhow, bail, Result};
7use pkg::manifest::ManifestFile;
8use std::collections::BTreeMap;
9use std::fmt;
10use std::path::Path;
11use std::path::PathBuf;
12use std::str::FromStr;
13use sway_core::fuel_prelude::fuel_tx;
14use toml_edit::{DocumentMut, InlineTable, Item, Table, Value};
15use tracing::info;
16
17#[derive(Clone, Debug, Default)]
18pub enum Action {
19    #[default]
20    Add,
21    Remove,
22}
23
24#[derive(Clone, Debug, Default)]
25pub struct ModifyOpts {
26    // === Manifest Options ===
27    pub manifest_path: Option<String>,
28    // === Package Selection ===
29    pub package: Option<String>,
30    // === Source (Add only) ===
31    pub source_path: Option<String>,
32    pub git: Option<String>,
33    pub branch: Option<String>,
34    pub tag: Option<String>,
35    pub rev: Option<String>,
36    pub ipfs: Option<String>,
37    // === Section ===
38    pub contract_deps: bool,
39    pub salt: Option<String>,
40    // === IPFS Node ===
41    pub ipfs_node: Option<IPFSNode>,
42    // === Dependencies & Flags ===
43    pub dependencies: Vec<String>,
44    pub dry_run: bool,
45    pub offline: bool,
46    pub action: Action,
47}
48
49pub fn modify_dependencies(opts: ModifyOpts) -> Result<()> {
50    let manifest_file = if let Some(p) = &opts.manifest_path {
51        let path = &PathBuf::from(p);
52        ManifestFile::from_file(path)?
53    } else {
54        let cwd = std::env::current_dir()?;
55        ManifestFile::from_dir(cwd)?
56    };
57
58    let root_dir = manifest_file.root_dir();
59    let member_manifests = manifest_file.member_manifests()?;
60
61    let package_manifest_dir =
62        resolve_package_path(&manifest_file, &opts.package, &root_dir, &member_manifests)?;
63
64    let content = std::fs::read_to_string(&package_manifest_dir)?;
65    let mut toml_doc = content.parse::<DocumentMut>()?;
66    let backup_doc = toml_doc.clone();
67
68    let old_package_manifest = PackageManifestFile::from_file(&package_manifest_dir)?;
69    let lock_path = old_package_manifest.lock_path()?;
70    let old_lock = Lock::from_path(&lock_path).ok().unwrap_or_default();
71
72    let section = if opts.contract_deps {
73        Section::ContractDeps
74    } else {
75        Section::Deps
76    };
77
78    match opts.action {
79        Action::Add => {
80            for dependency in &opts.dependencies {
81                let (dep_name, dependency_data) = resolve_dependency(
82                    dependency,
83                    &opts,
84                    &member_manifests,
85                    &old_package_manifest.dir().to_path_buf(),
86                )?;
87
88                section.add_deps_manifest_table(
89                    &mut toml_doc,
90                    dep_name,
91                    dependency_data,
92                    opts.salt.clone(),
93                )?;
94            }
95        }
96        Action::Remove => {
97            let dep_refs: Vec<&str> = opts.dependencies.iter().map(String::as_str).collect();
98
99            section.remove_deps_manifest_table(&mut toml_doc, &dep_refs)?;
100        }
101    }
102
103    // write updates to toml doc
104    std::fs::write(&package_manifest_dir, toml_doc.to_string())?;
105
106    let updated_package_manifest = PackageManifestFile::from_file(&package_manifest_dir)?;
107
108    let member_manifests = updated_package_manifest.member_manifests()?;
109
110    let new_plan = pkg::BuildPlan::from_lock_and_manifests(
111        &lock_path,
112        &member_manifests,
113        false,
114        opts.offline,
115        &opts.ipfs_node.clone().unwrap_or_default(),
116    );
117
118    new_plan.or_else(|e| {
119        std::fs::write(&package_manifest_dir, backup_doc.to_string())
120            .map_err(|write_err| anyhow!("failed to write toml file: {}", write_err))?;
121        Err(e)
122    })?;
123
124    if opts.dry_run {
125        info!("Dry run enabled. toml file not modified.");
126        std::fs::write(&package_manifest_dir, backup_doc.to_string())?;
127
128        let string = toml::ser::to_string_pretty(&old_lock)?;
129        std::fs::write(&lock_path, string)?;
130
131        return Ok(());
132    }
133
134    Ok(())
135}
136
137fn resolve_package_path(
138    manifest_file: &ManifestFile,
139    package: &Option<String>,
140    root_dir: &Path,
141    member_manifests: &BTreeMap<String, PackageManifestFile>,
142) -> Result<PathBuf> {
143    if manifest_file.is_workspace() {
144        let Some(package_name) = package else {
145            let packages = member_manifests
146                .keys()
147                .cloned()
148                .collect::<Vec<_>>()
149                .join(", ");
150            bail!("`forc add` could not determine which package to modify. Use --package.\nAvailable: {}", packages);
151        };
152
153        resolve_workspace_path_inner(member_manifests, package_name, root_dir)
154    } else if let Some(package_name) = package {
155        resolve_workspace_path_inner(member_manifests, package_name, root_dir)
156    } else {
157        Ok(manifest_file.path().to_path_buf())
158    }
159}
160
161fn resolve_workspace_path_inner(
162    member_manifests: &BTreeMap<String, PackageManifestFile>,
163    package_name: &str,
164    root_dir: &Path,
165) -> Result<PathBuf> {
166    if let Some(dir) = member_manifests.get(package_name) {
167        Ok(dir.path().to_path_buf())
168    } else {
169        bail!(
170            "package(s) {} not found in workspace {}",
171            package_name,
172            root_dir.to_string_lossy()
173        )
174    }
175}
176
177fn resolve_dependency(
178    raw: &str,
179    opts: &ModifyOpts,
180    member_manifests: &BTreeMap<String, PackageManifestFile>,
181    package_dir: &PathBuf,
182) -> Result<(String, Dependency)> {
183    let dep_spec: DepSpec = raw.parse()?;
184    let dep_name = dep_spec.name;
185
186    let mut details = DependencyDetails {
187        version: dep_spec.version_req.clone(),
188        namespace: None,
189        path: opts.source_path.clone(),
190        git: opts.git.clone(),
191        branch: opts.branch.clone(),
192        tag: opts.tag.clone(),
193        package: None,
194        rev: opts.rev.clone(),
195        ipfs: opts.ipfs.clone(),
196    };
197
198    details.validate()?;
199
200    let dependency_data = if let Some(version) = dep_spec.version_req {
201        Dependency::Simple(version)
202    } else if details.is_source_empty() {
203        if let Some(member) = member_manifests.get(&dep_name) {
204            if member.dir() == package_dir {
205                bail!("cannot add `{}` as a dependency to itself", dep_name);
206            }
207
208            let sibling_parent = package_dir.parent().unwrap();
209            let rel_path = member
210                .dir()
211                .strip_prefix(sibling_parent)
212                .map(|p| PathBuf::from("..").join(p))
213                .unwrap_or_else(|_| member.dir().to_path_buf());
214
215            details.path = Some(rel_path.to_string_lossy().to_string());
216            Dependency::Detailed(details)
217        } else {
218            // Fallback: no explicit source & not a sibling package.
219            // TODO: Integrate registry support (e.g., forc.pub) here.
220            bail!(
221                "dependency `{}` source not specified. Please specify a source (e.g., git, path) or version.",
222                dep_name
223            );
224        }
225    } else {
226        Dependency::Detailed(details)
227    };
228
229    Ok((dep_name, dependency_data))
230}
231
232/// Reference to a package to be added as a dependency.
233///
234/// See `forc add` help for more info.
235#[derive(Clone, Debug, Default)]
236pub struct DepSpec {
237    pub name: String,
238    pub version_req: Option<String>,
239}
240
241impl FromStr for DepSpec {
242    type Err = anyhow::Error;
243
244    fn from_str(s: &str) -> anyhow::Result<Self> {
245        if s.trim().is_empty() {
246            bail!("Dependency spec cannot be empty");
247        }
248
249        let mut s = s.trim().split('@');
250
251        let name = s
252            .next()
253            .ok_or_else(|| anyhow::anyhow!("missing dependency name"))?;
254
255        let version_req = s.next().map(|s| s.to_string());
256
257        if let Some(ref v) = version_req {
258            semver::VersionReq::parse(v)
259                .map_err(|_| anyhow::anyhow!("invalid version requirement `{v}`"))?;
260        }
261
262        Ok(Self {
263            name: name.to_string(),
264            version_req,
265        })
266    }
267}
268
269impl fmt::Display for DepSpec {
270    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
271        match &self.version_req {
272            Some(version) => write!(f, "{}@{}", self.name, version),
273            None => write!(f, "{}", self.name),
274        }
275    }
276}
277
278#[derive(Clone)]
279pub enum Section {
280    Deps,
281    ContractDeps,
282}
283
284impl fmt::Display for Section {
285    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
286        let section = match self {
287            Section::Deps => "dependencies",
288            Section::ContractDeps => "contract-dependencies",
289        };
290        write!(f, "{section}")
291    }
292}
293
294impl Section {
295    pub fn add_deps_manifest_table(
296        &self,
297        doc: &mut DocumentMut,
298        dep_name: String,
299        dep_data: Dependency,
300        salt: Option<String>,
301    ) -> Result<()> {
302        let section_name = self.to_string();
303
304        if !doc.as_table().contains_key(&section_name) {
305            doc[&section_name] = Item::Table(Table::new());
306        }
307
308        let table = doc[section_name.as_str()].as_table_mut().unwrap();
309
310        match self {
311            Section::Deps => {
312                let item = match dep_data {
313                    Dependency::Simple(ver) => ver.to_string().into(),
314                    Dependency::Detailed(details) => {
315                        Item::Value(toml_edit::Value::InlineTable(generate_table(&details)))
316                    }
317                };
318                table.insert(&dep_name, item);
319            }
320            Section::ContractDeps => {
321                let resolved_salt = match salt.as_ref().or(salt.as_ref()) {
322                    Some(s) => {
323                        HexSalt::from_str(s).map_err(|e| anyhow!("Invalid salt format: {}", e))?
324                    }
325                    None => HexSalt(fuel_tx::Salt::default()),
326                };
327                let contract_dep = ContractDependency {
328                    dependency: dep_data,
329                    salt: resolved_salt.clone(),
330                };
331
332                let dep = &contract_dep.dependency;
333                let salt: &HexSalt = &contract_dep.salt;
334                let item = match dep {
335                    Dependency::Simple(ver) => {
336                        let mut inline = InlineTable::default();
337                        inline.insert("version", Value::from(ver.to_string()));
338                        inline.insert("salt", Value::from(format!("0x{salt}")));
339                        Item::Value(toml_edit::Value::InlineTable(inline))
340                    }
341                    Dependency::Detailed(details) => {
342                        let mut inline = generate_table(details);
343                        inline.insert("salt", Value::from(format!("0x{salt}")));
344                        Item::Value(toml_edit::Value::InlineTable(inline))
345                    }
346                };
347                table.insert(&dep_name, item);
348            }
349        };
350
351        Ok(())
352    }
353
354    pub fn remove_deps_manifest_table(self, doc: &mut DocumentMut, deps: &[&str]) -> Result<()> {
355        let section_name = self.to_string();
356
357        let section_table = doc[section_name.as_str()].as_table_mut().ok_or_else(|| {
358            anyhow!(
359                "the dependency `{}` could not be found in `{}`",
360                deps.join(", "),
361                section_name,
362            )
363        })?;
364
365        match self {
366            Section::Deps => {
367                for dep in deps {
368                    if !section_table.contains_key(dep) {
369                        bail!(
370                            "the dependency `{}` could not be found in `{}`",
371                            dep,
372                            section_name
373                        );
374                    }
375                    section_table.remove(dep);
376                }
377            }
378            Section::ContractDeps => {
379                for dep in deps {
380                    if !section_table.contains_key(dep) {
381                        bail!(
382                            "the dependency `{}` could not be found in `{}`",
383                            dep,
384                            section_name
385                        );
386                    }
387                    section_table.remove(dep);
388                }
389            }
390        }
391        Ok(())
392    }
393}
394
395fn generate_table(details: &DependencyDetails) -> InlineTable {
396    let mut inline = InlineTable::default();
397
398    if let Some(version) = &details.version {
399        inline.insert("version", Value::from(version.to_string()));
400    }
401    if let Some(git) = &details.git {
402        inline.insert("git", Value::from(git.to_string()));
403    }
404    if let Some(branch) = &details.branch {
405        inline.insert("branch", Value::from(branch.to_string()));
406    }
407    if let Some(tag) = &details.tag {
408        inline.insert("tag", Value::from(tag.to_string()));
409    }
410    if let Some(rev) = &details.rev {
411        inline.insert("rev", Value::from(rev.to_string()));
412    }
413    if let Some(path) = &details.path {
414        inline.insert("path", Value::from(path.to_string()));
415    }
416    if let Some(ipfs) = &details.ipfs {
417        inline.insert("cid", Value::from(ipfs.to_string()));
418    }
419
420    inline
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use crate::WorkspaceManifestFile;
427    use std::fs;
428    use std::str::FromStr;
429    use tempfile::{tempdir, TempDir};
430
431    fn create_test_package(
432        name: &str,
433        source_files: Vec<(&str, &str)>,
434    ) -> Result<(TempDir, PackageManifestFile)> {
435        let temp_dir = tempdir()?;
436        let base_path = temp_dir.path();
437
438        // Create package structure
439        fs::create_dir_all(base_path.join("src"))?;
440
441        // Create Forc.toml
442        let forc_toml = format!(
443            r#"
444            [project]
445            authors = ["Test"]
446            entry = "main.sw"
447            license = "MIT"
448            name = "{name}"
449            
450            [dependencies]
451        "#
452        );
453        fs::write(base_path.join("Forc.toml"), forc_toml)?;
454
455        // Create source files
456        for (file_name, content) in source_files {
457            // Handle nested directories in the file path
458            let file_path = base_path.join("src").join(file_name);
459            if let Some(parent) = file_path.parent() {
460                fs::create_dir_all(parent)?;
461            }
462            fs::write(file_path, content)?;
463        }
464
465        // Create the manifest file
466        let manifest_file = PackageManifestFile::from_file(base_path.join("Forc.toml"))?;
467
468        Ok((temp_dir, manifest_file))
469    }
470
471    fn create_test_workspace(
472        members: Vec<(&str, Vec<(&str, &str)>)>,
473    ) -> Result<(TempDir, WorkspaceManifestFile)> {
474        let temp_dir = tempdir()?;
475        let base_path = temp_dir.path();
476
477        // Create workspace Forc.toml
478        let mut workspace_toml = "[workspace]\nmembers = [".to_string();
479
480        for (i, (name, _)) in members.iter().enumerate() {
481            if i > 0 {
482                workspace_toml.push_str(", ");
483            }
484            workspace_toml.push_str(&format!("\"{name}\""));
485        }
486        workspace_toml.push_str("]\n");
487
488        fs::write(base_path.join("Forc.toml"), workspace_toml)?;
489
490        // Create each member
491        for (name, source_files) in members {
492            let member_path = base_path.join(name);
493            fs::create_dir_all(member_path.join("src"))?;
494
495            // Create member Forc.toml
496            let forc_toml = format!(
497                r#"
498                [project]
499                authors = ["Test"]
500                entry = "main.sw"
501                license = "MIT"
502                name = "{name}"
503                
504                [dependencies]
505            "#
506            );
507            fs::write(member_path.join("Forc.toml"), forc_toml)?;
508
509            // Create source files
510            for (file_name, content) in source_files {
511                // Handle nested directories in the file path
512                let file_path = member_path.join("src").join(file_name);
513                if let Some(parent) = file_path.parent() {
514                    fs::create_dir_all(parent)?;
515                }
516                fs::write(file_path, content)?;
517            }
518        }
519
520        // Create the workspace manifest file
521        let manifest_file = WorkspaceManifestFile::from_file(base_path.join("Forc.toml"))?;
522
523        Ok((temp_dir, manifest_file))
524    }
525
526    #[test]
527    fn test_dep_from_str_name_only() {
528        let dep: DepSpec = "abc".parse().expect("parsing dep spec failed");
529        assert_eq!(dep.name, "abc".to_string());
530        assert_eq!(dep.version_req, None);
531    }
532
533    #[test]
534    fn test_dep_from_str_name_and_version() {
535        let dep: DepSpec = "abc@1".parse().expect("parsing dep spec failed");
536        assert_eq!(dep.name, "abc".to_string());
537        assert_eq!(dep.version_req, Some("1".to_string()));
538    }
539
540    #[test]
541    fn test_dep_spec_invalid_version_req() {
542        let input = "foo@not-a-version";
543        let result = DepSpec::from_str(input);
544
545        assert!(result.is_err());
546        assert!(
547            result
548                .unwrap_err()
549                .to_string()
550                .contains("invalid version requirement"),
551            "Expected version requirement parse failure"
552        );
553    }
554
555    #[test]
556    fn test_dep_from_str_invalid() {
557        assert!(DepSpec::from_str("").is_err());
558    }
559
560    #[test]
561    fn test_resolve_package_path_single_package_mode() {
562        let (temp_dir, pkg_manifest) =
563            create_test_package("test_pkg", vec![("main.sw", "fn main() -> u64 { 42 }")]).unwrap();
564
565        let package_spec_dir = temp_dir.path().to_path_buf();
566        let expected_path = pkg_manifest.path;
567
568        let manifest_file = ManifestFile::from_dir(&package_spec_dir).unwrap();
569
570        let members = manifest_file.member_manifests().unwrap();
571        let root_dir = manifest_file.root_dir();
572        let result = resolve_package_path(&manifest_file, &None, &root_dir, &members).unwrap();
573
574        assert_eq!(result, expected_path);
575    }
576
577    #[test]
578    fn test_resolve_package_path_workspace_with_package_found() {
579        let (temp_dir, _) = create_test_workspace(vec![
580            ("pkg1", vec![("main.sw", "fn main() -> u64 { 1 }")]),
581            ("pkg2", vec![("main.sw", "fn main() -> u64 { 2 }")]),
582        ])
583        .unwrap();
584
585        let base_path = temp_dir.path();
586
587        let expected_path = base_path.join("pkg1/Forc.toml");
588
589        let manifest_file = ManifestFile::from_dir(base_path).unwrap();
590        let members = manifest_file.member_manifests().unwrap();
591        let root_dir = manifest_file.root_dir();
592
593        let package = "pkg1".to_string();
594        let result =
595            resolve_package_path(&manifest_file, &Some(package), &root_dir, &members).unwrap();
596
597        assert_eq!(result, expected_path);
598    }
599
600    #[test]
601    fn test_resolve_package_path_workspace_package_not_found() {
602        let (temp_dir, _) = create_test_workspace(vec![
603            ("pkg1", vec![("main.sw", "fn main() -> u64 { 1 }")]),
604            ("pkg2", vec![("main.sw", "fn main() -> u64 { 2 }")]),
605        ])
606        .unwrap();
607
608        let base_path = temp_dir.path();
609
610        let manifest_file = ManifestFile::from_dir(base_path).unwrap();
611        let members = manifest_file.member_manifests().unwrap();
612        let root_dir = manifest_file.root_dir();
613
614        let err = resolve_package_path(
615            &manifest_file,
616            &Some("missing_pkg".into()),
617            &root_dir,
618            &members,
619        )
620        .unwrap_err();
621
622        assert!(
623            err.to_string().contains("package(s) missing_pkg not found"),
624            "unexpected error: {err}"
625        );
626    }
627
628    #[test]
629    fn test_resolve_package_path_workspace_package_not_set() {
630        let (temp_dir, _) = create_test_workspace(vec![
631            ("pkg1", vec![("main.sw", "fn main() -> u64 { 1 }")]),
632            ("pkg2", vec![("main.sw", "fn main() -> u64 { 2 }")]),
633        ])
634        .unwrap();
635
636        let base_path = temp_dir.path();
637
638        let manifest_file = ManifestFile::from_dir(base_path).unwrap();
639        let members = manifest_file.member_manifests().unwrap();
640        let root_dir = manifest_file.root_dir();
641
642        let err = resolve_package_path(&manifest_file, &None, &root_dir, &members).unwrap_err();
643
644        let resp = "`forc add` could not determine which package to modify. Use --package.\nAvailable: pkg1, pkg2".to_string();
645        assert!(err.to_string().contains(&resp), "unexpected error: {err}");
646    }
647
648    #[test]
649    fn test_resolve_dependency_simple_version() {
650        let opts = ModifyOpts {
651            dependencies: vec!["dep@1.0.0".to_string()],
652            ..Default::default()
653        };
654
655        let (temp_dir, _) =
656            create_test_package("test_pkg", vec![("main.sw", "fn main() -> u64 { 42 }")]).unwrap();
657
658        let package_spec_dir = temp_dir.path().to_path_buf();
659
660        let manifest_file = ManifestFile::from_dir(&package_spec_dir).unwrap();
661        let members = manifest_file.member_manifests().unwrap();
662
663        let (name, data) =
664            resolve_dependency("dep@1.0.0", &opts, &members, &package_spec_dir).unwrap();
665
666        assert_eq!(name, "dep");
667        match data {
668            Dependency::Simple(v) => assert_eq!(v, "1.0.0"),
669            _ => panic!("Expected simple dependency"),
670        }
671    }
672
673    #[test]
674    fn test_resolve_dependency_detailed_variants() {
675        let base_opts = ModifyOpts {
676            ..Default::default()
677        };
678
679        let (temp_dir, _) =
680            create_test_package("test_pkg", vec![("main.sw", "fn main() -> u64 { 42 }")]).unwrap();
681
682        let package_spec_dir = temp_dir.path().to_path_buf();
683
684        let manifest_file = ManifestFile::from_dir(&package_spec_dir).unwrap();
685        let members = manifest_file.member_manifests().unwrap();
686        let dep = "dummy_dep";
687        let git = "https://github.com/example/repo.git";
688
689        // Git alone
690        {
691            let mut opts = base_opts.clone();
692            opts.git = Some(git.to_string());
693
694            let (name, data) = resolve_dependency(dep, &opts, &members, &package_spec_dir).unwrap();
695            assert_eq!(name, dep);
696            match data {
697                Dependency::Detailed(details) => {
698                    assert_eq!(details.git.as_deref(), Some(git));
699                }
700                _ => panic!("Expected detailed dependency with git"),
701            }
702        }
703
704        // Git + branch
705        {
706            let mut opts = base_opts.clone();
707            opts.git = Some(git.to_string());
708            opts.branch = Some("main".to_string());
709
710            let (name, data) = resolve_dependency(dep, &opts, &members, &package_spec_dir).unwrap();
711            assert_eq!(name, dep);
712            match data {
713                Dependency::Detailed(details) => {
714                    assert_eq!(details.git.as_deref(), Some(git));
715                    assert_eq!(details.branch.as_deref(), Some("main"));
716                }
717                _ => panic!("Expected detailed dependency with git+branch"),
718            }
719        }
720
721        // Git + rev
722        {
723            let mut opts = base_opts.clone();
724            opts.git = Some(git.to_string());
725            opts.rev = Some("deadbeef".to_string());
726
727            let (name, data) = resolve_dependency(dep, &opts, &members, &package_spec_dir).unwrap();
728            assert_eq!(name, dep);
729            match data {
730                Dependency::Detailed(details) => {
731                    assert_eq!(details.git.as_deref(), Some(git));
732                    assert_eq!(details.rev.as_deref(), Some("deadbeef"));
733                }
734                _ => panic!("Expected detailed dependency with git+rev"),
735            }
736        }
737
738        // Git + tag
739        {
740            let mut opts = base_opts.clone();
741            opts.git = Some(git.to_string());
742            opts.tag = Some("v1.2.3".to_string());
743
744            let (name, data) = resolve_dependency(dep, &opts, &members, &package_spec_dir).unwrap();
745            assert_eq!(name, dep);
746            match data {
747                Dependency::Detailed(details) => {
748                    assert_eq!(details.git.as_deref(), Some(git));
749                    assert_eq!(details.tag.as_deref(), Some("v1.2.3"));
750                }
751                _ => panic!("Expected detailed dependency with git+tag"),
752            }
753        }
754
755        // dep + ipfs
756        {
757            let mut opts = base_opts.clone();
758            opts.ipfs = Some("QmYwAPJzv5CZsnA".to_string());
759
760            let (name, data) = resolve_dependency(dep, &opts, &members, &package_spec_dir).unwrap();
761            assert_eq!(name, dep);
762            match data {
763                Dependency::Detailed(details) => {
764                    assert_eq!(details.ipfs.as_deref(), Some("QmYwAPJzv5CZsnA"));
765                }
766                _ => panic!("Expected detailed dependency with git+tag"),
767            }
768        }
769    }
770
771    #[test]
772    fn test_resolve_dependency_detailed_variant_failure() {
773        let base_opts = ModifyOpts {
774            ..Default::default()
775        };
776
777        let (temp_dir, _) =
778            create_test_package("test_pkg", vec![("main.sw", "fn main() -> u64 { 42 }")]).unwrap();
779
780        let package_spec_dir = temp_dir.path().to_path_buf();
781        let manifest_file = ManifestFile::from_dir(&package_spec_dir).unwrap();
782        let members = manifest_file.member_manifests().unwrap();
783        let dep = "dummy_dep";
784        let git = "https://github.com/example/repo.git";
785
786        // no Git + branch
787        {
788            let mut opts = base_opts.clone();
789            opts.branch = Some("main".to_string());
790            let result = resolve_dependency(dep, &opts, &members, &package_spec_dir);
791            assert!(result.is_err());
792            assert!(result
793                .unwrap_err()
794                .to_string()
795                .contains("Details reserved for git sources used without a git field"));
796        }
797
798        // no Git + rev
799        {
800            let mut opts = base_opts.clone();
801            opts.rev = Some("deadbeef".to_string());
802
803            let result = resolve_dependency(dep, &opts, &members, &package_spec_dir);
804            assert!(result.is_err());
805            assert!(result
806                .unwrap_err()
807                .to_string()
808                .contains("Details reserved for git sources used without a git field"));
809        }
810
811        // no Git + tag
812        {
813            let mut opts = base_opts.clone();
814            opts.tag = Some("v1.2.3".to_string());
815
816            let result = resolve_dependency(dep, &opts, &members, &package_spec_dir);
817            assert!(result.is_err());
818            assert!(result
819                .unwrap_err()
820                .to_string()
821                .contains("Details reserved for git sources used without a git field"));
822        }
823
824        // git + tag + rev + branch
825        {
826            let mut opts = base_opts.clone();
827            opts.git = Some(git.to_string());
828            opts.tag = Some("v1.2.3".to_string());
829            opts.rev = Some("deadbeef".to_string());
830            opts.branch = Some("main".to_string());
831
832            let result = resolve_dependency(dep, &opts, &members, &package_spec_dir);
833            assert!(result.is_err());
834            assert!(result
835                .unwrap_err()
836                .to_string()
837                .contains("Cannot specify `branch`, `tag`, and `rev` together for dependency with a Git source"));
838        }
839
840        // git + branch + tag
841        {
842            let mut opts = base_opts.clone();
843            opts.git = Some(git.to_string());
844            opts.tag = Some("v1.2.3".to_string());
845            opts.branch = Some("main".to_string());
846
847            let result = resolve_dependency(dep, &opts, &members, &package_spec_dir);
848            assert!(result.is_err());
849            assert!(result.unwrap_err().to_string().contains(
850                "Cannot specify both `branch` and `tag` for dependency with a Git source"
851            ));
852        }
853
854        // git + tag + rev
855        {
856            let mut opts = base_opts.clone();
857            opts.git = Some(git.to_string());
858            opts.tag = Some("v1.2.3".to_string());
859            opts.rev = Some("deadbeef".to_string());
860
861            let result = resolve_dependency(dep, &opts, &members, &package_spec_dir);
862            assert!(result.is_err());
863            assert!(result
864                .unwrap_err()
865                .to_string()
866                .contains("Cannot specify both `rev` and `tag` for dependency with a Git source"));
867        }
868
869        // git + branch + rev
870        {
871            let mut opts = base_opts.clone();
872            opts.git = Some(git.to_string());
873            opts.rev = Some("deadbeef".to_string());
874            opts.branch = Some("main".to_string());
875
876            let result = resolve_dependency(dep, &opts, &members, &package_spec_dir);
877            assert!(result.is_err());
878            assert!(result.unwrap_err().to_string().contains(
879                "Cannot specify both `branch` and `rev` for dependency with a Git source"
880            ));
881        }
882
883        // no source provided
884        {
885            let opts = base_opts.clone();
886            let result = resolve_dependency(dep, &opts, &members, &package_spec_dir);
887
888            assert!(result.is_err());
889            assert!(result.unwrap_err().to_string().contains(
890                "dependency `dummy_dep` source not specified. Please specify a source (e.g., git, path) or version"
891            ));
892        }
893    }
894
895    #[test]
896    fn test_resolve_dependency_from_workspace_sibling() {
897        let (temp_dir, _) = create_test_workspace(vec![
898            ("pkg1", vec![("main.sw", "fn main() -> u64 { 1 }")]),
899            ("pkg2", vec![("main.sw", "fn main() -> u64 { 2 }")]),
900        ])
901        .unwrap();
902
903        let base_path = temp_dir.path();
904        let package_dir = base_path.join("pkg2");
905
906        let dep = "pkg1";
907
908        let manifest_file = ManifestFile::from_dir(base_path).unwrap();
909        let members = manifest_file.member_manifests().unwrap();
910
911        let opts = ModifyOpts {
912            source_path: None,
913            dependencies: vec![dep.to_string()],
914            package: Some("pkg2".to_string()),
915            ..Default::default()
916        };
917
918        let (name, data) =
919            resolve_dependency(dep, &opts, &members, &package_dir).expect("should resolve");
920
921        assert_eq!(name, dep);
922        match data {
923            Dependency::Detailed(details) => {
924                assert!(details.path.is_some());
925                let actual_path = details.path.as_ref().unwrap();
926                assert_eq!(actual_path, "../pkg1");
927            }
928            _ => panic!("Expected detailed dependency with fallback path"),
929        }
930    }
931
932    #[test]
933    fn test_resolve_dependency_self_dependency_error() {
934        let (temp_dir, _) = create_test_workspace(vec![
935            ("pkg1", vec![("main.sw", "fn main() -> u64 { 1 }")]),
936            ("pkg2", vec![("main.sw", "fn main() -> u64 { 2 }")]),
937        ])
938        .unwrap();
939
940        let base_path = temp_dir.path();
941        let package_dir = base_path.join("pkg1");
942        let dep = "pkg1";
943        let resp = format!("cannot add `{dep}` as a dependency to itself");
944
945        let manifest_file = ManifestFile::from_dir(base_path).unwrap();
946        let members = manifest_file.member_manifests().unwrap();
947
948        let opts = ModifyOpts {
949            dependencies: vec![dep.to_string()],
950            package: Some("package-1".to_string()),
951            ..Default::default()
952        };
953
954        let error = resolve_dependency(dep, &opts, &members, &package_dir).unwrap_err();
955        assert!(error.to_string().contains(&resp));
956    }
957
958    #[test]
959    fn test_resolve_dependency_invalid_string() {
960        let opts = ModifyOpts {
961            dependencies: vec!["".to_string()],
962            ..Default::default()
963        };
964
965        let result = resolve_dependency("", &opts, &BTreeMap::new(), &PathBuf::new());
966        assert!(result.is_err());
967        assert!(result
968            .unwrap_err()
969            .to_string()
970            .contains("Dependency spec cannot be empty"));
971    }
972
973    #[test]
974    fn test_dep_section_add_to_toml_regular_dependency_success() {
975        let toml_str = r#"
976            [project]
977            name = "package"
978            entry = "main.sw"
979            license = "Apache-2.0"
980            authors = ["Fuel Labs"]
981        "#;
982        let mut doc: DocumentMut = toml_str.parse().unwrap();
983
984        let dep_data = Dependency::Simple("1.0.0".into());
985
986        let section = Section::Deps;
987
988        section
989            .add_deps_manifest_table(&mut doc, "dep1".into(), dep_data, None)
990            .unwrap();
991
992        assert_eq!(doc["dependencies"]["dep1"].as_str(), Some("1.0.0"));
993    }
994
995    #[test]
996    fn test_dep_section_add_to_toml_regular_detailed_dependency_success() {
997        let toml_str = r#"
998        [project]
999        name = "package"
1000        entry = "main.sw"
1001        license = "Apache-2.0"
1002        authors = ["Fuel Labs"]
1003    "#;
1004        let mut doc: DocumentMut = toml_str.parse().unwrap();
1005
1006        let dep_data = Dependency::Detailed(DependencyDetails {
1007            git: Some("https://github.com/example/repo".to_string()),
1008            tag: Some("v1.2.3".to_string()),
1009            ..Default::default()
1010        });
1011
1012        let section = Section::Deps;
1013
1014        section
1015            .add_deps_manifest_table(&mut doc, "dep2".into(), dep_data, None)
1016            .unwrap();
1017
1018        let table = doc["dependencies"]["dep2"].as_inline_table().unwrap();
1019        assert_eq!(
1020            table.get("git").unwrap().as_str(),
1021            Some("https://github.com/example/repo")
1022        );
1023        assert_eq!(table.get("tag").unwrap().as_str(), Some("v1.2.3"));
1024    }
1025
1026    #[test]
1027    fn test_dep_section_add_contract_dependency_with_salt() {
1028        let toml_str = r#"
1029            [project]
1030            name = "contract_pkg"
1031            entry = "main.sw"
1032            license = "Apache-2.0"
1033            authors = ["Fuel Labs"]
1034        "#;
1035
1036        let mut doc: DocumentMut = toml_str.parse().unwrap();
1037
1038        let section = Section::ContractDeps;
1039        let dep_name = "custom_dep";
1040        let dep_data = Dependency::Simple("1.0.0".to_string());
1041        let salt_str = "0x2222222222222222222222222222222222222222222222222222222222222222";
1042        let hex_salt = HexSalt::from_str(salt_str).unwrap();
1043
1044        section
1045            .add_deps_manifest_table(
1046                &mut doc,
1047                dep_name.to_string(),
1048                dep_data,
1049                Some(salt_str.to_string()),
1050            )
1051            .unwrap();
1052
1053        let contract_table = doc["contract-dependencies"][dep_name]
1054            .as_inline_table()
1055            .expect("inline table not found");
1056
1057        assert_eq!(
1058            contract_table.get("version").unwrap().as_str(),
1059            Some("1.0.0")
1060        );
1061        assert_eq!(
1062            contract_table.get("salt").unwrap().as_str(),
1063            Some(format!("0x{hex_salt}").as_str())
1064        );
1065    }
1066
1067    #[test]
1068    fn test_dep_section_add_contract_dependency_with_default_salt() {
1069        let toml_str = r#"
1070            [project]
1071            name = "contract_pkg"
1072            entry = "main.sw"
1073            license = "Apache-2.0"
1074            authors = ["Fuel Labs"]
1075        "#;
1076
1077        let mut doc: DocumentMut = toml_str.parse().unwrap();
1078
1079        let section = Section::ContractDeps;
1080        let dep_name = "custom_dep";
1081        let dep_data = Dependency::Simple("1.0.0".to_string());
1082
1083        section
1084            .add_deps_manifest_table(&mut doc, dep_name.to_string(), dep_data, None)
1085            .unwrap();
1086
1087        let contract_table = doc["contract-dependencies"][dep_name]
1088            .as_inline_table()
1089            .expect("inline table not found");
1090
1091        assert_eq!(
1092            contract_table.get("version").unwrap().as_str(),
1093            Some("1.0.0")
1094        );
1095        assert_eq!(
1096            contract_table.get("salt").unwrap().as_str(),
1097            Some(format!("0x{}", fuel_tx::Salt::default()).as_str())
1098        );
1099    }
1100
1101    #[test]
1102    fn test_dep_section_add_contract_dependency_with_invalid_salt() {
1103        let toml_str = r#"
1104            [project]
1105            name = "contract_pkg"
1106            entry = "main.sw"
1107            license = "Apache-2.0"
1108            authors = ["Fuel Labs"]
1109        "#;
1110
1111        let mut doc: DocumentMut = toml_str.parse().unwrap();
1112
1113        let section = Section::ContractDeps;
1114        let dep_name = "custom_dep";
1115        let dep_data = Dependency::Simple("1.0.0".to_string());
1116
1117        let result = section.add_deps_manifest_table(
1118            &mut doc,
1119            dep_name.to_string(),
1120            dep_data,
1121            Some("not_hex".to_string()),
1122        );
1123
1124        assert!(result.is_err());
1125        assert!(format!("{}", result.unwrap_err()).contains("Invalid salt format"));
1126    }
1127
1128    #[test]
1129    fn test_dep_section_remove_regular_dependency_success() {
1130        let toml_str = r#"
1131            [project]
1132            name = "package"
1133            entry = "main.sw"
1134            license = "Apache-2.0"
1135            authors = ["Fuel Labs"]
1136
1137            [dependencies]
1138            foo = "1.0.0"
1139            bar = "2.0.0"
1140        "#;
1141
1142        let mut doc: DocumentMut = toml_str.parse().unwrap();
1143
1144        let section = Section::Deps;
1145        section
1146            .remove_deps_manifest_table(&mut doc, &["foo"])
1147            .unwrap();
1148
1149        assert!(doc["dependencies"].as_table().unwrap().get("foo").is_none());
1150        assert!(doc["dependencies"].as_table().unwrap().get("bar").is_some());
1151    }
1152
1153    #[test]
1154    fn test_dep_section_remove_regular_dependency_not_found() {
1155        let toml_str = r#"
1156            [project]
1157            name = "package"
1158            entry = "main.sw"
1159            license = "Apache-2.0"
1160            authors = ["Fuel Labs"]
1161
1162            [dependencies]
1163            bar = "2.0.0"
1164        "#;
1165
1166        let mut doc: DocumentMut = toml_str.parse().unwrap();
1167
1168        let section = Section::Deps;
1169
1170        let err = section
1171            .remove_deps_manifest_table(&mut doc, &["notfound"])
1172            .unwrap_err()
1173            .to_string();
1174
1175        assert!(err.contains("the dependency `notfound` could not be found in `dependencies`"));
1176    }
1177
1178    #[test]
1179    fn test_dep_section_remove_contract_dependency_success() {
1180        let toml_str = r#"
1181            [project]
1182            name = "package"
1183            entry = "main.sw"
1184            license = "Apache-2.0"
1185            authors = ["Fuel Labs"]
1186
1187            [contract-dependencies]
1188            baz = { path = "../baz", salt = "0x1111111111111111111111111111111111111111111111111111111111111111" }
1189        "#;
1190
1191        let mut doc: DocumentMut = toml_str.parse().unwrap();
1192
1193        let section = Section::ContractDeps;
1194        section
1195            .remove_deps_manifest_table(&mut doc, &["baz"])
1196            .unwrap();
1197
1198        assert!(doc["contract-dependencies"]
1199            .as_table()
1200            .unwrap()
1201            .get("baz")
1202            .is_none());
1203    }
1204
1205    #[test]
1206    fn test_dep_section_remove_contract_dependency_not_found() {
1207        let toml_str = r#"
1208            [project]
1209            name = "package"
1210            entry = "main.sw"
1211            license = "Apache-2.0"
1212            authors = ["Fuel Labs"]
1213
1214            [contract-dependencies]
1215            baz = { path = "../baz", salt = "0x1111111111111111111111111111111111111111111111111111111111111111" }
1216        "#;
1217
1218        let mut doc: DocumentMut = toml_str.parse().unwrap();
1219
1220        let section = Section::ContractDeps;
1221
1222        let result = section.remove_deps_manifest_table(&mut doc, &["ghost"]);
1223        assert!(result.is_err());
1224        assert!(result
1225            .unwrap_err()
1226            .to_string()
1227            .contains("the dependency `ghost` could not be found in `contract-dependencies`"));
1228    }
1229
1230    #[test]
1231    fn test_dep_section_remove_from_missing_section() {
1232        let toml_str = r#"
1233            [project]
1234            authors = ["Fuel Labs <contact@fuel.sh>"]
1235            entry = "main.sw"
1236            license = "Apache-2.0"
1237            name = "package-1"
1238
1239            [dependencies]
1240            foo = "1.0.0"
1241        "#;
1242
1243        let mut doc: DocumentMut = toml_str.parse().unwrap();
1244
1245        let section = Section::ContractDeps;
1246
1247        let result = section.remove_deps_manifest_table(&mut doc, &["ghost"]);
1248
1249        assert!(result.is_err());
1250        assert!(result
1251            .unwrap_err()
1252            .to_string()
1253            .contains("the dependency `ghost` could not be found in `contract-dependencies`"));
1254    }
1255
1256    #[test]
1257    fn test_generate_table_basic_fields() {
1258        let details = DependencyDetails {
1259            version: Some("1.2.3".to_string()),
1260            git: Some("https://github.com/example/repo".to_string()),
1261            branch: Some("main".to_string()),
1262            tag: Some("v1.0.0".to_string()),
1263            rev: Some("deadbeef".to_string()),
1264            path: Some("./lib".to_string()),
1265            ipfs: Some("QmYw...".to_string()),
1266            namespace: None,
1267            package: None,
1268        };
1269
1270        let table = generate_table(&details);
1271
1272        assert_eq!(table.get("version").unwrap().as_str(), Some("1.2.3"));
1273        assert_eq!(
1274            table.get("git").unwrap().as_str(),
1275            Some("https://github.com/example/repo")
1276        );
1277        assert_eq!(table.get("branch").unwrap().as_str(), Some("main"));
1278        assert_eq!(table.get("tag").unwrap().as_str(), Some("v1.0.0"));
1279        assert_eq!(table.get("rev").unwrap().as_str(), Some("deadbeef"));
1280        assert_eq!(table.get("path").unwrap().as_str(), Some("./lib"));
1281        assert_eq!(table.get("cid").unwrap().as_str(), Some("QmYw..."));
1282    }
1283}