From 1d7efb22dc82780dc3fb1d3311188c0514d93991 Mon Sep 17 00:00:00 2001 From: 魏曹先生 <1992414357@qq.com> Date: Fri, 7 Aug 2026 16:05:00 +0800 Subject: feat: implement pkg-enable and pkg-disable commands Add package enable/disable functionality with version matching for pkg-enable, and fix help text to reflect implemented commands. --- mingling_cli/src/pkg_mgr/cmd_pkg_disable.rs | 108 +++++++++++++++++++++ mingling_cli/src/pkg_mgr/cmd_pkg_enable.rs | 142 ++++++++++++++++++++++++++++ mingling_cli/src/pkg_mgr/cmd_uninstall.rs | 8 +- 3 files changed, 254 insertions(+), 4 deletions(-) create mode 100644 mingling_cli/src/pkg_mgr/cmd_pkg_disable.rs create mode 100644 mingling_cli/src/pkg_mgr/cmd_pkg_enable.rs (limited to 'mingling_cli/src/pkg_mgr') diff --git a/mingling_cli/src/pkg_mgr/cmd_pkg_disable.rs b/mingling_cli/src/pkg_mgr/cmd_pkg_disable.rs new file mode 100644 index 0000000..679421c --- /dev/null +++ b/mingling_cli/src/pkg_mgr/cmd_pkg_disable.rs @@ -0,0 +1,108 @@ +use std::{fs, io}; + +use mingling::{ + Grouped, 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, + pkg_mgr::{ + ErrorNoDataDirectory, ErrorPackageNameRequired, ErrorPackageSpecInvalid, ResPackagesDir, + }, +}; + +/// Positional argument: package name +pub static ARG_NAME: PickerArg = arg![String]; + +pack_err!(ErrorPackageNotEnabled = String); + +// The name of the package to disable +pack!(StatePkgDisable = String); + +#[derive(Debug, Default, Grouped)] +pub struct ResultPkgDisable { + pub name: String, + pub removed: bool, +} + +#[metadata(EntryPkgDisable)] +pub fn desc_pkg_disable() -> Description { + "Disable the specified package".into() +} + +#[command(node = "pkg-disable", routeify)] +pub fn package_disable(args: EntryPkgDisable, packages_dir: &ResPackagesDir) -> Next { + let name = args + .pick_or_route(&ARG_NAME, || ErrorPackageNameRequired::default().to_chain()) + .to_result()?; + let packages_dir = &packages_dir.path; + if packages_dir.as_os_str().is_empty() { + return ErrorNoDataDirectory::default().to_chain(); + } + if name.contains('/') || name.contains('\\') || name.contains("..") || name.contains('@') { + return ErrorPackageSpecInvalid::new(name).to_chain(); + } + + StatePkgDisable::new(name).to_chain() +} + +#[chain(routeify)] +pub fn handle_state_pkg_disable(p: StatePkgDisable, packages_dir: &ResPackagesDir) -> Next { + let name = p.inner; + let packages_dir = &packages_dir.path; + if packages_dir.as_os_str().is_empty() { + return ErrorNoDataDirectory::default().to_chain(); + } + + let file = packages_dir.join(&name); + if !file.is_file() { + return ErrorPackageNotEnabled::new(name).to_chain(); + } + fs::remove_file(&file).map_err(|e| { + io::Error::new( + e.kind(), + format!("failed to remove {}: {e}", file.display()), + ) + })?; + + ResultPkgDisable { + name, + removed: true, + } + .to_chain() +} + +#[renderer(buffer)] +pub fn render_result_pkg_disable(r: ResultPkgDisable) { + r_println!("Disabled {}", r.name); +} + +#[renderer(buffer)] +pub fn render_error_package_not_enabled(err: ErrorPackageNotEnabled) { + r_println!("error: package is not enabled: {}", err.info); +} + +#[completion(EntryPkgDisable)] +pub fn complete_pkg_disable(ctx: &ShellContext, packages_dir: &ResPackagesDir) -> Suggest { + if ctx.previous_word != "pkg-disable" { + return Suggest::FileCompletion; + } + let mut suggest = Suggest::new(); + if let Ok(entries) = fs::read_dir(&packages_dir.path) { + for entry in entries.flatten() { + if !entry.file_type().is_ok_and(|t| t.is_file()) { + continue; + } + if let Some(name) = entry.file_name().to_str() { + suggest.insert(SuggestItem::Simple(name.to_string())); + } + } + } + suggest +} diff --git a/mingling_cli/src/pkg_mgr/cmd_pkg_enable.rs b/mingling_cli/src/pkg_mgr/cmd_pkg_enable.rs new file mode 100644 index 0000000..b545a4d --- /dev/null +++ b/mingling_cli/src/pkg_mgr/cmd_pkg_enable.rs @@ -0,0 +1,142 @@ +use std::{fs, io}; + +use mingling::{ + Grouped, 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, + pkg_mgr::{ + ErrorNoDataDirectory, ErrorPackageNameRequired, ErrorPackageSpecInvalid, ResPackagesDir, + }, +}; + +/// Positional argument: package spec (`foo`, `foo@0`, `foo@0.1`, `foo@0.1.2`) +pub static ARG_SPEC: PickerArg = arg![String]; + +pack_err!(ErrorNoMatchingVersion = String); +pack!(StatePkgEnable = (String, String)); + +#[derive(Debug, Default, Grouped)] +pub struct ResultPkgEnable { + pub name: String, + pub version: String, +} + +#[metadata(EntryPkgEnable)] +pub fn desc_pkg_enable() -> Description { + "Enable the specified package".into() +} + +#[command(node = "pkg-enable", routeify)] +pub fn package_enable(args: EntryPkgEnable, packages_dir: &ResPackagesDir) -> Next { + let spec = args + .pick_or_route(&ARG_SPEC, || ErrorPackageNameRequired::default().to_chain()) + .to_result()?; + let packages_dir = &packages_dir.path; + if packages_dir.as_os_str().is_empty() { + return ErrorNoDataDirectory::default().to_chain(); + } + if spec.contains('/') || spec.contains('\\') || spec.contains("..") { + return ErrorPackageSpecInvalid::new(spec).to_chain(); + } + + let (name, version_part) = match spec.split_once('@') { + Some((name, version)) => (name.to_string(), Some(version.to_string())), + None => (spec.clone(), None), + }; + + // Collect every installed version matching the spec, pick the newest + let mut candidates: Vec<(String, semver::Version)> = Vec::new(); + if let Ok(entries) = fs::read_dir(packages_dir) { + for entry in entries.flatten() { + if !entry.file_type().is_ok_and(|t| t.is_dir()) { + continue; + } + let Some(dir_name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + let Some((dir_pkg, dir_version)) = dir_name.split_once('@') else { + continue; + }; + if dir_pkg != name { + continue; + } + if let Some(part) = &version_part + && !version_matches(dir_version, part) + { + continue; + } + if let Ok(version) = semver::Version::parse(dir_version) { + candidates.push((dir_name, version)); + } + } + } + + let Some((_, version)) = candidates.into_iter().max_by(|a, b| a.1.cmp(&b.1)) else { + return ErrorNoMatchingVersion::new(spec).to_chain(); + }; + + StatePkgEnable::new((name, version.to_string())).to_chain() +} + +#[chain(routeify)] +pub fn handle_state_pkg_enable(p: StatePkgEnable, packages_dir: &ResPackagesDir) -> Next { + let (name, version) = p.inner; + let packages_dir = &packages_dir.path; + if packages_dir.as_os_str().is_empty() { + return ErrorNoDataDirectory::default().to_chain(); + } + + let file = packages_dir.join(&name); + fs::write(&file, &version).map_err(|e| { + io::Error::new(e.kind(), format!("failed to write {}: {e}", file.display())) + })?; + + ResultPkgEnable { name, version }.to_chain() +} + +#[renderer(buffer)] +pub fn render_result_pkg_enable(r: ResultPkgEnable) { + r_println!("Enabled {}@{}", r.name, r.version); +} + +#[completion(EntryPkgEnable)] +pub fn complete_pkg_enable(ctx: &ShellContext, packages_dir: &ResPackagesDir) -> Suggest { + if ctx.previous_word != "pkg-enable" { + return Suggest::FileCompletion; + } + let mut suggest = Suggest::new(); + if let Ok(entries) = fs::read_dir(&packages_dir.path) { + for entry in entries.flatten() { + if !entry.file_type().is_ok_and(|t| t.is_dir()) { + continue; + } + if let Some(name) = entry.file_name().to_str() { + suggest.insert(SuggestItem::Simple(name.to_string())); + } + } + } + suggest +} + +/// Whether `version` falls under the partial spec, compared segment by segment. +/// Pre-release suffixes are ignored during matching, e.g. `0.2.1` matches `0.2.1-rc10`. +fn version_matches(version: &str, partial: &str) -> bool { + let version = version.split('-').next().unwrap_or(version); + let version_parts: Vec<&str> = version.split('.').collect(); + let partial_parts: Vec<&str> = partial.split('.').collect(); + if partial_parts.len() > version_parts.len() { + return false; + } + partial_parts + .iter() + .zip(version_parts.iter()) + .all(|(p, v)| p == v) +} diff --git a/mingling_cli/src/pkg_mgr/cmd_uninstall.rs b/mingling_cli/src/pkg_mgr/cmd_uninstall.rs index d9a3004..69c5aa5 100644 --- a/mingling_cli/src/pkg_mgr/cmd_uninstall.rs +++ b/mingling_cli/src/pkg_mgr/cmd_uninstall.rs @@ -3,8 +3,7 @@ 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, + arg, buffer, chain, command, completion, metadata, pack, r_println, renderer, routeify, }, metadata::Description, picker::{EntryPicker, PickerArg}, @@ -13,13 +12,14 @@ use mingling::{ use crate::{ Next, metadata::setup::ResMetadata, - pkg_mgr::{ErrorNoDataDirectory, ErrorRootPackageNotFound, ResPackagesDir}, + pkg_mgr::{ + ErrorNoDataDirectory, ErrorPackageSpecInvalid, ErrorRootPackageNotFound, ResPackagesDir, + }, }; /// Optional positional argument: package spec (`name` or `name@version`) pub static ARG_PACKAGE: PickerArg> = arg![Option]; -pack_err!(ErrorPackageSpecInvalid = String); pack!(StateUninstallPackages = Vec); #[derive(Debug, Default, Grouped)] -- cgit