diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-21 17:32:07 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-21 17:32:07 +0900 |
| commit | 42b024a6a7a093f598e7e3448b5b3e29332bb064 (patch) | |
| tree | b139613de3980a235db2e9f64f86232d14a7917a /crates | |
| parent | ca27b7ca262b0115bea9ced660fb9a9e52b462c4 (diff) | |
| download | php-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.rs | 365 |
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); + } } |
