Skip to main content

zoi_transaction/
lib.rs

1pub mod rollback;
2
3use anyhow::{Result, anyhow};
4use chrono::Utc;
5use colored::*;
6use std::collections::HashSet;
7use std::fs;
8use std::path::PathBuf;
9use uuid::{Timestamp, Uuid};
10use zoi_audit as audit;
11use zoi_core::{sysroot, types};
12use zoi_install as install;
13use zoi_resolver::local;
14use zoi_uninstall as uninstall;
15
16fn create_shim(link_path: &std::path::Path) -> Result<()> {
17    let zoi_exe = std::env::current_exe()?;
18    zoi_core::utils::symlink_file(&zoi_exe, link_path)
19        .map_err(|e| anyhow!("Failed to create shim: {}", e))
20}
21
22pub(crate) fn get_completions_root(scope: types::Scope, shell: &str) -> Result<std::path::PathBuf> {
23    match scope {
24        types::Scope::User => {
25            let home_dir = zoi_core::utils::get_user_home()
26                .ok_or_else(|| anyhow!("Could not find home directory."))?;
27            Ok(zoi_core::sysroot::apply_sysroot(
28                home_dir.join(".zoi/pkgs/shell").join(shell),
29            ))
30        }
31        types::Scope::System => {
32            if cfg!(target_os = "windows") {
33                Ok(zoi_core::sysroot::apply_sysroot(std::path::PathBuf::from(
34                    format!("C:\\ProgramData\\zoi\\pkgs\\shell\\{}", shell),
35                )))
36            } else {
37                let base = match shell {
38                    "bash" => "/usr/share/bash-completion/completions",
39                    "zsh" => "/usr/share/zsh/site-functions",
40                    "fish" => "/usr/share/fish/vendor_completions.d",
41                    "elvish" => "/usr/share/elvish/lib",
42                    _ => "/usr/local/share/zoi/completions",
43                };
44                Ok(zoi_core::sysroot::apply_sysroot(std::path::PathBuf::from(
45                    base,
46                )))
47            }
48        }
49        types::Scope::Project => {
50            let current_dir = std::env::current_dir()?;
51            Ok(current_dir
52                .join(".zoi")
53                .join("pkgs")
54                .join("shell")
55                .join(shell))
56        }
57    }
58}
59
60pub(crate) fn create_completion_symlink(
61    source: &std::path::Path,
62    link: &std::path::Path,
63) -> Result<()> {
64    if link.exists() || link.is_symlink() {
65        fs::remove_file(link)?;
66    }
67    if let Some(parent) = link.parent() {
68        fs::create_dir_all(parent)?;
69    }
70    #[cfg(unix)]
71    {
72        std::os::unix::fs::symlink(source, link)
73            .map_err(|e| anyhow!("Failed to create completion symlink: {}", e))?;
74    }
75    #[cfg(windows)]
76    {
77        std::os::windows::fs::symlink_file(source, link)
78            .map_err(|e| anyhow!("Failed to create completion symlink: {}", e))?;
79    }
80    Ok(())
81}
82
83/// High-level metadata summarizing a completed or in-progress transaction.
84#[derive(Debug, Clone)]
85pub struct TransactionMetadata {
86    /// The UUID v7 identifier for the transaction.
87    pub id: String,
88    /// The RFC 3339 timestamp when the transaction began.
89    pub start_time: String,
90    /// The number of distinct package operations (install, uninstall, upgrade) recorded.
91    pub operation_count: usize,
92}
93
94fn get_transactions_dir() -> Result<PathBuf> {
95    let home_dir = zoi_core::utils::get_user_home()
96        .ok_or_else(|| anyhow!("Could not find home directory."))?;
97    let dir = zoi_core::sysroot::apply_sysroot(home_dir.join(".zoi")).join("transactions");
98    fs::create_dir_all(&dir)?;
99    Ok(dir)
100}
101
102fn get_transaction_path(id: &str) -> Result<PathBuf> {
103    let dir = get_transactions_dir()?;
104    let active_path = dir.join(format!("{}.json", id));
105    if active_path.exists() {
106        return Ok(active_path);
107    }
108    let history_path = dir.join("history").join(format!("{}.json", id));
109    Ok(history_path)
110}
111
112/// Starts a new package transaction.
113///
114/// Returns a `Transaction` object with a UUID v7 ID, which provides both
115/// uniqueness and chronological sorting. No log file is written until the
116/// first operation is recorded.
117pub fn begin() -> Result<types::Transaction> {
118    Ok(types::Transaction {
119        id: Uuid::new_v7(Timestamp::from_unix(
120            uuid::NoContext,
121            Utc::now().timestamp_millis() as u64,
122            0,
123        ))
124        .to_string(),
125        start_time: Utc::now().to_rfc3339(),
126        operations: Vec::new(),
127    })
128}
129
130pub fn read_transaction(transaction_id: &str) -> Result<types::Transaction> {
131    let path = get_transaction_path(transaction_id)?;
132    if !path.exists() {
133        return Err(anyhow!(
134            "Transaction log not found for ID: {}",
135            transaction_id
136        ));
137    }
138    let content = fs::read_to_string(path)?;
139    Ok(serde_json::from_str(&content)?)
140}
141
142pub fn record_operation(
143    transaction: &mut types::Transaction,
144    operation: types::TransactionOperation,
145) -> Result<()> {
146    match &operation {
147        types::TransactionOperation::Install { manifest } => {
148            audit::log_event(audit::AuditAction::Install, manifest)?;
149        }
150        types::TransactionOperation::Uninstall { manifest } => {
151            audit::log_event(audit::AuditAction::Uninstall, manifest)?;
152        }
153        types::TransactionOperation::Upgrade {
154            old_manifest: _,
155            new_manifest,
156        } => {
157            audit::log_event(audit::AuditAction::Upgrade, new_manifest)?;
158        }
159    }
160
161    transaction.operations.push(operation);
162
163    let path = get_transactions_dir()?.join(format!("{}.json", transaction.id));
164    let content = serde_json::to_string_pretty(&transaction)?;
165    fs::write(path, content)?;
166    Ok(())
167}
168
169pub fn commit(transaction_id: &str) -> Result<()> {
170    let dir = get_transactions_dir()?;
171    let path = dir.join(format!("{}.json", transaction_id));
172    if !path.exists() {
173        return Ok(());
174    }
175
176    let history_dir = dir.join("history");
177    fs::create_dir_all(&history_dir)?;
178    let dest = history_dir.join(format!("{}.json", transaction_id));
179    fs::rename(path, dest)?;
180    Ok(())
181}
182
183pub fn get_modified_files(transaction_id: &str) -> Result<Vec<String>> {
184    let path = get_transaction_path(transaction_id)?;
185    if !path.exists() {
186        return Ok(Vec::new());
187    }
188    let content = fs::read_to_string(&path)?;
189    let transaction: types::Transaction = serde_json::from_str(&content)?;
190
191    let mut files = HashSet::new();
192    for op in transaction.operations {
193        match op {
194            types::TransactionOperation::Install { manifest } => {
195                for file in manifest.installed_files {
196                    files.insert(file);
197                }
198            }
199            types::TransactionOperation::Uninstall { manifest } => {
200                for file in manifest.installed_files {
201                    files.insert(file);
202                }
203            }
204            types::TransactionOperation::Upgrade {
205                old_manifest,
206                new_manifest,
207            } => {
208                for file in old_manifest.installed_files {
209                    files.insert(file);
210                }
211                for file in new_manifest.installed_files {
212                    files.insert(file);
213                }
214            }
215        }
216    }
217    Ok(files.into_iter().collect())
218}
219
220pub fn get_modified_packages(transaction_id: &str) -> Result<Vec<String>> {
221    let path = get_transaction_path(transaction_id)?;
222    if !path.exists() {
223        return Ok(Vec::new());
224    }
225    let content = fs::read_to_string(&path)?;
226    let transaction: types::Transaction = serde_json::from_str(&content)?;
227
228    let mut packages = HashSet::new();
229    for op in transaction.operations {
230        match op {
231            types::TransactionOperation::Install { manifest } => {
232                packages.insert(manifest.name);
233            }
234            types::TransactionOperation::Uninstall { manifest } => {
235                packages.insert(manifest.name);
236            }
237            types::TransactionOperation::Upgrade {
238                old_manifest,
239                new_manifest,
240            } => {
241                packages.insert(old_manifest.name);
242                packages.insert(new_manifest.name);
243            }
244        }
245    }
246    Ok(packages.into_iter().collect())
247}
248
249pub fn delete_log(transaction_id: &str) -> Result<()> {
250    let path = get_transaction_path(transaction_id)?;
251    if path.exists() {
252        fs::remove_file(path)?;
253    }
254    Ok(())
255}
256
257pub fn list_transactions() -> Result<Vec<TransactionMetadata>> {
258    let dir = get_transactions_dir()?;
259    if !dir.exists() {
260        return Ok(Vec::new());
261    }
262
263    let mut transactions = Vec::new();
264    for entry in fs::read_dir(dir)? {
265        let entry = entry?;
266        let path = entry.path();
267        if !path.is_file() || path.extension().and_then(|s| s.to_str()) != Some("json") {
268            continue;
269        }
270
271        let content = fs::read_to_string(&path)?;
272        let transaction: types::Transaction = serde_json::from_str(&content)?;
273        transactions.push(TransactionMetadata {
274            id: transaction.id,
275            start_time: transaction.start_time,
276            operation_count: transaction.operations.len(),
277        });
278    }
279
280    transactions.sort_by(|a, b| b.start_time.cmp(&a.start_time));
281    Ok(transactions)
282}
283
284fn has_files_outside_store(manifest: &types::InstallManifest) -> bool {
285    if let Ok(store_base) = local::get_store_base_dir(manifest.scope) {
286        for file in &manifest.installed_files {
287            let p = std::path::Path::new(file);
288            if !p.starts_with(&store_base) {
289                return true;
290            }
291        }
292    }
293    false
294}
295
296fn install_source_for_manifest(manifest: &types::InstallManifest) -> String {
297    local::installed_manifest_source(manifest)
298}
299
300fn restore_shims(manifest: &types::InstallManifest) -> Result<()> {
301    if let Some(bins) = &manifest.bins {
302        let bin_root = match manifest.scope {
303            types::Scope::User => {
304                let home = zoi_core::utils::get_user_home()
305                    .ok_or_else(|| anyhow!("Could not find home directory."))?;
306                sysroot::apply_sysroot(home.join(".zoi/pkgs/bin"))
307            }
308            types::Scope::System => {
309                if cfg!(target_os = "windows") {
310                    sysroot::apply_sysroot(PathBuf::from("C:\\ProgramData\\zoi\\pkgs\\bin"))
311                } else {
312                    sysroot::apply_sysroot(PathBuf::from("/usr/local/bin"))
313                }
314            }
315            types::Scope::Project => {
316                let current_dir = std::env::current_dir()?;
317                current_dir.join(".zoi").join("pkgs").join("bin")
318            }
319        };
320
321        if !bin_root.exists() {
322            fs::create_dir_all(&bin_root)?;
323        }
324
325        for bin in bins {
326            let shim_path = bin_root.join(bin);
327            create_shim(&shim_path)?;
328        }
329    }
330    Ok(())
331}
332
333/// Reverts all operations recorded in a transaction.
334///
335/// This is the "Atomic Rollback" mechanism. It processes operations in reverse order:
336/// - Installs are uninstalled.
337/// - Uninstalls are re-installed (either from local store or registry).
338/// - Upgrades are reverted to the previous version.
339pub fn rollback(transaction_id: &str) -> Result<()> {
340    let path = get_transaction_path(transaction_id)?;
341    if !path.exists() {
342        return Ok(());
343    }
344    let content = fs::read_to_string(&path)?;
345    let transaction: types::Transaction = serde_json::from_str(&content)?;
346
347    println!("\n{} Starting Rollback...", "::".bold().blue());
348
349    for operation in transaction.operations.iter().rev() {
350        match operation {
351            types::TransactionOperation::Install { manifest } => {
352                println!(
353                    "Rolling back installation of {} v{}...",
354                    manifest.name.cyan(),
355                    manifest.version.yellow()
356                );
357                let source = install_source_for_manifest(manifest);
358                if let Err(e) = uninstall::run(&source, Some(manifest.scope), true, false, false) {
359                    eprintln!(
360                        "{} Failed to rollback install of '{}': {}",
361                        "Error:".red().bold(),
362                        manifest.name,
363                        e
364                    );
365                }
366            }
367            types::TransactionOperation::Uninstall { manifest } => {
368                println!(
369                    "Rolling back uninstallation of {} v{}...",
370                    manifest.name.cyan(),
371                    manifest.version.yellow()
372                );
373
374                let version_dir = match local::get_package_version_dir(
375                    manifest.scope,
376                    &manifest.registry_handle,
377                    &manifest.repo,
378                    &manifest.name,
379                    &manifest.version,
380                ) {
381                    Ok(dir) => dir,
382                    Err(e) => {
383                        eprintln!(
384                            "{} Failed to get version directory for rollback: {}",
385                            "Error:".red().bold(),
386                            e
387                        );
388                        continue;
389                    }
390                };
391
392                let manifest_filename = if let Some(sub) = &manifest.sub_package {
393                    format!("manifest-{}.yaml", sub)
394                } else {
395                    "manifest.yaml".to_string()
396                };
397                let manifest_path = version_dir.join(&manifest_filename);
398
399                if version_dir.exists()
400                    && manifest_path.exists()
401                    && !has_files_outside_store(manifest)
402                {
403                    println!("Restoring version {} from local store...", manifest.version);
404                    if let Err(e) = local::write_manifest(manifest) {
405                        eprintln!(
406                            "{} Failed to restore manifest for '{}': {}",
407                            "Error:".red().bold(),
408                            manifest.name,
409                            e
410                        );
411                    }
412                    let _ = restore_shims(manifest);
413                    continue;
414                }
415
416                println!(
417                    "Version not found locally or contains global files. Re-installing from registry..."
418                );
419
420                let source = install_source_for_manifest(manifest);
421                let (graph, _) = match install::resolver::resolve_dependency_graph(
422                    &[source],
423                    Some(manifest.scope),
424                    true,
425                    true,
426                    true,
427                    None,
428                    true,
429                ) {
430                    Ok(res) => res,
431                    Err(e) => {
432                        eprintln!(
433                            "{} Failed to resolve dependency graph for rollback of '{}': {}",
434                            "Error:".red().bold(),
435                            manifest.name,
436                            e
437                        );
438                        continue;
439                    }
440                };
441
442                let install_plan =
443                    match install::plan::create_install_plan(&graph.nodes, None, false) {
444                        Ok(plan) => plan,
445                        Err(e) => {
446                            eprintln!(
447                                "{} Failed to create install plan for rollback of '{}': {}",
448                                "Error:".red().bold(),
449                                manifest.name,
450                                e
451                            );
452                            continue;
453                        }
454                    };
455
456                let stages = match graph.toposort() {
457                    Ok(s) => s,
458                    Err(e) => {
459                        eprintln!(
460                            "{} Failed to sort dependency graph for rollback of '{}': {}",
461                            "Error:".red().bold(),
462                            manifest.name,
463                            e
464                        );
465                        continue;
466                    }
467                };
468
469                for stage in stages {
470                    for id in stage {
471                        let Some(node) = graph.nodes.get(&id) else {
472                            continue;
473                        };
474                        if let Some(action) = install_plan.get(&id)
475                            && let Err(e) = install::installer::install_node(
476                                node, action, None, None, true, true, true, false,
477                            )
478                        {
479                            eprintln!(
480                                "{} Failed to re-install during rollback of '{}': {}",
481                                "Error:".red().bold(),
482                                manifest.name,
483                                e
484                            );
485                        }
486                    }
487                }
488            }
489            types::TransactionOperation::Upgrade {
490                old_manifest,
491                new_manifest,
492            } => {
493                println!(
494                    "Rolling back upgrade of {} from {} to {}...",
495                    old_manifest.name.cyan(),
496                    new_manifest.version.yellow(),
497                    old_manifest.version.green()
498                );
499                let source = install_source_for_manifest(new_manifest);
500                if let Err(e) =
501                    uninstall::run(&source, Some(new_manifest.scope), true, false, false)
502                {
503                    eprintln!(
504                        "{} Failed to uninstall new version during upgrade-rollback for '{}': {}",
505                        "Error:".red().bold(),
506                        new_manifest.name,
507                        e
508                    );
509                }
510
511                let version_dir = match local::get_package_version_dir(
512                    old_manifest.scope,
513                    &old_manifest.registry_handle,
514                    &old_manifest.repo,
515                    &old_manifest.name,
516                    &old_manifest.version,
517                ) {
518                    Ok(dir) => dir,
519                    Err(e) => {
520                        eprintln!(
521                            "{} Failed to get version directory for rollback: {}",
522                            "Error:".red().bold(),
523                            e
524                        );
525                        continue;
526                    }
527                };
528
529                let manifest_filename = if let Some(sub) = &old_manifest.sub_package {
530                    format!("manifest-{}.yaml", sub)
531                } else {
532                    "manifest.yaml".to_string()
533                };
534                let manifest_path = version_dir.join(&manifest_filename);
535
536                if version_dir.exists()
537                    && manifest_path.exists()
538                    && !has_files_outside_store(old_manifest)
539                {
540                    println!(
541                        "Restoring version {} from local store...",
542                        old_manifest.version
543                    );
544                    if let Err(e) = local::write_manifest(old_manifest) {
545                        eprintln!(
546                            "{} Failed to restore manifest for '{}': {}",
547                            "Error:".red().bold(),
548                            old_manifest.name,
549                            e
550                        );
551                    }
552                    let _ = restore_shims(old_manifest);
553                    continue;
554                }
555
556                println!(
557                    "Version not found locally or contains global files. Re-installing from registry..."
558                );
559
560                let source = install_source_for_manifest(old_manifest);
561                let (graph, _) = match install::resolver::resolve_dependency_graph(
562                    std::slice::from_ref(&source),
563                    Some(old_manifest.scope),
564                    true,
565                    true,
566                    true,
567                    None,
568                    true,
569                ) {
570                    Ok(res) => res,
571                    Err(e) => {
572                        eprintln!(
573                            "{} Failed to resolve dependency graph for rollback of '{}': {}",
574                            "Error:".red().bold(),
575                            old_manifest.name,
576                            e
577                        );
578                        continue;
579                    }
580                };
581
582                let install_plan =
583                    match install::plan::create_install_plan(&graph.nodes, None, false) {
584                        Ok(plan) => plan,
585                        Err(e) => {
586                            eprintln!(
587                                "{} Failed to create install plan for rollback of '{}': {}",
588                                "Error:".red().bold(),
589                                old_manifest.name,
590                                e
591                            );
592                            continue;
593                        }
594                    };
595
596                let stages = match graph.toposort() {
597                    Ok(s) => s,
598                    Err(e) => {
599                        eprintln!(
600                            "{} Failed to sort dependency graph for rollback of '{}': {}",
601                            "Error:".red().bold(),
602                            old_manifest.name,
603                            e
604                        );
605                        continue;
606                    }
607                };
608
609                for stage in stages {
610                    for id in stage {
611                        let Some(node) = graph.nodes.get(&id) else {
612                            continue;
613                        };
614                        if let Some(action) = install_plan.get(&id)
615                            && let Err(e) = install::installer::install_node(
616                                node, action, None, None, true, true, true, false,
617                            )
618                        {
619                            eprintln!(
620                                "{} Failed to re-install during rollback of '{}': {}",
621                                "Error:".red().bold(),
622                                old_manifest.name,
623                                e
624                            );
625                        }
626                    }
627                }
628            }
629        }
630    }
631
632    println!("{}", ":: Rollback Complete".bold().blue());
633    delete_log(transaction_id)?;
634    Ok(())
635}
636
637pub fn get_last_transaction_id() -> Result<Option<String>> {
638    let dir = get_transactions_dir()?;
639    let mut last_modified_time = None;
640    let mut last_transaction_id = None;
641
642    if !dir.exists() {
643        return Ok(None);
644    }
645
646    for entry in fs::read_dir(dir)? {
647        let entry = entry?;
648        let path = entry.path();
649        if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("json") {
650            let metadata = fs::metadata(&path)?;
651            let modified_time = metadata.modified()?;
652
653            if last_modified_time.is_none_or(|last| modified_time > last) {
654                last_modified_time = Some(modified_time);
655                last_transaction_id = path.file_stem().and_then(|s| s.to_str()).map(String::from);
656            }
657        }
658    }
659
660    Ok(last_transaction_id)
661}