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::{sysroot, 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 => {
366                if cfg!(target_os = "windows") {
367                    sysroot::apply_sysroot(PathBuf::from(
368                        "C:\\ProgramData\\zoi\\pkgs\\bin"
369                    ))
370                } else {
371                    sysroot::apply_sysroot(PathBuf::from("/usr/local/bin"))
372                }
373            }
374            types::Scope::Project => {
375                let current_dir = std::env::current_dir()?;
376                current_dir.join(".zoi").join("pkgs").join("bin")
377            }
378        };
379
380        if !bin_root.exists() {
381            fs::create_dir_all(&bin_root)?;
382        }
383
384        for bin in bins {
385            let shim_path = bin_root.join(bin);
386            create_shim(&shim_path)?;
387        }
388    }
389    Ok(())
390}
391
392/// Reverts all operations recorded in a transaction.
393///
394/// This is the "Atomic Rollback" mechanism. It processes operations in reverse
395/// order:
396/// - Installs are uninstalled.
397/// - Uninstalls are re-installed (either from local store or registry).
398/// - Upgrades are reverted to the previous version.
399///
400/// # Errors
401///
402/// Returns an error if the transaction log file cannot be read, if the content
403/// is not valid JSON, or if the rollback operation fails.
404pub fn rollback(transaction_id: &str) -> Result<()> {
405    let path = get_transaction_path(transaction_id)?;
406    if !path.exists() {
407        return Ok(());
408    }
409    let content = fs::read_to_string(&path)?;
410    let transaction: types::Transaction = serde_json::from_str(&content)?;
411
412    println!("\n{} Starting Rollback...", "::".bold().blue());
413    let mut rollback_failed = false;
414
415    for operation in transaction.operations.iter().rev() {
416        match operation {
417            types::TransactionOperation::Install { manifest } => {
418                println!(
419                    "Rolling back installation of {} v{}...",
420                    manifest.name.cyan(),
421                    manifest.version.yellow()
422                );
423                let source = install_source_for_manifest(manifest);
424                if let Err(e) = uninstall::run(
425                    &source,
426                    Some(manifest.scope),
427                    true,
428                    false,
429                    false
430                ) {
431                    eprintln!(
432                        "{} Failed to rollback install of '{}': {}",
433                        "Error:".red().bold(),
434                        manifest.name,
435                        e
436                    );
437                    rollback_failed = true;
438                }
439            }
440            types::TransactionOperation::Uninstall { manifest } => {
441                println!(
442                    "Rolling back uninstallation of {} v{}...",
443                    manifest.name.cyan(),
444                    manifest.version.yellow()
445                );
446
447                let version_dir = match local::get_package_version_dir(
448                    manifest.scope,
449                    &manifest.registry_handle,
450                    &manifest.repo,
451                    &manifest.name,
452                    &manifest.version
453                ) {
454                    Ok(dir) => dir,
455                    Err(e) => {
456                        eprintln!(
457                            "{} Failed to get version directory for rollback: \
458                             {}",
459                            "Error:".red().bold(),
460                            e
461                        );
462                        rollback_failed = true;
463                        continue;
464                    }
465                };
466
467                let manifest_filename = if let Some(sub) = &manifest.sub_package
468                {
469                    format!("manifest-{sub}.yaml")
470                } else {
471                    "manifest.yaml".to_string()
472                };
473                let manifest_path = version_dir.join(&manifest_filename);
474
475                if version_dir.exists()
476                    && manifest_path.exists()
477                    && !has_files_outside_store(manifest)
478                {
479                    println!(
480                        "Restoring version {} from local store...",
481                        manifest.version
482                    );
483                    if let Err(e) = local::write_manifest(manifest) {
484                        eprintln!(
485                            "{} Failed to restore manifest for '{}': {}",
486                            "Error:".red().bold(),
487                            manifest.name,
488                            e
489                        );
490                        rollback_failed = true;
491                    }
492                    if let Err(e) = restore_shims(manifest) {
493                        eprintln!(
494                            "{} Failed to restore shims for '{}': {}",
495                            "Error:".red().bold(),
496                            manifest.name,
497                            e
498                        );
499                        rollback_failed = true;
500                    }
501                    continue;
502                }
503
504                println!(
505                    "Version not found locally or contains global files. \
506                     Re-installing from registry..."
507                );
508
509                let source = install_source_for_manifest(manifest);
510                let (graph, _) =
511                    match install::resolver::resolve_dependency_graph(
512                        &[source],
513                        Some(manifest.scope),
514                        true,
515                        true,
516                        true,
517                        None,
518                        true,
519                        None
520                    ) {
521                        Ok(res) => res,
522                        Err(e) => {
523                            eprintln!(
524                                "{} Failed to resolve dependency graph for \
525                                 rollback of '{}': {}",
526                                "Error:".red().bold(),
527                                manifest.name,
528                                e
529                            );
530                            rollback_failed = true;
531                            continue;
532                        }
533                    };
534
535                let install_plan = match install::plan::create_install_plan(
536                    &graph.nodes,
537                    None,
538                    false
539                ) {
540                    Ok(plan) => plan,
541                    Err(e) => {
542                        eprintln!(
543                            "{} Failed to create install plan for rollback of \
544                             '{}': {}",
545                            "Error:".red().bold(),
546                            manifest.name,
547                            e
548                        );
549                        rollback_failed = true;
550                        continue;
551                    }
552                };
553
554                let stages = match graph.toposort() {
555                    Ok(s) => s,
556                    Err(e) => {
557                        eprintln!(
558                            "{} Failed to sort dependency graph for rollback \
559                             of '{}': {}",
560                            "Error:".red().bold(),
561                            manifest.name,
562                            e
563                        );
564                        rollback_failed = true;
565                        continue;
566                    }
567                };
568
569                for stage in stages {
570                    for id in stage {
571                        let Some(node) = graph.nodes.get(&id) else {
572                            continue;
573                        };
574                        if let Some(action) = install_plan.get(&id)
575                            && let Err(e) = install::installer::install_node(
576                                node, action, None, None, true, true, true,
577                                false
578                            )
579                        {
580                            eprintln!(
581                                "{} Failed to re-install during rollback of \
582                                 '{}': {}",
583                                "Error:".red().bold(),
584                                manifest.name,
585                                e
586                            );
587                            rollback_failed = true;
588                        }
589                    }
590                }
591            }
592            types::TransactionOperation::Upgrade {
593                old_manifest,
594                new_manifest
595            } => {
596                println!(
597                    "Rolling back upgrade of {} from {} to {}...",
598                    old_manifest.name.cyan(),
599                    new_manifest.version.yellow(),
600                    old_manifest.version.green()
601                );
602                let source = install_source_for_manifest(new_manifest);
603                if let Err(e) = uninstall::run(
604                    &source,
605                    Some(new_manifest.scope),
606                    true,
607                    false,
608                    false
609                ) {
610                    eprintln!(
611                        "{} Failed to uninstall new version during \
612                         upgrade-rollback for '{}': {}",
613                        "Error:".red().bold(),
614                        new_manifest.name,
615                        e
616                    );
617                    rollback_failed = true;
618                }
619
620                let version_dir = match local::get_package_version_dir(
621                    old_manifest.scope,
622                    &old_manifest.registry_handle,
623                    &old_manifest.repo,
624                    &old_manifest.name,
625                    &old_manifest.version
626                ) {
627                    Ok(dir) => dir,
628                    Err(e) => {
629                        eprintln!(
630                            "{} Failed to get version directory for rollback: \
631                             {}",
632                            "Error:".red().bold(),
633                            e
634                        );
635                        rollback_failed = true;
636                        continue;
637                    }
638                };
639
640                let manifest_filename =
641                    if let Some(sub) = &old_manifest.sub_package {
642                        format!("manifest-{sub}.yaml")
643                    } else {
644                        "manifest.yaml".to_string()
645                    };
646                let manifest_path = version_dir.join(&manifest_filename);
647
648                if version_dir.exists()
649                    && manifest_path.exists()
650                    && !has_files_outside_store(old_manifest)
651                {
652                    println!(
653                        "Restoring version {} from local store...",
654                        old_manifest.version
655                    );
656                    if let Err(e) = local::write_manifest(old_manifest) {
657                        eprintln!(
658                            "{} Failed to restore manifest for '{}': {}",
659                            "Error:".red().bold(),
660                            old_manifest.name,
661                            e
662                        );
663                        rollback_failed = true;
664                    }
665                    if let Err(e) = restore_shims(old_manifest) {
666                        eprintln!(
667                            "{} Failed to restore shims for '{}': {}",
668                            "Error:".red().bold(),
669                            old_manifest.name,
670                            e
671                        );
672                        rollback_failed = true;
673                    }
674                    continue;
675                }
676
677                println!(
678                    "Version not found locally or contains global files. \
679                     Re-installing from registry..."
680                );
681
682                let source = install_source_for_manifest(old_manifest);
683                let (graph, _) =
684                    match install::resolver::resolve_dependency_graph(
685                        std::slice::from_ref(&source),
686                        Some(old_manifest.scope),
687                        true,
688                        true,
689                        true,
690                        None,
691                        true,
692                        None
693                    ) {
694                        Ok(res) => res,
695                        Err(e) => {
696                            eprintln!(
697                                "{} Failed to resolve dependency graph for \
698                                 rollback of '{}': {}",
699                                "Error:".red().bold(),
700                                old_manifest.name,
701                                e
702                            );
703                            rollback_failed = true;
704                            continue;
705                        }
706                    };
707
708                let install_plan = match install::plan::create_install_plan(
709                    &graph.nodes,
710                    None,
711                    false
712                ) {
713                    Ok(plan) => plan,
714                    Err(e) => {
715                        eprintln!(
716                            "{} Failed to create install plan for rollback of \
717                             '{}': {}",
718                            "Error:".red().bold(),
719                            old_manifest.name,
720                            e
721                        );
722                        rollback_failed = true;
723                        continue;
724                    }
725                };
726
727                let stages = match graph.toposort() {
728                    Ok(s) => s,
729                    Err(e) => {
730                        eprintln!(
731                            "{} Failed to sort dependency graph for rollback \
732                             of '{}': {}",
733                            "Error:".red().bold(),
734                            old_manifest.name,
735                            e
736                        );
737                        rollback_failed = true;
738                        continue;
739                    }
740                };
741
742                for stage in stages {
743                    for id in stage {
744                        let Some(node) = graph.nodes.get(&id) else {
745                            continue;
746                        };
747                        if let Some(action) = install_plan.get(&id)
748                            && let Err(e) = install::installer::install_node(
749                                node, action, None, None, true, true, true,
750                                false
751                            )
752                        {
753                            eprintln!(
754                                "{} Failed to re-install during rollback of \
755                                 '{}': {}",
756                                "Error:".red().bold(),
757                                old_manifest.name,
758                                e
759                            );
760                            rollback_failed = true;
761                        }
762                    }
763                }
764            }
765        }
766    }
767
768    if rollback_failed {
769        return Err(anyhow!(
770            "Rollback for transaction '{transaction_id}' was incomplete; its \
771             log was retained for recovery"
772        ));
773    }
774
775    println!("{}", ":: Rollback Complete".bold().blue());
776    delete_log(transaction_id)?;
777    Ok(())
778}
779
780/// Returns the ID of the most recently created transaction, if any.
781///
782/// # Errors
783///
784/// Returns an error if the transaction directory cannot be read.
785pub fn get_last_transaction_id() -> Result<Option<String>> {
786    let dir = get_transactions_dir()?;
787    let mut last_modified_time = None;
788    let mut last_transaction_id = None;
789
790    if !dir.exists() {
791        return Ok(None);
792    }
793
794    for entry in fs::read_dir(dir)? {
795        let entry = entry?;
796        let path = entry.path();
797        if path.is_file()
798            && path.extension().and_then(|s| s.to_str()) == Some("json")
799        {
800            let metadata = fs::metadata(&path)?;
801            let modified_time = metadata.modified()?;
802
803            if last_modified_time.is_none_or(|last| modified_time > last) {
804                last_modified_time = Some(modified_time);
805                last_transaction_id =
806                    path.file_stem().and_then(|s| s.to_str()).map(String::from);
807            }
808        }
809    }
810
811    Ok(last_transaction_id)
812}