diff options
Diffstat (limited to 'mingling_cli/src/pkg_mgr')
| -rw-r--r-- | mingling_cli/src/pkg_mgr/cmd_install.rs | 180 | ||||
| -rw-r--r-- | mingling_cli/src/pkg_mgr/cmd_uninstall.rs | 149 |
2 files changed, 324 insertions, 5 deletions
diff --git a/mingling_cli/src/pkg_mgr/cmd_install.rs b/mingling_cli/src/pkg_mgr/cmd_install.rs index ad45f04..a399e83 100644 --- a/mingling_cli/src/pkg_mgr/cmd_install.rs +++ b/mingling_cli/src/pkg_mgr/cmd_install.rs @@ -1,8 +1,178 @@ -use mingling::{LazyRes, macros::command}; +use std::{env, fs, io, path::PathBuf, process::Command}; -use crate::metadata::setup::ResMetadata; +use cargo_metadata::TargetKind; +use mingling::{ + Grouped, LazyRes, Routable, + macros::{buffer, chain, command, metadata, pack_err, r_println, renderer, routeify}, + metadata::Description, +}; -#[command] -pub fn install(_metadata: &mut LazyRes<ResMetadata>) { - // let metadata = metadata.get_ref().data(); +use crate::{ + Next, + metadata::setup::ResMetadata, + pkg_mgr::{ErrorNoDataDirectory, ErrorRootPackageNotFound, ResPackagesDir}, +}; + +pack_err!(ErrorBuildFailed = String); +pack_err!(ErrorBinaryNotFound = String); + +/// Resolved install paths, used by the build step. +#[derive(Debug, Default, Grouped)] +pub struct StateInstallBuild { + pub workspace_root: PathBuf, + pub install_dir: PathBuf, + pub release_dir: PathBuf, + pub exe_suffix: &'static str, +} + +/// State after `cargo build --release`, used by the copy step. +#[derive(Debug, Default, Grouped)] +pub struct StateInstallCopy { + pub install_dir: PathBuf, + pub release_dir: PathBuf, + pub exe_suffix: &'static str, + pub installed: Vec<PathBuf>, +} + +#[derive(Debug, Default, Grouped)] +pub struct ResultInstall { + pub install_dir: PathBuf, + pub installed: Vec<PathBuf>, +} + +/// Parse arguments and resolve the install directory: +/// {data_dir}/.mingling/{PACKAGE_NAME}@{PACKAGE_VERSION} +#[metadata(EntryInstall)] +pub fn desc_install() -> Description { + "Install the project to the Mingling package list".into() +} + +#[command(routeify)] +pub fn install(packages_dir: &ResPackagesDir, metadata: &mut LazyRes<ResMetadata>) -> Next { + let metadata = metadata.get_ref().data(); + let packages_dir = &packages_dir.path; + if packages_dir.as_os_str().is_empty() { + return ErrorNoDataDirectory::default().to_chain(); + } + + let root_package = metadata + .root_package() + .or_else(|| metadata.workspace_packages().first().copied()) + .ok_or(ErrorRootPackageNotFound::default())?; + + StateInstallBuild { + workspace_root: metadata.workspace_root.clone().into_std_path_buf(), + install_dir: packages_dir.join(format!("{}@{}", root_package.name, root_package.version)), + release_dir: metadata + .target_directory + .join("release") + .into_std_path_buf(), + exe_suffix: env::consts::EXE_SUFFIX, + } + .to_chain() +} + +/// Step 1: build release binaries. +#[chain(routeify)] +pub fn handle_state_install_build(state: StateInstallBuild) -> Next { + let status = Command::new("cargo") + .args(["build", "--release"]) + .current_dir(&state.workspace_root) + .status() + .map_err(|e| { + ErrorBuildFailed::new(format!("failed to run `cargo build --release`: {e}")) + })?; + if !status.success() { + return ErrorBuildFailed::new(format!("`cargo build --release` failed with {status}")) + .to_chain(); + } + + StateInstallCopy { + install_dir: state.install_dir, + release_dir: state.release_dir, + exe_suffix: state.exe_suffix, + installed: vec![], + } + .to_chain() +} + +/// Step 2: copy binaries and completion scripts. +#[chain(routeify)] +pub fn handle_state_install_copy( + mut state: StateInstallCopy, + metadata: &mut LazyRes<ResMetadata>, +) -> Next { + let metadata = metadata.get_ref().data(); + fs::create_dir_all(&state.install_dir).map_err(|e| { + io::Error::new( + e.kind(), + format!("failed to create {}: {e}", state.install_dir.display()), + ) + })?; + + for package in metadata.workspace_packages() { + let bin_targets: Vec<_> = package + .targets + .iter() + .filter(|target| target.kind.contains(&TargetKind::Bin)) + .collect(); + + for target in bin_targets { + let bin_file = format!("{}{}", target.name, state.exe_suffix); + let src = state.release_dir.join(&bin_file); + if !src.is_file() { + return ErrorBinaryNotFound::new(bin_file).to_chain(); + } + let dst = state.install_dir.join(&bin_file); + fs::copy(&src, &dst).map_err(|e| { + io::Error::new(e.kind(), format!("failed to copy {}: {e}", src.display())) + })?; + state.installed.push(dst); + } + } + + // Completion scripts are generated into the build profile directory + // (OUT_DIR/../../../), copy every one whose name contains `_comp`, + // regardless of its suffix + for entry in fs::read_dir(&state.release_dir).map_err(|e| { + io::Error::new( + e.kind(), + format!("failed to read {}: {e}", state.release_dir.display()), + ) + })? { + let entry = entry.map_err(|e| { + io::Error::new(e.kind(), format!("failed to read directory entry: {e}")) + })?; + let name = entry.file_name().to_string_lossy().into_owned(); + if name.contains("_comp") { + let dst = state.install_dir.join(&name); + fs::copy(entry.path(), &dst) + .map_err(|e| io::Error::new(e.kind(), format!("failed to copy {name}: {e}")))?; + state.installed.push(dst); + } + } + + ResultInstall { + install_dir: state.install_dir, + installed: state.installed, + } + .to_chain() +} + +#[renderer(buffer)] +pub fn render_result_install(r: ResultInstall) { + r_println!("Installed to {}", r.install_dir.display()); + for file in r.installed { + r_println!(" {}", file.display()); + } +} + +#[renderer(buffer)] +pub fn render_error_build_failed(err: ErrorBuildFailed) { + r_println!("error: {}", err.info); +} + +#[renderer(buffer)] +pub fn render_error_binary_not_found(err: ErrorBinaryNotFound) { + r_println!("error: binary not found: {}", err.info); } diff --git a/mingling_cli/src/pkg_mgr/cmd_uninstall.rs b/mingling_cli/src/pkg_mgr/cmd_uninstall.rs new file mode 100644 index 0000000..d9a3004 --- /dev/null +++ b/mingling_cli/src/pkg_mgr/cmd_uninstall.rs @@ -0,0 +1,149 @@ +use std::{fs, io, path::PathBuf}; + +use mingling::{ + Grouped, LazyRes, Routable, ShellContext, Suggest, SuggestItem, + macros::{ + arg, buffer, chain, command, completion, metadata, pack, pack_err, r_println, renderer, + routeify, + }, + metadata::Description, + picker::{EntryPicker, PickerArg}, +}; + +use crate::{ + Next, + metadata::setup::ResMetadata, + pkg_mgr::{ErrorNoDataDirectory, ErrorRootPackageNotFound, ResPackagesDir}, +}; + +/// Optional positional argument: package spec (`name` or `name@version`) +pub static ARG_PACKAGE: PickerArg<Option<String>> = arg![Option<String>]; + +pack_err!(ErrorPackageSpecInvalid = String); +pack!(StateUninstallPackages = Vec<String>); + +#[derive(Debug, Default, Grouped)] +pub struct ResultUninstall { + pub removed: Vec<PathBuf>, + pub not_installed: Vec<PathBuf>, +} + +/// `{data_dir}/.mingling` +#[metadata(EntryUninstall)] +pub fn desc_uninstall() -> Description { + "Uninstall the project from the Mingling package list".into() +} + +#[command(routeify)] +pub fn uninstall( + args: EntryUninstall, + packages_dir: &ResPackagesDir, + metadata: &mut LazyRes<ResMetadata>, +) -> Next { + let spec = args.pick(&ARG_PACKAGE).to_result()?; + let packages_dir = &packages_dir.path; + if packages_dir.as_os_str().is_empty() { + return ErrorNoDataDirectory::default().to_chain(); + } + + let targets = match spec { + // No spec: uninstall the version installed by the current project + None => { + let metadata = metadata.get_ref().data(); + let root_package = metadata + .root_package() + .or_else(|| metadata.workspace_packages().first().copied()) + .ok_or(ErrorRootPackageNotFound::default())?; + vec![format!("{}@{}", root_package.name, root_package.version)] + } + // `name` matches every installed version, `name@version` matches exactly + Some(spec) => { + if spec.contains('/') || spec.contains('\\') || spec.contains("..") { + return ErrorPackageSpecInvalid::new(spec).to_chain(); + } + if spec.contains('@') { + vec![spec] + } else { + let prefix = format!("{spec}@"); + if !packages_dir.is_dir() { + Vec::new() + } else { + fs::read_dir(packages_dir) + .map_err(|e| { + io::Error::new( + e.kind(), + format!("failed to read {}: {e}", packages_dir.display()), + ) + })? + .filter_map(|e| e.ok()) + .filter_map(|e| e.file_name().into_string().ok()) + .filter(|name| name.starts_with(&prefix)) + .collect() + } + } + } + }; + + StateUninstallPackages::new(targets).to_chain() +} + +#[chain(routeify)] +pub fn handle_state_uninstall_packages( + p: StateUninstallPackages, + packages_dir: &ResPackagesDir, +) -> Next { + let packages_dir = &packages_dir.path; + if packages_dir.as_os_str().is_empty() { + return ErrorNoDataDirectory::default().to_chain(); + } + let mut removed = Vec::new(); + let mut not_installed = Vec::new(); + + for name in p.inner { + let dir = packages_dir.join(&name); + if !dir.exists() { + not_installed.push(dir); + continue; + } + fs::remove_dir_all(&dir).map_err(|e| { + io::Error::new(e.kind(), format!("failed to remove {}: {e}", dir.display())) + })?; + removed.push(dir); + } + + ResultUninstall { + removed, + not_installed, + } + .to_chain() +} + +#[renderer(buffer)] +pub fn render_result_uninstall(r: ResultUninstall) { + if r.removed.is_empty() && r.not_installed.is_empty() { + r_println!("No matching packages installed"); + } else { + for dir in r.removed { + r_println!("Uninstalled: {}", dir.display()); + } + for dir in r.not_installed { + r_println!("Not installed: {}", dir.display()); + } + } +} + +#[completion(EntryUninstall)] +pub fn complete_uninstall(ctx: &ShellContext, packages_dir: &ResPackagesDir) -> Suggest { + if ctx.previous_word != "uninstall" { + return Suggest::FileCompletion; + } + let mut suggest = Suggest::new(); + if let Ok(entries) = fs::read_dir(&packages_dir.path) { + for entry in entries.flatten() { + if let Some(name) = entry.file_name().to_str() { + suggest.insert(SuggestItem::Simple(name.to_string())); + } + } + } + suggest +} |
