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/commands/init.rs | |
| 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/commands/init.rs')
| -rw-r--r-- | crates/mozart/src/commands/init.rs | 430 |
1 files changed, 428 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(()) } |
