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 = "{}"
449            
450            [dependencies]
451        "#,
452            name
453        );
454        fs::write(base_path.join("Forc.toml"), forc_toml)?;
455
456        // Create source files
457        for (file_name, content) in source_files {
458            // Handle nested directories in the file path
459            let file_path = base_path.join("src").join(file_name);
460            if let Some(parent) = file_path.parent() {
461                fs::create_dir_all(parent)?;
462            }
463            fs::write(file_path, content)?;
464        }
465
466        // Create the manifest file
467        let manifest_file = PackageManifestFile::from_file(base_path.join("Forc.toml"))?;
468
469        Ok((temp_dir, manifest_file))
470    }
471
472    fn create_test_workspace(
473        members: Vec<(&str, Vec<(&str, &str)>)>,
474    ) -> Result<(TempDir, WorkspaceManifestFile)> {
475        let temp_dir = tempdir()?;
476        let base_path = temp_dir.path();
477
478        // Create workspace Forc.toml
479        let mut workspace_toml = "[workspace]\nmembers = [".to_string();
480
481        for (i, (name, _)) in members.iter().enumerate() {
482            if i > 0 {
483                workspace_toml.push_str(", ");
484            }
485            workspace_toml.push_str(&format!("\"{name}\""));
486        }
487        workspace_toml.push_str("]\n");
488
489        fs::write(base_path.join("Forc.toml"), workspace_toml)?;
490
491        // Create each member
492        for (name, source_files) in members {
493            let member_path = base_path.join(name);
494            fs::create_dir_all(member_path.join("src"))?;
495
496            // Create member Forc.toml
497            let forc_toml = format!(
498                r#"
499                [project]
500                authors = ["Test"]
501                entry = "main.sw"
502                license = "MIT"
503                name = "{}"
504                
505                [dependencies]
506            "#,
507                name
508            );
509            fs::write(member_path.join("Forc.toml"), forc_toml)?;
510
511            // Create source files
512            for (file_name, content) in source_files {
513                // Handle nested directories in the file path
514                let file_path = member_path.join("src").join(file_name);
515                if let Some(parent) = file_path.parent() {
516                    fs::create_dir_all(parent)?;
517                }
518                fs::write(file_path, content)?;
519            }
520        }
521
522        // Create the workspace manifest file
523        let manifest_file = WorkspaceManifestFile::from_file(base_path.join("Forc.toml"))?;
524
525        Ok((temp_dir, manifest_file))
526    }
527
528    #[test]
529    fn test_dep_from_str_name_only() {
530        let dep: DepSpec = "abc".parse().expect("parsing dep spec failed");
531        assert_eq!(dep.name, "abc".to_string());
532        assert_eq!(dep.version_req, None);
533    }
534
535    #[test]
536    fn test_dep_from_str_name_and_version() {
537        let dep: DepSpec = "abc@1".parse().expect("parsing dep spec failed");
538        assert_eq!(dep.name, "abc".to_string());
539        assert_eq!(dep.version_req, Some("1".to_string()));
540    }
541
542    #[test]
543    fn test_dep_spec_invalid_version_req() {
544        let input = "foo@not-a-version";
545        let result = DepSpec::from_str(input);
546
547        assert!(result.is_err());
548        assert!(
549            result
550                .unwrap_err()
551                .to_string()
552                .contains("invalid version requirement"),
553            "Expected version requirement parse failure"
554        );
555    }
556
557    #[test]
558    fn test_dep_from_str_invalid() {
559        assert!(DepSpec::from_str("").is_err());
560    }
561
562    #[test]
563    fn test_resolve_package_path_single_package_mode() {
564        let (temp_dir, pkg_manifest) =
565            create_test_package("test_pkg", vec![("main.sw", "fn main() -> u64 { 42 }")]).unwrap();
566
567        let package_spec_dir = temp_dir.path().to_path_buf();
568        let expected_path = pkg_manifest.path;
569
570        let manifest_file = ManifestFile::from_dir(&package_spec_dir).unwrap();
571
572        let members = manifest_file.member_manifests().unwrap();
573        let root_dir = manifest_file.root_dir();
574        let result = resolve_package_path(&manifest_file, &None, &root_dir, &members).unwrap();
575
576        assert_eq!(result, expected_path);
577    }
578
579    #[test]
580    fn test_resolve_package_path_workspace_with_package_found() {
581        let (temp_dir, _) = create_test_workspace(vec![
582            ("pkg1", vec![("main.sw", "fn main() -> u64 { 1 }")]),
583            ("pkg2", vec![("main.sw", "fn main() -> u64 { 2 }")]),
584        ])
585        .unwrap();
586
587        let base_path = temp_dir.path();
588
589        let expected_path = base_path.join("pkg1/Forc.toml");
590
591        let manifest_file = ManifestFile::from_dir(base_path).unwrap();
592        let members = manifest_file.member_manifests().unwrap();
593        let root_dir = manifest_file.root_dir();
594
595        let package = "pkg1".to_string();
596        let result =
597            resolve_package_path(&manifest_file, &Some(package), &root_dir, &members).unwrap();
598
599        assert_eq!(result, expected_path);
600    }
601
602    #[test]
603    fn test_resolve_package_path_workspace_package_not_found() {
604        let (temp_dir, _) = create_test_workspace(vec![
605            ("pkg1", vec![("main.sw", "fn main() -> u64 { 1 }")]),
606            ("pkg2", vec![("main.sw", "fn main() -> u64 { 2 }")]),
607        ])
608        .unwrap();
609
610        let base_path = temp_dir.path();
611
612        let manifest_file = ManifestFile::from_dir(base_path).unwrap();
613        let members = manifest_file.member_manifests().unwrap();
614        let root_dir = manifest_file.root_dir();
615
616        let err = resolve_package_path(
617            &manifest_file,
618            &Some("missing_pkg".into()),
619            &root_dir,
620            &members,
621        )
622        .unwrap_err();
623
624        assert!(
625            err.to_string().contains("package(s) missing_pkg not found"),
626            "unexpected error: {err}"
627        );
628    }
629
630    #[test]
631    fn test_resolve_package_path_workspace_package_not_set() {
632        let (temp_dir, _) = create_test_workspace(vec![
633            ("pkg1", vec![("main.sw", "fn main() -> u64 { 1 }")]),
634            ("pkg2", vec![("main.sw", "fn main() -> u64 { 2 }")]),
635        ])
636        .unwrap();
637
638        let base_path = temp_dir.path();
639
640        let manifest_file = ManifestFile::from_dir(base_path).unwrap();
641        let members = manifest_file.member_manifests().unwrap();
642        let root_dir = manifest_file.root_dir();
643
644        let err = resolve_package_path(&manifest_file, &None, &root_dir, &members).unwrap_err();
645
646        let resp = "`forc add` could not determine which package to modify. Use --package.\nAvailable: pkg1, pkg2".to_string();
647        assert!(err.to_string().contains(&resp), "unexpected error: {err}");
648    }
649
650    #[test]
651    fn test_resolve_dependency_simple_version() {
652        let opts = ModifyOpts {
653            dependencies: vec!["dep@1.0.0".to_string()],
654            ..Default::default()
655        };
656
657        let (temp_dir, _) =
658            create_test_package("test_pkg", vec![("main.sw", "fn main() -> u64 { 42 }")]).unwrap();
659
660        let package_spec_dir = temp_dir.path().to_path_buf();
661
662        let manifest_file = ManifestFile::from_dir(&package_spec_dir).unwrap();
663        let members = manifest_file.member_manifests().unwrap();
664
665        let (name, data) =
666            resolve_dependency("dep@1.0.0", &opts, &members, &package_spec_dir).unwrap();
667
668        assert_eq!(name, "dep");
669        match data {
670            Dependency::Simple(v) => assert_eq!(v, "1.0.0"),
671            _ => panic!("Expected simple dependency"),
672        }
673    }
674
675    #[test]
676    fn test_resolve_dependency_detailed_variants() {
677        let base_opts = ModifyOpts {
678            ..Default::default()
679        };
680
681        let (temp_dir, _) =
682            create_test_package("test_pkg", vec![("main.sw", "fn main() -> u64 { 42 }")]).unwrap();
683
684        let package_spec_dir = temp_dir.path().to_path_buf();
685
686        let manifest_file = ManifestFile::from_dir(&package_spec_dir).unwrap();
687        let members = manifest_file.member_manifests().unwrap();
688        let dep = "dummy_dep";
689        let git = "https://github.com/example/repo.git";
690
691        // Git alone
692        {
693            let mut opts = base_opts.clone();
694            opts.git = Some(git.to_string());
695
696            let (name, data) = resolve_dependency(dep, &opts, &members, &package_spec_dir).unwrap();
697            assert_eq!(name, dep);
698            match data {
699                Dependency::Detailed(details) => {
700                    assert_eq!(details.git.as_deref(), Some(git));
701                }
702                _ => panic!("Expected detailed dependency with git"),
703            }
704        }
705
706        // Git + branch
707        {
708            let mut opts = base_opts.clone();
709            opts.git = Some(git.to_string());
710            opts.branch = Some("main".to_string());
711
712            let (name, data) = resolve_dependency(dep, &opts, &members, &package_spec_dir).unwrap();
713            assert_eq!(name, dep);
714            match data {
715                Dependency::Detailed(details) => {
716                    assert_eq!(details.git.as_deref(), Some(git));
717                    assert_eq!(details.branch.as_deref(), Some("main"));
718                }
719                _ => panic!("Expected detailed dependency with git+branch"),
720            }
721        }
722
723        // Git + rev
724        {
725            let mut opts = base_opts.clone();
726            opts.git = Some(git.to_string());
727            opts.rev = Some("deadbeef".to_string());
728
729            let (name, data) = resolve_dependency(dep, &opts, &members, &package_spec_dir).unwrap();
730            assert_eq!(name, dep);
731            match data {
732                Dependency::Detailed(details) => {
733                    assert_eq!(details.git.as_deref(), Some(git));
734                    assert_eq!(details.rev.as_deref(), Some("deadbeef"));
735                }
736                _ => panic!("Expected detailed dependency with git+rev"),
737            }
738        }
739
740        // Git + tag
741        {
742            let mut opts = base_opts.clone();
743            opts.git = Some(git.to_string());
744            opts.tag = Some("v1.2.3".to_string());
745
746            let (name, data) = resolve_dependency(dep, &opts, &members, &package_spec_dir).unwrap();
747            assert_eq!(name, dep);
748            match data {
749                Dependency::Detailed(details) => {
750                    assert_eq!(details.git.as_deref(), Some(git));
751                    assert_eq!(details.tag.as_deref(), Some("v1.2.3"));
752                }
753                _ => panic!("Expected detailed dependency with git+tag"),
754            }
755        }
756
757        // dep + ipfs
758        {
759            let mut opts = base_opts.clone();
760            opts.ipfs = Some("QmYwAPJzv5CZsnA".to_string());
761
762            let (name, data) = resolve_dependency(dep, &opts, &members, &package_spec_dir).unwrap();
763            assert_eq!(name, dep);
764            match data {
765                Dependency::Detailed(details) => {
766                    assert_eq!(details.ipfs.as_deref(), Some("QmYwAPJzv5CZsnA"));
767                }
768                _ => panic!("Expected detailed dependency with git+tag"),
769            }
770        }
771    }
772
773    #[test]
774    fn test_resolve_dependency_detailed_variant_failure() {
775        let base_opts = ModifyOpts {
776            ..Default::default()
777        };
778
779        let (temp_dir, _) =
780            create_test_package("test_pkg", vec![("main.sw", "fn main() -> u64 { 42 }")]).unwrap();
781
782        let package_spec_dir = temp_dir.path().to_path_buf();
783        let manifest_file = ManifestFile::from_dir(&package_spec_dir).unwrap();
784        let members = manifest_file.member_manifests().unwrap();
785        let dep = "dummy_dep";
786        let git = "https://github.com/example/repo.git";
787
788        // no Git + branch
789        {
790            let mut opts = base_opts.clone();
791            opts.branch = Some("main".to_string());
792            let result = resolve_dependency(dep, &opts, &members, &package_spec_dir);
793            assert!(result.is_err());
794            assert!(result
795                .unwrap_err()
796                .to_string()
797                .contains("Details reserved for git sources used without a git field"));
798        }
799
800        // no Git + rev
801        {
802            let mut opts = base_opts.clone();
803            opts.rev = Some("deadbeef".to_string());
804
805            let result = resolve_dependency(dep, &opts, &members, &package_spec_dir);
806            assert!(result.is_err());
807            assert!(result
808                .unwrap_err()
809                .to_string()
810                .contains("Details reserved for git sources used without a git field"));
811        }
812
813        // no Git + tag
814        {
815            let mut opts = base_opts.clone();
816            opts.tag = Some("v1.2.3".to_string());
817
818            let result = resolve_dependency(dep, &opts, &members, &package_spec_dir);
819            assert!(result.is_err());
820            assert!(result
821                .unwrap_err()
822                .to_string()
823                .contains("Details reserved for git sources used without a git field"));
824        }
825
826        // git + tag + rev + branch
827        {
828            let mut opts = base_opts.clone();
829            opts.git = Some(git.to_string());
830            opts.tag = Some("v1.2.3".to_string());
831            opts.rev = Some("deadbeef".to_string());
832            opts.branch = Some("main".to_string());
833
834            let result = resolve_dependency(dep, &opts, &members, &package_spec_dir);
835            assert!(result.is_err());
836            assert!(result
837                .unwrap_err()
838                .to_string()
839                .contains("Cannot specify `branch`, `tag`, and `rev` together for dependency with a Git source"));
840        }
841
842        // git + branch + tag
843        {
844            let mut opts = base_opts.clone();
845            opts.git = Some(git.to_string());
846            opts.tag = Some("v1.2.3".to_string());
847            opts.branch = Some("main".to_string());
848
849            let result = resolve_dependency(dep, &opts, &members, &package_spec_dir);
850            assert!(result.is_err());
851            assert!(result.unwrap_err().to_string().contains(
852                "Cannot specify both `branch` and `tag` for dependency with a Git source"
853            ));
854        }
855
856        // git + tag + rev
857        {
858            let mut opts = base_opts.clone();
859            opts.git = Some(git.to_string());
860            opts.tag = Some("v1.2.3".to_string());
861            opts.rev = Some("deadbeef".to_string());
862
863            let result = resolve_dependency(dep, &opts, &members, &package_spec_dir);
864            assert!(result.is_err());
865            assert!(result
866                .unwrap_err()
867                .to_string()
868                .contains("Cannot specify both `rev` and `tag` for dependency with a Git source"));
869        }
870
871        // git + branch + rev
872        {
873            let mut opts = base_opts.clone();
874            opts.git = Some(git.to_string());
875            opts.rev = Some("deadbeef".to_string());
876            opts.branch = Some("main".to_string());
877
878            let result = resolve_dependency(dep, &opts, &members, &package_spec_dir);
879            assert!(result.is_err());
880            assert!(result.unwrap_err().to_string().contains(
881                "Cannot specify both `branch` and `rev` for dependency with a Git source"
882            ));
883        }
884
885        // no source provided
886        {
887            let opts = base_opts.clone();
888            let result = resolve_dependency(dep, &opts, &members, &package_spec_dir);
889
890            assert!(result.is_err());
891            assert!(result.unwrap_err().to_string().contains(
892                "dependency `dummy_dep` source not specified. Please specify a source (e.g., git, path) or version"
893            ));
894        }
895    }
896
897    #[test]
898    fn test_resolve_dependency_from_workspace_sibling() {
899        let (temp_dir, _) = create_test_workspace(vec![
900            ("pkg1", vec![("main.sw", "fn main() -> u64 { 1 }")]),
901            ("pkg2", vec![("main.sw", "fn main() -> u64 { 2 }")]),
902        ])
903        .unwrap();
904
905        let base_path = temp_dir.path();
906        let package_dir = base_path.join("pkg2");
907
908        let dep = "pkg1";
909
910        let manifest_file = ManifestFile::from_dir(base_path).unwrap();
911        let members = manifest_file.member_manifests().unwrap();
912
913        let opts = ModifyOpts {
914            source_path: None,
915            dependencies: vec![dep.to_string()],
916            package: Some("pkg2".to_string()),
917            ..Default::default()
918        };
919
920        let (name, data) =
921            resolve_dependency(dep, &opts, &members, &package_dir).expect("should resolve");
922
923        assert_eq!(name, dep);
924        match data {
925            Dependency::Detailed(details) => {
926                assert!(details.path.is_some());
927                let actual_path = details.path.as_ref().unwrap();
928                assert_eq!(actual_path, "../pkg1");
929            }
930            _ => panic!("Expected detailed dependency with fallback path"),
931        }
932    }
933
934    #[test]
935    fn test_resolve_dependency_self_dependency_error() {
936        let (temp_dir, _) = create_test_workspace(vec![
937            ("pkg1", vec![("main.sw", "fn main() -> u64 { 1 }")]),
938            ("pkg2", vec![("main.sw", "fn main() -> u64 { 2 }")]),
939        ])
940        .unwrap();
941
942        let base_path = temp_dir.path();
943        let package_dir = base_path.join("pkg1");
944        let dep = "pkg1";
945        let resp = format!("cannot add `{}` as a dependency to itself", dep);
946
947        let manifest_file = ManifestFile::from_dir(base_path).unwrap();
948        let members = manifest_file.member_manifests().unwrap();
949
950        let opts = ModifyOpts {
951            dependencies: vec![dep.to_string()],
952            package: Some("package-1".to_string()),
953            ..Default::default()
954        };
955
956        let error = resolve_dependency(dep, &opts, &members, &package_dir).unwrap_err();
957        assert!(error.to_string().contains(&resp));
958    }
959
960    #[test]
961    fn test_resolve_dependency_invalid_string() {
962        let opts = ModifyOpts {
963            dependencies: vec!["".to_string()],
964            ..Default::default()
965        };
966
967        let result = resolve_dependency("", &opts, &BTreeMap::new(), &PathBuf::new());
968        assert!(result.is_err());
969        assert!(result
970            .unwrap_err()
971            .to_string()
972            .contains("Dependency spec cannot be empty"));
973    }
974
975    #[test]
976    fn test_dep_section_add_to_toml_regular_dependency_success() {
977        let toml_str = r#"
978            [project]
979            name = "package"
980            entry = "main.sw"
981            license = "Apache-2.0"
982            authors = ["Fuel Labs"]
983        "#;
984        let mut doc: DocumentMut = toml_str.parse().unwrap();
985
986        let dep_data = Dependency::Simple("1.0.0".into());
987
988        let section = Section::Deps;
989
990        section
991            .add_deps_manifest_table(&mut doc, "dep1".into(), dep_data, None)
992            .unwrap();
993
994        assert_eq!(doc["dependencies"]["dep1"].as_str(), Some("1.0.0"));
995    }
996
997    #[test]
998    fn test_dep_section_add_to_toml_regular_detailed_dependency_success() {
999        let toml_str = r#"
1000        [project]
1001        name = "package"
1002        entry = "main.sw"
1003        license = "Apache-2.0"
1004        authors = ["Fuel Labs"]
1005    "#;
1006        let mut doc: DocumentMut = toml_str.parse().unwrap();
1007
1008        let dep_data = Dependency::Detailed(DependencyDetails {
1009            git: Some("https://github.com/example/repo".to_string()),
1010            tag: Some("v1.2.3".to_string()),
1011            ..Default::default()
1012        });
1013
1014        let section = Section::Deps;
1015
1016        section
1017            .add_deps_manifest_table(&mut doc, "dep2".into(), dep_data, None)
1018            .unwrap();
1019
1020        let table = doc["dependencies"]["dep2"].as_inline_table().unwrap();
1021        assert_eq!(
1022            table.get("git").unwrap().as_str(),
1023            Some("https://github.com/example/repo")
1024        );
1025        assert_eq!(table.get("tag").unwrap().as_str(), Some("v1.2.3"));
1026    }
1027
1028    #[test]
1029    fn test_dep_section_add_contract_dependency_with_salt() {
1030        let toml_str = r#"
1031            [project]
1032            name = "contract_pkg"
1033            entry = "main.sw"
1034            license = "Apache-2.0"
1035            authors = ["Fuel Labs"]
1036        "#;
1037
1038        let mut doc: DocumentMut = toml_str.parse().unwrap();
1039
1040        let section = Section::ContractDeps;
1041        let dep_name = "custom_dep";
1042        let dep_data = Dependency::Simple("1.0.0".to_string());
1043        let salt_str = "0x2222222222222222222222222222222222222222222222222222222222222222";
1044        let hex_salt = HexSalt::from_str(salt_str).unwrap();
1045
1046        section
1047            .add_deps_manifest_table(
1048                &mut doc,
1049                dep_name.to_string(),
1050                dep_data,
1051                Some(salt_str.to_string()),
1052            )
1053            .unwrap();
1054
1055        let contract_table = doc["contract-dependencies"][dep_name]
1056            .as_inline_table()
1057            .expect("inline table not found");
1058
1059        assert_eq!(
1060            contract_table.get("version").unwrap().as_str(),
1061            Some("1.0.0")
1062        );
1063        assert_eq!(
1064            contract_table.get("salt").unwrap().as_str(),
1065            Some(format!("0x{}", hex_salt).as_str())
1066        );
1067    }
1068
1069    #[test]
1070    fn test_dep_section_add_contract_dependency_with_default_salt() {
1071        let toml_str = r#"
1072            [project]
1073            name = "contract_pkg"
1074            entry = "main.sw"
1075            license = "Apache-2.0"
1076            authors = ["Fuel Labs"]
1077        "#;
1078
1079        let mut doc: DocumentMut = toml_str.parse().unwrap();
1080
1081        let section = Section::ContractDeps;
1082        let dep_name = "custom_dep";
1083        let dep_data = Dependency::Simple("1.0.0".to_string());
1084
1085        section
1086            .add_deps_manifest_table(&mut doc, dep_name.to_string(), dep_data, None)
1087            .unwrap();
1088
1089        let contract_table = doc["contract-dependencies"][dep_name]
1090            .as_inline_table()
1091            .expect("inline table not found");
1092
1093        assert_eq!(
1094            contract_table.get("version").unwrap().as_str(),
1095            Some("1.0.0")
1096        );
1097        assert_eq!(
1098            contract_table.get("salt").unwrap().as_str(),
1099            Some(format!("0x{}", fuel_tx::Salt::default()).as_str())
1100        );
1101    }
1102
1103    #[test]
1104    fn test_dep_section_add_contract_dependency_with_invalid_salt() {
1105        let toml_str = r#"
1106            [project]
1107            name = "contract_pkg"
1108            entry = "main.sw"
1109            license = "Apache-2.0"
1110            authors = ["Fuel Labs"]
1111        "#;
1112
1113        let mut doc: DocumentMut = toml_str.parse().unwrap();
1114
1115        let section = Section::ContractDeps;
1116        let dep_name = "custom_dep";
1117        let dep_data = Dependency::Simple("1.0.0".to_string());
1118
1119        let result = section.add_deps_manifest_table(
1120            &mut doc,
1121            dep_name.to_string(),
1122            dep_data,
1123            Some("not_hex".to_string()),
1124        );
1125
1126        assert!(result.is_err());
1127        assert!(format!("{}", result.unwrap_err()).contains("Invalid salt format"));
1128    }
1129
1130    #[test]
1131    fn test_dep_section_remove_regular_dependency_success() {
1132        let toml_str = r#"
1133            [project]
1134            name = "package"
1135            entry = "main.sw"
1136            license = "Apache-2.0"
1137            authors = ["Fuel Labs"]
1138
1139            [dependencies]
1140            foo = "1.0.0"
1141            bar = "2.0.0"
1142        "#;
1143
1144        let mut doc: DocumentMut = toml_str.parse().unwrap();
1145
1146        let section = Section::Deps;
1147        section
1148            .remove_deps_manifest_table(&mut doc, &["foo"])
1149            .unwrap();
1150
1151        assert!(doc["dependencies"].as_table().unwrap().get("foo").is_none());
1152        assert!(doc["dependencies"].as_table().unwrap().get("bar").is_some());
1153    }
1154
1155    #[test]
1156    fn test_dep_section_remove_regular_dependency_not_found() {
1157        let toml_str = r#"
1158            [project]
1159            name = "package"
1160            entry = "main.sw"
1161            license = "Apache-2.0"
1162            authors = ["Fuel Labs"]
1163
1164            [dependencies]
1165            bar = "2.0.0"
1166        "#;
1167
1168        let mut doc: DocumentMut = toml_str.parse().unwrap();
1169
1170        let section = Section::Deps;
1171
1172        let err = section
1173            .remove_deps_manifest_table(&mut doc, &["notfound"])
1174            .unwrap_err()
1175            .to_string();
1176
1177        assert!(err.contains("the dependency `notfound` could not be found in `dependencies`"));
1178    }
1179
1180    #[test]
1181    fn test_dep_section_remove_contract_dependency_success() {
1182        let toml_str = r#"
1183            [project]
1184            name = "package"
1185            entry = "main.sw"
1186            license = "Apache-2.0"
1187            authors = ["Fuel Labs"]
1188
1189            [contract-dependencies]
1190            baz = { path = "../baz", salt = "0x1111111111111111111111111111111111111111111111111111111111111111" }
1191        "#;
1192
1193        let mut doc: DocumentMut = toml_str.parse().unwrap();
1194
1195        let section = Section::ContractDeps;
1196        section
1197            .remove_deps_manifest_table(&mut doc, &["baz"])
1198            .unwrap();
1199
1200        assert!(doc["contract-dependencies"]
1201            .as_table()
1202            .unwrap()
1203            .get("baz")
1204            .is_none());
1205    }
1206
1207    #[test]
1208    fn test_dep_section_remove_contract_dependency_not_found() {
1209        let toml_str = r#"
1210            [project]
1211            name = "package"
1212            entry = "main.sw"
1213            license = "Apache-2.0"
1214            authors = ["Fuel Labs"]
1215
1216            [contract-dependencies]
1217            baz = { path = "../baz", salt = "0x1111111111111111111111111111111111111111111111111111111111111111" }
1218        "#;
1219
1220        let mut doc: DocumentMut = toml_str.parse().unwrap();
1221
1222        let section = Section::ContractDeps;
1223
1224        let result = section.remove_deps_manifest_table(&mut doc, &["ghost"]);
1225        assert!(result.is_err());
1226        assert!(result
1227            .unwrap_err()
1228            .to_string()
1229            .contains("the dependency `ghost` could not be found in `contract-dependencies`"));
1230    }
1231
1232    #[test]
1233    fn test_dep_section_remove_from_missing_section() {
1234        let toml_str = r#"
1235            [project]
1236            authors = ["Fuel Labs <contact@fuel.sh>"]
1237            entry = "main.sw"
1238            license = "Apache-2.0"
1239            name = "package-1"
1240
1241            [dependencies]
1242            foo = "1.0.0"
1243        "#;
1244
1245        let mut doc: DocumentMut = toml_str.parse().unwrap();
1246
1247        let section = Section::ContractDeps;
1248
1249        let result = section.remove_deps_manifest_table(&mut doc, &["ghost"]);
1250
1251        assert!(result.is_err());
1252        assert!(result
1253            .unwrap_err()
1254            .to_string()
1255            .contains("the dependency `ghost` could not be found in `contract-dependencies`"));
1256    }
1257
1258    #[test]
1259    fn test_generate_table_basic_fields() {
1260        let details = DependencyDetails {
1261            version: Some("1.2.3".to_string()),
1262            git: Some("https://github.com/example/repo".to_string()),
1263            branch: Some("main".to_string()),
1264            tag: Some("v1.0.0".to_string()),
1265            rev: Some("deadbeef".to_string()),
1266            path: Some("./lib".to_string()),
1267            ipfs: Some("QmYw...".to_string()),
1268            namespace: None,
1269            package: None,
1270        };
1271
1272        let table = generate_table(&details);
1273
1274        assert_eq!(table.get("version").unwrap().as_str(), Some("1.2.3"));
1275        assert_eq!(
1276            table.get("git").unwrap().as_str(),
1277            Some("https://github.com/example/repo")
1278        );
1279        assert_eq!(table.get("branch").unwrap().as_str(), Some("main"));
1280        assert_eq!(table.get("tag").unwrap().as_str(), Some("v1.0.0"));
1281        assert_eq!(table.get("rev").unwrap().as_str(), Some("deadbeef"));
1282        assert_eq!(table.get("path").unwrap().as_str(), Some("./lib"));
1283        assert_eq!(table.get("cid").unwrap().as_str(), Some("QmYw..."));
1284    }
1285}