From 8480c5c95b3cb32ddd6f1a53a5f2dc92e7af61f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 07:25:22 +0000 Subject: [PATCH 1/4] Start fix for #586 Assisted-by: Claude Code:claude-opus-5-5 From 995596e973ec582685863499228a93dd74a55553 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 07:37:24 +0000 Subject: [PATCH 2/4] Find Composer 2 global home before ~/.composer When composer isn't on PATH and COMPOSER_HOME is unset, global discovery guessed the Composer home in Composer 1's order and checked ~/.composer first. Composer 2 checks the XDG config dir first on an XDG system (any XDG_* variable set, or /etc/xdg present), so a leftover ~/.composer hid the real global install and scan -g, apply -g and vex -g silently found nothing. Probe $XDG_CONFIG_HOME/composer (or ~/.config/composer) before ~/.composer on XDG systems, as Composer 2's Factory::getHomeDir does. Non-XDG systems and Windows keep their current order. Fixes #586 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/composer_crawler.rs | 104 ++++++++++++++++-- .../tests/covgap_crawlers_composer_crawler.rs | 68 ++++++++++++ 2 files changed, 163 insertions(+), 9 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/composer_crawler.rs b/crates/socket-patch-core/src/crawlers/composer_crawler.rs index 9629233ff..e8ccbcf8d 100644 --- a/crates/socket-patch-core/src/crawlers/composer_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/composer_crawler.rs @@ -395,12 +395,19 @@ async fn get_composer_home() -> Option { } /// The directories Composer itself uses as its home when `COMPOSER_HOME` -/// is unset, in the order to probe them (`Factory::getHomeDir`): +/// is unset, in the order to probe them (Composer 2's +/// `Factory::getHomeDir`): /// /// - Windows: `%APPDATA%\Composer` — the only default there; the /// `~/.composer` probe is kept after it for older layouts. -/// - elsewhere: `~/.composer` when it exists, else the XDG location, -/// `$XDG_CONFIG_HOME/composer` or `~/.config/composer`. +/// - an XDG system (see [`uses_xdg`]): the XDG home, +/// `$XDG_CONFIG_HOME/composer` or else `~/.config/composer`, when it +/// exists, else `~/.composer`. A leftover `~/.composer` from the +/// Composer 1 era (which probed it first) must not hide the real home. +/// - elsewhere: `~/.composer`. +/// +/// `~/.config/composer` stays as a last probe on every platform, so a +/// home there is still found when the XDG detection misses it. /// /// A set-but-empty or relative variable counts as unset: honoring `""` /// would turn these probes into CWD-relative paths inside the user's @@ -408,6 +415,22 @@ async fn get_composer_home() -> Option { pub(crate) fn composer_home_candidates( var: &impl Fn(&str) -> Option, windows: bool, +) -> Vec { + composer_home_candidates_for(var, windows, !windows && uses_xdg()) +} + +/// Composer's `Factory::useXdg`: any `XDG_*` environment variable is +/// set, or `/etc/xdg` exists. +fn uses_xdg() -> bool { + std::env::vars_os().any(|(key, _)| key.to_string_lossy().starts_with("XDG_")) + || is_dir_sync(Path::new("/etc/xdg")) +} + +/// [`composer_home_candidates`] with the XDG detection passed in. +fn composer_home_candidates_for( + var: &impl Fn(&str) -> Option, + windows: bool, + xdg: bool, ) -> Vec { let absolute = |name: &str| { var(name) @@ -421,16 +444,21 @@ pub(crate) fn composer_home_candidates( candidates.push(app_data.join("Composer")); } } + if xdg { + let config = + absolute("XDG_CONFIG_HOME").or_else(|| home.as_ref().map(|h| h.join(".config"))); + if let Some(config) = config { + candidates.push(config.join("composer")); + } + } if let Some(home) = &home { candidates.push(home.join(".composer")); } - if !windows { - if let Some(xdg) = absolute("XDG_CONFIG_HOME") { - candidates.push(xdg.join("composer")); - } - } if let Some(home) = &home { - candidates.push(home.join(".config").join("composer")); + let config = home.join(".config").join("composer"); + if !candidates.contains(&config) { + candidates.push(config); + } } candidates } @@ -742,6 +770,64 @@ async fn read_installed_json(vendor_path: &Path) -> Vec { mod tests { use super::*; + fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { + move |name: &str| { + vars.iter() + .find(|(key, _)| *key == name) + .map(|(_, value)| std::ffi::OsString::from(value)) + } + } + + /// #586: Composer 2 on an XDG system probes the XDG home before + /// `~/.composer`. + #[cfg(unix)] + #[test] + fn test_home_candidates_xdg_system_probes_xdg_home_first() { + assert_eq!( + composer_home_candidates_for(&env(&[("HOME", "/h")]), false, true), + vec![ + PathBuf::from("/h/.config/composer"), + PathBuf::from("/h/.composer"), + ] + ); + assert_eq!( + composer_home_candidates_for( + &env(&[("HOME", "/h"), ("XDG_CONFIG_HOME", "/x")]), + false, + true + ), + vec![ + PathBuf::from("/x/composer"), + PathBuf::from("/h/.composer"), + PathBuf::from("/h/.config/composer"), + ] + ); + // A relative XDG_CONFIG_HOME counts as unset. + assert_eq!( + composer_home_candidates_for( + &env(&[("HOME", "/h"), ("XDG_CONFIG_HOME", "rel")]), + false, + true + )[0], + PathBuf::from("/h/.config/composer") + ); + } + + /// Off XDG, Composer only uses `~/.composer`; `~/.config/composer` + /// stays as a last probe. + #[cfg(unix)] + #[test] + fn test_home_candidates_non_xdg_system_probes_dot_composer_first() { + assert_eq!( + composer_home_candidates_for(&env(&[("HOME", "/h")]), false, false), + vec![ + PathBuf::from("/h/.composer"), + PathBuf::from("/h/.config/composer"), + ] + ); + assert!(composer_home_candidates_for(&env(&[("HOME", "")]), false, true).is_empty()); + } + #[tokio::test] async fn test_crawl_all_composer() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs b/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs index 997175812..5f001924e 100644 --- a/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs +++ b/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs @@ -178,3 +178,71 @@ async fn get_vendor_paths_global_composer_cli_nonexistent_home_falls_back() { "a CLI answer naming a nonexistent home must fall through to HOME/.composer" ); } + +/// #586: on an XDG system Composer 2 looks for its home in the XDG config +/// dir first and only then in `~/.composer` (`Factory::getHomeDir`). A +/// leftover `~/.composer` (Composer 1 era, even an empty one) must not +/// hide the real global install under `~/.config/composer` when the +/// `composer` CLI can't be asked. +#[cfg(not(windows))] +#[tokio::test] +#[serial_test::serial] +async fn get_vendor_paths_global_xdg_home_outranks_stale_dot_composer() { + let home = tempfile::tempdir().unwrap(); + tokio::fs::create_dir_all(home.path().join(".composer")) + .await + .unwrap(); + let xdg_vendor = home.path().join(".config").join("composer").join("vendor"); + tokio::fs::create_dir_all(&xdg_vendor).await.unwrap(); + let empty_path = tempfile::tempdir().unwrap(); + + let _composer_home = EnvVarGuard::remove("COMPOSER_HOME"); + let _home = EnvVarGuard::set("HOME", home.path().as_os_str()); + let _path = EnvVarGuard::set("PATH", empty_path.path().as_os_str()); + let _xdg_config = EnvVarGuard::remove("XDG_CONFIG_HOME"); + // Any `XDG_*` variable makes Composer treat the system as XDG. + let _xdg_marker = EnvVarGuard::set("XDG_SESSION_TYPE", std::ffi::OsStr::new("tty")); + + let paths = ComposerCrawler + .get_vendor_paths(&global_options_at(home.path())) + .await + .unwrap(); + + assert_eq!( + paths, + vec![xdg_vendor], + "Composer 2 picks ~/.config/composer over ~/.composer on an XDG system" + ); +} + +/// The same layout with `XDG_CONFIG_HOME` set: Composer 2 uses +/// `$XDG_CONFIG_HOME/composer`, still ahead of a stale `~/.composer`. +#[cfg(not(windows))] +#[tokio::test] +#[serial_test::serial] +async fn get_vendor_paths_global_xdg_config_home_outranks_stale_dot_composer() { + let home = tempfile::tempdir().unwrap(); + tokio::fs::create_dir_all(home.path().join(".composer").join("vendor")) + .await + .unwrap(); + let xdg = tempfile::tempdir().unwrap(); + let xdg_vendor = xdg.path().join("composer").join("vendor"); + tokio::fs::create_dir_all(&xdg_vendor).await.unwrap(); + let empty_path = tempfile::tempdir().unwrap(); + + let _composer_home = EnvVarGuard::remove("COMPOSER_HOME"); + let _home = EnvVarGuard::set("HOME", home.path().as_os_str()); + let _path = EnvVarGuard::set("PATH", empty_path.path().as_os_str()); + let _xdg_config = EnvVarGuard::set("XDG_CONFIG_HOME", xdg.path().as_os_str()); + + let paths = ComposerCrawler + .get_vendor_paths(&global_options_at(home.path())) + .await + .unwrap(); + + assert_eq!( + paths, + vec![xdg_vendor], + "Composer 2 picks $XDG_CONFIG_HOME/composer over ~/.composer" + ); +} From c83fcc435d56e0ff48028562eedd28794ba1f665 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 07:49:39 +0000 Subject: [PATCH 3/4] Gate a unix-only test helper The env() helper in the composer crawler tests is only used by unix-only tests, so it would be dead code (and a clippy error) on Windows. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/crawlers/composer_crawler.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/socket-patch-core/src/crawlers/composer_crawler.rs b/crates/socket-patch-core/src/crawlers/composer_crawler.rs index e8ccbcf8d..d3a661863 100644 --- a/crates/socket-patch-core/src/crawlers/composer_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/composer_crawler.rs @@ -770,6 +770,7 @@ async fn read_installed_json(vendor_path: &Path) -> Vec { mod tests { use super::*; + #[cfg(unix)] fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { move |name: &str| { vars.iter() From ab0ffcb15795acd48fbedb67fbff6cbf909e785a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 08:30:35 +0000 Subject: [PATCH 4/4] Tighten Composer XDG home detection Follow-ups from review of the XDG-first order: - Count an XDG_CONFIG_HOME in the supplied environment as an XDG system, so it is never dropped from the probes. - Skip the XDG home when XDG_CONFIG_HOME is relative instead of guessing ~/.config, which Composer would not use; an empty value still means ~/.config, as in Composer. - Build the candidate list on the blocking pool, since the XDG check stats /etc/xdg. - Clear XDG_CONFIG_HOME in a shim test that relies on the HOME fallback, and fix a mislabeled unit-test assertion. Refs #586 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/composer_crawler.rs | 54 +++++++++++++++---- .../tests/covgap_crawlers_composer_crawler.rs | 2 + .../tests/global_probe_spawn_e2e.rs | 4 +- 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/composer_crawler.rs b/crates/socket-patch-core/src/crawlers/composer_crawler.rs index d3a661863..af9757201 100644 --- a/crates/socket-patch-core/src/crawlers/composer_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/composer_crawler.rs @@ -384,8 +384,12 @@ async fn get_composer_home() -> Option { } // Platform defaults (see `composer_home_candidates`). - let var = |name: &str| std::env::var_os(name); - for candidate in composer_home_candidates(&var, cfg!(windows)) { + // Built on the blocking pool: the XDG check stats `/etc/xdg`. + let candidates = run_blocking(|| { + composer_home_candidates(&|name: &str| std::env::var_os(name), cfg!(windows)) + }) + .await; + for candidate in candidates { if is_dir(&candidate).await { return Some(candidate); } @@ -404,6 +408,8 @@ async fn get_composer_home() -> Option { /// `$XDG_CONFIG_HOME/composer` or else `~/.config/composer`, when it /// exists, else `~/.composer`. A leftover `~/.composer` from the /// Composer 1 era (which probed it first) must not hide the real home. +/// A relative `XDG_CONFIG_HOME` names a directory relative to wherever +/// Composer ran, so its XDG home is skipped rather than guessed. /// - elsewhere: `~/.composer`. /// /// `~/.config/composer` stays as a last probe on every platform, so a @@ -416,13 +422,14 @@ pub(crate) fn composer_home_candidates( var: &impl Fn(&str) -> Option, windows: bool, ) -> Vec { - composer_home_candidates_for(var, windows, !windows && uses_xdg()) + let xdg = !windows && (var("XDG_CONFIG_HOME").is_some() || uses_xdg()); + composer_home_candidates_for(var, windows, xdg) } /// Composer's `Factory::useXdg`: any `XDG_*` environment variable is /// set, or `/etc/xdg` exists. fn uses_xdg() -> bool { - std::env::vars_os().any(|(key, _)| key.to_string_lossy().starts_with("XDG_")) + std::env::vars_os().any(|(key, _)| key.as_encoded_bytes().starts_with(b"XDG_")) || is_dir_sync(Path::new("/etc/xdg")) } @@ -445,8 +452,11 @@ fn composer_home_candidates_for( } } if xdg { - let config = - absolute("XDG_CONFIG_HOME").or_else(|| home.as_ref().map(|h| h.join(".config"))); + // Composer treats an empty XDG_CONFIG_HOME as unset. + let config = match var("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) { + Some(_) => absolute("XDG_CONFIG_HOME"), + None => home.as_ref().map(|home| home.join(".config")), + }; if let Some(config) = config { candidates.push(config.join("composer")); } @@ -803,15 +813,30 @@ mod tests { PathBuf::from("/h/.config/composer"), ] ); - // A relative XDG_CONFIG_HOME counts as unset. + // An empty XDG_CONFIG_HOME counts as unset, as in Composer. assert_eq!( composer_home_candidates_for( - &env(&[("HOME", "/h"), ("XDG_CONFIG_HOME", "rel")]), + &env(&[("HOME", "/h"), ("XDG_CONFIG_HOME", "")]), false, true )[0], PathBuf::from("/h/.config/composer") ); + // A relative one points somewhere under Composer's CWD, not at + // `~/.config`: probe `~/.composer` first instead of guessing. + assert_eq!( + composer_home_candidates_for( + &env(&[("HOME", "/h"), ("XDG_CONFIG_HOME", "rel")]), + false, + true + ), + vec![ + PathBuf::from("/h/.composer"), + PathBuf::from("/h/.config/composer"), + ] + ); + // With HOME unset nothing is left to probe. + assert!(composer_home_candidates_for(&env(&[("HOME", "")]), false, true).is_empty()); } /// Off XDG, Composer only uses `~/.composer`; `~/.config/composer` @@ -826,7 +851,18 @@ mod tests { PathBuf::from("/h/.config/composer"), ] ); - assert!(composer_home_candidates_for(&env(&[("HOME", "")]), false, true).is_empty()); + assert!(composer_home_candidates_for(&env(&[("HOME", "")]), false, false).is_empty()); + } + + /// An XDG_CONFIG_HOME in the supplied environment makes it an XDG + /// system even when the process environment has no `XDG_*` variable. + #[cfg(unix)] + #[test] + fn test_home_candidates_supplied_xdg_config_home_counts_as_xdg() { + assert_eq!( + composer_home_candidates(&env(&[("HOME", "/h"), ("XDG_CONFIG_HOME", "/x")]), false)[0], + PathBuf::from("/x/composer") + ); } #[tokio::test] diff --git a/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs b/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs index 5f001924e..db21a33fd 100644 --- a/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs +++ b/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs @@ -165,6 +165,8 @@ async fn get_vendor_paths_global_composer_cli_nonexistent_home_falls_back() { let _composer_home = EnvVarGuard::remove("COMPOSER_HOME"); let _home = EnvVarGuard::set("HOME", home.path().as_os_str()); let _path = EnvVarGuard::set("PATH", shim_dir.path().as_os_str()); + // `$XDG_CONFIG_HOME/composer` would outrank the HOME candidate here. + let _xdg = EnvVarGuard::remove("XDG_CONFIG_HOME"); let crawler = ComposerCrawler; let paths = crawler diff --git a/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs b/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs index 4cd9f219d..29e251336 100644 --- a/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs +++ b/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs @@ -340,8 +340,8 @@ async fn composer_home_falls_back_to_appdata_on_windows() { assert_eq!(paths, vec![vendor]); } -/// #438 (2), Unix: with no `composer` to ask and no `~/.composer`, -/// Composer uses `$XDG_CONFIG_HOME/composer`. +/// #438 (2), Unix: with no `composer` to ask, Composer 2 uses +/// `$XDG_CONFIG_HOME/composer` (ahead of `~/.composer` since #586). #[cfg(unix)] #[tokio::test] #[serial]