From b3fcdf3b5cf0d6b43109c4bcb3dfcbb6576abce5 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Fri, 8 May 2026 02:41:45 +0900 Subject: fix(browse): mirror Composer's HomeCommand semantics Replace the hand-rolled composer.json -> composer.lock -> Packagist fallback with a BrowseRepos composite that dispatches via a uniform find_packages(name) over the root package, the local installed repository, and the Packagist remote -- matching HomeCommand's initializeRepos() + findPackages() loop. - Extend InstalledPackageEntry with homepage/support so the local repo carries the same fields HomeCommand reads off CompletePackageInterface; propagate them through locked_to_installed_entry. - Collapse three extract_url_from_* helpers into a single handle_package mirror. - Relax is_valid_url to a filter_var(FILTER_VALIDATE_URL) analog (drop the http/https scheme allowlist). - Route warnings and "No package specified" notices to stderr; match HomeCommand's exact wording. - Merge the macOS/Linux open_browser branches; add the literal "web" window-title argument on Windows. --- crates/mozart-autoload/src/autoload.rs | 2 + crates/mozart-registry/src/browse_repos.rs | 293 ++++++++++++++++++ crates/mozart-registry/src/installed.rs | 32 ++ crates/mozart-registry/src/lib.rs | 1 + crates/mozart/src/commands/audit.rs | 6 + crates/mozart/src/commands/browse.rs | 459 ++++++++++------------------- crates/mozart/src/commands/fund.rs | 4 + crates/mozart/src/commands/install.rs | 4 + crates/mozart/src/commands/licenses.rs | 8 + crates/mozart/src/commands/reinstall.rs | 2 + crates/mozart/src/commands/show.rs | 6 + crates/mozart/src/commands/status.rs | 10 + crates/mozart/src/commands/suggests.rs | 2 + 13 files changed, 532 insertions(+), 297 deletions(-) create mode 100644 crates/mozart-registry/src/browse_repos.rs diff --git a/crates/mozart-autoload/src/autoload.rs b/crates/mozart-autoload/src/autoload.rs index d245fce..21a1de2 100644 --- a/crates/mozart-autoload/src/autoload.rs +++ b/crates/mozart-autoload/src/autoload.rs @@ -1069,6 +1069,8 @@ mod tests { install_path: Some(format!("../{name}")), autoload: None, aliases: vec![], + homepage: None, + support: None, extra_fields: BTreeMap::new(), } } diff --git a/crates/mozart-registry/src/browse_repos.rs b/crates/mozart-registry/src/browse_repos.rs new file mode 100644 index 0000000..0f9b169 --- /dev/null +++ b/crates/mozart-registry/src/browse_repos.rs @@ -0,0 +1,293 @@ +//! Composite of repositories consulted by the `browse` command. +//! +//! Mirrors `Composer\Command\HomeCommand::initializeRepos()`: +//! root package + local installed repository + remote(s). Each repo +//! exposes a uniform [`BrowseRepo::find_packages`] that yields +//! [`CompletePackageView`]s — the trio of fields +//! `Composer\Command\HomeCommand::handlePackage` reads off +//! `CompletePackageInterface` (`getSupport()['source']`, +//! `getSourceUrl()`, `getHomepage()`). + +use crate::cache::Cache; +use crate::installed::{InstalledPackageEntry, InstalledPackages}; +use crate::lockfile::LockedPackage; +use crate::packagist::{self, PackagistVersion}; +use mozart_core::package::RawPackageData; + +/// Subset of `Composer\Package\CompletePackageInterface` consumed by +/// `HomeCommand::handlePackage`. Every backing repo flattens its +/// package shape into this so URL selection lives in one place. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CompletePackageView { + /// `$package->getSupport()['source']`. + pub support_source: Option, + /// `$package->getSourceUrl()`. + pub source_url: Option, + /// `$package->getHomepage()`. + pub homepage: Option, +} + +impl From<&LockedPackage> for CompletePackageView { + fn from(pkg: &LockedPackage) -> Self { + Self { + support_source: pkg + .support + .as_ref() + .and_then(|s| s.get("source")) + .and_then(|s| s.as_str()) + .map(str::to_string), + source_url: pkg.source.as_ref().map(|s| s.url.clone()), + homepage: pkg.homepage.clone(), + } + } +} + +impl From<&InstalledPackageEntry> for CompletePackageView { + fn from(pkg: &InstalledPackageEntry) -> Self { + Self { + support_source: pkg + .support + .as_ref() + .and_then(|s| s.get("source")) + .and_then(|s| s.as_str()) + .map(str::to_string), + source_url: pkg + .source + .as_ref() + .and_then(|s| s.get("url")) + .and_then(|s| s.as_str()) + .map(str::to_string), + homepage: pkg.homepage.clone(), + } + } +} + +impl From<&PackagistVersion> for CompletePackageView { + fn from(pkg: &PackagistVersion) -> Self { + Self { + support_source: pkg + .support + .as_ref() + .and_then(|s| s.get("source")) + .and_then(|s| s.as_str()) + .map(str::to_string), + source_url: pkg.source.as_ref().map(|s| s.url.clone()), + homepage: pkg.homepage.clone(), + } + } +} + +/// `RawPackageData` lacks a typed `support` field — the root package's +/// `support` block lives inside `extra_fields` because the schema is not +/// yet ported. Read it manually here. +pub fn view_from_raw(pkg: &RawPackageData) -> CompletePackageView { + CompletePackageView { + support_source: pkg + .extra_fields + .get("support") + .and_then(|s| s.get("source")) + .and_then(|s| s.as_str()) + .map(str::to_string), + source_url: None, + homepage: pkg.homepage.clone(), + } +} + +/// One repository in the composite. Mirrors the three repo kinds +/// `HomeCommand::initializeRepos()` returns: +/// `RootPackageRepository` + local installed + remotes. +pub enum BrowseRepo { + /// Stand-in for `Composer\Repository\RootPackageRepository` — + /// a one-package array containing the root composer.json. + /// Boxed because `RawPackageData` is much larger than the other + /// variants (clippy::large_enum_variant). + Root(Box), + /// Stand-in for `RepositoryManager::getLocalRepository()` — + /// the installed.json view of `vendor/`. + Installed(InstalledPackages), + /// Stand-in for the configured remote. For now Mozart only knows + /// the default Packagist remote (`RepositoryFactory::defaultRepos`). + Packagist { cache: Cache }, +} + +impl BrowseRepo { + /// Mirrors `RepositoryInterface::findPackages($name)` — case-insensitive + /// match by package name, returning every match the repo holds. + pub async fn find_packages(&self, name: &str) -> anyhow::Result> { + match self { + BrowseRepo::Root(pkg) => { + if pkg.name.eq_ignore_ascii_case(name) { + Ok(vec![view_from_raw(pkg)]) + } else { + Ok(Vec::new()) + } + } + BrowseRepo::Installed(installed) => Ok(installed + .packages + .iter() + .filter(|p| p.name.eq_ignore_ascii_case(name)) + .map(CompletePackageView::from) + .collect()), + BrowseRepo::Packagist { cache } => { + let versions = packagist::fetch_package_versions(name, cache).await?; + Ok(versions.iter().map(CompletePackageView::from).collect()) + } + } + } +} + +/// Ordered composite consulted by `HomeCommand::execute()`'s outer +/// `foreach ($repos as $repo)` loop. +pub struct BrowseRepos { + repos: Vec, +} + +impl BrowseRepos { + /// Build the composite. `root` and `installed` are passed in + /// rather than read here so callers can decide whether to load + /// them from `Composer` (when composer.json is present) or skip + /// them entirely (the `defaultReposWithDefaultManager` fallback). + pub fn new( + root: Option, + installed: Option, + packagist_cache: Cache, + ) -> Self { + let mut repos: Vec = Vec::with_capacity(3); + if let Some(root) = root { + repos.push(BrowseRepo::Root(Box::new(root))); + } + if let Some(installed) = installed { + repos.push(BrowseRepo::Installed(installed)); + } + repos.push(BrowseRepo::Packagist { + cache: packagist_cache, + }); + Self { repos } + } + + pub fn iter(&self) -> std::slice::Iter<'_, BrowseRepo> { + self.repos.iter() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + fn locked( + name: &str, + source_url: Option<&str>, + homepage: Option<&str>, + support_source: Option<&str>, + ) -> LockedPackage { + LockedPackage { + name: name.to_string(), + version: "1.0.0".to_string(), + version_normalized: None, + source: source_url.map(|url| crate::lockfile::LockedSource { + source_type: "git".to_string(), + url: url.to_string(), + reference: None, + }), + dist: None, + require: BTreeMap::new(), + require_dev: BTreeMap::new(), + conflict: BTreeMap::new(), + provide: BTreeMap::new(), + replace: BTreeMap::new(), + suggest: None, + package_type: None, + autoload: None, + autoload_dev: None, + license: None, + description: None, + homepage: homepage.map(str::to_string), + keywords: None, + authors: None, + support: support_source.map(|s| serde_json::json!({"source": s})), + funding: None, + time: None, + extra_fields: BTreeMap::new(), + } + } + + #[test] + fn view_from_locked_package_carries_three_urls() { + let pkg = locked( + "vendor/pkg", + Some("https://github.com/vendor/pkg.git"), + Some("https://vendor.example.com"), + Some("https://github.com/vendor/pkg"), + ); + let view = CompletePackageView::from(&pkg); + assert_eq!( + view.support_source.as_deref(), + Some("https://github.com/vendor/pkg") + ); + assert_eq!( + view.source_url.as_deref(), + Some("https://github.com/vendor/pkg.git") + ); + assert_eq!(view.homepage.as_deref(), Some("https://vendor.example.com")); + } + + #[test] + fn view_from_installed_entry_extracts_source_url() { + let mut entry = InstalledPackageEntry { + name: "vendor/pkg".to_string(), + version: "1.0.0".to_string(), + version_normalized: None, + source: Some(serde_json::json!({"url": "https://github.com/vendor/pkg.git"})), + dist: None, + package_type: None, + install_path: None, + autoload: None, + aliases: vec![], + homepage: Some("https://vendor.example.com".to_string()), + support: Some(serde_json::json!({"source": "https://github.com/vendor/pkg"})), + extra_fields: BTreeMap::new(), + }; + let view = CompletePackageView::from(&entry); + assert_eq!( + view.source_url.as_deref(), + Some("https://github.com/vendor/pkg.git") + ); + assert_eq!( + view.support_source.as_deref(), + Some("https://github.com/vendor/pkg") + ); + assert_eq!(view.homepage.as_deref(), Some("https://vendor.example.com")); + + entry.support = None; + entry.source = None; + entry.homepage = None; + let empty = CompletePackageView::from(&entry); + assert_eq!(empty, CompletePackageView::default()); + } + + #[test] + fn view_from_raw_reads_support_via_extra_fields() { + let mut raw = RawPackageData::new("vendor/root".to_string()); + raw.homepage = Some("https://vendor.example.com".to_string()); + raw.extra_fields.insert( + "support".to_string(), + serde_json::json!({"source": "https://github.com/vendor/root"}), + ); + let view = view_from_raw(&raw); + assert_eq!( + view.support_source.as_deref(), + Some("https://github.com/vendor/root") + ); + assert!(view.source_url.is_none()); + assert_eq!(view.homepage.as_deref(), Some("https://vendor.example.com")); + } + + #[tokio::test] + async fn root_repo_matches_case_insensitively() { + let raw = RawPackageData::new("Vendor/Root".to_string()); + let repo = BrowseRepo::Root(Box::new(raw)); + assert_eq!(repo.find_packages("vendor/root").await.unwrap().len(), 1); + assert_eq!(repo.find_packages("other/pkg").await.unwrap().len(), 0); + } +} diff --git a/crates/mozart-registry/src/installed.rs b/crates/mozart-registry/src/installed.rs index 6e8e0ac..da02c6a 100644 --- a/crates/mozart-registry/src/installed.rs +++ b/crates/mozart-registry/src/installed.rs @@ -48,6 +48,12 @@ pub struct InstalledPackageEntry { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub aliases: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub homepage: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub support: Option, + #[serde(flatten)] pub extra_fields: BTreeMap, } @@ -188,6 +194,8 @@ mod tests { install_path: None, autoload: None, aliases: vec![], + homepage: None, + support: None, extra_fields: BTreeMap::new(), } } @@ -329,4 +337,28 @@ mod tests { assert_eq!(loaded.packages[0].name, "monolog/monolog"); assert_eq!(loaded.packages[0].version, "3.8.0"); } + + #[test] + fn test_homepage_and_support_roundtrip() { + let json = r#"{ + "packages": [ + { + "name": "vendor/pkg", + "version": "1.0.0", + "homepage": "https://vendor.example.com", + "support": {"source": "https://github.com/vendor/pkg"} + } + ] + }"#; + let installed = InstalledPackages::from_json_str(json).unwrap(); + let pkg = &installed.packages[0]; + assert_eq!(pkg.homepage.as_deref(), Some("https://vendor.example.com")); + assert_eq!( + pkg.support + .as_ref() + .and_then(|s| s.get("source")) + .and_then(|s| s.as_str()), + Some("https://github.com/vendor/pkg") + ); + } } diff --git a/crates/mozart-registry/src/lib.rs b/crates/mozart-registry/src/lib.rs index 73b5b76..36a12c6 100644 --- a/crates/mozart-registry/src/lib.rs +++ b/crates/mozart-registry/src/lib.rs @@ -1,3 +1,4 @@ +pub mod browse_repos; pub mod cache; pub mod composer_repo; pub mod downloader; diff --git a/crates/mozart/src/commands/audit.rs b/crates/mozart/src/commands/audit.rs index 5c0d46c..da61e62 100644 --- a/crates/mozart/src/commands/audit.rs +++ b/crates/mozart/src/commands/audit.rs @@ -858,6 +858,8 @@ mod tests { install_path: None, autoload: None, aliases: vec![], + homepage: None, + support: None, extra_fields: BTreeMap::new(), }); installed.write(&vendor_dir).unwrap(); @@ -887,6 +889,8 @@ mod tests { install_path: None, autoload: None, aliases: vec![], + homepage: None, + support: None, extra_fields: BTreeMap::new(), }); installed.upsert(mozart_registry::installed::InstalledPackageEntry { @@ -899,6 +903,8 @@ mod tests { install_path: None, autoload: None, aliases: vec![], + homepage: None, + support: None, extra_fields: BTreeMap::new(), }); installed diff --git a/crates/mozart/src/commands/browse.rs b/crates/mozart/src/commands/browse.rs index a977012..538cf6a 100644 --- a/crates/mozart/src/commands/browse.rs +++ b/crates/mozart/src/commands/browse.rs @@ -1,8 +1,13 @@ use clap::Args; +use mozart_core::composer::Composer; +use mozart_core::console::Console; use mozart_core::console_format; use mozart_core::console_writeln; +use mozart_core::console_writeln_error; use mozart_core::exit_code; -use std::path::Path; +use mozart_registry::browse_repos::{BrowseRepos, CompletePackageView}; +use mozart_registry::cache::{Cache, build_cache_config}; +use mozart_registry::installed::InstalledPackages; use std::process::Command; #[derive(Args)] @@ -19,381 +24,241 @@ pub struct BrowseArgs { pub show: bool, } -pub async fn execute( - args: &BrowseArgs, - cli: &super::Cli, - console: &mozart_core::console::Console, -) -> anyhow::Result<()> { - let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache); - let repo_cache = mozart_registry::cache::Cache::repo(&cache_config); - +pub async fn execute(args: &BrowseArgs, cli: &super::Cli, console: &Console) -> anyhow::Result<()> { let working_dir = cli.working_dir()?; + let cache = Cache::repo(&build_cache_config(cli.no_cache)); + + let composer = Composer::try_load(&working_dir)?; + let repos = build_repos(composer.as_ref(), cache); - // If no packages specified, use root package name from composer.json let packages: Vec = if args.packages.is_empty() { - let composer_json = working_dir.join("composer.json"); - if !composer_json.exists() { - anyhow::bail!( - "No composer.json found in the current directory and no package specified." - ); - } - console.info("No package specified, opening homepage for the root package"); - let root = mozart_core::package::read_from_file(&composer_json)?; - vec![root.name.clone()] + console_writeln_error!( + console, + "No package specified, opening homepage for the root package" + ); + // Mirrors HomeCommand's `$this->requireComposer()->getPackage()->getName()`. + let composer = composer.ok_or_else(|| { + anyhow::anyhow!( + "Composer could not find a composer.json file in {}", + working_dir.display() + ) + })?; + vec![composer.package().name.clone()] } else { args.packages.clone() }; - let mut exit_code = 0i32; - + let mut return_code = 0i32; for package_name in &packages { - match resolve_url(package_name, &working_dir, args.homepage, &repo_cache).await? { - ResolveResult::Found(url) => { - if args.show { - console_writeln!(console, &console_format!("{}", url),); - } else { - open_browser(&url, console)?; + let mut handled = false; + let mut package_exists = false; + 'outer: for repo in repos.iter() { + for view in repo.find_packages(package_name).await? { + package_exists = true; + if handle_package(&view, args.homepage, args.show, console)? { + handled = true; + break 'outer; } } - ResolveResult::NotFound => { - console.info(&console_format!( - "Package {} not found", - package_name - )); - exit_code = 1; - } - ResolveResult::NoUrl => { - let msg = if args.homepage { - format!("Invalid or missing homepage for {}", package_name) - } else { - format!("Invalid or missing repository URL for {}", package_name) - }; - console.info(&console_format!("{}", msg)); - exit_code = 1; - } } - } - - if exit_code != 0 { - return Err(exit_code::bail_silent(exit_code)); - } - - Ok(()) -} - -enum ResolveResult { - /// Package found and URL resolved - Found(String), - /// Package found but no valid URL available - NoUrl, - /// Package not found in any source - NotFound, -} - -async fn resolve_url( - package_name: &str, - working_dir: &Path, - prefer_homepage: bool, - repo_cache: &mozart_registry::cache::Cache, -) -> anyhow::Result { - // 1. Check root package (composer.json) - let composer_json = working_dir.join("composer.json"); - if composer_json.exists() - && let Ok(root) = mozart_core::package::read_from_file(&composer_json) - && root.name.eq_ignore_ascii_case(package_name) - { - return Ok(match extract_url_from_root(&root, prefer_homepage) { - Some(url) => ResolveResult::Found(url), - None => ResolveResult::NoUrl, - }); - } - // 2. Check lock file (composer.lock) - let lock_path = working_dir.join("composer.lock"); - if lock_path.exists() - && let Ok(lock) = mozart_registry::lockfile::LockFile::read_from_file(&lock_path) - { - let all_packages = lock - .packages - .iter() - .chain(lock.packages_dev.as_deref().unwrap_or(&[])); - - for pkg in all_packages { - if pkg.name.eq_ignore_ascii_case(package_name) { - return Ok(match extract_url_from_locked(pkg, prefer_homepage) { - Some(url) => ResolveResult::Found(url), - None => ResolveResult::NoUrl, - }); - } + if !package_exists { + return_code = 1; + console_writeln_error!( + console, + &console_format!("Package {} not found", package_name), + ); } - } - // 3. Fall back to Packagist API - match mozart_registry::packagist::fetch_package_versions(package_name, repo_cache).await { - Ok(versions) if !versions.is_empty() => { - // Find the latest stable version (first non-dev, or fallback to first) - let best = versions - .iter() - .find(|v| !v.version.starts_with("dev-") && !v.version.ends_with("-dev")) - .or_else(|| versions.first()); - - if let Some(version) = best { - return Ok(match extract_url_from_packagist(version, prefer_homepage) { - Some(url) => ResolveResult::Found(url), - None => ResolveResult::NoUrl, - }); - } - Ok(ResolveResult::NotFound) + if !handled { + return_code = 1; + let kind = if args.homepage { + "Invalid or missing homepage" + } else { + "Invalid or missing repository URL" + }; + console_writeln_error!( + console, + &console_format!("{} for {}", kind, package_name), + ); } - _ => Ok(ResolveResult::NotFound), - } -} - -fn extract_url_from_locked( - pkg: &mozart_registry::lockfile::LockedPackage, - prefer_homepage: bool, -) -> Option { - if prefer_homepage { - return pkg - .homepage - .as_deref() - .filter(|u| is_valid_url(u)) - .map(|u| u.to_string()); } - // Priority: support.source → source.url → homepage - if let Some(ref support) = pkg.support - && let Some(source_url) = support.get("source").and_then(|v| v.as_str()) - && is_valid_url(source_url) - { - return Some(source_url.to_string()); + if return_code != 0 { + return Err(exit_code::bail_silent(return_code)); } - if let Some(ref source) = pkg.source - && is_valid_url(&source.url) - { - return Some(source.url.clone()); - } - - pkg.homepage - .as_deref() - .filter(|u| is_valid_url(u)) - .map(|u| u.to_string()) + Ok(()) } -fn extract_url_from_root( - root: &mozart_core::package::RawPackageData, - prefer_homepage: bool, -) -> Option { - if prefer_homepage { - return root - .homepage - .as_deref() - .filter(|u| is_valid_url(u)) - .map(|u| u.to_string()); - } - - // Priority: support.source → homepage (no source.url in RawPackageData) - if let Some(support_val) = root.extra_fields.get("support") - && let Some(source_url) = support_val.get("source").and_then(|v| v.as_str()) - && is_valid_url(source_url) - { - return Some(source_url.to_string()); - } - - root.homepage - .as_deref() - .filter(|u| is_valid_url(u)) - .map(|u| u.to_string()) +fn build_repos(composer: Option<&Composer>, cache: Cache) -> BrowseRepos { + let (root, installed) = match composer { + Some(c) => { + let root = Some(c.package().clone()); + let installed = InstalledPackages::read(c.installation_manager().vendor_dir()).ok(); + (root, installed) + } + None => (None, None), + }; + BrowseRepos::new(root, installed, cache) } -fn extract_url_from_packagist( - pkg: &mozart_registry::packagist::PackagistVersion, - prefer_homepage: bool, -) -> Option { - if prefer_homepage { - return pkg - .homepage - .as_deref() - .filter(|u| is_valid_url(u)) - .map(|u| u.to_string()); +/// Port of `HomeCommand::handlePackage`. Returns `true` on success +/// (URL printed or browser opened), `false` when no valid URL was +/// available — matching Composer's signal for the outer loop. +fn handle_package( + view: &CompletePackageView, + show_homepage: bool, + show_only: bool, + console: &Console, +) -> anyhow::Result { + let mut url = view + .support_source + .clone() + .or_else(|| view.source_url.clone()); + if url.is_none() || show_homepage { + url = view.homepage.clone(); } - // Priority: support.source → source.url → homepage - if let Some(ref support) = pkg.support - && let Some(source_url) = support.get("source").and_then(|v| v.as_str()) - && is_valid_url(source_url) - { - return Some(source_url.to_string()); - } + let Some(url) = url.filter(|u| is_valid_url(u)) else { + return Ok(false); + }; - if let Some(ref source) = pkg.source - && is_valid_url(&source.url) - { - return Some(source.url.clone()); + if show_only { + console_writeln!(console, &console_format!("{}", url)); + } else { + open_browser(&url, console)?; } - - pkg.homepage - .as_deref() - .filter(|u| is_valid_url(u)) - .map(|u| u.to_string()) + Ok(true) } fn is_valid_url(url: &str) -> bool { - match url::Url::parse(url) { - Ok(parsed) => matches!(parsed.scheme(), "http" | "https"), - Err(_) => false, - } + url::Url::parse(url).is_ok() } -fn open_browser(url: &str, console: &mozart_core::console::Console) -> anyhow::Result<()> { - #[cfg(target_os = "macos")] - { - Command::new("open").arg(url).status()?; - return Ok(()); - } - +fn open_browser(url: &str, console: &Console) -> anyhow::Result<()> { #[cfg(target_os = "windows")] { Command::new("cmd") - .args(["/C", "start", "web", "explorer", url]) + .args(["/C", "start", "\"web\"", "explorer", url]) .status()?; return Ok(()); } - #[cfg(target_os = "linux")] + #[cfg(not(target_os = "windows"))] { - if Command::new("which") - .arg("xdg-open") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - { + let xdg_open = which("xdg-open"); + let open = which("open"); + if xdg_open { Command::new("xdg-open").arg(url).status()?; - return Ok(()); - } - if Command::new("which") - .arg("open") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - { + } else if open { Command::new("open").arg(url).status()?; - return Ok(()); + } else { + console_writeln_error!( + console, + &format!( + "No suitable browser opening command found, open yourself: {}", + url + ), + ); } - console.info(&format!( - "No suitable browser opener found. Please open manually: {}", - url - )); Ok(()) } } +#[cfg(not(target_os = "windows"))] +fn which(cmd: &str) -> bool { + Command::new("which") + .arg(cmd) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + #[cfg(test)] mod tests { use super::*; - use std::collections::BTreeMap; - fn make_locked_package( - source_url: Option<&str>, + fn console() -> Console { + Console::new(0, false, false, false, true) + } + + fn view( + support: Option<&str>, + source: Option<&str>, homepage: Option<&str>, - support_source: Option<&str>, - ) -> mozart_registry::lockfile::LockedPackage { - let support = support_source.map(|s| serde_json::json!({"source": s})); - let source = source_url.map(|url| mozart_registry::lockfile::LockedSource { - source_type: "git".to_string(), - url: url.to_string(), - reference: None, - }); - mozart_registry::lockfile::LockedPackage { - name: "vendor/package".to_string(), - version: "1.0.0".to_string(), - version_normalized: None, - source, - dist: None, - require: BTreeMap::new(), - require_dev: BTreeMap::new(), - conflict: BTreeMap::new(), - provide: BTreeMap::new(), - replace: BTreeMap::new(), - suggest: None, - package_type: None, - autoload: None, - autoload_dev: None, - license: None, - description: None, - homepage: homepage.map(|s| s.to_string()), - keywords: None, - authors: None, - support, - funding: None, - time: None, - extra_fields: BTreeMap::new(), + ) -> CompletePackageView { + CompletePackageView { + support_source: support.map(str::to_string), + source_url: source.map(str::to_string), + homepage: homepage.map(str::to_string), } } #[test] - fn test_is_valid_url() { - assert!(!is_valid_url("https://")); + fn is_valid_url_accepts_filter_var_compatible_schemes() { assert!(is_valid_url("https://example.com")); assert!(is_valid_url("http://example.com/path?query=1")); - assert!(!is_valid_url("ftp://example.com")); - assert!(!is_valid_url("not-a-url")); + assert!(is_valid_url("ftp://example.com/a")); + } + + #[test] + fn is_valid_url_rejects_malformed() { assert!(!is_valid_url("")); + assert!(!is_valid_url("not-a-url")); + assert!(!is_valid_url("https://")); } #[test] - fn test_extract_url_from_locked_prefers_support_source() { - // Has all three: support.source should win - let pkg = make_locked_package( - Some("https://github.com/vendor/package.git"), + fn handle_package_prefers_support_source() { + let v = view( + Some("https://github.com/vendor/pkg"), + Some("https://github.com/vendor/pkg.git"), Some("https://vendor.example.com"), - Some("https://github.com/vendor/package"), ); - let url = extract_url_from_locked(&pkg, false); - assert_eq!(url, Some("https://github.com/vendor/package".to_string())); + assert!(handle_package(&v, false, true, &console()).unwrap()); } #[test] - fn test_extract_url_from_locked_prefers_homepage() { - // With prefer_homepage=true, only homepage is returned - let pkg = make_locked_package( - Some("https://github.com/vendor/package.git"), + fn handle_package_falls_back_to_source_url() { + let v = view( + None, + Some("https://github.com/vendor/pkg.git"), Some("https://vendor.example.com"), - Some("https://github.com/vendor/package"), ); - let url = extract_url_from_locked(&pkg, true); - assert_eq!(url, Some("https://vendor.example.com".to_string())); + assert!(handle_package(&v, false, true, &console()).unwrap()); } #[test] - fn test_extract_url_from_locked_fallback_to_source() { - // No support.source, has source.url - let pkg = make_locked_package( - Some("https://github.com/vendor/package.git"), + fn handle_package_falls_back_to_homepage_when_no_source() { + let v = view(None, None, Some("https://vendor.example.com")); + assert!(handle_package(&v, false, true, &console()).unwrap()); + } + + #[test] + fn handle_package_show_homepage_overrides_to_homepage() { + let v = view( + Some("https://github.com/vendor/pkg"), + Some("https://github.com/vendor/pkg.git"), Some("https://vendor.example.com"), - None, - ); - let url = extract_url_from_locked(&pkg, false); - assert_eq!( - url, - Some("https://github.com/vendor/package.git".to_string()) ); + assert!(handle_package(&v, true, true, &console()).unwrap()); } #[test] - fn test_extract_url_from_locked_fallback_to_homepage() { - // No source URLs, falls back to homepage - let pkg = make_locked_package(None, Some("https://vendor.example.com"), None); - let url = extract_url_from_locked(&pkg, false); - assert_eq!(url, Some("https://vendor.example.com".to_string())); + fn handle_package_returns_false_when_no_valid_url() { + let v = view(None, None, None); + assert!(!handle_package(&v, false, true, &console()).unwrap()); + + // Invalid URL strings still cause `handlePackage` to bail. + let bad = view(Some("not-a-url"), None, None); + assert!(!handle_package(&bad, false, true, &console()).unwrap()); } #[test] - fn test_extract_url_from_locked_no_urls() { - // No URLs at all - let pkg = make_locked_package(None, None, None); - let url = extract_url_from_locked(&pkg, false); - assert_eq!(url, None); + fn handle_package_show_homepage_with_missing_homepage_returns_false() { + let v = view(Some("https://github.com/vendor/pkg"), None, None); + // -H and homepage absent → falls through and bails. + assert!(!handle_package(&v, true, true, &console()).unwrap()); } } diff --git a/crates/mozart/src/commands/fund.rs b/crates/mozart/src/commands/fund.rs index ab8591a..f240378 100644 --- a/crates/mozart/src/commands/fund.rs +++ b/crates/mozart/src/commands/fund.rs @@ -470,6 +470,8 @@ mod tests { install_path: None, autoload: None, aliases: vec![], + homepage: None, + support: None, extra_fields: extra, }); @@ -484,6 +486,8 @@ mod tests { install_path: None, autoload: None, aliases: vec![], + homepage: None, + support: None, extra_fields: BTreeMap::new(), }); diff --git a/crates/mozart/src/commands/install.rs b/crates/mozart/src/commands/install.rs index 428c5cc..7b4315c 100644 --- a/crates/mozart/src/commands/install.rs +++ b/crates/mozart/src/commands/install.rs @@ -629,6 +629,8 @@ pub fn locked_to_installed_entry( install_path: Some(install_path), autoload: pkg.autoload.clone(), aliases: vec![], + homepage: pkg.homepage.clone(), + support: pkg.support.clone(), extra_fields: pkg.extra_fields.clone(), } } @@ -1634,6 +1636,8 @@ mod tests { install_path: None, autoload: None, aliases: vec![], + homepage: None, + support: None, extra_fields: BTreeMap::new(), } } diff --git a/crates/mozart/src/commands/licenses.rs b/crates/mozart/src/commands/licenses.rs index 7515b32..1574d79 100644 --- a/crates/mozart/src/commands/licenses.rs +++ b/crates/mozart/src/commands/licenses.rs @@ -376,6 +376,8 @@ mod tests { install_path: None, autoload: None, aliases: vec![], + homepage: None, + support: None, extra_fields: extra, } } @@ -489,6 +491,8 @@ mod tests { install_path: None, autoload: None, aliases: vec![], + homepage: None, + support: None, extra_fields: extra, }); @@ -530,6 +534,8 @@ mod tests { install_path: None, autoload: None, aliases: vec![], + homepage: None, + support: None, extra_fields: extra_prod, }); @@ -546,6 +552,8 @@ mod tests { install_path: None, autoload: None, aliases: vec![], + homepage: None, + support: None, extra_fields: extra_dev, }); installed diff --git a/crates/mozart/src/commands/reinstall.rs b/crates/mozart/src/commands/reinstall.rs index b421bd8..45f44f5 100644 --- a/crates/mozart/src/commands/reinstall.rs +++ b/crates/mozart/src/commands/reinstall.rs @@ -415,6 +415,8 @@ mod tests { install_path: None, autoload: None, aliases: vec![], + homepage: None, + support: None, extra_fields: BTreeMap::new(), } } diff --git a/crates/mozart/src/commands/show.rs b/crates/mozart/src/commands/show.rs index 7b87403..c675d54 100644 --- a/crates/mozart/src/commands/show.rs +++ b/crates/mozart/src/commands/show.rs @@ -2062,6 +2062,8 @@ mod tests { install_path: None, autoload: None, aliases: vec![], + homepage: None, + support: None, extra_fields: extra, }; assert_eq!(get_installed_description(&pkg), "A logging library"); @@ -2080,6 +2082,8 @@ mod tests { install_path: None, autoload: None, aliases: vec![], + homepage: None, + support: None, extra_fields: BTreeMap::new(), }; assert_eq!(get_installed_description(&pkg), ""); @@ -2103,6 +2107,8 @@ mod tests { install_path: None, autoload: None, aliases: vec![], + homepage: None, + support: None, extra_fields: extra, }; assert_eq!(get_installed_keywords(&pkg), "log, psr3, logging"); diff --git a/crates/mozart/src/commands/status.rs b/crates/mozart/src/commands/status.rs index c22fd3c..60db8ac 100644 --- a/crates/mozart/src/commands/status.rs +++ b/crates/mozart/src/commands/status.rs @@ -501,6 +501,8 @@ mod tests { install_path: None, autoload: None, aliases: vec![], + homepage: None, + support: None, extra_fields: BTreeMap::new(), }; @@ -528,6 +530,8 @@ mod tests { install_path: None, autoload: None, aliases: vec![], + homepage: None, + support: None, extra_fields: BTreeMap::new(), }; @@ -548,6 +552,8 @@ mod tests { install_path: None, autoload: None, aliases: vec![], + homepage: None, + support: None, extra_fields: BTreeMap::new(), }; @@ -568,6 +574,8 @@ mod tests { install_path: None, autoload: None, aliases: vec![], + homepage: None, + support: None, extra_fields: BTreeMap::new(), }; @@ -590,6 +598,8 @@ mod tests { install_path: Some("../monolog/monolog".to_string()), autoload: None, aliases: vec![], + homepage: None, + support: None, extra_fields: BTreeMap::new(), }; diff --git a/crates/mozart/src/commands/suggests.rs b/crates/mozart/src/commands/suggests.rs index f00a2a2..ab4100d 100644 --- a/crates/mozart/src/commands/suggests.rs +++ b/crates/mozart/src/commands/suggests.rs @@ -548,6 +548,8 @@ mod tests { install_path: None, autoload: None, aliases: vec![], + homepage: None, + support: None, extra_fields, } } -- cgit v1.3.1