aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart/src
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-14 15:20:44 +0900
committernsfisis <nsfisis@gmail.com>2026-02-14 15:20:44 +0900
commit999fc4157cf631f967a5adedeccb83ae6d0cb0f8 (patch)
tree39394948eaee36fd873ede5d2c8345ace121259c /crates/mozart/src
parent06970e0ff8b24440908e0333204471d4b99781f0 (diff)
downloadphp-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.rs430
-rw-r--r--crates/mozart/src/console.rs81
-rw-r--r--crates/mozart/src/lib.rs2
-rw-r--r--crates/mozart/src/validation.rs226
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());
+ }
+}