1use std::fs;
2use std::path::PathBuf;
3
4use anyhow::{Result, anyhow};
5use mlua::LuaSerdeExt;
6use zoi_core::{config, pgp, types};
7use zoi_hooks as hooks;
8use zoi_lua;
9use zoi_resolver::{local, resolve};
10
11use crate::PluginManager;
12
13const EXTENSION_STATE_FILE: &str = "extension-state.yaml";
15
16#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
19struct ExtensionState {
20 #[serde(default, skip_serializing_if = "Option::is_none")]
22 previous_default_registry: Option<types::Registry>,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
25 project_file_path: Option<PathBuf>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
28 installed_extension: Option<types::ExtensionInfo>
29}
30
31fn get_extension_state_path(
33 manifest: &types::InstallManifest
34) -> Result<PathBuf> {
35 let version_dir = local::get_package_version_dir(
36 manifest.scope,
37 &manifest.registry_handle,
38 &manifest.repo,
39 &manifest.name,
40 &manifest.version
41 )?;
42 Ok(version_dir.join(EXTENSION_STATE_FILE))
43}
44
45fn write_extension_state(
47 manifest: &types::InstallManifest,
48 extension_state: &ExtensionState
49) -> Result<()> {
50 let state_path = get_extension_state_path(manifest)?;
51 fs::write(state_path, serde_yaml::to_string(extension_state)?)?;
52 Ok(())
53}
54
55fn read_extension_state(
57 manifest: &types::InstallManifest
58) -> Result<Option<ExtensionState>> {
59 let state_path = get_extension_state_path(manifest)?;
60 if !state_path.exists() {
61 return Ok(None);
62 }
63 let content = fs::read_to_string(state_path)?;
64 Ok(Some(serde_yaml::from_str(&content)?))
65}
66
67fn restore_default_registry(
69 saved_state: Option<&ExtensionState>,
70 added_registry_url: &str
71) -> Result<()> {
72 if let Some(saved_state) = saved_state {
73 return config::set_user_default_registry(
74 saved_state.previous_default_registry.clone()
75 );
76 }
77
78 let user_config = config::read_user_config()?;
79 let should_clear = user_config
80 .default_registry
81 .as_ref()
82 .is_some_and(|registry| registry.url == added_registry_url);
83 if should_clear {
84 config::set_user_default_registry(None)?;
85 }
86 Ok(())
87}
88
89fn extension_state_requires_persistence(
91 extension_state: &ExtensionState
92) -> bool {
93 extension_state.previous_default_registry.is_some()
94 || extension_state.project_file_path.is_some()
95 || extension_state.installed_extension.is_some()
96}
97
98fn get_project_file_path(saved_state: Option<&ExtensionState>) -> PathBuf {
101 saved_state
102 .and_then(|state| state.project_file_path.clone())
103 .unwrap_or_else(|| PathBuf::from("zoi.lua"))
104}
105
106fn get_repo_name_from_url(url: &str) -> &str {
108 url.trim_end_matches('/')
109 .split('/')
110 .next_back()
111 .unwrap_or_default()
112 .trim_end_matches(".git")
113}
114
115fn revert_extension_change(
117 change: &types::ExtensionChange,
118 saved_state: Option<&ExtensionState>
119) -> Result<()> {
120 match change {
121 types::ExtensionChange::RepoGit { add } => {
122 let repo_name = get_repo_name_from_url(add);
123 if !repo_name.is_empty() {
124 config::remove_git_repo(repo_name)?;
125 }
126 }
127 types::ExtensionChange::RegistryRepo { add } => {
128 restore_default_registry(saved_state, add)?;
129 }
130 types::ExtensionChange::RegistryAdd { add } => {
131 config::remove_added_registry(add)?;
132 }
133 types::ExtensionChange::RepoAdd { add } => {
134 config::remove_repo(add)?;
135 }
136 types::ExtensionChange::Project { add: _ } => {
137 let project_file_path = get_project_file_path(saved_state);
138 if project_file_path.exists() {
139 fs::remove_file(project_file_path)?;
140 }
141 }
142 types::ExtensionChange::Pgp { name, key: _ } => {
143 pgp::remove_key_by_name(name)?;
144 }
145 types::ExtensionChange::Plugin { name, script: _ } => {
146 let plugin_dir = crate::get_plugin_dir()?;
147 let plugin_path = plugin_dir.join(format!("{name}.lua"));
148 if plugin_path.exists() {
149 fs::remove_file(plugin_path)?;
150 }
151 }
152 types::ExtensionChange::Hook { name, content: _ } => {
153 let hooks_dir = hooks::global::get_user_hooks_dir()?;
154 let hook_path = hooks_dir.join(format!("{name}.hook.yaml"));
155 if hook_path.exists() {
156 fs::remove_file(hook_path)?;
157 }
158 }
159 }
160 Ok(())
161}
162
163pub fn add(
171 ext_name: &str,
172 yes: bool,
173 plugin_manager: Option<&PluginManager>
174) -> Result<()> {
175 println!("Adding extension: {ext_name}");
176
177 let (pkg, _, _, pkg_lua_path, registry_handle, repo_type, _) =
178 resolve::resolve_package_and_version(ext_name, None, false, yes)?;
179
180 if pkg.package_type != types::PackageType::Extension {
181 return Err(anyhow!("'{ext_name}' is not an extension package."));
182 }
183
184 let mut pkg_val = None;
185 if let Some(pm) = plugin_manager {
186 let v = pm
187 .lua
188 .to_value(&pkg)
189 .map_err(|e: mlua::Error| anyhow!(e.to_string()))?;
190 pm.trigger_hook("on_pre_extension_add", Some(&v.clone()))?;
191 pkg_val = Some(v);
192 }
193
194 let Some(extension_info) = pkg.extension else {
195 return Err(anyhow!(
196 "'{ext_name}' is an extension package but contains no extension \
197 data."
198 ));
199 };
200 if extension_info.extension_type != "zoi" {
201 return Err(anyhow!(
202 "Unsupported extension type: {}",
203 extension_info.extension_type
204 ));
205 }
206 let has_registry_repo_change =
207 extension_info.changes.iter().any(|change| {
208 matches!(change, types::ExtensionChange::RegistryRepo { .. })
209 });
210 let has_project_change = extension_info
211 .changes
212 .iter()
213 .any(|change| matches!(change, types::ExtensionChange::Project { .. }));
214 let previous_default_registry = if has_registry_repo_change {
215 config::read_user_config()?.default_registry.clone()
216 } else {
217 None
218 };
219 let project_file_path = if has_project_change {
220 Some(std::env::current_dir()?.join("zoi.lua"))
221 } else {
222 None
223 };
224 let extension_state = ExtensionState {
225 previous_default_registry,
226 project_file_path,
227 installed_extension: Some(extension_info.clone())
228 };
229
230 let manifest = types::InstallManifest {
231 name: pkg.name.clone(),
232 version: pkg.version.clone().unwrap_or_default(),
233 epoch: pkg.epoch,
234 revision: pkg.revision.clone(),
235 sub_package: None,
236 repo: pkg.repo.clone(),
237 repo_type: repo_type.unwrap_or_else(|| "unofficial".to_string()),
238 registry_handle: registry_handle.unwrap_or_default(),
239 package_type: pkg.package_type,
240 description: pkg.description.clone(),
241 reason: types::InstallReason::Direct,
242 scope: pkg.scope,
243 bins: None,
244 alternatives: None,
245 conflicts: None,
246 replaces: None,
247 provides: None,
248 backup: None,
249 installed_dependencies: vec![],
250 dependencies_v2: None,
251 chosen_options: vec![],
252 chosen_optionals: vec![],
253 install_method: None,
254 platform: zoi_core::utils::get_platform().unwrap_or_default(),
255 service: None,
256 installed_files: vec![],
257 file_digests: None,
258 installed_size: pkg.installed_size,
259 sandbox: None,
260 completions: None
261 };
262 let mut wrote_manifest = false;
263 let mut applied_changes = Vec::new();
264 let add_result = (|| -> Result<()> {
265 if extension_state_requires_persistence(&extension_state) {
266 local::write_manifest(&manifest)?;
267 local::persist_package_source(&manifest, &pkg_lua_path)?;
268 wrote_manifest = true;
269 write_extension_state(&manifest, &extension_state)?;
270 }
271
272 println!("Applying extension changes...");
273 for change in &extension_info.changes {
274 match change {
275 types::ExtensionChange::RepoGit { add } => {
276 println!("Adding git repository: {add}");
277 config::clone_git_repo(add)?;
278 }
279 types::ExtensionChange::RegistryRepo { add } => {
280 println!("Setting registry to: {add}");
281 config::set_default_registry(add)?;
282 }
283 types::ExtensionChange::RegistryAdd { add } => {
284 println!("Adding registry: {add}");
285 config::add_added_registry(add)?;
286 }
287 types::ExtensionChange::RepoAdd { add } => {
288 println!("Adding repository: {add}");
289 config::add_repo(add)?;
290 }
291 types::ExtensionChange::Project { add } => {
292 let project_file_path =
293 get_project_file_path(Some(&extension_state));
294 println!("Creating {}...", project_file_path.display());
295 if project_file_path.exists() {
296 return Err(anyhow!(
297 "A 'zoi.lua' file already exists at '{}'. Please \
298 remove it first.",
299 project_file_path.display()
300 ));
301 }
302 fs::write(&project_file_path, add)?;
303 }
304 types::ExtensionChange::Pgp { name, key } => {
305 println!("Adding PGP key: {name} from {key}");
306 if key.starts_with("http") {
307 pgp::add_key_from_url(key, name, false)?;
308 } else {
309 pgp::add_key_from_fingerprint(key, name, false)?;
310 }
311 }
312 types::ExtensionChange::Plugin { name, script } => {
313 println!("Adding plugin: {name}");
314 let plugin_dir = crate::get_plugin_dir()?;
315 let plugin_path = plugin_dir.join(format!("{name}.lua"));
316 fs::write(plugin_path, script)?;
317 }
318 types::ExtensionChange::Hook { name, content } => {
319 println!("Adding global hook: {name}");
320 let hooks_dir = hooks::global::get_user_hooks_dir()?;
321 let hook_path = hooks_dir.join(format!("{name}.hook.yaml"));
322 fs::write(hook_path, content)?;
323 }
324 }
325 applied_changes.push(change.clone());
326 }
327 if !wrote_manifest {
328 local::write_manifest(&manifest)?;
329 local::persist_package_source(&manifest, &pkg_lua_path)?;
330 wrote_manifest = true;
331 }
332 Ok(())
333 })();
334 if let Err(error) = add_result {
335 for change in applied_changes.iter().rev() {
336 if let Err(rollback_error) =
337 revert_extension_change(change, Some(&extension_state))
338 {
339 eprintln!(
340 "Warning: failed to roll back extension change \
341 {change:?}: {rollback_error}"
342 );
343 }
344 }
345 if wrote_manifest
346 && let Ok(package_dir) = local::get_package_dir(
347 manifest.scope,
348 &manifest.registry_handle,
349 &manifest.repo,
350 &manifest.name
351 )
352 {
353 let _ = fs::remove_dir_all(package_dir);
354 }
355 return Err(error);
356 }
357
358 if let (Some(pm), Some(v)) = (plugin_manager, pkg_val) {
359 pm.trigger_hook_nonfatal("on_post_extension_add", Some(&v));
360 }
361
362 println!("Successfully added extension '{ext_name}'.");
363
364 Ok(())
365}
366
367pub fn remove(
375 ext_name: &str,
376 yes: bool,
377 plugin_manager: Option<&PluginManager>
378) -> Result<()> {
379 println!("Removing extension: {ext_name}");
380
381 let request = resolve::parse_source_string(ext_name)?;
382 let mut candidates = Vec::new();
383 for scope in [
384 types::Scope::Project,
385 types::Scope::User,
386 types::Scope::System
387 ] {
388 candidates
389 .extend(local::find_installed_manifests_matching(&request, scope)?);
390 }
391
392 if candidates.is_empty() {
393 return Err(anyhow!("Extension '{ext_name}' is not installed."));
394 }
395
396 let manifest = select_candidate(ext_name, candidates, yes)?;
397 let scope = manifest.scope;
398
399 let mut manifest_val = None;
400 if let Some(pm) = plugin_manager {
401 let v = pm
402 .lua
403 .to_value(&manifest)
404 .map_err(|e: mlua::Error| anyhow!(e.to_string()))?;
405 pm.trigger_hook("on_pre_extension_remove", Some(&v.clone()))?;
406 manifest_val = Some(v);
407 }
408
409 if manifest.package_type != types::PackageType::Extension {
410 return Err(anyhow!("'{ext_name}' is not an extension package."));
411 }
412
413 let installed_source_path = local::get_package_source_path(&manifest)?;
414 let pkg = if installed_source_path.exists() {
415 let path = installed_source_path.to_str().ok_or_else(|| {
416 anyhow!("Stored package source path contains invalid UTF-8")
417 })?;
418 zoi_lua::parser::parse_lua_package(
419 path,
420 Some(&manifest.version),
421 Some(manifest.scope),
422 true
423 )?
424 } else {
425 let source = local::installed_manifest_source(&manifest);
426 let (pkg, _, _, _, _, _, _) = resolve::resolve_package_and_version(
427 &source,
428 Some(manifest.scope),
429 true,
430 yes
431 )?;
432 pkg
433 };
434
435 let extension_state = read_extension_state(&manifest)?;
436 let extension_info = extension_state
437 .as_ref()
438 .and_then(|state| state.installed_extension.clone())
439 .or(pkg.extension);
440
441 if let Some(extension_info) = extension_info {
442 if extension_info.extension_type != "zoi" {
443 return Err(anyhow!(
444 "Unsupported extension type: {}",
445 extension_info.extension_type
446 ));
447 }
448
449 println!("Reverting extension changes...");
450 for change in extension_info.changes.iter().rev() {
451 match change {
452 types::ExtensionChange::RepoGit { add } => {
453 let repo_name = get_repo_name_from_url(add);
454 if !repo_name.is_empty() {
455 println!("Removing git repository: {repo_name}");
456 if let Err(e) = revert_extension_change(
457 change,
458 extension_state.as_ref()
459 ) {
460 eprintln!(
461 "Warning: failed to remove git repo \
462 '{repo_name}': {e}"
463 );
464 }
465 }
466 }
467 types::ExtensionChange::RegistryRepo { add: _ } => {
468 println!("Restoring previous default registry");
469 if let Err(e) = revert_extension_change(
470 change,
471 extension_state.as_ref()
472 ) {
473 eprintln!(
474 "Warning: failed to restore default registry: {e}"
475 );
476 }
477 }
478 types::ExtensionChange::RegistryAdd { add } => {
479 println!("Removing registry: {add}");
480 if let Err(e) = revert_extension_change(
481 change,
482 extension_state.as_ref()
483 ) {
484 eprintln!(
485 "Warning: failed to remove registry '{add}': {e}"
486 );
487 }
488 }
489 types::ExtensionChange::RepoAdd { add } => {
490 println!("Removing repository: {add}");
491 if let Err(e) = revert_extension_change(
492 change,
493 extension_state.as_ref()
494 ) {
495 eprintln!(
496 "Warning: failed to remove repo '{add}': {e}"
497 );
498 }
499 }
500 types::ExtensionChange::Project { add: _ } => {
501 let project_file_path =
502 get_project_file_path(extension_state.as_ref());
503 println!("Removing {}...", project_file_path.display());
504 if let Err(e) = revert_extension_change(
505 change,
506 extension_state.as_ref()
507 ) {
508 eprintln!(
509 "Warning: failed to remove '{}': {e}",
510 project_file_path.display()
511 );
512 }
513 }
514 types::ExtensionChange::Pgp { name, key: _ } => {
515 println!("Removing PGP key: {name}");
516 if let Err(e) = revert_extension_change(
517 change,
518 extension_state.as_ref()
519 ) {
520 eprintln!(
521 "Warning: failed to remove PGP key '{name}': {e}"
522 );
523 }
524 }
525 types::ExtensionChange::Plugin { name, script: _ } => {
526 println!("Removing plugin: {name}");
527 if let Err(e) = revert_extension_change(
528 change,
529 extension_state.as_ref()
530 ) {
531 eprintln!(
532 "Warning: failed to remove plugin '{name}': {e}"
533 );
534 }
535 }
536 types::ExtensionChange::Hook { name, content: _ } => {
537 println!("Removing global hook: {name}");
538 if let Err(e) = revert_extension_change(
539 change,
540 extension_state.as_ref()
541 ) {
542 eprintln!(
543 "Warning: failed to remove global hook '{name}': \
544 {e}"
545 );
546 }
547 }
548 }
549 }
550 } else {
551 return Err(anyhow!(
552 "'{ext_name}' is an extension package but contains no extension \
553 data."
554 ));
555 }
556
557 let package_dir = local::get_package_dir(
558 scope,
559 &manifest.registry_handle,
560 &manifest.repo,
561 &manifest.name
562 )?;
563
564 if package_dir.exists() {
565 fs::remove_dir_all(&package_dir)?;
566 }
567
568 if let (Some(pm), Some(v)) = (plugin_manager, manifest_val) {
569 pm.trigger_hook_nonfatal("on_post_extension_remove", Some(&v));
570 }
571
572 println!("Successfully removed extension '{ext_name}'.");
573
574 Ok(())
575}
576
577fn select_candidate(
580 package_name: &str,
581 candidates: Vec<types::InstallManifest>,
582 yes: bool
583) -> Result<types::InstallManifest> {
584 use colored::Colorize;
585 use comfy_table::Table;
586 use comfy_table::presets::UTF8_FULL;
587 use dialoguer::Select;
588 use dialoguer::theme::ColorfulTheme;
589
590 if candidates.is_empty() {
591 return Err(anyhow!("Package '{package_name}' is not installed."));
592 }
593 if candidates.len() == 1 {
594 return Ok(candidates
595 .into_iter()
596 .next()
597 .expect("Already checked length"));
598 }
599 if yes {
600 return Err(anyhow!(
601 "Package '{package_name}' matches multiple installed packages. \
602 Use an explicit source like '#handle@repo/name[:sub]@version'."
603 ));
604 }
605
606 let displays: Vec<_> = candidates
607 .iter()
608 .map(|m| {
609 let source = local::installed_manifest_source(m);
610 let scope_label = match m.scope {
611 types::Scope::User => "user",
612 types::Scope::System => "system",
613 types::Scope::Project => "project"
614 };
615 format!("{} ({}, v{})", source, scope_label, m.version)
616 })
617 .collect();
618
619 let mut table = Table::new();
620 table.load_style(UTF8_FULL);
621 table.set_header(vec!["#", "Source", "Version"]);
622 for (i, m) in candidates.iter().enumerate() {
623 table.add_row(vec![
624 (i + 1).to_string(),
625 local::installed_manifest_source(m),
626 m.version.clone(),
627 ]);
628 }
629 println!(
630 "Found multiple installed packages matching '{}'. Please choose one:",
631 package_name.cyan()
632 );
633 println!("{table}");
634
635 let selection = Select::with_theme(&ColorfulTheme::default())
636 .with_prompt("Select an installed package")
637 .items(&displays)
638 .default(0)
639 .interact()?;
640
641 Ok(candidates
642 .get(selection)
643 .cloned()
644 .expect("Invalid selection"))
645}