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#[derive(Debug, Clone)]
85pub struct TransactionMetadata {
86 pub id: String,
88 pub start_time: String,
90 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
112pub 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
333pub 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}