diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-14 15:20:44 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-14 15:20:44 +0900 |
| commit | 999fc4157cf631f967a5adedeccb83ae6d0cb0f8 (patch) | |
| tree | 39394948eaee36fd873ede5d2c8345ace121259c /crates/mozart/src | |
| parent | 06970e0ff8b24440908e0333204471d4b99781f0 (diff) | |
| download | php-mozart-999fc4157cf631f967a5adedeccb83ae6d0cb0f8.tar.gz php-mozart-999fc4157cf631f967a5adedeccb83ae6d0cb0f8.tar.zst php-mozart-999fc4157cf631f967a5adedeccb83ae6d0cb0f8.zip | |
implement init command
Diffstat (limited to 'crates/mozart/src')
| -rw-r--r-- | crates/mozart/src/commands/init.rs | 430 | ||||
| -rw-r--r-- | crates/mozart/src/console.rs | 81 | ||||
| -rw-r--r-- | crates/mozart/src/lib.rs | 2 | ||||
| -rw-r--r-- | crates/mozart/src/validation.rs | 226 |
4 files changed, 737 insertions, 2 deletions
diff --git a/crates/mozart/src/commands/init.rs b/crates/mozart/src/commands/init.rs index 0f10186..64a0263 100644 --- a/crates/mozart/src/commands/init.rs +++ b/crates/mozart/src/commands/init.rs @@ -1,4 +1,14 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::Command; +use anyhow::{Context, bail}; use clap::Args; +use colored::Colorize; +use crate::console; +use crate::package::{ + self, RawAuthor, RawAutoload, RawPackageData, RawRepository, +}; +use crate::validation; #[derive(Args)] pub struct InitArgs { @@ -47,6 +57,422 @@ pub struct InitArgs { pub autoload: Option<String>, } -pub fn execute(_args: &InitArgs, _cli: &super::Cli) -> anyhow::Result<()> { - todo!() +pub fn execute(args: &InitArgs, cli: &super::Cli) -> anyhow::Result<()> { + let console = console::Console::new(cli.no_interaction, cli.quiet); + + let working_dir = match &cli.working_dir { + Some(dir) => PathBuf::from(dir), + None => std::env::current_dir().context("Failed to get current directory")?, + }; + + let composer_file = working_dir.join("composer.json"); + if composer_file.exists() { + bail!("composer.json already exists in {}", working_dir.display()); + } + + // Validate --name if provided via CLI + if let Some(ref name) = args.name + && !validation::validate_package_name(name) + { + bail!( + "The package name {name} is invalid, it should be lowercase and have a vendor name, a forward slash, and a package name, matching: [a-z0-9_.-]+/[a-z0-9_.-]+" + ); + } + + let composer = if console.interactive { + build_interactive(args, &console, &working_dir)? + } else { + build_non_interactive(args, &working_dir)? + }; + + let json = package::to_json_pretty(&composer)?; + + if console.interactive { + console.info(""); + console.info(&json); + console.info(""); + + if !console.confirm(&format!( + "Do you confirm generation [{}]?", + console::comment("yes") + )) { + console.error("Command aborted"); + bail!("Command aborted"); + } + } else { + console.info(&format!("Writing {}", composer_file.display())); + } + + package::write_to_file(&composer, &composer_file) + .context("Failed to write composer.json")?; + + // Create autoload directory if specified + if let Some(ref autoload) = composer.autoload { + for path in autoload.psr4.values() { + let dir = working_dir.join(path); + if !dir.exists() { + std::fs::create_dir_all(&dir) + .with_context(|| format!("Failed to create directory {}", dir.display()))?; + } + } + } + + // Offer to add /vendor/ to .gitignore + if console.interactive && working_dir.join(".git").is_dir() { + let gitignore_path = working_dir.join(".gitignore"); + if !has_vendor_ignore(&gitignore_path) + && console.confirm(&format!( + "Would you like the {} directory added to your {} [{}]?", + console::info("vendor"), + console::info(".gitignore"), + console::comment("yes"), + )) + { + add_vendor_ignore(&gitignore_path)?; + } + } + + // Show autoload info + if let Some(ref autoload) = composer.autoload + && let Some((ns, path)) = autoload.psr4.iter().next() + { + console.info(&format!( + "PSR-4 autoloading configured. Use \"{}\" in {path}", + console::comment(&format!("namespace {ns};")), + )); + console.info(&format!( + "Include the Composer autoloader with: {}", + console::comment("require 'vendor/autoload.php';"), + )); + } + + Ok(()) +} + +fn build_non_interactive(args: &InitArgs, working_dir: &Path) -> anyhow::Result<RawPackageData> { + let name = match &args.name { + Some(n) => n.clone(), + None => get_default_package_name(working_dir), + }; + + let mut composer = RawPackageData::new(name.clone()); + composer.description = args.description.clone(); + composer.package_type = args.r#type.clone(); + composer.homepage = args.homepage.clone(); + composer.license = args.license.clone(); + + if let Some(ref stability) = args.stability { + if !validation::validate_stability(stability) { + bail!( + "Invalid minimum stability \"{stability}\". Must be one of: dev, alpha, beta, rc, stable" + ); + } + composer.minimum_stability = Some(stability.to_lowercase()); + } + + let author_str = args.author.clone().or_else(get_default_author); + if let Some(ref a) = author_str { + let parsed = validation::parse_author(a).map_err(|e| anyhow::anyhow!(e))?; + composer.authors = vec![RawAuthor { + name: parsed.name, + email: parsed.email, + }]; + } + + composer.require = parse_requirements(&args.require)?; + composer.require_dev = parse_requirements(&args.require_dev)?; + composer.repositories = parse_repositories(&args.repository)?; + + if let Some(ref autoload_path) = args.autoload { + composer.autoload = build_autoload(autoload_path, &name); + } + + Ok(composer) +} + +fn build_interactive( + args: &InitArgs, + console: &console::Console, + working_dir: &Path, +) -> anyhow::Result<RawPackageData> { + console.info(""); + console.info(&format!( + " {} ", + "Welcome to the Mozart config generator".white().on_blue() + )); + console.info(""); + console.info("This command will guide you through creating your composer.json config."); + console.info(""); + + // Package name + let default_name = args + .name + .clone() + .unwrap_or_else(|| get_default_package_name(working_dir)); + let name = console.ask_validated( + &format!( + "Package name (<vendor>/<name>) [{}]", + crate::console::comment(&default_name), + ), + &default_name, + |val| { + if validation::validate_package_name(val) { + Ok(()) + } else { + Err(format!( + "The package name {val} is invalid, it should be lowercase and have a vendor name, a forward slash, and a package name" + )) + } + }, + ) + .map_err(|e| anyhow::anyhow!(e))?; + + // Description + let default_desc = args.description.clone().unwrap_or_default(); + let description = console.ask( + &format!("Description [{}]", crate::console::comment(&default_desc)), + &default_desc, + ); + let description = if description.is_empty() { + None + } else { + Some(description) + }; + + // Author + let default_author = args + .author + .clone() + .or_else(get_default_author) + .unwrap_or_default(); + let author_input = console.ask( + &format!( + "Author [{}n to skip]", + if !default_author.is_empty() { + format!("{}, ", crate::console::comment(&default_author)) + } else { + String::new() + } + ), + &default_author, + ); + let authors = if author_input == "n" || author_input == "no" || author_input.is_empty() { + Vec::new() + } else { + match validation::parse_author(&author_input) { + Ok(parsed) => vec![RawAuthor { + name: parsed.name, + email: parsed.email, + }], + Err(_) => Vec::new(), + } + }; + + // Minimum Stability + let default_stability = args.stability.clone().unwrap_or_default(); + let stability_input = console.ask( + &format!( + "Minimum Stability [{}]", + crate::console::comment(&default_stability), + ), + &default_stability, + ); + let minimum_stability = if stability_input.is_empty() { + None + } else if validation::validate_stability(&stability_input) { + Some(stability_input.to_lowercase()) + } else { + console.error(&format!( + "Invalid minimum stability \"{stability_input}\". Using empty." + )); + None + }; + + // Package Type + let default_type = args.r#type.clone().unwrap_or_default(); + let type_input = console.ask( + &format!( + "Package Type (e.g. library, project, metapackage, composer-plugin) [{}]", + crate::console::comment(&default_type), + ), + &default_type, + ); + let package_type = if type_input.is_empty() { + None + } else { + Some(type_input) + }; + + // License + let default_license = args.license.clone().unwrap_or_default(); + let license_input = console.ask( + &format!("License [{}]", crate::console::comment(&default_license),), + &default_license, + ); + let license = if license_input.is_empty() { + None + } else { + Some(license_input) + }; + + // Dependencies + // TODO: support selecting dependencies interactively + console.info(""); + console.info(&format!( + "{}", + crate::console::info("Define your dependencies.") + )); + console.info(""); + let require = parse_requirements(&args.require)?; + + // Dev Dependencies + // TODO: support selecting dependencies interactively + let require_dev = parse_requirements(&args.require_dev)?; + + // PSR-4 Autoload + let default_autoload = args.autoload.clone().unwrap_or_else(|| "src/".to_string()); + let namespace = validation::namespace_from_package_name(&name).unwrap_or_default(); + let autoload_input = console.ask( + &format!( + "Add PSR-4 autoload mapping? Maps namespace \"{}\" to the entered relative path. [{}, n to skip]", + namespace, + crate::console::comment(&default_autoload), + ), + &default_autoload, + ); + let autoload = if autoload_input == "n" || autoload_input == "no" { + None + } else { + let path = if autoload_input.is_empty() { + default_autoload + } else { + autoload_input + }; + build_autoload(&path, &name) + }; + + let repositories = parse_repositories(&args.repository)?; + + let mut composer = RawPackageData::new(name); + composer.description = description; + composer.package_type = package_type; + composer.homepage = args.homepage.clone(); + composer.license = license; + composer.authors = authors; + composer.minimum_stability = minimum_stability; + composer.require = require; + composer.require_dev = require_dev; + composer.repositories = repositories; + composer.autoload = autoload; + + Ok(composer) +} + +fn get_default_package_name(working_dir: &Path) -> String { + let dir_name = working_dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("project"); + let name = validation::sanitize_package_name_component(dir_name); + + let vendor = get_git_config_value("github.user") + .or_else(|| std::env::var("USER").ok()) + .or_else(|| std::env::var("USERNAME").ok()) + .map(|v| validation::sanitize_package_name_component(&v)) + .unwrap_or_else(|| name.clone()); + + format!("{vendor}/{name}") +} + +fn get_default_author() -> Option<String> { + let name = get_git_config_value("user.name")?; + let email = get_git_config_value("user.email"); + + match email { + Some(email) => Some(format!("{name} <{email}>")), + None => Some(name), + } +} + +fn get_git_config_value(key: &str) -> Option<String> { + Command::new("git") + .args(["config", "--get", key]) + .output() + .ok() + .and_then(|output| { + if output.status.success() { + String::from_utf8(output.stdout) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + } else { + None + } + }) +} + +fn parse_requirements(reqs: &[String]) -> anyhow::Result<BTreeMap<String, String>> { + let mut map = BTreeMap::new(); + for req in reqs { + let (name, version) = + validation::parse_require_string(req).map_err(|e| anyhow::anyhow!(e))?; + map.insert(name, version); + } + Ok(map) +} + +fn build_autoload(path: &str, package_name: &str) -> Option<RawAutoload> { + let namespace = validation::namespace_from_package_name(package_name)?; + let mut psr4 = BTreeMap::new(); + psr4.insert(format!("{namespace}\\"), path.to_string()); + Some(RawAutoload { psr4 }) +} + +fn parse_repositories(repos: &[String]) -> anyhow::Result<Vec<RawRepository>> { + let mut result = Vec::new(); + for repo in repos { + if repo.starts_with('{') { + // JSON format + let parsed: serde_json::Value = + serde_json::from_str(repo).context("Invalid repository JSON")?; + let repo_type = parsed["type"].as_str().unwrap_or("vcs").to_string(); + let url = parsed["url"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Repository JSON must contain a 'url' field"))? + .to_string(); + result.push(RawRepository { repo_type, url }); + } else { + // Plain URL + result.push(RawRepository { + repo_type: "vcs".to_string(), + url: repo.clone(), + }); + } + } + Ok(result) +} + +fn has_vendor_ignore(gitignore_path: &Path) -> bool { + let Ok(content) = std::fs::read_to_string(gitignore_path) else { + return false; + }; + + let pattern = regex::Regex::new(r"^/?vendor(/\*?)?$").unwrap(); + content.lines().any(|line| pattern.is_match(line.trim())) +} + +fn add_vendor_ignore(gitignore_path: &Path) -> anyhow::Result<()> { + let mut contents = if gitignore_path.exists() { + std::fs::read_to_string(gitignore_path)? + } else { + String::new() + }; + + if !contents.is_empty() && !contents.ends_with('\n') { + contents.push('\n'); + } + + contents.push_str("/vendor/\n"); + std::fs::write(gitignore_path, contents)?; + Ok(()) } diff --git a/crates/mozart/src/console.rs b/crates/mozart/src/console.rs index 43399a5..07eaf67 100644 --- a/crates/mozart/src/console.rs +++ b/crates/mozart/src/console.rs @@ -1,4 +1,5 @@ use colored::{ColoredString, Colorize}; +use dialoguer::{Confirm, Input}; /// `<info>` — green foreground pub fn info(message: &str) -> ColoredString { @@ -29,3 +30,83 @@ pub fn highlight(message: &str) -> ColoredString { pub fn warning(message: &str) -> ColoredString { message.black().on_yellow() } + +pub struct Console { + pub interactive: bool, + pub quiet: bool, +} + +impl Console { + pub fn new(no_interaction: bool, quiet: bool) -> Self { + Self { + interactive: !no_interaction, + quiet, + } + } + + pub fn info(&self, msg: &str) { + if !self.quiet { + eprintln!("{msg}"); + } + } + + pub fn error(&self, msg: &str) { + eprintln!("{}", console::error(msg)); + } + + pub fn ask(&self, prompt: &str, default: &str) -> String { + if !self.interactive { + return default.to_string(); + } + + Input::new() + .with_prompt(prompt) + .default(default.to_string()) + .allow_empty(true) + .interact_text() + .unwrap_or_else(|_| default.to_string()) + } + + pub fn ask_validated<F>( + &self, + prompt: &str, + default: &str, + validator: F, + ) -> Result<String, String> + where + F: Fn(&str) -> Result<(), String>, + { + if !self.interactive { + validator(default)?; + return Ok(default.to_string()); + } + + loop { + let input: String = Input::new() + .with_prompt(prompt) + .default(default.to_string()) + .allow_empty(true) + .interact_text() + .unwrap_or_else(|_| default.to_string()); + + match validator(&input) { + Ok(()) => return Ok(input), + Err(e) => { + self.error(&e); + } + } + } + } + + pub fn confirm(&self, prompt: &str) -> bool { + if !self.interactive { + return true; + } + + Confirm::new() + .with_prompt(prompt) + .default(true) + .interact() + .unwrap_or(true) + } +} diff --git a/crates/mozart/src/lib.rs b/crates/mozart/src/lib.rs index 7a110c5..6506d11 100644 --- a/crates/mozart/src/lib.rs +++ b/crates/mozart/src/lib.rs @@ -1,2 +1,4 @@ pub mod commands; pub mod console; +pub mod package; +pub mod validation; diff --git a/crates/mozart/src/validation.rs b/crates/mozart/src/validation.rs new file mode 100644 index 0000000..7f946ae --- /dev/null +++ b/crates/mozart/src/validation.rs @@ -0,0 +1,226 @@ +use regex::Regex; +use std::sync::LazyLock; + +static PACKAGE_NAME_RE: LazyLock<Regex> = LazyLock::new(|| { + Regex::new(r"^[a-z0-9]([_.\-]?[a-z0-9]+)*/[a-z0-9](([_.]|\-{1,2})?[a-z0-9]+)*$").unwrap() +}); + +static AUTHOR_RE: LazyLock<Regex> = LazyLock::new(|| { + Regex::new(r"^(?P<name>[- .,\pL\pN\pM''\x{201C}\x{201D}()]+)(?:\s+<(?P<email>.+?)>)?$").unwrap() +}); + +static AUTOLOAD_PATH_RE: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r"^[^/][A-Za-z0-9\-_/]+/$").unwrap()); + +static CAMEL_SPLIT_RE: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r"(?:([a-z])([A-Z])|([A-Z])([A-Z][a-z]))").unwrap()); + +static SANITIZE_EDGES_RE: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r"^[_.\-]+|[_.\-]+$|[^a-z0-9_.\-]").unwrap()); + +static SANITIZE_REPEATS_RE: LazyLock<Regex> = + LazyLock::new(|| Regex::new(r"([_.\-]){2,}").unwrap()); + +static NON_ALNUM_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[^a-zA-Z0-9]").unwrap()); + +const VALID_STABILITIES: &[&str] = &["dev", "alpha", "beta", "rc", "stable"]; + +pub fn validate_package_name(name: &str) -> bool { + PACKAGE_NAME_RE.is_match(name) +} + +pub struct ParsedAuthor { + pub name: String, + pub email: Option<String>, +} + +pub fn parse_author(input: &str) -> Result<ParsedAuthor, String> { + if let Some(caps) = AUTHOR_RE.captures(input) { + let name = caps.name("name").unwrap().as_str().trim().to_string(); + let email = caps.name("email").map(|m| m.as_str().to_string()); + Ok(ParsedAuthor { name, email }) + } else { + Err( + "Invalid author string. Must be in the formats: Jane Doe or John Smith <john@example.com>" + .to_string(), + ) + } +} + +pub fn validate_stability(s: &str) -> bool { + VALID_STABILITIES.contains(&s.to_lowercase().as_str()) +} + +pub fn validate_license(s: &str) -> bool { + // TODO: check SPDX Identifier + !s.is_empty() +} + +pub fn validate_autoload_path(s: &str) -> bool { + AUTOLOAD_PATH_RE.is_match(s) +} + +pub fn namespace_from_package_name(package_name: &str) -> Option<String> { + if package_name.is_empty() || !package_name.contains('/') { + return None; + } + + let parts: Vec<String> = package_name + .split('/') + .map(|part| { + let replaced = NON_ALNUM_RE.replace_all(part, " "); + let words: Vec<String> = replaced + .split_whitespace() + .map(|w| { + let mut chars = w.chars(); + match chars.next() { + Some(c) => c.to_uppercase().to_string() + &chars.collect::<String>(), + None => String::new(), + } + }) + .collect(); + words.join("") + }) + .collect(); + + Some(parts.join("\\")) +} + +pub fn sanitize_package_name_component(name: &str) -> String { + // CamelCase → kebab-case + let name = CAMEL_SPLIT_RE.replace_all(name, "${1}${3}-${2}${4}"); + let name = name.to_lowercase(); + // Remove leading/trailing separators and non-alnum chars + let name = SANITIZE_EDGES_RE.replace_all(&name, ""); + // Collapse repeated separators + let name = SANITIZE_REPEATS_RE.replace_all(&name, "$1"); + name.to_string() +} + +pub fn parse_require_string(s: &str) -> Result<(String, String), String> { + // Formats: "foo/bar:^1.0", "foo/bar=^1.0", "foo/bar ^1.0" + let s = s.trim(); + + for sep in [':', '=', ' '] { + if let Some(pos) = s.find(sep) { + let name = s[..pos].trim(); + let version = s[pos + sep.len_utf8()..].trim(); + if !name.is_empty() && !version.is_empty() { + return Ok((name.to_string(), version.to_string())); + } + } + } + + Err(format!( + "Could not parse requirement \"{s}\". Expected format: vendor/package:version" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_package_names() { + assert!(validate_package_name("vendor/package")); + assert!(validate_package_name("my-vendor/my-package")); + assert!(validate_package_name("vendor/pkg123")); + assert!(validate_package_name("a/b")); + assert!(validate_package_name("vendor/my_package")); + assert!(validate_package_name("vendor/my.package")); + assert!(validate_package_name("vendor/my--package")); + } + + #[test] + fn test_invalid_package_names() { + assert!(!validate_package_name("novendor")); + assert!(!validate_package_name("/package")); + assert!(!validate_package_name("vendor/")); + assert!(!validate_package_name("Vendor/Package")); + assert!(!validate_package_name("vendor/pack age")); + assert!(!validate_package_name("")); + } + + #[test] + fn test_parse_author_name_and_email() { + let a = parse_author("John Smith <john@example.com>").unwrap(); + assert_eq!(a.name, "John Smith"); + assert_eq!(a.email.as_deref(), Some("john@example.com")); + } + + #[test] + fn test_parse_author_name_only() { + let a = parse_author("Jane Doe").unwrap(); + assert_eq!(a.name, "Jane Doe"); + assert!(a.email.is_none()); + } + + #[test] + fn test_parse_author_invalid() { + assert!(parse_author("").is_err()); + } + + #[test] + fn test_validate_stability() { + assert!(validate_stability("dev")); + assert!(validate_stability("alpha")); + assert!(validate_stability("beta")); + assert!(validate_stability("rc")); + assert!(validate_stability("stable")); + assert!(validate_stability("Dev")); + assert!(validate_stability("STABLE")); + assert!(!validate_stability("invalid")); + assert!(!validate_stability("")); + } + + #[test] + fn test_validate_autoload_path() { + assert!(validate_autoload_path("src/")); + assert!(validate_autoload_path("lib/src/")); + assert!(!validate_autoload_path("/src/")); + assert!(!validate_autoload_path("src")); + assert!(!validate_autoload_path("")); + } + + #[test] + fn test_namespace_from_package_name() { + assert_eq!( + namespace_from_package_name("acme/my-pkg"), + Some("Acme\\MyPkg".to_string()) + ); + assert_eq!( + namespace_from_package_name("new_projects.acme-extra/package-name"), + Some("NewProjectsAcmeExtra\\PackageName".to_string()) + ); + assert_eq!(namespace_from_package_name(""), None); + assert_eq!(namespace_from_package_name("novendor"), None); + } + + #[test] + fn test_sanitize_package_name_component() { + assert_eq!(sanitize_package_name_component("MyPackage"), "my-package"); + assert_eq!( + sanitize_package_name_component("CamelCaseTest"), + "camel-case-test" + ); + assert_eq!(sanitize_package_name_component("already-ok"), "already-ok"); + assert_eq!(sanitize_package_name_component("__bad__"), "bad"); + } + + #[test] + fn test_parse_require_string() { + let (name, ver) = parse_require_string("foo/bar:^1.0").unwrap(); + assert_eq!(name, "foo/bar"); + assert_eq!(ver, "^1.0"); + + let (name, ver) = parse_require_string("foo/bar=^1.0").unwrap(); + assert_eq!(name, "foo/bar"); + assert_eq!(ver, "^1.0"); + + let (name, ver) = parse_require_string("foo/bar ^1.0").unwrap(); + assert_eq!(name, "foo/bar"); + assert_eq!(ver, "^1.0"); + + assert!(parse_require_string("invalid").is_err()); + } +} |
