aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart/src/commands
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-23 01:23:40 +0900
committernsfisis <nsfisis@gmail.com>2026-02-23 01:23:40 +0900
commitc7a5859e1770dbc7cbc7cb1785c34b880162dfac (patch)
tree58081330c415776c9020771f12227889abcffa12 /crates/mozart/src/commands
parent7372dc668476cde4bcb789e586cb9a65af1afca0 (diff)
downloadphp-mozart-c7a5859e1770dbc7cbc7cb1785c34b880162dfac.tar.gz
php-mozart-c7a5859e1770dbc7cbc7cb1785c34b880162dfac.tar.zst
php-mozart-c7a5859e1770dbc7cbc7cb1785c34b880162dfac.zip
fix(global): add ~/.composer fallback, XDG auto-detection, and no-arg handling
Rewrite composer_home() with Composer-compatible resolution: detect XDG environment (any XDG_* var or /etc/xdg), prefer existing directories, and fall back to ~/.composer for legacy systems. Consolidate duplicate composer_home_dir() from global.rs into shared config_helpers. Accept no subcommand gracefully with a helpful error instead of a parse error. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart/src/commands')
-rw-r--r--crates/mozart/src/commands/config.rs6
-rw-r--r--crates/mozart/src/commands/config_helpers.rs60
-rw-r--r--crates/mozart/src/commands/dump_autoload.rs17
-rw-r--r--crates/mozart/src/commands/global.rs99
-rw-r--r--crates/mozart/src/commands/repository.rs2
5 files changed, 84 insertions, 100 deletions
diff --git a/crates/mozart/src/commands/config.rs b/crates/mozart/src/commands/config.rs
index 5105fd6..92f9b59 100644
--- a/crates/mozart/src/commands/config.rs
+++ b/crates/mozart/src/commands/config.rs
@@ -147,7 +147,7 @@ impl ComposerConfig {
.unwrap_or("vendor")
.to_string();
- let home = composer_home();
+ let home = composer_home().to_string_lossy().into_owned();
let cache_dir = self
.values
@@ -466,7 +466,7 @@ fn resolve_config_file_path(args: &ConfigArgs, cli: &super::Cli) -> anyhow::Resu
anyhow::bail!("Cannot combine --global and --file");
}
if args.global {
- return Ok(PathBuf::from(composer_home()).join("config.json"));
+ return Ok(composer_home().join("config.json"));
}
if let Some(ref file) = args.file {
return Ok(PathBuf::from(file));
@@ -900,7 +900,7 @@ fn execute_read(
let mut config = ComposerConfig::defaults();
if args.global {
- let global_config_path = PathBuf::from(composer_home()).join("config.json");
+ let global_config_path = composer_home().join("config.json");
let overrides = load_config_section(&global_config_path)?;
config.merge(&overrides);
} else {
diff --git a/crates/mozart/src/commands/config_helpers.rs b/crates/mozart/src/commands/config_helpers.rs
index 31792f4..fe5deb8 100644
--- a/crates/mozart/src/commands/config_helpers.rs
+++ b/crates/mozart/src/commands/config_helpers.rs
@@ -2,32 +2,64 @@ use anyhow::anyhow;
use std::path::{Path, PathBuf};
/// Return the Composer home directory, respecting `COMPOSER_HOME` and
-/// falling back to the platform default (`~/.config/composer` on Unix,
-/// `%APPDATA%/Composer` on Windows).
-pub(crate) fn composer_home() -> String {
- if let Ok(home) = std::env::var("COMPOSER_HOME") {
- return home;
+/// falling back to the platform default using Composer-compatible logic.
+///
+/// On Unix:
+/// - If XDG is in use (any `XDG_*` env var exists, or `/etc/xdg` exists),
+/// prefer `$XDG_CONFIG_HOME/composer` (or `$HOME/.config/composer`).
+/// - Always include `$HOME/.composer` as a fallback candidate.
+/// - Return the first candidate directory that exists on disk;
+/// if none exist, return the first candidate.
+pub(crate) fn composer_home() -> PathBuf {
+ if let Ok(val) = std::env::var("COMPOSER_HOME")
+ && !val.is_empty()
+ {
+ return PathBuf::from(val);
}
#[cfg(target_os = "windows")]
{
- std::env::var("APPDATA")
- .map(|p| format!("{p}/Composer"))
- .unwrap_or_else(|_| "C:/ProgramData/ComposerSetup/bin".to_string())
+ if let Ok(appdata) = std::env::var("APPDATA")
+ && !appdata.is_empty()
+ {
+ return PathBuf::from(appdata).join("Composer");
+ }
+ return PathBuf::from("C:/ProgramData/ComposerSetup/bin");
}
#[cfg(not(target_os = "windows"))]
{
- if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
- format!("{xdg}/composer")
- } else {
- std::env::var("HOME")
- .map(|h| format!("{h}/.config/composer"))
- .unwrap_or_else(|_| "/tmp/composer".to_string())
+ let home_dir = std::env::var("HOME")
+ .map(PathBuf::from)
+ .unwrap_or_else(|_| PathBuf::from("/tmp"));
+
+ let mut candidates: Vec<PathBuf> = Vec::new();
+
+ if use_xdg() {
+ let xdg_config = std::env::var("XDG_CONFIG_HOME")
+ .map(PathBuf::from)
+ .unwrap_or_else(|_| home_dir.join(".config"));
+ candidates.push(xdg_config.join("composer"));
}
+
+ candidates.push(home_dir.join(".composer"));
+
+ // Return first candidate that exists; otherwise return the first
+ candidates
+ .iter()
+ .find(|p| p.is_dir())
+ .cloned()
+ .unwrap_or_else(|| candidates.into_iter().next().unwrap())
}
}
+/// Check whether XDG base directories are in use:
+/// any env var starting with `XDG_` exists, OR `/etc/xdg` directory exists.
+fn use_xdg() -> bool {
+ std::env::vars().any(|(k, _)| k.starts_with("XDG_"))
+ || std::path::Path::new("/etc/xdg").is_dir()
+}
+
/// Build the working directory path, preferring `--working-dir` over `cwd`.
pub(crate) fn working_dir(cli: &super::Cli) -> anyhow::Result<PathBuf> {
match &cli.working_dir {
diff --git a/crates/mozart/src/commands/dump_autoload.rs b/crates/mozart/src/commands/dump_autoload.rs
index 0828288..ca559f8 100644
--- a/crates/mozart/src/commands/dump_autoload.rs
+++ b/crates/mozart/src/commands/dump_autoload.rs
@@ -71,14 +71,15 @@ pub async fn execute(
let mut composer_config = super::config::ComposerConfig::defaults();
if composer_json_path.exists()
&& let Ok(content) = std::fs::read_to_string(&composer_json_path)
- && let Ok(value) = serde_json::from_str::<serde_json::Value>(&content)
- && let Some(cfg_obj) = value.get("config").and_then(|v| v.as_object()) {
- let overrides: std::collections::BTreeMap<String, serde_json::Value> = cfg_obj
- .iter()
- .map(|(k, v)| (k.clone(), v.clone()))
- .collect();
- composer_config.merge(&overrides);
- }
+ && let Ok(value) = serde_json::from_str::<serde_json::Value>(&content)
+ && let Some(cfg_obj) = value.get("config").and_then(|v| v.as_object())
+ {
+ let overrides: std::collections::BTreeMap<String, serde_json::Value> = cfg_obj
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect();
+ composer_config.merge(&overrides);
+ }
let optimize = args.optimize
|| composer_config
diff --git a/crates/mozart/src/commands/global.rs b/crates/mozart/src/commands/global.rs
index dfa4fc2..12b3a4e 100644
--- a/crates/mozart/src/commands/global.rs
+++ b/crates/mozart/src/commands/global.rs
@@ -1,10 +1,9 @@
use clap::Args;
-use std::path::PathBuf;
#[derive(Args)]
pub struct GlobalArgs {
/// The command name to run
- pub command_name: String,
+ pub command_name: Option<String>,
/// Arguments to pass to the command
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
@@ -21,7 +20,16 @@ pub async fn execute(
use clap::Parser as _;
use std::fs;
- let home = composer_home_dir()?;
+ let command_name = match &args.command_name {
+ Some(name) => name.clone(),
+ None => {
+ anyhow::bail!(
+ "The global command requires a subcommand, e.g. `mozart global require package/name`"
+ );
+ }
+ };
+
+ let home = super::config_helpers::composer_home();
fs::create_dir_all(&home)?;
@@ -36,7 +44,7 @@ pub async fn execute(
argv.extend(append_global_options(cli));
argv.push("--working-dir".to_string());
argv.push(home.to_string_lossy().into_owned());
- argv.push(args.command_name.clone());
+ argv.push(command_name);
argv.extend(args.args.iter().cloned());
let new_cli = super::Cli::try_parse_from(&argv)?;
@@ -45,26 +53,6 @@ pub async fn execute(
// ─── Helpers ─────────────────────────────────────────────────────────────────
-fn composer_home_dir() -> anyhow::Result<PathBuf> {
- if let Ok(val) = std::env::var("COMPOSER_HOME")
- && !val.is_empty()
- {
- return Ok(PathBuf::from(val));
- }
-
- if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME")
- && !xdg.is_empty()
- {
- return Ok(PathBuf::from(xdg).join("composer"));
- }
-
- let home = std::env::var("HOME")
- .map(PathBuf::from)
- .map_err(|_| anyhow::anyhow!("Cannot determine home directory: $HOME is not set"))?;
-
- Ok(home.join(".config").join("composer"))
-}
-
fn append_global_options(cli: &super::Cli) -> Vec<String> {
let mut opts: Vec<String> = Vec::new();
@@ -119,54 +107,6 @@ mod tests {
Cli::try_parse_from(["mozart", "about"]).unwrap()
}
- // ── composer_home_dir tests ───────────────────────────────────────────────
-
- /// Guards env-var mutations so the three composer_home_dir tests
- /// cannot race each other when `cargo test` runs them in parallel.
- static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
-
- #[test]
- fn test_composer_home_dir_from_env() {
- let _lock = ENV_MUTEX.lock().unwrap();
- // SAFETY: test-only; protected by ENV_MUTEX
- unsafe {
- std::env::set_var("COMPOSER_HOME", "/tmp/test-composer-home");
- }
- let result = composer_home_dir().unwrap();
- unsafe {
- std::env::remove_var("COMPOSER_HOME");
- }
- assert_eq!(result, PathBuf::from("/tmp/test-composer-home"));
- }
-
- #[test]
- fn test_composer_home_dir_xdg() {
- let _lock = ENV_MUTEX.lock().unwrap();
- // SAFETY: test-only; protected by ENV_MUTEX
- unsafe {
- std::env::remove_var("COMPOSER_HOME");
- std::env::set_var("XDG_CONFIG_HOME", "/tmp/test-xdg-config");
- }
- let result = composer_home_dir().unwrap();
- unsafe {
- std::env::remove_var("XDG_CONFIG_HOME");
- }
- assert_eq!(result, PathBuf::from("/tmp/test-xdg-config/composer"));
- }
-
- #[test]
- fn test_composer_home_dir_default() {
- let _lock = ENV_MUTEX.lock().unwrap();
- // SAFETY: test-only; protected by ENV_MUTEX
- unsafe {
- std::env::remove_var("COMPOSER_HOME");
- std::env::remove_var("XDG_CONFIG_HOME");
- }
- let result = composer_home_dir().unwrap();
- let home = std::env::var("HOME").map(PathBuf::from).unwrap();
- assert_eq!(result, home.join(".config").join("composer"));
- }
-
// ── append_global_options tests ───────────────────────────────────────────
#[test]
@@ -222,7 +162,7 @@ mod tests {
// Verify GlobalArgs parses correctly through the CLI
let cli = Cli::try_parse_from(["mozart", "global", "require", "vendor/package"]).unwrap();
if let Some(Commands::Global(args)) = cli.command {
- assert_eq!(args.command_name, "require");
+ assert_eq!(args.command_name, Some("require".to_string()));
assert_eq!(args.args, vec!["vendor/package"]);
} else {
panic!("Expected Global command");
@@ -235,10 +175,21 @@ mod tests {
let cli = Cli::try_parse_from(["mozart", "global", "require", "vendor/pkg", "--no-update"])
.unwrap();
if let Some(Commands::Global(args)) = cli.command {
- assert_eq!(args.command_name, "require");
+ assert_eq!(args.command_name, Some("require".to_string()));
assert!(args.args.contains(&"--no-update".to_string()));
} else {
panic!("Expected Global command");
}
}
+
+ #[test]
+ fn test_global_args_no_subcommand() {
+ // Verify that no subcommand parses to None
+ let cli = Cli::try_parse_from(["mozart", "global"]).unwrap();
+ if let Some(Commands::Global(args)) = cli.command {
+ assert_eq!(args.command_name, None);
+ } else {
+ panic!("Expected Global command");
+ }
+ }
}
diff --git a/crates/mozart/src/commands/repository.rs b/crates/mozart/src/commands/repository.rs
index e9c52f4..f8e151d 100644
--- a/crates/mozart/src/commands/repository.rs
+++ b/crates/mozart/src/commands/repository.rs
@@ -47,7 +47,7 @@ fn resolve_file_path(args: &RepositoryArgs, cli: &super::Cli) -> anyhow::Result<
anyhow::bail!("Cannot combine --global and --file");
}
if args.global {
- return Ok(PathBuf::from(composer_home()).join("config.json"));
+ return Ok(composer_home().join("config.json"));
}
if let Some(ref file) = args.file {
return Ok(PathBuf::from(file));