Skip to main content

zoi_transaction/
lib.rs

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