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_path = local::find_store_manifest(
460                    &version_dir,
461                    manifest.sub_package.as_deref()
462                );
463
464                if version_dir.exists()
465                    && manifest_path.is_some()
466                    && !has_files_outside_store(manifest)
467                {
468                    println!(
469                        "Restoring version {} from local store...",
470                        manifest.version
471                    );
472                    if let Err(e) = local::write_manifest(manifest) {
473                        eprintln!(
474                            "{} Failed to restore manifest for '{}': {}",
475                            "Error:".red().bold(),
476                            manifest.name,
477                            e
478                        );
479                        rollback_failed = true;
480                    }
481                    if let Err(e) = restore_shims(manifest) {
482                        eprintln!(
483                            "{} Failed to restore shims for '{}': {}",
484                            "Error:".red().bold(),
485                            manifest.name,
486                            e
487                        );
488                        rollback_failed = true;
489                    }
490                    continue;
491                }
492
493                println!(
494                    "Version not found locally or contains global files. \
495                     Re-installing from registry..."
496                );
497
498                let source = install_source_for_manifest(manifest);
499                let (graph, _) =
500                    match install::resolver::resolve_dependency_graph(
501                        &[source],
502                        Some(manifest.scope),
503                        true,
504                        true,
505                        true,
506                        None,
507                        true,
508                        None
509                    ) {
510                        Ok(res) => res,
511                        Err(e) => {
512                            eprintln!(
513                                "{} Failed to resolve dependency graph for \
514                                 rollback of '{}': {}",
515                                "Error:".red().bold(),
516                                manifest.name,
517                                e
518                            );
519                            rollback_failed = true;
520                            continue;
521                        }
522                    };
523
524                let install_plan = match install::plan::create_install_plan(
525                    &graph.nodes,
526                    None,
527                    false
528                ) {
529                    Ok(plan) => plan,
530                    Err(e) => {
531                        eprintln!(
532                            "{} Failed to create install plan for rollback of \
533                             '{}': {}",
534                            "Error:".red().bold(),
535                            manifest.name,
536                            e
537                        );
538                        rollback_failed = true;
539                        continue;
540                    }
541                };
542
543                let stages = match graph.toposort() {
544                    Ok(s) => s,
545                    Err(e) => {
546                        eprintln!(
547                            "{} Failed to sort dependency graph for rollback \
548                             of '{}': {}",
549                            "Error:".red().bold(),
550                            manifest.name,
551                            e
552                        );
553                        rollback_failed = true;
554                        continue;
555                    }
556                };
557
558                for stage in stages {
559                    for id in stage {
560                        let Some(node) = graph.nodes.get(&id) else {
561                            continue;
562                        };
563                        if let Some(action) = install_plan.get(&id)
564                            && let Err(e) = install::installer::install_node(
565                                node, action, None, None, true, true, true,
566                                false, false
567                            )
568                        {
569                            eprintln!(
570                                "{} Failed to re-install during rollback of \
571                                 '{}': {}",
572                                "Error:".red().bold(),
573                                manifest.name,
574                                e
575                            );
576                            rollback_failed = true;
577                        }
578                    }
579                }
580            }
581            types::TransactionOperation::Upgrade {
582                old_manifest,
583                new_manifest
584            } => {
585                println!(
586                    "Rolling back upgrade of {} from {} to {}...",
587                    old_manifest.name.cyan(),
588                    new_manifest.version.yellow(),
589                    old_manifest.version.green()
590                );
591                let source = install_source_for_manifest(new_manifest);
592                if let Err(e) = uninstall::run(
593                    &source,
594                    Some(new_manifest.scope),
595                    true,
596                    false,
597                    false
598                ) {
599                    eprintln!(
600                        "{} Failed to uninstall new version during \
601                         upgrade-rollback for '{}': {}",
602                        "Error:".red().bold(),
603                        new_manifest.name,
604                        e
605                    );
606                    rollback_failed = true;
607                }
608
609                let version_dir = match local::get_package_version_dir(
610                    old_manifest.scope,
611                    &old_manifest.registry_handle,
612                    &old_manifest.repo,
613                    &old_manifest.name,
614                    &old_manifest.version
615                ) {
616                    Ok(dir) => dir,
617                    Err(e) => {
618                        eprintln!(
619                            "{} Failed to get version directory for rollback: \
620                             {}",
621                            "Error:".red().bold(),
622                            e
623                        );
624                        rollback_failed = true;
625                        continue;
626                    }
627                };
628
629                let manifest_path = local::find_store_manifest(
630                    &version_dir,
631                    old_manifest.sub_package.as_deref()
632                );
633
634                if version_dir.exists()
635                    && manifest_path.is_some()
636                    && !has_files_outside_store(old_manifest)
637                {
638                    println!(
639                        "Restoring version {} from local store...",
640                        old_manifest.version
641                    );
642                    if let Err(e) = local::write_manifest(old_manifest) {
643                        eprintln!(
644                            "{} Failed to restore manifest for '{}': {}",
645                            "Error:".red().bold(),
646                            old_manifest.name,
647                            e
648                        );
649                        rollback_failed = true;
650                    }
651                    if let Err(e) = restore_shims(old_manifest) {
652                        eprintln!(
653                            "{} Failed to restore shims for '{}': {}",
654                            "Error:".red().bold(),
655                            old_manifest.name,
656                            e
657                        );
658                        rollback_failed = true;
659                    }
660                    continue;
661                }
662
663                println!(
664                    "Version not found locally or contains global files. \
665                     Re-installing from registry..."
666                );
667
668                let source = install_source_for_manifest(old_manifest);
669                let (graph, _) =
670                    match install::resolver::resolve_dependency_graph(
671                        std::slice::from_ref(&source),
672                        Some(old_manifest.scope),
673                        true,
674                        true,
675                        true,
676                        None,
677                        true,
678                        None
679                    ) {
680                        Ok(res) => res,
681                        Err(e) => {
682                            eprintln!(
683                                "{} Failed to resolve dependency graph for \
684                                 rollback of '{}': {}",
685                                "Error:".red().bold(),
686                                old_manifest.name,
687                                e
688                            );
689                            rollback_failed = true;
690                            continue;
691                        }
692                    };
693
694                let install_plan = match install::plan::create_install_plan(
695                    &graph.nodes,
696                    None,
697                    false
698                ) {
699                    Ok(plan) => plan,
700                    Err(e) => {
701                        eprintln!(
702                            "{} Failed to create install plan for rollback of \
703                             '{}': {}",
704                            "Error:".red().bold(),
705                            old_manifest.name,
706                            e
707                        );
708                        rollback_failed = true;
709                        continue;
710                    }
711                };
712
713                let stages = match graph.toposort() {
714                    Ok(s) => s,
715                    Err(e) => {
716                        eprintln!(
717                            "{} Failed to sort dependency graph for rollback \
718                             of '{}': {}",
719                            "Error:".red().bold(),
720                            old_manifest.name,
721                            e
722                        );
723                        rollback_failed = true;
724                        continue;
725                    }
726                };
727
728                for stage in stages {
729                    for id in stage {
730                        let Some(node) = graph.nodes.get(&id) else {
731                            continue;
732                        };
733                        if let Some(action) = install_plan.get(&id)
734                            && let Err(e) = install::installer::install_node(
735                                node, action, None, None, true, true, true,
736                                false, false
737                            )
738                        {
739                            eprintln!(
740                                "{} Failed to re-install during rollback of \
741                                 '{}': {}",
742                                "Error:".red().bold(),
743                                old_manifest.name,
744                                e
745                            );
746                            rollback_failed = true;
747                        }
748                    }
749                }
750            }
751        }
752    }
753
754    if rollback_failed {
755        return Err(anyhow!(
756            "Rollback for transaction '{transaction_id}' was incomplete; its \
757             log was retained for recovery"
758        ));
759    }
760
761    println!("{}", ":: Rollback Complete".bold().blue());
762    delete_log(transaction_id)?;
763    Ok(())
764}
765
766/// Returns the ID of the most recently created transaction, if any.
767///
768/// # Errors
769///
770/// Returns an error if the transaction directory cannot be read.
771pub fn get_last_transaction_id() -> Result<Option<String>> {
772    let dir = get_transactions_dir()?;
773    let mut last_modified_time = None;
774    let mut last_transaction_id = None;
775
776    if !dir.exists() {
777        return Ok(None);
778    }
779
780    for entry in fs::read_dir(dir)? {
781        let entry = entry?;
782        let path = entry.path();
783        if path.is_file()
784            && path.extension().and_then(|s| s.to_str()) == Some("json")
785        {
786            let metadata = fs::metadata(&path)?;
787            let modified_time = metadata.modified()?;
788
789            if last_modified_time.is_none_or(|last| modified_time > last) {
790                last_modified_time = Some(modified_time);
791                last_transaction_id =
792                    path.file_stem().and_then(|s| s.to_str()).map(String::from);
793            }
794        }
795    }
796
797    Ok(last_transaction_id)
798}