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_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
766pub 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}