Skip to main content

loadsmith_install/ops/
install.rs

1use std::{
2    fmt::Debug,
3    fs::{self},
4    path::Path,
5};
6
7use camino::Utf8PathBuf;
8use loadsmith_core::{Checksum, InstalledFile, InstalledPackage, PackageRef};
9use tracing::{debug, trace};
10use walkdir::WalkDir;
11
12use crate::{
13    InstallRule, InstallRuleset,
14    error::{Error, Result},
15};
16
17/// Install a package's files into a game profile directory.
18///
19/// Walks `source` recursively, maps each file through the given `ruleset`, and
20/// copies (or hard-links) the mapped files into `profile`. Returns the
21/// [`InstalledPackage`](loadsmith_core::InstalledPackage) descriptor and a list
22/// of file paths that were overwritten.
23///
24/// # Examples
25///
26/// ```rust,no_run
27/// use camino::Utf8Path;
28/// use loadsmith_core::{PackageRef, Version, PackageId};
29/// use loadsmith_install::{install, InstallRuleset, InstallRule, GlobRule};
30///
31/// let pkg = PackageRef::new(PackageId::new("denikson-BepInExPack_Valheim"), Version::new(5, 4, 22));
32/// let rule = InstallRule::Glob(
33///     GlobRule::try_from_pattern("**", Utf8Path::new("BepInEx")).unwrap()
34/// );
35/// let rules = [rule];
36/// let ruleset = InstallRuleset::new(&rules);
37/// let (installed, overwritten) = install(
38///     pkg,
39///     ruleset,
40///     "C:\\extracted\\package",
41///     "C:\\games\\Valheim\\profile",
42///     false,
43///     None,
44/// ).unwrap();
45/// ```
46pub fn install(
47    package: PackageRef,
48    ruleset: InstallRuleset,
49    source: impl AsRef<Path>,
50    profile: impl AsRef<Path>,
51    no_links: bool,
52    checksum: Option<Checksum>,
53) -> Result<(InstalledPackage, Vec<Utf8PathBuf>)> {
54    let source = source.as_ref();
55    let profile = profile.as_ref();
56
57    let mut installed_files = Vec::new();
58    let mut overriden_files = Vec::new();
59
60    let walkdir = WalkDir::new(source).follow_links(false).into_iter();
61
62    for entry in walkdir {
63        let entry = entry?;
64
65        if entry.file_type().is_dir() {
66            continue; // we create the necessary dirs when creating files instead
67        }
68
69        let relative_path = entry
70            .path()
71            .strip_prefix(source)
72            .expect("entry path should be relative to source");
73
74        let relative_path = Utf8PathBuf::try_from(relative_path.to_path_buf())?;
75
76        let Some((mapped, rule)) = ruleset.map_file_and_return_rule(&relative_path, &package)
77        else {
78            debug!(%relative_path, "files left unmapped by ruleset, skipping");
79            continue;
80        };
81
82        trace!(
83            from = ?relative_path,
84            to = ?mapped,
85            "mapped file"
86        );
87
88        let (installed, overwrote) =
89            install_file(entry.path(), &profile.join(&mapped), mapped, rule, no_links)?;
90
91        if let Some(installed) = installed {
92            installed_files.push(installed);
93        }
94
95        if overwrote {
96            overriden_files.push(relative_path);
97        }
98    }
99
100    Ok((
101        InstalledPackage::now(package, installed_files, checksum),
102        overriden_files,
103    ))
104}
105
106fn install_file(
107    source: &Path,
108    target: &Path,
109    mapped_relative: Utf8PathBuf,
110    rule: &InstallRule,
111    no_links: bool,
112) -> Result<(Option<InstalledFile>, bool)> {
113    let link = !no_links && rule.use_links();
114    let mut overwrote = false;
115
116    if target.exists() {
117        match rule.conflict_strategy() {
118            ConflictStrategy::Overwrite => {
119                trace!(%mapped_relative, "overwriting existing file");
120                overwrote = true;
121                // fs::copy already overwrites the file, no need to remove it first
122                if link {
123                    fs::remove_file(&target)?;
124                }
125            }
126            ConflictStrategy::Skip => {
127                trace!(%mapped_relative, "skipping existing file");
128                return Ok((None, false));
129            }
130            ConflictStrategy::Error => {
131                return Err(Error::FileAlreadyExists(target.into()));
132            }
133        }
134    }
135
136    loadsmith_util::create_parent_dirs(&target)?;
137
138    if link {
139        trace!(%mapped_relative, "link file");
140        fs::hard_link(source, &target)?;
141    } else {
142        trace!(%mapped_relative, "copy file");
143        fs::copy(source, &target)?;
144    }
145
146    Ok((Some(InstalledFile::new(mapped_relative, link)), overwrote))
147}
148
149/// Strategy for handling file conflicts during installation.
150///
151/// # Examples
152///
153/// ```rust
154/// use loadsmith_install::ConflictStrategy;
155///
156/// let strategy = ConflictStrategy::Overwrite;
157/// assert_eq!(strategy, ConflictStrategy::Overwrite);
158/// ```
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum ConflictStrategy {
161    /// Overwrite the existing file.
162    Overwrite,
163    /// Skip the existing file and keep the original.
164    Skip,
165    /// Return a [`FileAlreadyExists`](crate::Error::FileAlreadyExists) error.
166    Error,
167}