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