Skip to main content

zoi_transaction/
rollback.rs

1//! Rollback logic for Zoi transactions.
2
3use std::fs;
4use std::path::PathBuf;
5
6use anyhow::{Result, anyhow};
7use colored::Colorize;
8use zoi_core::{types, utils as core_utils};
9use zoi_resolver::{local, resolve};
10
11/// Rolls back a package to its previous version.
12/// # Errors
13///
14/// Returns an error if the rollback fails.
15/// # Panics
16///
17/// Panics if the scope cannot be resolved.
18pub fn run(package_name: &str, yes: bool) -> Result<()> {
19    println!("Attempting to roll back '{}'...", package_name.cyan());
20
21    let request = resolve::parse_source_string(package_name)?;
22    let sub_package = request.sub_package.clone();
23
24    let scope_order = [
25        types::Scope::User,
26        types::Scope::System,
27        types::Scope::Project
28    ];
29    let mut current_manifest = None;
30    let mut scope = None;
31    for candidate_scope in scope_order {
32        let mut matches = local::find_installed_manifests_matching(
33            &request,
34            candidate_scope
35        )?;
36        match matches.len() {
37            0 => {}
38            1 => {
39                current_manifest = Some(matches.remove(0));
40                scope = Some(candidate_scope);
41                break;
42            }
43            _ => {
44                return Err(anyhow!(
45                    "Ambiguous package name '{package_name}' matches multiple \
46                     installed packages.",
47                ));
48            }
49        }
50    }
51
52    let Some(current_manifest) = current_manifest else {
53        return Err(anyhow!("Package '{package_name}' is not installed."));
54    };
55    let scope = scope.expect("Scope should be resolved if manifest is found");
56
57    let package_dir = local::get_package_dir(
58        scope,
59        &current_manifest.registry_handle,
60        &current_manifest.repo,
61        &current_manifest.name
62    )?;
63
64    let current_version = current_manifest.version.clone();
65    let manifest_filename = local::manifest_filename(sub_package.as_deref());
66
67    let mut versions: Vec<String> = Vec::new();
68    if let Ok(entries) = fs::read_dir(&package_dir) {
69        for entry in entries.flatten() {
70            let name = entry.file_name().to_string_lossy().to_string();
71            if name != "latest"
72                && name != "dependents"
73                && name != current_version
74            {
75                versions.push(name);
76            }
77        }
78    }
79
80    if versions.is_empty() {
81        return Err(anyhow!(
82            "No previous versions found for package '{}'.",
83            current_manifest.name
84        ));
85    }
86
87    versions.sort_by(|a, b| {
88        let va = semver::Version::parse(a)
89            .unwrap_or_else(|_| semver::Version::new(0, 0, 0));
90        let vb = semver::Version::parse(b)
91            .unwrap_or_else(|_| semver::Version::new(0, 0, 0));
92        va.cmp(&vb)
93    });
94    let previous_version = versions.last().expect("Versions list is not empty");
95
96    println!(
97        "Rolling back from version {} to {}...",
98        current_version.yellow(),
99        previous_version.green()
100    );
101
102    let prev_version_dir = package_dir.join(previous_version);
103    let prev_manifest_path =
104        local::find_store_manifest(&prev_version_dir, sub_package.as_deref())
105            .or_else(|| {
106                // Fall back to the precomputed name for a precise error below.
107                let candidate = prev_version_dir.join(&manifest_filename);
108                candidate.exists().then_some(candidate)
109            });
110    let Some(prev_manifest_path) = prev_manifest_path else {
111        return Err(anyhow!(
112            "Previous manifest not found in: {}",
113            prev_version_dir.display()
114        ));
115    };
116
117    let prev_manifest = local::read_store_manifest(&prev_manifest_path)?;
118
119    if !yes
120        && !core_utils::ask_for_confirmation("Do you want to proceed?", false)
121    {
122        return Err(anyhow!("Rollback aborted by user."));
123    }
124
125    for file_path_str in &prev_manifest.installed_files {
126        let file_path = std::path::Path::new(file_path_str);
127        create_shim(file_path);
128    }
129
130    if let Some(completions) = &prev_manifest.completions {
131        for completion in completions {
132            let store_path = package_dir
133                .join(previous_version)
134                .join("data/shell")
135                .join(&completion.shell)
136                .join(&completion.filename);
137            let completions_root =
138                super::get_completions_root(scope, &completion.shell)?;
139            let pkg_dir = completions_root.join(&prev_manifest.name);
140            let link_path = pkg_dir.join(&completion.filename);
141            if store_path.exists() {
142                let _ =
143                    super::create_completion_symlink(&store_path, &link_path);
144            }
145        }
146    }
147
148    let current_version_dir = package_dir.join(&current_version);
149    let mut has_other_manifests = false;
150    if let Ok(entries) = fs::read_dir(&current_version_dir) {
151        for entry in entries.flatten() {
152            let name = entry.file_name().to_string_lossy().to_string();
153            if name.starts_with("manifest")
154                && std::path::Path::new(&name).extension().is_some_and(|ext| {
155                    ext.eq_ignore_ascii_case("json")
156                        || ext.eq_ignore_ascii_case("yaml")
157                })
158                && name != manifest_filename
159            {
160                has_other_manifests = true;
161                break;
162            }
163        }
164    }
165
166    if !has_other_manifests {
167        for file_path_str in &current_manifest.installed_files {
168            // Manifest entries use placeholders such as ${usrroot}, so they
169            // must be expanded before any filesystem lookup.
170            let expanded = core_utils::expand_placeholders(
171                file_path_str,
172                &current_version_dir,
173                scope
174            )?;
175            let file_path = PathBuf::from(expanded);
176            // Use symlink_metadata so links are removed even when they dangle
177            // or point to a directory; only the link is deleted.
178            let Ok(meta) = fs::symlink_metadata(&file_path) else {
179                continue;
180            };
181            if meta.file_type().is_symlink() {
182                let _ = fs::remove_file(&file_path);
183            } else if meta.is_dir() {
184                // Only remove if empty to be safe: a populated directory may
185                // contain files that no longer belong to this package.
186                if fs::read_dir(&file_path)
187                    .is_ok_and(|mut entries| entries.next().is_none())
188                {
189                    let _ = fs::remove_dir(&file_path);
190                }
191            } else {
192                let _ = fs::remove_file(&file_path);
193            }
194        }
195    }
196
197    let current_manifest_path = current_version_dir.join(&manifest_filename);
198    if current_manifest_path.exists() {
199        fs::remove_file(current_manifest_path)?;
200    }
201
202    if !has_other_manifests && current_version_dir.exists() {
203        let _ = fs::remove_dir_all(current_version_dir);
204    }
205
206    let latest_link = package_dir.join("latest");
207    if latest_link.exists() || latest_link.is_symlink() {
208        let _ = fs::remove_file(&latest_link);
209    }
210
211    #[cfg(unix)]
212    std::os::unix::fs::symlink(previous_version, latest_link)?;
213    #[cfg(windows)]
214    let _ = std::os::windows::fs::symlink_dir(previous_version, latest_link);
215
216    println!(
217        "{} Successfully rolled back {} to version {}.",
218        "::".bold().green(),
219        current_manifest.name.cyan(),
220        previous_version.green()
221    );
222
223    Ok(())
224}
225
226/// Searches for a binary in the previous manifest to determine if a shim is
227/// needed.
228#[allow(dead_code)]
229fn find_binary_in_prev_manifest(_path: &std::path::Path) -> Option<PathBuf> {
230    None
231}
232
233/// Ensures a shim or binary entry exists at the given path.
234fn create_shim(path: &std::path::Path) {
235    let _ = zoi_install::shim::create_shim(path);
236}