aboutsummaryrefslogtreecommitdiff
path: root/mingling_cli/src
diff options
context:
space:
mode:
Diffstat (limited to 'mingling_cli/src')
-rw-r--r--mingling_cli/src/bin/cli.rs2
-rw-r--r--mingling_cli/src/pkg_mgr.rs38
-rw-r--r--mingling_cli/src/pkg_mgr/cmd_install.rs180
-rw-r--r--mingling_cli/src/pkg_mgr/cmd_uninstall.rs149
4 files changed, 364 insertions, 5 deletions
diff --git a/mingling_cli/src/bin/cli.rs b/mingling_cli/src/bin/cli.rs
index fe2e60a..6f7556e 100644
--- a/mingling_cli/src/bin/cli.rs
+++ b/mingling_cli/src/bin/cli.rs
@@ -1,6 +1,7 @@
use mingling::setup::{ExitCodeSetup, picker::HelpFlagSetup};
use mingling_cli::{
ThisProgram, linter::registry::LintRegistrySetup, metadata::MinglingMetadataSetup,
+ pkg_mgr::PackageManagerSetup,
};
#[tokio::main]
@@ -13,6 +14,7 @@ async fn main() {
program.with_setup(MinglingMetadataSetup);
program.with_setup(LintRegistrySetup);
+ program.with_setup(PackageManagerSetup);
// Exec
program.exec_and_exit().await;
diff --git a/mingling_cli/src/pkg_mgr.rs b/mingling_cli/src/pkg_mgr.rs
index b77c3b3..7aea59d 100644
--- a/mingling_cli/src/pkg_mgr.rs
+++ b/mingling_cli/src/pkg_mgr.rs
@@ -1,2 +1,40 @@
pub mod cmd_install;
+pub mod cmd_uninstall;
+use std::path::PathBuf;
+
+use mingling::{
+ Program,
+ macros::{buffer, pack_err, program_setup, r_println, renderer},
+};
+
+use crate::ThisProgram;
+
+pack_err!(ErrorRootPackageNotFound);
+pack_err!(ErrorNoDataDirectory);
+
+/// The `.mingling` packages directory under the user's data directory.
+#[derive(Debug, Default, Clone)]
+pub struct ResPackagesDir {
+ pub path: PathBuf,
+}
+
+#[program_setup]
+pub fn package_manager_setup(program: &mut Program<ThisProgram>) {
+ let path = dirs::data_dir()
+ .map(|data_dir| data_dir.join(".mingling"))
+ .unwrap_or_default();
+ program.with_resource(ResPackagesDir { path });
+}
+
+#[renderer(buffer)]
+pub fn render_error_root_package_not_found(_: ErrorRootPackageNotFound) {
+ r_println!("error: failed to determine the root package");
+ r_println!("");
+ r_println!("Run `mling install` / `mling uninstall` inside a Cargo workspace");
+}
+
+#[renderer(buffer)]
+pub fn render_error_no_data_directory(_: ErrorNoDataDirectory) {
+ r_println!("error: failed to determine the data directory");
+}
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
+}