aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--crates/mozart-autoload/src/autoload.rs2
-rw-r--r--crates/mozart-registry/src/browse_repos.rs293
-rw-r--r--crates/mozart-registry/src/installed.rs32
-rw-r--r--crates/mozart-registry/src/lib.rs1
-rw-r--r--crates/mozart/src/commands/audit.rs6
-rw-r--r--crates/mozart/src/commands/browse.rs459
-rw-r--r--crates/mozart/src/commands/fund.rs4
-rw-r--r--crates/mozart/src/commands/install.rs4
-rw-r--r--crates/mozart/src/commands/licenses.rs8
-rw-r--r--crates/mozart/src/commands/reinstall.rs2
-rw-r--r--crates/mozart/src/commands/show.rs6
-rw-r--r--crates/mozart/src/commands/status.rs10
-rw-r--r--crates/mozart/src/commands/suggests.rs2
13 files changed, 532 insertions, 297 deletions
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<String>,
+ /// `$package->getSourceUrl()`.
+ pub source_url: Option<String>,
+ /// `$package->getHomepage()`.
+ pub homepage: Option<String>,
+}
+
+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<RawPackageData>),
+ /// 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<Vec<CompletePackageView>> {
+ 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<BrowseRepo>,
+}
+
+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<RawPackageData>,
+ installed: Option<InstalledPackages>,
+ packagist_cache: Cache,
+ ) -> Self {
+ let mut repos: Vec<BrowseRepo> = 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<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub homepage: Option<String>,
+
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub support: Option<serde_json::Value>,
+
#[serde(flatten)]
pub extra_fields: BTreeMap<String, serde_json::Value>,
}
@@ -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<String> = 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!("<info>{}</info>", 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!(
- "<warning>Package {} not found</warning>",
- 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!("<warning>{}</warning>", 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<ResolveResult> {
- // 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!("<warning>Package {} not found</warning>", 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!("<warning>{} for {}</warning>", kind, package_name),
+ );
}
- _ => Ok(ResolveResult::NotFound),
- }
-}
-
-fn extract_url_from_locked(
- pkg: &mozart_registry::lockfile::LockedPackage,
- prefer_homepage: bool,
-) -> Option<String> {
- 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<String> {
- 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<String> {
- 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<bool> {
+ 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!("<info>{}</info>", 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,
}
}