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.yaml"))
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.yaml"))
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 conflicts: None,
245 replaces: None,
246 provides: None,
247 backup: None,
248 installed_dependencies: vec![],
249 dependencies_v2: None,
250 chosen_options: vec![],
251 chosen_optionals: vec![],
252 install_method: None,
253 platform: zoi_core::utils::get_platform().unwrap_or_default(),
254 service: None,
255 installed_files: vec![],
256 file_digests: None,
257 installed_size: pkg.installed_size,
258 sandbox: None,
259 completions: None
260 };
261 let mut wrote_manifest = false;
262 let mut applied_changes = Vec::new();
263 let add_result = (|| -> Result<()> {
264 if extension_state_requires_persistence(&extension_state) {
265 local::write_manifest(&manifest)?;
266 local::persist_package_source(&manifest, &pkg_lua_path)?;
267 wrote_manifest = true;
268 write_extension_state(&manifest, &extension_state)?;
269 }
270
271 println!("Applying extension changes...");
272 for change in &extension_info.changes {
273 match change {
274 types::ExtensionChange::RepoGit { add } => {
275 println!("Adding git repository: {add}");
276 config::clone_git_repo(add)?;
277 }
278 types::ExtensionChange::RegistryRepo { add } => {
279 println!("Setting registry to: {add}");
280 config::set_default_registry(add)?;
281 }
282 types::ExtensionChange::RegistryAdd { add } => {
283 println!("Adding registry: {add}");
284 config::add_added_registry(add)?;
285 }
286 types::ExtensionChange::RepoAdd { add } => {
287 println!("Adding repository: {add}");
288 config::add_repo(add)?;
289 }
290 types::ExtensionChange::Project { add } => {
291 let project_file_path =
292 get_project_file_path(Some(&extension_state));
293 println!("Creating {}...", project_file_path.display());
294 if project_file_path.exists() {
295 return Err(anyhow!(
296 "A 'zoi.yaml' file already exists at '{}'. Please \
297 remove it first.",
298 project_file_path.display()
299 ));
300 }
301 fs::write(&project_file_path, add)?;
302 }
303 types::ExtensionChange::Pgp { name, key } => {
304 println!("Adding PGP key: {name} from {key}");
305 if key.starts_with("http") {
306 pgp::add_key_from_url(key, name, false)?;
307 } else {
308 pgp::add_key_from_fingerprint(key, name, false)?;
309 }
310 }
311 types::ExtensionChange::Plugin { name, script } => {
312 println!("Adding plugin: {name}");
313 let plugin_dir = crate::get_plugin_dir()?;
314 let plugin_path = plugin_dir.join(format!("{name}.lua"));
315 fs::write(plugin_path, script)?;
316 }
317 types::ExtensionChange::Hook { name, content } => {
318 println!("Adding global hook: {name}");
319 let hooks_dir = hooks::global::get_user_hooks_dir()?;
320 let hook_path = hooks_dir.join(format!("{name}.hook.yaml"));
321 fs::write(hook_path, content)?;
322 }
323 }
324 applied_changes.push(change.clone());
325 }
326 if !wrote_manifest {
327 local::write_manifest(&manifest)?;
328 local::persist_package_source(&manifest, &pkg_lua_path)?;
329 wrote_manifest = true;
330 }
331 Ok(())
332 })();
333 if let Err(error) = add_result {
334 for change in applied_changes.iter().rev() {
335 if let Err(rollback_error) =
336 revert_extension_change(change, Some(&extension_state))
337 {
338 eprintln!(
339 "Warning: failed to roll back extension change \
340 {change:?}: {rollback_error}"
341 );
342 }
343 }
344 if wrote_manifest
345 && let Ok(package_dir) = local::get_package_dir(
346 manifest.scope,
347 &manifest.registry_handle,
348 &manifest.repo,
349 &manifest.name
350 )
351 {
352 let _ = fs::remove_dir_all(package_dir);
353 }
354 return Err(error);
355 }
356
357 if let (Some(pm), Some(v)) = (plugin_manager, pkg_val) {
358 pm.trigger_hook_nonfatal("on_post_extension_add", Some(&v));
359 }
360
361 println!("Successfully added extension '{ext_name}'.");
362
363 Ok(())
364}
365
366pub fn remove(
374 ext_name: &str,
375 yes: bool,
376 plugin_manager: Option<&PluginManager>
377) -> Result<()> {
378 println!("Removing extension: {ext_name}");
379
380 let request = resolve::parse_source_string(ext_name)?;
381 let mut candidates = Vec::new();
382 for scope in [
383 types::Scope::Project,
384 types::Scope::User,
385 types::Scope::System
386 ] {
387 candidates
388 .extend(local::find_installed_manifests_matching(&request, scope)?);
389 }
390
391 if candidates.is_empty() {
392 return Err(anyhow!("Extension '{ext_name}' is not installed."));
393 }
394
395 let manifest = select_candidate(ext_name, candidates, yes)?;
396 let scope = manifest.scope;
397
398 let mut manifest_val = None;
399 if let Some(pm) = plugin_manager {
400 let v = pm
401 .lua
402 .to_value(&manifest)
403 .map_err(|e: mlua::Error| anyhow!(e.to_string()))?;
404 pm.trigger_hook("on_pre_extension_remove", Some(&v.clone()))?;
405 manifest_val = Some(v);
406 }
407
408 if manifest.package_type != types::PackageType::Extension {
409 return Err(anyhow!("'{ext_name}' is not an extension package."));
410 }
411
412 let installed_source_path = local::get_package_source_path(&manifest)?;
413 let pkg = if installed_source_path.exists() {
414 let path = installed_source_path.to_str().ok_or_else(|| {
415 anyhow!("Stored package source path contains invalid UTF-8")
416 })?;
417 zoi_lua::parser::parse_lua_package(
418 path,
419 Some(&manifest.version),
420 Some(manifest.scope),
421 true
422 )?
423 } else {
424 let source = local::installed_manifest_source(&manifest);
425 let (pkg, _, _, _, _, _, _) = resolve::resolve_package_and_version(
426 &source,
427 Some(manifest.scope),
428 true,
429 yes
430 )?;
431 pkg
432 };
433
434 let extension_state = read_extension_state(&manifest)?;
435 let extension_info = extension_state
436 .as_ref()
437 .and_then(|state| state.installed_extension.clone())
438 .or(pkg.extension);
439
440 if let Some(extension_info) = extension_info {
441 if extension_info.extension_type != "zoi" {
442 return Err(anyhow!(
443 "Unsupported extension type: {}",
444 extension_info.extension_type
445 ));
446 }
447
448 println!("Reverting extension changes...");
449 for change in extension_info.changes.iter().rev() {
450 match change {
451 types::ExtensionChange::RepoGit { add } => {
452 let repo_name = get_repo_name_from_url(add);
453 if !repo_name.is_empty() {
454 println!("Removing git repository: {repo_name}");
455 if let Err(e) = revert_extension_change(
456 change,
457 extension_state.as_ref()
458 ) {
459 eprintln!(
460 "Warning: failed to remove git repo \
461 '{repo_name}': {e}"
462 );
463 }
464 }
465 }
466 types::ExtensionChange::RegistryRepo { add: _ } => {
467 println!("Restoring previous default registry");
468 if let Err(e) = revert_extension_change(
469 change,
470 extension_state.as_ref()
471 ) {
472 eprintln!(
473 "Warning: failed to restore default registry: {e}"
474 );
475 }
476 }
477 types::ExtensionChange::RegistryAdd { add } => {
478 println!("Removing registry: {add}");
479 if let Err(e) = revert_extension_change(
480 change,
481 extension_state.as_ref()
482 ) {
483 eprintln!(
484 "Warning: failed to remove registry '{add}': {e}"
485 );
486 }
487 }
488 types::ExtensionChange::RepoAdd { add } => {
489 println!("Removing repository: {add}");
490 if let Err(e) = revert_extension_change(
491 change,
492 extension_state.as_ref()
493 ) {
494 eprintln!(
495 "Warning: failed to remove repo '{add}': {e}"
496 );
497 }
498 }
499 types::ExtensionChange::Project { add: _ } => {
500 let project_file_path =
501 get_project_file_path(extension_state.as_ref());
502 println!("Removing {}...", project_file_path.display());
503 if let Err(e) = revert_extension_change(
504 change,
505 extension_state.as_ref()
506 ) {
507 eprintln!(
508 "Warning: failed to remove '{}': {e}",
509 project_file_path.display()
510 );
511 }
512 }
513 types::ExtensionChange::Pgp { name, key: _ } => {
514 println!("Removing PGP key: {name}");
515 if let Err(e) = revert_extension_change(
516 change,
517 extension_state.as_ref()
518 ) {
519 eprintln!(
520 "Warning: failed to remove PGP key '{name}': {e}"
521 );
522 }
523 }
524 types::ExtensionChange::Plugin { name, script: _ } => {
525 println!("Removing plugin: {name}");
526 if let Err(e) = revert_extension_change(
527 change,
528 extension_state.as_ref()
529 ) {
530 eprintln!(
531 "Warning: failed to remove plugin '{name}': {e}"
532 );
533 }
534 }
535 types::ExtensionChange::Hook { name, content: _ } => {
536 println!("Removing global hook: {name}");
537 if let Err(e) = revert_extension_change(
538 change,
539 extension_state.as_ref()
540 ) {
541 eprintln!(
542 "Warning: failed to remove global hook '{name}': \
543 {e}"
544 );
545 }
546 }
547 }
548 }
549 } else {
550 return Err(anyhow!(
551 "'{ext_name}' is an extension package but contains no extension \
552 data."
553 ));
554 }
555
556 let package_dir = local::get_package_dir(
557 scope,
558 &manifest.registry_handle,
559 &manifest.repo,
560 &manifest.name
561 )?;
562
563 if package_dir.exists() {
564 fs::remove_dir_all(&package_dir)?;
565 }
566
567 if let (Some(pm), Some(v)) = (plugin_manager, manifest_val) {
568 pm.trigger_hook_nonfatal("on_post_extension_remove", Some(&v));
569 }
570
571 println!("Successfully removed extension '{ext_name}'.");
572
573 Ok(())
574}
575
576fn select_candidate(
579 package_name: &str,
580 candidates: Vec<types::InstallManifest>,
581 yes: bool
582) -> Result<types::InstallManifest> {
583 use colored::Colorize;
584 use comfy_table::Table;
585 use comfy_table::presets::UTF8_FULL;
586 use dialoguer::Select;
587 use dialoguer::theme::ColorfulTheme;
588
589 if candidates.is_empty() {
590 return Err(anyhow!("Package '{package_name}' is not installed."));
591 }
592 if candidates.len() == 1 {
593 return Ok(candidates
594 .into_iter()
595 .next()
596 .expect("Already checked length"));
597 }
598 if yes {
599 return Err(anyhow!(
600 "Package '{package_name}' matches multiple installed packages. \
601 Use an explicit source like '#handle@repo/name[:sub]@version'."
602 ));
603 }
604
605 let displays: Vec<_> = candidates
606 .iter()
607 .map(|m| {
608 let source = local::installed_manifest_source(m);
609 let scope_label = match m.scope {
610 types::Scope::User => "user",
611 types::Scope::System => "system",
612 types::Scope::Project => "project"
613 };
614 format!("{} ({}, v{})", source, scope_label, m.version)
615 })
616 .collect();
617
618 let mut table = Table::new();
619 table.load_style(UTF8_FULL);
620 table.set_header(vec!["#", "Source", "Version"]);
621 for (i, m) in candidates.iter().enumerate() {
622 table.add_row(vec![
623 (i + 1).to_string(),
624 local::installed_manifest_source(m),
625 m.version.clone(),
626 ]);
627 }
628 println!(
629 "Found multiple installed packages matching '{}'. Please choose one:",
630 package_name.cyan()
631 );
632 println!("{table}");
633
634 let selection = Select::with_theme(&ColorfulTheme::default())
635 .with_prompt("Select an installed package")
636 .items(&displays)
637 .default(0)
638 .interact()?;
639
640 Ok(candidates
641 .get(selection)
642 .cloned()
643 .expect("Invalid selection"))
644}