Skip to main content

zoi_transaction/
lib.rs

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