aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-21 17:32:07 +0900
committernsfisis <nsfisis@gmail.com>2026-02-21 17:32:07 +0900
commit42b024a6a7a093f598e7e3448b5b3e29332bb064 (patch)
treeb139613de3980a235db2e9f64f86232d14a7917a /crates
parentca27b7ca262b0115bea9ced660fb9a9e52b462c4 (diff)
downloadphp-mozart-42b024a6a7a093f598e7e3448b5b3e29332bb064.tar.gz
php-mozart-42b024a6a7a093f598e7e3448b5b3e29332bb064.tar.zst
php-mozart-42b024a6a7a093f598e7e3448b5b3e29332bb064.zip
feat(browse): implement browse command to open package URLs in browser
Resolves URLs from composer.json, composer.lock, or Packagist API with priority: support.source > source.url > homepage. Supports --homepage flag, --show for printing URLs, and cross-platform browser opening. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates')
-rw-r--r--crates/mozart/src/commands/browse.rs365
1 files changed, 363 insertions, 2 deletions
diff --git a/crates/mozart/src/commands/browse.rs b/crates/mozart/src/commands/browse.rs
index e3b9287..d17c6d0 100644
--- a/crates/mozart/src/commands/browse.rs
+++ b/crates/mozart/src/commands/browse.rs
@@ -1,4 +1,6 @@
use clap::Args;
+use std::path::{Path, PathBuf};
+use std::process::Command;
#[derive(Args)]
pub struct BrowseArgs {
@@ -14,6 +16,365 @@ pub struct BrowseArgs {
pub show: bool,
}
-pub fn execute(_args: &BrowseArgs, _cli: &super::Cli) -> anyhow::Result<()> {
- todo!()
+// ─── Main entry point ────────────────────────────────────────────────────────
+
+pub fn execute(args: &BrowseArgs, cli: &super::Cli) -> anyhow::Result<()> {
+ let working_dir = match &cli.working_dir {
+ Some(dir) => PathBuf::from(dir),
+ None => std::env::current_dir()?,
+ };
+
+ // 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."
+ );
+ }
+ let root = crate::package::read_from_file(&composer_json)?;
+ vec![root.name.clone()]
+ } else {
+ args.packages.clone()
+ };
+
+ let mut exit_code = 0i32;
+
+ for package_name in &packages {
+ match resolve_url(package_name, &working_dir, args.homepage)? {
+ Some(url) => {
+ if args.show {
+ println!("{}", url);
+ } else {
+ println!(
+ "{}",
+ crate::console::info(&format!("Opening {} in browser.", url))
+ );
+ open_browser(&url)?;
+ }
+ }
+ None => {
+ eprintln!(
+ "{}",
+ crate::console::warning(&format!(
+ "No URL found for package \"{}\".",
+ package_name
+ ))
+ );
+ exit_code = 1;
+ }
+ }
+ }
+
+ if exit_code != 0 {
+ std::process::exit(exit_code);
+ }
+
+ Ok(())
+}
+
+// ─── URL resolution ───────────────────────────────────────────────────────────
+
+fn resolve_url(
+ package_name: &str,
+ working_dir: &Path,
+ prefer_homepage: bool,
+) -> anyhow::Result<Option<String>> {
+ // 1. Check root package (composer.json)
+ let composer_json = working_dir.join("composer.json");
+ if composer_json.exists()
+ && let Ok(root) = crate::package::read_from_file(&composer_json)
+ && root.name.eq_ignore_ascii_case(package_name)
+ && let Some(url) = extract_url_from_root(&root, prefer_homepage)
+ {
+ return Ok(Some(url));
+ }
+
+ // 2. Check lock file (composer.lock)
+ let lock_path = working_dir.join("composer.lock");
+ if lock_path.exists()
+ && let Ok(lock) = crate::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(extract_url_from_locked(pkg, prefer_homepage));
+ }
+ }
+ }
+
+ // 3. Fall back to Packagist API
+ match crate::packagist::fetch_package_versions(package_name, None) {
+ Ok(versions) => {
+ // 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(extract_url_from_packagist(version, prefer_homepage));
+ }
+ Ok(None)
+ }
+ Err(_) => Ok(None),
+ }
+}
+
+// ─── URL extraction ───────────────────────────────────────────────────────────
+
+fn extract_url_from_locked(
+ pkg: &crate::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 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())
+}
+
+fn extract_url_from_root(
+ root: &crate::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 extract_url_from_packagist(
+ pkg: &crate::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());
+ }
+
+ // 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 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())
+}
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+fn is_valid_url(url: &str) -> bool {
+ url.starts_with("http://") || url.starts_with("https://")
+}
+
+fn open_browser(url: &str) -> anyhow::Result<()> {
+ #[cfg(target_os = "macos")]
+ {
+ Command::new("open").arg(url).status()?;
+ return Ok(());
+ }
+
+ #[cfg(target_os = "windows")]
+ {
+ Command::new("cmd")
+ .args(["/C", "start", "", url])
+ .status()?;
+ return Ok(());
+ }
+
+ #[cfg(target_os = "linux")]
+ {
+ if Command::new("which")
+ .arg("xdg-open")
+ .output()
+ .map(|o| o.status.success())
+ .unwrap_or(false)
+ {
+ Command::new("xdg-open").arg(url).status()?;
+ return Ok(());
+ }
+ if Command::new("which")
+ .arg("open")
+ .output()
+ .map(|o| o.status.success())
+ .unwrap_or(false)
+ {
+ Command::new("open").arg(url).status()?;
+ return Ok(());
+ }
+ eprintln!(
+ "No suitable browser opener found. Please open manually: {}",
+ url
+ );
+ Ok(())
+ }
+}
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::collections::BTreeMap;
+
+ fn make_locked_package(
+ source_url: Option<&str>,
+ homepage: Option<&str>,
+ support_source: Option<&str>,
+ ) -> crate::lockfile::LockedPackage {
+ let support = support_source.map(|s| serde_json::json!({"source": s}));
+ let source = source_url.map(|url| crate::lockfile::LockedSource {
+ source_type: "git".to_string(),
+ url: url.to_string(),
+ reference: None,
+ });
+ crate::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(),
+ 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(),
+ }
+ }
+
+ // ── is_valid_url ──────────────────────────────────────────────────────────
+
+ #[test]
+ fn test_is_valid_url() {
+ assert!(is_valid_url("https://github.com/foo/bar"));
+ assert!(is_valid_url("http://example.com"));
+ assert!(!is_valid_url("ftp://example.com"));
+ assert!(!is_valid_url("git@github.com:foo/bar.git"));
+ assert!(!is_valid_url(""));
+ assert!(!is_valid_url("not-a-url"));
+ }
+
+ // ── extract_url_from_locked ───────────────────────────────────────────────
+
+ #[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"),
+ 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()));
+ }
+
+ #[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"),
+ 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()));
+ }
+
+ #[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"),
+ 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())
+ );
+ }
+
+ #[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()));
+ }
+
+ #[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);
+ }
}