aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart/src/commands/init.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/mozart/src/commands/init.rs')
-rw-r--r--crates/mozart/src/commands/init.rs361
1 files changed, 249 insertions, 112 deletions
diff --git a/crates/mozart/src/commands/init.rs b/crates/mozart/src/commands/init.rs
index 3378755..5d6d501 100644
--- a/crates/mozart/src/commands/init.rs
+++ b/crates/mozart/src/commands/init.rs
@@ -12,6 +12,7 @@ use std::collections::BTreeMap;
use std::io::{BufRead, Write};
use std::path::Path;
use std::process::Command;
+use std::sync::OnceLock;
#[derive(Args)]
pub struct InitArgs {
@@ -111,7 +112,12 @@ pub async fn execute(
package::write_to_file(&composer, &composer_file).context("Failed to write composer.json")?;
- // Create autoload directory if specified
+ let has_dependencies = !composer.require.is_empty() || !composer.require_dev.is_empty();
+
+ // --autoload — create the source folder. When the project has no
+ // dependencies, Composer also runs `dump-autoload` so the autoloader is
+ // immediately usable; failures are downgraded to a warning to mirror
+ // Composer's try/catch around `runDumpAutoloadCommand`.
if let Some(ref autoload) = composer.autoload {
for path in autoload.psr4.values() {
let dir = working_dir.join(path);
@@ -120,6 +126,13 @@ pub async fn execute(
.with_context(|| format!("Failed to create directory {}", dir.display()))?;
}
}
+
+ if !has_dependencies {
+ let dump_args = super::dump_autoload::DumpAutoloadArgs::default();
+ if let Err(e) = super::dump_autoload::execute(&dump_args, cli, console).await {
+ console.error(&format!("Could not run dump-autoload. ({e})"));
+ }
+ }
}
// Offer to add /vendor/ to .gitignore
@@ -134,6 +147,22 @@ pub async fn execute(
}
}
+ // Run `composer update` after init when the new project has dependencies
+ // and the user confirms — Composer's L190-193.
+ if console.interactive
+ && has_dependencies
+ && console.confirm(&console_format!(
+ "Would you like to install dependencies now [<comment>yes</comment>]?"
+ ))
+ {
+ let update_args = super::update::UpdateArgs::default();
+ if let Err(e) = super::update::execute(&update_args, cli, console).await {
+ console.error(&format!(
+ "Could not update dependencies. Run `composer update` to see more information. ({e})"
+ ));
+ }
+ }
+
// Show autoload info
if let Some(ref autoload) = composer.autoload
&& let Some((ns, path)) = autoload.psr4.iter().next()
@@ -278,24 +307,32 @@ async fn build_interactive(
}
};
- // Minimum Stability
+ // Minimum Stability — Composer's askAndValidate loops until valid (the
+ // validator throws InvalidArgumentException, Symfony's QuestionHelper
+ // catches it and re-prompts when maxAttempts is null).
let default_stability = args.stability.clone().unwrap_or_default();
- let stability_input = console.ask(
- &console_format!(
- "Minimum Stability [<comment>{}</comment>]",
- &default_stability
- ),
- &default_stability,
- );
+ let stability_input = console
+ .ask_validated(
+ &console_format!(
+ "Minimum Stability [<comment>{}</comment>]",
+ &default_stability
+ ),
+ &default_stability,
+ |val| {
+ if val.is_empty() || validation::validate_stability(val) {
+ Ok(())
+ } else {
+ Err(format!(
+ "Invalid minimum stability \"{val}\". Must be empty or one of: dev, alpha, beta, rc, stable"
+ ))
+ }
+ },
+ )
+ .map_err(|e| anyhow::anyhow!(e))?;
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
+ Some(stability_input.to_lowercase())
};
// Package Type
@@ -313,28 +350,27 @@ async fn build_interactive(
Some(type_input)
};
- // License
+ // License — Composer prompts once, then validates outside the prompt and
+ // throws on invalid (no retry loop). See InitCommand::interact L364-372.
let default_license = args
.license
.clone()
.or_else(|| std::env::var("COMPOSER_DEFAULT_LICENSE").ok())
.unwrap_or_default();
- let license = loop {
- let license_input = console.ask(
- &console_format!("License [<comment>{}</comment>]", &default_license),
- &default_license,
+ let license_input = console.ask(
+ &console_format!("License [<comment>{}</comment>]", &default_license),
+ &default_license,
+ );
+ let license = if license_input.is_empty() {
+ None
+ } else if validation::validate_license(&license_input)
+ || license_input.eq_ignore_ascii_case("proprietary")
+ {
+ Some(license_input)
+ } else {
+ bail!(
+ "Invalid license provided: {license_input}. Only SPDX license identifiers (https://spdx.org/licenses/) or \"proprietary\" are accepted."
);
- if license_input.is_empty() {
- break None;
- } else if validation::validate_license(&license_input)
- || license_input.eq_ignore_ascii_case("proprietary")
- {
- break Some(license_input);
- } else {
- console.error(&format!(
- "Invalid license provided: {license_input}. Only SPDX license identifiers (https://spdx.org/licenses/) or \"proprietary\" are accepted."
- ));
- }
};
// Dependencies
@@ -347,17 +383,25 @@ async fn build_interactive(
console.info(&console_format!("<info>Define your dependencies.</info>"));
console.info("");
+ // Composer (InitCommand::interact L389-403): if --require was passed,
+ // skip the confirmation; otherwise ask before entering the discovery loop.
let mut require = parse_requirements(&args.require)?;
- let interactive_require = interactive_search_packages(
- "require",
- &require,
- preferred_stability,
- repo_cache,
- console,
- )
- .await?;
- for (name, constraint) in interactive_require {
- require.insert(name, constraint);
+ if !require.is_empty()
+ || console.confirm(&console_format!(
+ "Would you like to define your dependencies (require) interactively [<comment>yes</comment>]?"
+ ))
+ {
+ let interactive_require = interactive_search_packages(
+ "require",
+ &require,
+ preferred_stability,
+ repo_cache,
+ console,
+ )
+ .await?;
+ for (name, constraint) in interactive_require {
+ require.insert(name, constraint);
+ }
}
// Dev Dependencies
@@ -368,42 +412,55 @@ async fn build_interactive(
console.info("");
let mut require_dev = parse_requirements(&args.require_dev)?;
- let all_required: BTreeMap<String, String> = require
- .iter()
- .chain(require_dev.iter())
- .map(|(k, v)| (k.clone(), v.clone()))
- .collect();
- let interactive_dev = interactive_search_packages(
- "require-dev",
- &all_required,
- preferred_stability,
- repo_cache,
- console,
- )
- .await?;
- for (name, constraint) in interactive_dev {
- require_dev.insert(name, constraint);
+ if !require_dev.is_empty()
+ || console.confirm(&console_format!(
+ "Would you like to define your dev dependencies (require-dev) interactively [<comment>yes</comment>]?"
+ ))
+ {
+ let all_required: BTreeMap<String, String> = require
+ .iter()
+ .chain(require_dev.iter())
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect();
+ let interactive_dev = interactive_search_packages(
+ "require-dev",
+ &all_required,
+ preferred_stability,
+ repo_cache,
+ console,
+ )
+ .await?;
+ for (name, constraint) in interactive_dev {
+ require_dev.insert(name, constraint);
+ }
}
- // PSR-4 Autoload
+ // PSR-4 Autoload — Composer validates with regex `^[^/][A-Za-z0-9\-_/]+/$`
+ // via askAndValidate (loops until valid). `n`/`no` skips.
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(
- &console_format!(
- "Add PSR-4 autoload mapping? Maps namespace \"{namespace}\" to the entered relative path. [<comment>{}</comment>, n to skip]",
+ let autoload_input = console
+ .ask_validated(
+ &console_format!(
+ "Add PSR-4 autoload mapping? Maps namespace \"{namespace}\" to the entered relative path. [<comment>{}</comment>, n to skip]",
+ &default_autoload,
+ ),
&default_autoload,
- ),
- &default_autoload,
- );
+ |val| {
+ if val == "n" || val == "no" || validation::validate_autoload_path(val) {
+ Ok(())
+ } else {
+ Err(format!(
+ "The src folder name \"{val}\" is invalid. Please add a relative path with tailing forward slash. [A-Za-z0-9_-/]+/"
+ ))
+ }
+ },
+ )
+ .map_err(|e| anyhow::anyhow!(e))?;
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)
+ build_autoload(&autoload_input, &name)
};
let repositories = parse_repositories(&args.repository)?;
@@ -651,21 +708,33 @@ fn get_default_author() -> Option<String> {
}
}
-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
+/// `git config -l` parsed into `key=value` pairs and cached for the life of
+/// the process. Mirrors Composer's `InitCommand::getGitConfig`, which runs
+/// the command once and memoises the parsed map.
+fn get_git_config() -> &'static BTreeMap<String, String> {
+ static GIT_CONFIG: OnceLock<BTreeMap<String, String>> = OnceLock::new();
+ GIT_CONFIG.get_or_init(|| {
+ let mut map = BTreeMap::new();
+ let Ok(output) = Command::new("git").args(["config", "-l"]).output() else {
+ return map;
+ };
+ if !output.status.success() {
+ return map;
+ }
+ let Ok(text) = String::from_utf8(output.stdout) else {
+ return map;
+ };
+ for line in text.lines() {
+ if let Some((key, value)) = line.split_once('=') {
+ map.insert(key.to_string(), value.to_string());
}
- })
+ }
+ map
+ })
+}
+
+fn get_git_config_value(key: &str) -> Option<String> {
+ get_git_config().get(key).cloned().filter(|v| !v.is_empty())
}
fn parse_requirements(reqs: &[String]) -> anyhow::Result<BTreeMap<String, String>> {
@@ -685,39 +754,58 @@ fn build_autoload(path: &str, package_name: &str) -> Option<RawAutoload> {
Some(RawAutoload { psr4 })
}
+/// Parse `--repository` arguments. Mirrors
+/// `Composer\Repository\RepositoryFactory::configFromString`:
+///
+/// * `http(s)://...` → `{type: composer, url: $repo}`.
+/// * `{...}` → JSON object parsed verbatim into a repository config.
+/// * `*.json` file path → composer-type repo file (deferred; not yet supported).
+/// * anything else → reject with an error matching Composer's wording.
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: Some(url),
- package: None,
- only: None,
- exclude: None,
- canonical: None,
- security_advisories: None,
- });
+ let parsed: serde_json::Value = if repo.starts_with("http") {
+ serde_json::json!({ "type": "composer", "url": repo })
+ } else if repo.starts_with('{') {
+ serde_json::from_str(repo).context("Invalid repository JSON")?
} else {
- // Plain URL
- result.push(RawRepository {
- repo_type: "vcs".to_string(),
- url: Some(repo.clone()),
- package: None,
- only: None,
- exclude: None,
- canonical: None,
- security_advisories: None,
- });
- }
+ bail!(
+ "Invalid repository url ({repo}) given. Has to be a .json file, an http url or a JSON object."
+ );
+ };
+
+ let repo_type = parsed
+ .get("type")
+ .and_then(|v| v.as_str())
+ .ok_or_else(|| anyhow::anyhow!("Repository JSON must contain a 'type' field"))?
+ .to_string();
+ let url = parsed
+ .get("url")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+ let package = parsed.get("package").cloned();
+ let only = parsed.get("only").and_then(|v| v.as_array()).map(|a| {
+ a.iter()
+ .filter_map(|x| x.as_str().map(String::from))
+ .collect()
+ });
+ let exclude = parsed.get("exclude").and_then(|v| v.as_array()).map(|a| {
+ a.iter()
+ .filter_map(|x| x.as_str().map(String::from))
+ .collect()
+ });
+ let canonical = parsed.get("canonical").and_then(|v| v.as_bool());
+ let security_advisories = parsed.get("security-advisories").cloned();
+
+ result.push(RawRepository {
+ repo_type,
+ url,
+ package,
+ only,
+ exclude,
+ canonical,
+ security_advisories,
+ });
}
Ok(result)
}
@@ -746,3 +834,52 @@ fn add_vendor_ignore(gitignore_path: &Path) -> anyhow::Result<()> {
std::fs::write(gitignore_path, contents)?;
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parse_repositories_http_url_yields_composer_type() {
+ let repos = parse_repositories(&["https://repo.example.com".to_string()]).unwrap();
+ assert_eq!(repos.len(), 1);
+ assert_eq!(repos[0].repo_type, "composer");
+ assert_eq!(repos[0].url.as_deref(), Some("https://repo.example.com"));
+ }
+
+ #[test]
+ fn parse_repositories_http_scheme_also_matches() {
+ let repos = parse_repositories(&["http://example.com".to_string()]).unwrap();
+ assert_eq!(repos[0].repo_type, "composer");
+ }
+
+ #[test]
+ fn parse_repositories_json_object_preserved() {
+ let repos = parse_repositories(&[
+ r#"{"type":"vcs","url":"https://github.com/acme/repo"}"#.to_string()
+ ])
+ .unwrap();
+ assert_eq!(repos[0].repo_type, "vcs");
+ assert_eq!(
+ repos[0].url.as_deref(),
+ Some("https://github.com/acme/repo")
+ );
+ }
+
+ #[test]
+ fn parse_repositories_unknown_form_is_error() {
+ let err = parse_repositories(&["not-a-url-or-json".to_string()]).unwrap_err();
+ assert!(
+ err.to_string()
+ .contains("Has to be a .json file, an http url or a JSON object"),
+ "{err}",
+ );
+ }
+
+ #[test]
+ fn parse_repositories_json_without_type_is_error() {
+ let err =
+ parse_repositories(&[r#"{"url":"https://example.com"}"#.to_string()]).unwrap_err();
+ assert!(err.to_string().contains("'type'"), "{err}");
+ }
+}