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 => {
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
72pub(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#[derive(Debug, Clone)]
99pub struct TransactionMetadata {
100 pub id: String,
102 pub start_time: String,
104 pub operation_count: usize
107}
108
109fn 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
119fn 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
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 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
222pub 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
261pub 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
294pub 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
307pub 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
341fn 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
354fn install_source_for_manifest(manifest: &types::InstallManifest) -> String {
356 local::installed_manifest_source(manifest)
357}
358
359fn 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
395pub 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
745pub 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}