diff options
| author | 魏曹先生 <1992414357@qq.com> | 2026-08-09 19:39:44 +0800 |
|---|---|---|
| committer | 魏曹先生 <1992414357@qq.com> | 2026-08-09 19:39:44 +0800 |
| commit | be03071241c0fb54a7dfbfa1980a64a6e8686fec (patch) | |
| tree | a3992f6c67a6909a62fee3c988d5ebfe97d6c06e /mingling_cli | |
| parent | 0b02cbd797ab13ff0877031d83e2ecc85a7916b5 (diff) | |
feat(class-add): implement class-add command
Add the `class-add` command to create project classes from
templates registered in `.mling/classes.toml`. Supports name
derivation (snake_case, PascalCase, etc.), template expansion,
and shell completions.
Diffstat (limited to 'mingling_cli')
| -rw-r--r-- | mingling_cli/help/help.txt | 2 | ||||
| -rw-r--r-- | mingling_cli/src/proj_mgr.rs | 1 | ||||
| -rw-r--r-- | mingling_cli/src/proj_mgr/cmd_class_add.rs | 267 |
3 files changed, 269 insertions, 1 deletions
diff --git a/mingling_cli/help/help.txt b/mingling_cli/help/help.txt index dd6ea3d..04bb972 100644 --- a/mingling_cli/help/help.txt +++ b/mingling_cli/help/help.txt @@ -22,7 +22,7 @@ __ explain <LINT> *Explain the meaning of the specified Lint* __ **PROJECT MANAGE:** __ proj-init <TEMPLATE> *Initialize a project* -__ class-add <CLASS> <PARAMS...> **[NOT_IMPL]** *Create a Class of the specified type in the project* +__ class-add <CLASS> <NAME> *Create a Class of the specified type in the project* __ **PACKAGE MANAGE:** __ install *Install the project to the Mingling package list* diff --git a/mingling_cli/src/proj_mgr.rs b/mingling_cli/src/proj_mgr.rs index 66130f3..8ff445c 100644 --- a/mingling_cli/src/proj_mgr.rs +++ b/mingling_cli/src/proj_mgr.rs @@ -1,3 +1,4 @@ +pub mod cmd_class_add; pub mod cmd_proj_init; pub mod rule_solver; pub mod template_source; diff --git a/mingling_cli/src/proj_mgr/cmd_class_add.rs b/mingling_cli/src/proj_mgr/cmd_class_add.rs new file mode 100644 index 0000000..281b567 --- /dev/null +++ b/mingling_cli/src/proj_mgr/cmd_class_add.rs @@ -0,0 +1,267 @@ +use std::{fs, path::PathBuf}; + +use just_fmt::{camel_case, kebab_case, pascal_case, snake_case}; +use just_template::Template; +use mingling::{ + Grouped, RenderResult, Routable, ShellContext, Suggest, SuggestItem, + macros::{arg, chain, command, completion, metadata, pack, pack_err, renderer, routeify}, + metadata::Description, + picker::EntryPicker, + res::ResCurrentDir, +}; +use toml_edit::DocumentMut; + +use crate::{Next, eprintln_cargo, println_cargo}; + +/// A `[[classes]]` entry in `.mling/classes.toml`. +#[derive(Debug, Clone)] +pub struct ClassEntry { + /// Class type name, e.g. `subcommand`. + pub name: String, + /// Template path relative to `.mling/`, e.g. `classes/subcommand.rs`. + pub template: String, + /// Output directory relative to the project root, e.g. `src/command/`. + pub output_dir: String, + /// Short description shown in completions, may be empty. + pub description: String, +} + +pack!(StateClassAdd = (String, String)); + +/// Result of adding a class: the generated file path. +#[derive(Debug, Default, Grouped)] +pub struct ResultClassAdd { + pub output: PathBuf, +} + +pack_err!(ErrorClassNameRequired = ()); +pack_err!(ErrorClassConfigMissing = String); +pack_err!(ErrorClassNotFound = String); +pack_err!(ErrorClassTemplateMissing = String); +pack_err!(ErrorClassWriteFailed = String); + +#[command(node = "class-add", routeify)] +pub fn class_add(args: EntryClassAdd) -> Next { + let (class_name, name) = args + .pick_or_route(&arg![String], || ErrorClassNameRequired::new(()).to_chain()) + .pick_or_route(&arg![String], || ErrorClassNameRequired::new(()).to_chain()) + .to_result()?; + StateClassAdd::new((class_name, name)).to_chain() +} + +/// Read `.mling/classes.toml`, find the class template and render it with the +/// name-derived parameters into `<output-dir>/<snake_case>.rs`. +#[chain(routeify)] +pub fn handle_state_class_add(state: StateClassAdd, cwd: &ResCurrentDir) -> Next { + let (class_name, name) = state.inner; + + // Read `.mling/classes.toml` (the class registry). + let classes_path = cwd.join(".mling").join("classes.toml"); + let content = fs::read_to_string(&classes_path).map_err(|e| { + ErrorClassConfigMissing::new(format!("failed to read {}: {e}", classes_path.display())) + })?; + let classes = parse_classes(&content) + .map_err(|e| ErrorClassConfigMissing::new(format!("invalid classes.toml: {e}")))?; + + // Find the requested class type. + let Some(entry) = classes.iter().find(|c| c.name == class_name) else { + return ErrorClassNotFound::new(format!( + "class `{class_name}` not found in {}", + classes_path.display() + )) + .to_chain(); + }; + + // Read the class template (relative to `.mling/`). + let template_path = cwd.join(".mling").join(&entry.template); + let template_content = fs::read_to_string(&template_path).map_err(|e| { + ErrorClassTemplateMissing::new(format!("failed to read {}: {e}", template_path.display())) + })?; + + // Derive the name variants used by the template placeholders. + let snake = snake_case!(name.as_str()); + let pascal = pascal_case!(name.as_str()); + let kebab = kebab_case!(name.as_str()); + let upper_snake = snake.to_uppercase(); + let camel = camel_case!(name.as_str()); + + let mut tmpl = Template::from(template_content); + tmpl.insert_param("snake_case".to_string(), snake.clone()); + tmpl.insert_param("pascal_case".to_string(), pascal); + tmpl.insert_param("kebab_case".to_string(), kebab); + tmpl.insert_param("upper_snake_case".to_string(), upper_snake); + tmpl.insert_param("camel_case".to_string(), camel); + let expanded = tmpl.expand().ok_or_else(|| { + ErrorClassWriteFailed::new(format!( + "failed to expand class template: {}", + template_path.display() + )) + })?; + + // Write to `<output-dir>/<snake_case>.rs`. + let output_dir = cwd.join(&entry.output_dir); + let output = output_dir.join(format!("{snake}.rs")); + fs::create_dir_all(&output_dir).map_err(|e| { + ErrorClassWriteFailed::new(format!("failed to create {}: {e}", output_dir.display())) + })?; + fs::write(&output, expanded).map_err(|e| { + ErrorClassWriteFailed::new(format!("failed to write {}: {e}", output.display())) + })?; + + ResultClassAdd { output }.to_chain() +} + +/// Parse `.mling/classes.toml` into class entries. +fn parse_classes(content: &str) -> Result<Vec<ClassEntry>, String> { + let doc = content.parse::<DocumentMut>().map_err(|e| e.to_string())?; + let mut entries = Vec::new(); + if let Some(tables) = doc + .get("classes") + .and_then(|item| item.as_array_of_tables()) + { + for table in tables { + let name = table + .get("name") + .and_then(|v| v.as_str()) + .ok_or("[[classes]] entry is missing `name`")? + .to_string(); + let template = table + .get("template") + .and_then(|v| v.as_str()) + .ok_or("[[classes]] entry is missing `template`")? + .to_string(); + let output_dir = table + .get("output-dir") + .and_then(|v| v.as_str()) + .ok_or("[[classes]] entry is missing `output-dir`")? + .to_string(); + let description = table + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + entries.push(ClassEntry { + name, + template, + output_dir, + description, + }); + } + } + Ok(entries) +} + +#[renderer] +pub fn render_result_class_add(result: ResultClassAdd) -> RenderResult { + let mut r = RenderResult::new(); + println_cargo!(r, "Class added: {}", result.output.display()); + r +} + +#[renderer] +pub fn render_error_class_name_required(_err: ErrorClassNameRequired) -> RenderResult { + let mut r = RenderResult::new(); + eprintln_cargo!(r, "usage: mling class-add <class> <name>"); + r +} + +#[renderer] +pub fn render_error_class_config_missing(err: ErrorClassConfigMissing) -> RenderResult { + let mut r = RenderResult::new(); + eprintln_cargo!(r, "{}", err.info); + r +} + +#[renderer] +pub fn render_error_class_not_found(err: ErrorClassNotFound) -> RenderResult { + let mut r = RenderResult::new(); + eprintln_cargo!(r, "{}", err.info); + r +} + +#[renderer] +pub fn render_error_class_template_missing(err: ErrorClassTemplateMissing) -> RenderResult { + let mut r = RenderResult::new(); + eprintln_cargo!(r, "{}", err.info); + r +} + +#[renderer] +pub fn render_error_class_write_failed(err: ErrorClassWriteFailed) -> RenderResult { + let mut r = RenderResult::new(); + eprintln_cargo!(r, "{}", err.info); + r +} + +#[completion(EntryClassAdd)] +pub fn complete_class_add(ctx: &ShellContext, cwd: &ResCurrentDir) -> Suggest { + if ctx.previous_word != "class-add" { + return Suggest::file_comp(); + } + let mut suggest = Suggest::new(); + let classes_path = cwd.join(".mling").join("classes.toml"); + let Ok(content) = fs::read_to_string(&classes_path) else { + return suggest; + }; + let Ok(classes) = parse_classes(&content) else { + return suggest; + }; + for entry in classes { + if entry.description.is_empty() { + suggest.insert(SuggestItem::new(entry.name)); + } else { + suggest.insert(SuggestItem::new_with_desc(entry.name, entry.description)); + } + } + suggest +} + +#[metadata(EntryClassAdd)] +pub fn desc_class_add() -> Description { + "Add a class instance to the project from a registered template".into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_class_entries() { + let content = r#" +[[classes]] +name = "subcommand" +template = "classes/subcommand.rs" +output-dir = "src/command/" +description = "Add a subcommand" + +[[classes]] +name = "resource" +template = "classes/resource.rs" +output-dir = "src/resource/" +"#; + let entries = parse_classes(content).unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].name, "subcommand"); + assert_eq!(entries[0].template, "classes/subcommand.rs"); + assert_eq!(entries[0].output_dir, "src/command/"); + assert_eq!(entries[0].description, "Add a subcommand"); + assert_eq!(entries[1].name, "resource"); + // Description is optional and defaults to empty. + assert_eq!(entries[1].description, ""); + } + + #[test] + fn parses_empty_classes() { + let entries = parse_classes("").unwrap(); + assert!(entries.is_empty()); + } + + #[test] + fn missing_fields_are_rejected() { + let content = r#" +[[classes]] +name = "subcommand" +"#; + assert!(parse_classes(content).is_err()); + } +} |
