1pub 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
23fn 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
30pub(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
66pub(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#[derive(Debug, Clone)]
93pub struct TransactionMetadata {
94 pub id: String,
96 pub start_time: String,
98 pub operation_count: usize
101}
102
103fn 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
110fn validate_transaction_id(id: &str) -> Result<()> {
113 Uuid::parse_str(id)
114 .map(|_| ())
115 .map_err(|_| anyhow!("Invalid transaction ID: {id}"))
116}
117
118fn 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
130pub 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
152pub 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
169pub 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
202pub 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
223pub 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
262pub 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
295pub 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
308pub 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
342fn 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
355fn install_source_for_manifest(manifest: &types::InstallManifest) -> String {
357 local::installed_manifest_source(manifest)
358}
359
360fn 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
392pub 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
780pub 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}