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 => {
37            let home_dir = zoi_core::utils::get_user_home()
38                .ok_or_else(|| anyhow!("Could not find home directory."))?;
39            Ok(zoi_core::sysroot::apply_sysroot(
40                home_dir.join(".zoi/pkgs/shell").join(shell)
41            ))
42        }
43        types::Scope::System => {
44            if cfg!(target_os = "windows") {
45                Ok(zoi_core::sysroot::apply_sysroot(std::path::PathBuf::from(
46                    format!("C:\\ProgramData\\zoi\\pkgs\\shell\\{shell}")
47                )))
48            } else {
49                let base = match shell {
50                    "bash" => "/usr/share/bash-completion/completions",
51                    "zsh" => "/usr/share/zsh/site-functions",
52                    "fish" => "/usr/share/fish/vendor_completions.d",
53                    "elvish" => "/usr/share/elvish/lib",
54                    _ => "/usr/local/share/zoi/completions"
55                };
56                Ok(zoi_core::sysroot::apply_sysroot(std::path::PathBuf::from(
57                    base
58                )))
59            }
60        }
61        types::Scope::Project => {
62            let current_dir = std::env::current_dir()?;
63            Ok(current_dir
64                .join(".zoi")
65                .join("pkgs")
66                .join("shell")
67                .join(shell))
68        }
69    }
70}
71
72/// Creates a symlink for a shell completion file.
73pub(crate) fn create_completion_symlink(
74    source: &std::path::Path,
75    link: &std::path::Path
76) -> Result<()> {
77    if link.exists() || link.is_symlink() {
78        fs::remove_file(link)?;
79    }
80    if let Some(parent) = link.parent() {
81        fs::create_dir_all(parent)?;
82    }
83    #[cfg(unix)]
84    {
85        std::os::unix::fs::symlink(source, link)
86            .map_err(|e| anyhow!("Failed to create completion symlink: {e}"))?;
87    }
88    #[cfg(windows)]
89    {
90        std::os::windows::fs::symlink_file(source, link).map_err(|e| {
91            anyhow!("Failed to create completion symlink: {}", e)
92        })?;
93    }
94    Ok(())
95}
96
97/// High-level metadata summarizing a completed or in-progress transaction.
98#[derive(Debug, Clone)]
99pub struct TransactionMetadata {
100    /// The UUID v7 identifier for the transaction.
101    pub id: String,
102    /// The RFC 3339 timestamp when the transaction began.
103    pub start_time: String,
104    /// The number of distinct package operations (install, uninstall, upgrade)
105    /// recorded.
106    pub operation_count: usize
107}
108
109/// Gets the directory where transaction logs are stored.
110fn get_transactions_dir() -> Result<PathBuf> {
111    let home_dir = zoi_core::utils::get_user_home()
112        .ok_or_else(|| anyhow!("Could not find home directory."))?;
113    let dir = zoi_core::sysroot::apply_sysroot(home_dir.join(".zoi"))
114        .join("transactions");
115    fs::create_dir_all(&dir)?;
116    Ok(dir)
117}
118
119/// Gets the path to a specific transaction log file.
120fn get_transaction_path(id: &str) -> Result<PathBuf> {
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    let dir = get_transactions_dir()?;
210    let path = dir.join(format!("{transaction_id}.json"));
211    if !path.exists() {
212        return Ok(());
213    }
214
215    let history_dir = dir.join("history");
216    fs::create_dir_all(&history_dir)?;
217    let dest = history_dir.join(format!("{transaction_id}.json"));
218    fs::rename(path, dest)?;
219    Ok(())
220}
221
222/// Returns a list of all files modified during a transaction.
223///
224/// # Errors
225///
226/// Returns an error if the transaction log file cannot be read or if the
227/// content is not valid JSON.
228pub fn get_modified_files(transaction_id: &str) -> Result<Vec<String>> {
229    let path = get_transaction_path(transaction_id)?;
230    if !path.exists() {
231        return Ok(Vec::new());
232    }
233    let content = fs::read_to_string(&path)?;
234    let transaction: types::Transaction = serde_json::from_str(&content)?;
235
236    let mut files = HashSet::new();
237    for op in transaction.operations {
238        match op {
239            types::TransactionOperation::Install { manifest }
240            | types::TransactionOperation::Uninstall { manifest } => {
241                for file in manifest.installed_files {
242                    files.insert(file);
243                }
244            }
245            types::TransactionOperation::Upgrade {
246                old_manifest,
247                new_manifest
248            } => {
249                for file in old_manifest.installed_files {
250                    files.insert(file);
251                }
252                for file in new_manifest.installed_files {
253                    files.insert(file);
254                }
255            }
256        }
257    }
258    Ok(files.into_iter().collect())
259}
260
261/// Returns a list of all packages modified during a transaction.
262///
263/// # Errors
264///
265/// Returns an error if the transaction log file cannot be read or if the
266/// content is not valid JSON.
267pub fn get_modified_packages(transaction_id: &str) -> Result<Vec<String>> {
268    let path = get_transaction_path(transaction_id)?;
269    if !path.exists() {
270        return Ok(Vec::new());
271    }
272    let content = fs::read_to_string(&path)?;
273    let transaction: types::Transaction = serde_json::from_str(&content)?;
274
275    let mut packages = HashSet::new();
276    for op in transaction.operations {
277        match op {
278            types::TransactionOperation::Install { manifest }
279            | types::TransactionOperation::Uninstall { manifest } => {
280                packages.insert(manifest.name);
281            }
282            types::TransactionOperation::Upgrade {
283                old_manifest,
284                new_manifest
285            } => {
286                packages.insert(old_manifest.name);
287                packages.insert(new_manifest.name);
288            }
289        }
290    }
291    Ok(packages.into_iter().collect())
292}
293
294/// Deletes a transaction log file.
295///
296/// # Errors
297///
298/// Returns an error if the transaction log file cannot be deleted.
299pub fn delete_log(transaction_id: &str) -> Result<()> {
300    let path = get_transaction_path(transaction_id)?;
301    if path.exists() {
302        fs::remove_file(path)?;
303    }
304    Ok(())
305}
306
307/// Lists all completed and in-progress transactions.
308///
309/// # Errors
310///
311/// Returns an error if the transaction directory cannot be read.
312pub fn list_transactions() -> Result<Vec<TransactionMetadata>> {
313    let dir = get_transactions_dir()?;
314    if !dir.exists() {
315        return Ok(Vec::new());
316    }
317
318    let mut transactions = Vec::new();
319    for entry in fs::read_dir(dir)? {
320        let entry = entry?;
321        let path = entry.path();
322        if !path.is_file()
323            || path.extension().and_then(|s| s.to_str()) != Some("json")
324        {
325            continue;
326        }
327
328        let content = fs::read_to_string(&path)?;
329        let transaction: types::Transaction = serde_json::from_str(&content)?;
330        transactions.push(TransactionMetadata {
331            id: transaction.id,
332            start_time: transaction.start_time,
333            operation_count: transaction.operations.len()
334        });
335    }
336
337    transactions.sort_by(|a, b| b.start_time.cmp(&a.start_time));
338    Ok(transactions)
339}
340
341/// Checks if a package has installed files outside of the Zoi store.
342fn has_files_outside_store(manifest: &types::InstallManifest) -> bool {
343    if let Ok(store_base) = local::get_store_base_dir(manifest.scope) {
344        for file in &manifest.installed_files {
345            let p = std::path::Path::new(file);
346            if !p.starts_with(&store_base) {
347                return true;
348            }
349        }
350    }
351    false
352}
353
354/// Generates an installation source string for a manifest.
355fn install_source_for_manifest(manifest: &types::InstallManifest) -> String {
356    local::installed_manifest_source(manifest)
357}
358
359/// Restores shims for a package.
360fn restore_shims(manifest: &types::InstallManifest) -> Result<()> {
361    if let Some(bins) = &manifest.bins {
362        let bin_root = match manifest.scope {
363            types::Scope::User => {
364                let home = zoi_core::utils::get_user_home()
365                    .ok_or_else(|| anyhow!("Could not find home directory."))?;
366                sysroot::apply_sysroot(home.join(".zoi/pkgs/bin"))
367            }
368            types::Scope::System => {
369                if cfg!(target_os = "windows") {
370                    sysroot::apply_sysroot(PathBuf::from(
371                        "C:\\ProgramData\\zoi\\pkgs\\bin"
372                    ))
373                } else {
374                    sysroot::apply_sysroot(PathBuf::from("/usr/local/bin"))
375                }
376            }
377            types::Scope::Project => {
378                let current_dir = std::env::current_dir()?;
379                current_dir.join(".zoi").join("pkgs").join("bin")
380            }
381        };
382
383        if !bin_root.exists() {
384            fs::create_dir_all(&bin_root)?;
385        }
386
387        for bin in bins {
388            let shim_path = bin_root.join(bin);
389            create_shim(&shim_path)?;
390        }
391    }
392    Ok(())
393}
394
395/// Reverts all operations recorded in a transaction.
396///
397/// This is the "Atomic Rollback" mechanism. It processes operations in reverse
398/// order:
399/// - Installs are uninstalled.
400/// - Uninstalls are re-installed (either from local store or registry).
401/// - Upgrades are reverted to the previous version.
402///
403/// # Errors
404///
405/// Returns an error if the transaction log file cannot be read, if the content
406/// is not valid JSON, or if the rollback operation fails.
407pub fn rollback(transaction_id: &str) -> Result<()> {
408    let path = get_transaction_path(transaction_id)?;
409    if !path.exists() {
410        return Ok(());
411    }
412    let content = fs::read_to_string(&path)?;
413    let transaction: types::Transaction = serde_json::from_str(&content)?;
414
415    println!("\n{} Starting Rollback...", "::".bold().blue());
416
417    for operation in transaction.operations.iter().rev() {
418        match operation {
419            types::TransactionOperation::Install { manifest } => {
420                println!(
421                    "Rolling back installation of {} v{}...",
422                    manifest.name.cyan(),
423                    manifest.version.yellow()
424                );
425                let source = install_source_for_manifest(manifest);
426                if let Err(e) = uninstall::run(
427                    &source,
428                    Some(manifest.scope),
429                    true,
430                    false,
431                    false
432                ) {
433                    eprintln!(
434                        "{} Failed to rollback install of '{}': {}",
435                        "Error:".red().bold(),
436                        manifest.name,
437                        e
438                    );
439                }
440            }
441            types::TransactionOperation::Uninstall { manifest } => {
442                println!(
443                    "Rolling back uninstallation of {} v{}...",
444                    manifest.name.cyan(),
445                    manifest.version.yellow()
446                );
447
448                let version_dir = match local::get_package_version_dir(
449                    manifest.scope,
450                    &manifest.registry_handle,
451                    &manifest.repo,
452                    &manifest.name,
453                    &manifest.version
454                ) {
455                    Ok(dir) => dir,
456                    Err(e) => {
457                        eprintln!(
458                            "{} Failed to get version directory for rollback: \
459                             {}",
460                            "Error:".red().bold(),
461                            e
462                        );
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                    }
491                    let _ = restore_shims(manifest);
492                    continue;
493                }
494
495                println!(
496                    "Version not found locally or contains global files. \
497                     Re-installing from registry..."
498                );
499
500                let source = install_source_for_manifest(manifest);
501                let (graph, _) =
502                    match install::resolver::resolve_dependency_graph(
503                        &[source],
504                        Some(manifest.scope),
505                        true,
506                        true,
507                        true,
508                        None,
509                        true,
510                        None
511                    ) {
512                        Ok(res) => res,
513                        Err(e) => {
514                            eprintln!(
515                                "{} Failed to resolve dependency graph for \
516                                 rollback of '{}': {}",
517                                "Error:".red().bold(),
518                                manifest.name,
519                                e
520                            );
521                            continue;
522                        }
523                    };
524
525                let install_plan = match install::plan::create_install_plan(
526                    &graph.nodes,
527                    None,
528                    false
529                ) {
530                    Ok(plan) => plan,
531                    Err(e) => {
532                        eprintln!(
533                            "{} Failed to create install plan for rollback of \
534                             '{}': {}",
535                            "Error:".red().bold(),
536                            manifest.name,
537                            e
538                        );
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                        continue;
554                    }
555                };
556
557                for stage in stages {
558                    for id in stage {
559                        let Some(node) = graph.nodes.get(&id) else {
560                            continue;
561                        };
562                        if let Some(action) = install_plan.get(&id)
563                            && let Err(e) = install::installer::install_node(
564                                node, action, None, None, true, true, true,
565                                false
566                            )
567                        {
568                            eprintln!(
569                                "{} Failed to re-install during rollback of \
570                                 '{}': {}",
571                                "Error:".red().bold(),
572                                manifest.name,
573                                e
574                            );
575                        }
576                    }
577                }
578            }
579            types::TransactionOperation::Upgrade {
580                old_manifest,
581                new_manifest
582            } => {
583                println!(
584                    "Rolling back upgrade of {} from {} to {}...",
585                    old_manifest.name.cyan(),
586                    new_manifest.version.yellow(),
587                    old_manifest.version.green()
588                );
589                let source = install_source_for_manifest(new_manifest);
590                if let Err(e) = uninstall::run(
591                    &source,
592                    Some(new_manifest.scope),
593                    true,
594                    false,
595                    false
596                ) {
597                    eprintln!(
598                        "{} Failed to uninstall new version during \
599                         upgrade-rollback for '{}': {}",
600                        "Error:".red().bold(),
601                        new_manifest.name,
602                        e
603                    );
604                }
605
606                let version_dir = match local::get_package_version_dir(
607                    old_manifest.scope,
608                    &old_manifest.registry_handle,
609                    &old_manifest.repo,
610                    &old_manifest.name,
611                    &old_manifest.version
612                ) {
613                    Ok(dir) => dir,
614                    Err(e) => {
615                        eprintln!(
616                            "{} Failed to get version directory for rollback: \
617                             {}",
618                            "Error:".red().bold(),
619                            e
620                        );
621                        continue;
622                    }
623                };
624
625                let manifest_filename =
626                    if let Some(sub) = &old_manifest.sub_package {
627                        format!("manifest-{sub}.yaml")
628                    } else {
629                        "manifest.yaml".to_string()
630                    };
631                let manifest_path = version_dir.join(&manifest_filename);
632
633                if version_dir.exists()
634                    && manifest_path.exists()
635                    && !has_files_outside_store(old_manifest)
636                {
637                    println!(
638                        "Restoring version {} from local store...",
639                        old_manifest.version
640                    );
641                    if let Err(e) = local::write_manifest(old_manifest) {
642                        eprintln!(
643                            "{} Failed to restore manifest for '{}': {}",
644                            "Error:".red().bold(),
645                            old_manifest.name,
646                            e
647                        );
648                    }
649                    let _ = restore_shims(old_manifest);
650                    continue;
651                }
652
653                println!(
654                    "Version not found locally or contains global files. \
655                     Re-installing from registry..."
656                );
657
658                let source = install_source_for_manifest(old_manifest);
659                let (graph, _) =
660                    match install::resolver::resolve_dependency_graph(
661                        std::slice::from_ref(&source),
662                        Some(old_manifest.scope),
663                        true,
664                        true,
665                        true,
666                        None,
667                        true,
668                        None
669                    ) {
670                        Ok(res) => res,
671                        Err(e) => {
672                            eprintln!(
673                                "{} Failed to resolve dependency graph for \
674                                 rollback of '{}': {}",
675                                "Error:".red().bold(),
676                                old_manifest.name,
677                                e
678                            );
679                            continue;
680                        }
681                    };
682
683                let install_plan = match install::plan::create_install_plan(
684                    &graph.nodes,
685                    None,
686                    false
687                ) {
688                    Ok(plan) => plan,
689                    Err(e) => {
690                        eprintln!(
691                            "{} Failed to create install plan for rollback of \
692                             '{}': {}",
693                            "Error:".red().bold(),
694                            old_manifest.name,
695                            e
696                        );
697                        continue;
698                    }
699                };
700
701                let stages = match graph.toposort() {
702                    Ok(s) => s,
703                    Err(e) => {
704                        eprintln!(
705                            "{} Failed to sort dependency graph for rollback \
706                             of '{}': {}",
707                            "Error:".red().bold(),
708                            old_manifest.name,
709                            e
710                        );
711                        continue;
712                    }
713                };
714
715                for stage in stages {
716                    for id in stage {
717                        let Some(node) = graph.nodes.get(&id) else {
718                            continue;
719                        };
720                        if let Some(action) = install_plan.get(&id)
721                            && let Err(e) = install::installer::install_node(
722                                node, action, None, None, true, true, true,
723                                false
724                            )
725                        {
726                            eprintln!(
727                                "{} Failed to re-install during rollback of \
728                                 '{}': {}",
729                                "Error:".red().bold(),
730                                old_manifest.name,
731                                e
732                            );
733                        }
734                    }
735                }
736            }
737        }
738    }
739
740    println!("{}", ":: Rollback Complete".bold().blue());
741    delete_log(transaction_id)?;
742    Ok(())
743}
744
745/// Returns the ID of the most recently created transaction, if any.
746///
747/// # Errors
748///
749/// Returns an error if the transaction directory cannot be read.
750pub fn get_last_transaction_id() -> Result<Option<String>> {
751    let dir = get_transactions_dir()?;
752    let mut last_modified_time = None;
753    let mut last_transaction_id = None;
754
755    if !dir.exists() {
756        return Ok(None);
757    }
758
759    for entry in fs::read_dir(dir)? {
760        let entry = entry?;
761        let path = entry.path();
762        if path.is_file()
763            && path.extension().and_then(|s| s.to_str()) == Some("json")
764        {
765            let metadata = fs::metadata(&path)?;
766            let modified_time = metadata.modified()?;
767
768            if last_modified_time.is_none_or(|last| modified_time > last) {
769                last_modified_time = Some(modified_time);
770                last_transaction_id =
771                    path.file_stem().and_then(|s| s.to_str()).map(String::from);
772            }
773        }
774    }
775
776    Ok(last_transaction_id)
777}