forc_pkg/
lock.rs

1use crate::{pkg, source, DepKind, Edge};
2use anyhow::{anyhow, Result};
3use forc_tracing::{println_action_green, println_action_red};
4use petgraph::{visit::EdgeRef, Direction};
5use serde::{Deserialize, Serialize};
6use std::{
7    borrow::Cow,
8    collections::{BTreeSet, HashMap, HashSet},
9    fs,
10    path::Path,
11    str::FromStr,
12};
13use sway_core::fuel_prelude::fuel_tx;
14
15/// The graph of pinned packages represented as a toml-serialization-friendly structure.
16#[derive(Debug, Default, Deserialize, Serialize)]
17pub struct Lock {
18    // Named `package` so that each entry serializes to lock file under `[[package]]` like cargo.
19    pub(crate) package: BTreeSet<PkgLock>,
20}
21
22/// Packages that have been removed and added between two `Lock` instances.
23///
24/// The result of `new_lock.diff(&old_lock)`.
25pub struct Diff<'a> {
26    pub removed: BTreeSet<&'a PkgLock>,
27    pub added: BTreeSet<&'a PkgLock>,
28}
29
30#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
31#[serde(rename_all = "kebab-case")]
32pub struct PkgLock {
33    pub(crate) name: String,
34    // TODO: Cargo *always* includes version, whereas we don't even parse it when reading a
35    // project's `Manifest` yet. If we decide to enforce versions, we'll want to remove the
36    // `Option`.
37    version: Option<semver::Version>,
38    // Short-hand string describing where this package is sourced from.
39    source: String,
40    dependencies: Option<Vec<PkgDepLine>>,
41    contract_dependencies: Option<Vec<PkgDepLine>>,
42}
43
44/// `PkgDepLine` is a terse, single-line, git-diff-friendly description of a package's
45/// dependency. It is formatted like so:
46///
47/// ```ignore
48/// (<dep_name>) <pkg_name> <source_string> (<salt>)
49/// ```
50///
51/// The `(<dep_name>)` segment is only included in the uncommon case that the dependency name does
52/// not match the package name, i.e. if the `package` field was specified for the dependency.
53///
54/// The source string is included in order to be able to uniquely distinguish between multiple
55/// different versions of the same package.
56pub type PkgDepLine = String;
57
58impl PkgLock {
59    /// Construct a package lock given a package's entry in the package graph.
60    pub fn from_node(graph: &pkg::Graph, node: pkg::NodeIx, disambiguate: &HashSet<&str>) -> Self {
61        let pinned = &graph[node];
62        let name = pinned.name.clone();
63        let version = pinned.source.semver();
64        let source = pinned.source.to_string();
65        // Collection of all dependencies, so this includes both contract-dependencies and
66        // lib-dependencies
67        let all_dependencies: Vec<(String, DepKind)> = graph
68            .edges_directed(node, Direction::Outgoing)
69            .map(|edge| {
70                let dep_edge = edge.weight();
71                let dep_node = edge.target();
72                let dep_pkg = &graph[dep_node];
73                let dep_name = if *dep_edge.name != dep_pkg.name {
74                    Some(&dep_edge.name[..])
75                } else {
76                    None
77                };
78                let dep_kind = &dep_edge.kind;
79                let disambiguate = disambiguate.contains(&dep_pkg.name[..]);
80                (
81                    pkg_dep_line(
82                        dep_name,
83                        &dep_pkg.name,
84                        &dep_pkg.source,
85                        dep_kind,
86                        disambiguate,
87                    ),
88                    dep_kind.clone(),
89                )
90            })
91            .collect();
92        let mut dependencies: Vec<String> = all_dependencies
93            .iter()
94            .filter_map(|(dep_pkg, dep_kind)| {
95                (*dep_kind == DepKind::Library).then_some(dep_pkg.clone())
96            })
97            .collect();
98        let mut contract_dependencies: Vec<String> = all_dependencies
99            .iter()
100            .filter_map(|(dep_pkg, dep_kind)| {
101                matches!(*dep_kind, DepKind::Contract { .. }).then_some(dep_pkg.clone())
102            })
103            .collect();
104        dependencies.sort();
105        contract_dependencies.sort();
106
107        let dependencies = if !dependencies.is_empty() {
108            Some(dependencies)
109        } else {
110            None
111        };
112
113        let contract_dependencies = if !contract_dependencies.is_empty() {
114            Some(contract_dependencies)
115        } else {
116            None
117        };
118
119        Self {
120            name,
121            version,
122            source,
123            dependencies,
124            contract_dependencies,
125        }
126    }
127
128    /// A string that uniquely identifies a package and its source.
129    ///
130    /// Formatted as `<name> <source>`.
131    pub fn unique_string(&self) -> String {
132        pkg_unique_string(&self.name, &self.source)
133    }
134
135    /// The string representation used for specifying this package as a dependency.
136    ///
137    /// If this package's name is not enough to disambiguate it from other packages within the
138    /// graph, this returns `<name> <source>`. If it is, it simply returns the name.
139    pub fn name_disambiguated(&self, disambiguate: &HashSet<&str>) -> Cow<str> {
140        let disambiguate = disambiguate.contains(&self.name[..]);
141        pkg_name_disambiguated(&self.name, &self.source, disambiguate)
142    }
143}
144
145/// Represents a `DepKind` before getting parsed.
146///
147/// Used to carry on the type of the `DepKind` until parsing. After parsing pkg_dep_line converted into `DepKind`.
148enum UnparsedDepKind {
149    Library,
150    Contract,
151}
152
153impl Lock {
154    /// Load the `Lock` structure from the TOML `Forc.lock` file at the specified path.
155    pub fn from_path(path: &Path) -> Result<Self> {
156        let string = fs::read_to_string(path)
157            .map_err(|e| anyhow!("failed to read {}: {}", path.display(), e))?;
158        toml::de::from_str(&string).map_err(|e| anyhow!("failed to parse lock file: {}", e))
159    }
160
161    /// Given a graph of pinned packages, create a `Lock` representing the `Forc.lock` file
162    /// structure.
163    pub fn from_graph(graph: &pkg::Graph) -> Self {
164        let names = graph.node_indices().map(|n| &graph[n].name[..]);
165        let disambiguate: HashSet<_> = names_requiring_disambiguation(names).collect();
166        // Collect the packages.
167        let package: BTreeSet<_> = graph
168            .node_indices()
169            .map(|node| PkgLock::from_node(graph, node, &disambiguate))
170            .collect();
171        Self { package }
172    }
173
174    /// Given a `Lock` loaded from a `Forc.lock` file, produce the graph of pinned dependencies.
175    pub fn to_graph(&self) -> Result<pkg::Graph> {
176        let mut graph = pkg::Graph::new();
177
178        // Track the names which need to be disambiguated in the dependency list.
179        let names = self.package.iter().map(|pkg| &pkg.name[..]);
180        let disambiguate: HashSet<_> = names_requiring_disambiguation(names).collect();
181
182        // Add all nodes to the graph.
183        // Keep track of "<name> <source>" to node-index mappings for the edge collection pass.
184        let mut pkg_to_node: HashMap<String, pkg::NodeIx> = HashMap::new();
185        for pkg in &self.package {
186            // Note: `key` may be either `<name> <source>` or just `<name>` if disambiguation not
187            // required.
188            let key = pkg.name_disambiguated(&disambiguate).into_owned();
189            let name = pkg.name.clone();
190            let source: source::Pinned = pkg.source.parse().map_err(|e| {
191                anyhow!("invalid 'source' entry for package {} lock: {:?}", name, e)
192            })?;
193            let pkg = pkg::Pinned { name, source };
194            let node = graph.add_node(pkg);
195            pkg_to_node.insert(key, node);
196        }
197
198        // On the second pass, add all edges.
199        for pkg in &self.package {
200            let key = pkg.name_disambiguated(&disambiguate);
201            let node = pkg_to_node[&key[..]];
202            // If `pkg.contract_dependencies` is None, we will be collecting an empty list of
203            // contract_deps so that we will omit them during edge adding phase
204            let contract_deps = pkg
205                .contract_dependencies
206                .as_ref()
207                .into_iter()
208                .flatten()
209                .map(|contract_dep| (contract_dep, UnparsedDepKind::Contract));
210            // If `pkg.dependencies` is None, we will be collecting an empty list of
211            // lib_deps so that we will omit them during edge adding phase
212            let lib_deps = pkg
213                .dependencies
214                .as_ref()
215                .into_iter()
216                .flatten()
217                .map(|lib_dep| (lib_dep, UnparsedDepKind::Library));
218            for (dep_line, dep_kind) in lib_deps.chain(contract_deps) {
219                let (dep_name, dep_key, dep_salt) = parse_pkg_dep_line(dep_line)
220                    .map_err(|e| anyhow!("failed to parse dependency \"{}\": {}", dep_line, e))?;
221                let dep_node = pkg_to_node
222                    .get(dep_key)
223                    .copied()
224                    .ok_or_else(|| anyhow!("found dep {} without node entry in graph", dep_key))?;
225                let dep_name = dep_name.unwrap_or(&graph[dep_node].name).to_string();
226                let dep_kind = match dep_kind {
227                    UnparsedDepKind::Library => DepKind::Library,
228                    UnparsedDepKind::Contract => {
229                        let dep_salt = dep_salt.unwrap_or_default();
230                        DepKind::Contract { salt: dep_salt }
231                    }
232                };
233                let dep_edge = Edge::new(dep_name, dep_kind);
234                graph.update_edge(node, dep_node, dep_edge);
235            }
236        }
237
238        Ok(graph)
239    }
240
241    /// Create a diff between `self` and the `old` `Lock`.
242    ///
243    /// Useful for showing the user which dependencies are out of date, or which have been updated.
244    pub fn diff<'a>(&'a self, old: &'a Self) -> Diff<'a> {
245        let added = self.package.difference(&old.package).collect();
246        let removed = old.package.difference(&self.package).collect();
247        Diff { added, removed }
248    }
249}
250
251/// Collect the set of package names that require disambiguation.
252fn names_requiring_disambiguation<'a, I>(names: I) -> impl Iterator<Item = &'a str>
253where
254    I: IntoIterator<Item = &'a str>,
255{
256    let mut visited = BTreeSet::default();
257    names.into_iter().filter(move |&name| !visited.insert(name))
258}
259
260fn pkg_name_disambiguated<'a>(name: &'a str, source: &'a str, disambiguate: bool) -> Cow<'a, str> {
261    match disambiguate {
262        true => Cow::Owned(pkg_unique_string(name, source)),
263        false => Cow::Borrowed(name),
264    }
265}
266
267fn pkg_unique_string(name: &str, source: &str) -> String {
268    format!("{name} {source}")
269}
270
271fn pkg_dep_line(
272    dep_name: Option<&str>,
273    name: &str,
274    source: &source::Pinned,
275    dep_kind: &DepKind,
276    disambiguate: bool,
277) -> PkgDepLine {
278    // Only include the full unique string in the case that this dep requires disambiguation.
279    let source_string = source.to_string();
280    let pkg_string = pkg_name_disambiguated(name, &source_string, disambiguate);
281    // Prefix the dependency name if it differs from the package name.
282    let pkg_string = match dep_name {
283        None => pkg_string.into_owned(),
284        Some(dep_name) => format!("({dep_name}) {pkg_string}"),
285    };
286    // Append the salt if dep_kind is DepKind::Contract.
287    match dep_kind {
288        DepKind::Library => pkg_string,
289        DepKind::Contract { salt } => {
290            if *salt == fuel_tx::Salt::zeroed() {
291                pkg_string
292            } else {
293                format!("{pkg_string} ({salt})")
294            }
295        }
296    }
297}
298
299type ParsedPkgLine<'a> = (Option<&'a str>, &'a str, Option<fuel_tx::Salt>);
300// Parse the given `PkgDepLine` into its dependency name and unique string segments.
301//
302// I.e. given "(<dep_name>) <name> <source> (<salt>)", returns ("<dep_name>", "<name> <source>", "<salt>").
303//
304// Note that <source> may not appear in the case it is not required for disambiguation.
305fn parse_pkg_dep_line(pkg_dep_line: &str) -> anyhow::Result<ParsedPkgLine> {
306    let s = pkg_dep_line.trim();
307    let (dep_name, s) = match s.starts_with('(') {
308        false => (None, s),
309        true => {
310            // If we have the open bracket, grab everything until the closing bracket.
311            let s = &s["(".len()..];
312            let mut iter = s.split(')');
313            let dep_name = iter
314                .next()
315                .ok_or_else(|| anyhow!("missing closing parenthesis"))?;
316            // The rest is the unique package string and possibly the salt.
317            let s = &s[dep_name.len() + ")".len()..];
318            (Some(dep_name), s)
319        }
320    };
321
322    // Check for salt.
323    let mut iter = s.split('(');
324    let pkg_str = iter
325        .next()
326        .ok_or_else(|| anyhow!("missing pkg string"))?
327        .trim();
328    let salt_str = iter.next().map(|s| s.trim()).map(|s| &s[..s.len() - 1]);
329    let salt = match salt_str {
330        Some(salt_str) => Some(
331            fuel_tx::Salt::from_str(salt_str)
332                .map_err(|e| anyhow!("invalid salt in lock file: {e}"))?,
333        ),
334        None => None,
335    };
336
337    Ok((dep_name, pkg_str, salt))
338}
339
340pub fn print_diff(member_names: &HashSet<String>, diff: &Diff) {
341    print_removed_pkgs(member_names, diff.removed.iter().copied());
342    print_added_pkgs(member_names, diff.added.iter().copied());
343}
344
345pub fn print_removed_pkgs<'a, I>(member_names: &HashSet<String>, removed: I)
346where
347    I: IntoIterator<Item = &'a PkgLock>,
348{
349    for pkg in removed {
350        if !member_names.contains(&pkg.name) {
351            let src = match pkg.source.starts_with(source::git::Pinned::PREFIX) {
352                true => format!(" {}", pkg.source),
353                false => String::new(),
354            };
355            println_action_red(
356                "Removing",
357                &format!("{}{src}", ansiterm::Style::new().bold().paint(&pkg.name)),
358            );
359        }
360    }
361}
362
363pub fn print_added_pkgs<'a, I>(member_names: &HashSet<String>, removed: I)
364where
365    I: IntoIterator<Item = &'a PkgLock>,
366{
367    for pkg in removed {
368        if !member_names.contains(&pkg.name) {
369            let src = match pkg.source.starts_with(source::git::Pinned::PREFIX) {
370                true => format!(" {}", pkg.source),
371                false => "".to_string(),
372            };
373            println_action_green(
374                "Adding",
375                &format!("{}{src}", ansiterm::Style::new().bold().paint(&pkg.name)),
376            );
377        }
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use sway_core::fuel_prelude::fuel_tx;
384
385    use super::parse_pkg_dep_line;
386
387    #[test]
388    fn test_parse_pkg_line_with_salt_with_dep_name() {
389        let pkg_dep_line = "(std2) std path+from-root (0000000000000000000000000000000000000000000000000000000000000000)";
390        let (dep_name, pkg_string, salt) = parse_pkg_dep_line(pkg_dep_line).unwrap();
391        assert_eq!(salt, Some(fuel_tx::Salt::zeroed()));
392        assert_eq!(dep_name, Some("std2"));
393        assert_eq!(pkg_string, "std path+from-root");
394    }
395
396    #[test]
397    fn test_parse_pkg_line_with_salt_without_dep_name() {
398        let pkg_dep_line =
399            "std path+from-root (0000000000000000000000000000000000000000000000000000000000000000)";
400        let (dep_name, pkg_string, salt) = parse_pkg_dep_line(pkg_dep_line).unwrap();
401        assert_eq!(salt, Some(fuel_tx::Salt::zeroed()));
402        assert_eq!(dep_name, None);
403        assert_eq!(pkg_string, "std path+from-root");
404    }
405
406    #[test]
407    fn test_parse_pkg_line_without_salt_with_dep_name() {
408        let pkg_dep_line = "(std2) std path+from-root";
409        let (dep_name, pkg_string, salt) = parse_pkg_dep_line(pkg_dep_line).unwrap();
410        assert_eq!(salt, None);
411        assert_eq!(dep_name, Some("std2"));
412        assert_eq!(pkg_string, "std path+from-root");
413    }
414
415    #[test]
416    fn test_parse_pkg_line_without_salt_without_dep_name() {
417        let pkg_dep_line = "std path+from-root";
418        let (dep_name, pkg_string, salt) = parse_pkg_dep_line(pkg_dep_line).unwrap();
419        assert_eq!(salt, None);
420        assert_eq!(dep_name, None);
421        assert_eq!(pkg_string, "std path+from-root");
422    }
423
424    #[test]
425    #[should_panic]
426    fn test_parse_pkg_line_invalid_salt() {
427        let pkg_dep_line = "std path+from-root (1)";
428        parse_pkg_dep_line(pkg_dep_line).unwrap();
429    }
430}