aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--crates/mozart/src/commands/fund.rs541
1 files changed, 539 insertions, 2 deletions
diff --git a/crates/mozart/src/commands/fund.rs b/crates/mozart/src/commands/fund.rs
index a672b18..19e4825 100644
--- a/crates/mozart/src/commands/fund.rs
+++ b/crates/mozart/src/commands/fund.rs
@@ -1,4 +1,6 @@
use clap::Args;
+use std::collections::BTreeMap;
+use std::path::{Path, PathBuf};
#[derive(Args)]
pub struct FundArgs {
@@ -7,6 +9,541 @@ pub struct FundArgs {
pub format: Option<String>,
}
-pub fn execute(_args: &FundArgs, _cli: &super::Cli) -> anyhow::Result<()> {
- todo!()
+// ─── Data structures ────────────────────────────────────────────────────────
+
+struct FundingLink {
+ url: String,
+ funding_type: Option<String>,
+}
+
+struct FundingEntry {
+ full_name: String,
+ links: Vec<FundingLink>,
+}
+
+// ─── Main entry point ───────────────────────────────────────────────────────
+
+pub fn execute(args: &FundArgs, cli: &super::Cli) -> anyhow::Result<()> {
+ let working_dir = match &cli.working_dir {
+ Some(dir) => PathBuf::from(dir),
+ None => std::env::current_dir()?,
+ };
+
+ // Validate format
+ let format = args.format.as_deref().unwrap_or("text");
+ if format != "text" && format != "json" {
+ anyhow::bail!(
+ "Invalid format \"{}\". Supported formats: text, json",
+ format
+ );
+ }
+
+ // Try lock file first (preferred), fall back to installed.json
+ let lock_path = working_dir.join("composer.lock");
+ let entries = if lock_path.exists() {
+ collect_funding_from_locked(&working_dir)?
+ } else {
+ collect_funding_from_installed(&working_dir)?
+ };
+
+ let grouped = group_by_vendor(&entries);
+
+ match format {
+ "json" => render_json(&grouped)?,
+ _ => render_text(&grouped),
+ }
+
+ Ok(())
+}
+
+// ─── Package loading ─────────────────────────────────────────────────────────
+
+fn collect_funding_from_locked(working_dir: &Path) -> anyhow::Result<Vec<FundingEntry>> {
+ let lock_path = working_dir.join("composer.lock");
+ let lock = crate::lockfile::LockFile::read_from_file(&lock_path)?;
+
+ let mut all_packages: Vec<&crate::lockfile::LockedPackage> = lock.packages.iter().collect();
+ if let Some(ref pkgs_dev) = lock.packages_dev {
+ all_packages.extend(pkgs_dev.iter());
+ }
+
+ let entries = all_packages
+ .iter()
+ .filter_map(|p| {
+ let funding_vec = p.funding.as_deref()?;
+ if funding_vec.is_empty() {
+ return None;
+ }
+ let links = extract_funding_links(funding_vec);
+ if links.is_empty() {
+ return None;
+ }
+ Some(FundingEntry {
+ full_name: p.name.clone(),
+ links,
+ })
+ })
+ .collect();
+
+ Ok(entries)
+}
+
+fn collect_funding_from_installed(working_dir: &Path) -> anyhow::Result<Vec<FundingEntry>> {
+ let vendor_dir = working_dir.join("vendor");
+ let installed = crate::installed::InstalledPackages::read(&vendor_dir)?;
+
+ let entries = installed
+ .packages
+ .iter()
+ .filter_map(|p| {
+ let funding_val = p.extra_fields.get("funding")?;
+ let funding_arr = funding_val.as_array()?;
+ if funding_arr.is_empty() {
+ return None;
+ }
+ let links = extract_funding_links(funding_arr);
+ if links.is_empty() {
+ return None;
+ }
+ Some(FundingEntry {
+ full_name: p.name.clone(),
+ links,
+ })
+ })
+ .collect();
+
+ Ok(entries)
+}
+
+// ─── Funding helpers ──────────────────────────────────────────────────────────
+
+fn extract_funding_links(funding_json: &[serde_json::Value]) -> Vec<FundingLink> {
+ funding_json
+ .iter()
+ .filter_map(|entry| {
+ let url = entry.get("url").and_then(|v| v.as_str()).unwrap_or("");
+ if url.is_empty() {
+ return None;
+ }
+ let funding_type = entry
+ .get("type")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+ Some(FundingLink {
+ url: url.to_string(),
+ funding_type,
+ })
+ })
+ .collect()
+}
+
+fn rewrite_github_url(url: &str, funding_type: Option<&str>) -> String {
+ if funding_type != Some("github") {
+ return url.to_string();
+ }
+ // Match exactly https://github.com/{user} with no further path segments
+ if let Some(rest) = url.strip_prefix("https://github.com/") {
+ // rest must be a single path segment (no '/')
+ if !rest.is_empty() && !rest.contains('/') {
+ return format!("https://github.com/sponsors/{}", rest);
+ }
+ }
+ url.to_string()
+}
+
+fn group_by_vendor(entries: &[FundingEntry]) -> BTreeMap<String, BTreeMap<String, Vec<String>>> {
+ let mut grouped: BTreeMap<String, BTreeMap<String, Vec<String>>> = BTreeMap::new();
+
+ for entry in entries {
+ // Split full_name into vendor and package parts
+ let (vendor, package_name) = match entry.full_name.split_once('/') {
+ Some((v, p)) => (v.to_string(), p.to_string()),
+ None => (entry.full_name.clone(), entry.full_name.clone()),
+ };
+
+ let vendor_map = grouped.entry(vendor).or_default();
+
+ for link in &entry.links {
+ let url = rewrite_github_url(&link.url, link.funding_type.as_deref());
+ vendor_map
+ .entry(url)
+ .or_default()
+ .push(package_name.clone());
+ }
+ }
+
+ grouped
+}
+
+// ─── Rendering ───────────────────────────────────────────────────────────────
+
+fn render_text(grouped: &BTreeMap<String, BTreeMap<String, Vec<String>>>) {
+ if grouped.is_empty() {
+ println!(
+ "No funding links were found in your package dependencies. \
+ This doesn't mean they don't need your support!"
+ );
+ return;
+ }
+
+ println!(
+ "The following packages were found in your dependencies which publish funding information:"
+ );
+
+ for (vendor, url_map) in grouped {
+ println!();
+ println!("{}", vendor);
+ for (url, packages) in url_map {
+ // Deduplicate consecutive identical entries and join with ", "
+ let mut deduped: Vec<&str> = Vec::new();
+ for pkg in packages {
+ if deduped.last().copied() != Some(pkg.as_str()) {
+ deduped.push(pkg.as_str());
+ }
+ }
+ println!(" {}", deduped.join(", "));
+ println!(" {}", url);
+ }
+ }
+
+ println!();
+ println!("Please consider following these links and sponsoring the work of package authors!");
+ println!("Thank you!");
+}
+
+fn render_json(grouped: &BTreeMap<String, BTreeMap<String, Vec<String>>>) -> anyhow::Result<()> {
+ println!("{}", serde_json::to_string_pretty(grouped)?);
+ Ok(())
+}
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ // ── Helper ────────────────────────────────────────────────────────────────
+
+ fn make_funding_json(entries: &[(&str, &str)]) -> Vec<serde_json::Value> {
+ entries
+ .iter()
+ .map(|(t, u)| serde_json::json!({"type": t, "url": u}))
+ .collect()
+ }
+
+ // ── extract_funding_links ─────────────────────────────────────────────────
+
+ #[test]
+ fn test_extract_funding_links_basic() {
+ let json = make_funding_json(&[("github", "https://github.com/Seldaek")]);
+ let links = extract_funding_links(&json);
+ assert_eq!(links.len(), 1);
+ assert_eq!(links[0].url, "https://github.com/Seldaek");
+ assert_eq!(links[0].funding_type.as_deref(), Some("github"));
+ }
+
+ #[test]
+ fn test_extract_funding_links_missing_url() {
+ let json = vec![
+ serde_json::json!({"type": "github", "url": ""}),
+ serde_json::json!({"type": "tidelift"}),
+ serde_json::json!({"type": "github", "url": "https://github.com/user"}),
+ ];
+ let links = extract_funding_links(&json);
+ // Only the last entry has a non-empty url
+ assert_eq!(links.len(), 1);
+ assert_eq!(links[0].url, "https://github.com/user");
+ }
+
+ #[test]
+ fn test_extract_funding_links_empty() {
+ let links = extract_funding_links(&[]);
+ assert!(links.is_empty());
+ }
+
+ // ── rewrite_github_url ────────────────────────────────────────────────────
+
+ #[test]
+ fn test_rewrite_github_url_profile() {
+ let result = rewrite_github_url("https://github.com/Seldaek", Some("github"));
+ assert_eq!(result, "https://github.com/sponsors/Seldaek");
+ }
+
+ #[test]
+ fn test_rewrite_github_url_already_sponsors() {
+ // Has a second path segment, so not rewritten
+ let result = rewrite_github_url("https://github.com/sponsors/Seldaek", Some("github"));
+ assert_eq!(result, "https://github.com/sponsors/Seldaek");
+ }
+
+ #[test]
+ fn test_rewrite_github_url_non_github_type() {
+ let result = rewrite_github_url("https://github.com/fabpot", Some("tidelift"));
+ assert_eq!(result, "https://github.com/fabpot");
+ }
+
+ #[test]
+ fn test_rewrite_github_url_deep_path() {
+ // https://github.com/user/repo has a second path segment
+ let result = rewrite_github_url("https://github.com/user/repo", Some("github"));
+ assert_eq!(result, "https://github.com/user/repo");
+ }
+
+ // ── group_by_vendor ───────────────────────────────────────────────────────
+
+ #[test]
+ fn test_group_by_vendor_basic() {
+ let entries = vec![
+ FundingEntry {
+ full_name: "symfony/console".to_string(),
+ links: vec![FundingLink {
+ url: "https://github.com/fabpot".to_string(),
+ funding_type: Some("github".to_string()),
+ }],
+ },
+ FundingEntry {
+ full_name: "symfony/http-kernel".to_string(),
+ links: vec![FundingLink {
+ url: "https://github.com/fabpot".to_string(),
+ funding_type: Some("github".to_string()),
+ }],
+ },
+ ];
+
+ let grouped = group_by_vendor(&entries);
+ assert_eq!(grouped.len(), 1);
+ let symfony = grouped.get("symfony").unwrap();
+ // URL should be rewritten to sponsors
+ let url = "https://github.com/sponsors/fabpot";
+ let packages = symfony.get(url).unwrap();
+ assert_eq!(packages.len(), 2);
+ assert!(packages.contains(&"console".to_string()));
+ assert!(packages.contains(&"http-kernel".to_string()));
+ }
+
+ #[test]
+ fn test_group_by_vendor_multiple_urls() {
+ let entries = vec![FundingEntry {
+ full_name: "symfony/console".to_string(),
+ links: vec![
+ FundingLink {
+ url: "https://github.com/fabpot".to_string(),
+ funding_type: Some("github".to_string()),
+ },
+ FundingLink {
+ url: "https://tidelift.com/funding/github/packagist/symfony/symfony"
+ .to_string(),
+ funding_type: Some("tidelift".to_string()),
+ },
+ ],
+ }];
+
+ let grouped = group_by_vendor(&entries);
+ let symfony = grouped.get("symfony").unwrap();
+ assert_eq!(symfony.len(), 2);
+ assert!(symfony.contains_key("https://github.com/sponsors/fabpot"));
+ assert!(
+ symfony.contains_key("https://tidelift.com/funding/github/packagist/symfony/symfony")
+ );
+ }
+
+ #[test]
+ fn test_group_by_vendor_empty() {
+ let grouped = group_by_vendor(&[]);
+ assert!(grouped.is_empty());
+ }
+
+ // ── Integration tests ─────────────────────────────────────────────────────
+
+ #[test]
+ fn test_fund_from_lockfile() {
+ use crate::lockfile::{LockFile, LockedPackage};
+ use tempfile::tempdir;
+
+ let dir = tempdir().unwrap();
+ let working_dir = dir.path();
+
+ let lock = LockFile {
+ readme: LockFile::default_readme(),
+ content_hash: "abc123".to_string(),
+ packages: vec![
+ LockedPackage {
+ name: "monolog/monolog".to_string(),
+ version: "3.0.0".to_string(),
+ version_normalized: None,
+ source: None,
+ dist: None,
+ require: BTreeMap::new(),
+ require_dev: BTreeMap::new(),
+ conflict: BTreeMap::new(),
+ suggest: None,
+ package_type: None,
+ autoload: None,
+ autoload_dev: None,
+ license: Some(vec!["MIT".to_string()]),
+ description: None,
+ homepage: None,
+ keywords: None,
+ authors: None,
+ support: None,
+ funding: Some(vec![serde_json::json!({
+ "type": "github",
+ "url": "https://github.com/Seldaek"
+ })]),
+ time: None,
+ extra_fields: BTreeMap::new(),
+ },
+ LockedPackage {
+ name: "psr/log".to_string(),
+ version: "3.0.0".to_string(),
+ version_normalized: None,
+ source: None,
+ 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: None,
+ keywords: None,
+ authors: None,
+ support: None,
+ funding: None, // no funding
+ time: None,
+ extra_fields: BTreeMap::new(),
+ },
+ ],
+ packages_dev: Some(vec![]),
+ aliases: vec![],
+ minimum_stability: "stable".to_string(),
+ stability_flags: serde_json::json!({}),
+ prefer_stable: false,
+ prefer_lowest: false,
+ platform: serde_json::json!({}),
+ platform_dev: serde_json::json!({}),
+ plugin_api_version: Some("2.6.0".to_string()),
+ };
+
+ lock.write_to_file(&working_dir.join("composer.lock"))
+ .unwrap();
+
+ let entries = collect_funding_from_locked(working_dir).unwrap();
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries[0].full_name, "monolog/monolog");
+ assert_eq!(entries[0].links.len(), 1);
+ assert_eq!(entries[0].links[0].url, "https://github.com/Seldaek");
+ assert_eq!(entries[0].links[0].funding_type.as_deref(), Some("github"));
+ }
+
+ #[test]
+ fn test_fund_from_installed() {
+ use tempfile::tempdir;
+
+ let dir = tempdir().unwrap();
+ let working_dir = dir.path();
+ let vendor_dir = working_dir.join("vendor");
+
+ let mut installed = crate::installed::InstalledPackages::new();
+
+ let mut extra = BTreeMap::new();
+ extra.insert(
+ "funding".to_string(),
+ serde_json::json!([{
+ "type": "github",
+ "url": "https://github.com/Seldaek"
+ }]),
+ );
+ installed.upsert(crate::installed::InstalledPackageEntry {
+ name: "monolog/monolog".to_string(),
+ version: "3.0.0".to_string(),
+ version_normalized: None,
+ source: None,
+ dist: None,
+ package_type: None,
+ install_path: None,
+ autoload: None,
+ aliases: vec![],
+ extra_fields: extra,
+ });
+
+ // Package without funding
+ installed.upsert(crate::installed::InstalledPackageEntry {
+ name: "psr/log".to_string(),
+ version: "3.0.0".to_string(),
+ version_normalized: None,
+ source: None,
+ dist: None,
+ package_type: None,
+ install_path: None,
+ autoload: None,
+ aliases: vec![],
+ extra_fields: BTreeMap::new(),
+ });
+
+ installed.write(&vendor_dir).unwrap();
+
+ let entries = collect_funding_from_installed(working_dir).unwrap();
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries[0].full_name, "monolog/monolog");
+ assert_eq!(entries[0].links[0].url, "https://github.com/Seldaek");
+ }
+
+ #[test]
+ fn test_fund_no_funding_data() {
+ use crate::lockfile::{LockFile, LockedPackage};
+ use tempfile::tempdir;
+
+ let dir = tempdir().unwrap();
+ let working_dir = dir.path();
+
+ let lock = LockFile {
+ readme: LockFile::default_readme(),
+ content_hash: "abc123".to_string(),
+ packages: vec![LockedPackage {
+ name: "psr/log".to_string(),
+ version: "3.0.0".to_string(),
+ version_normalized: None,
+ source: None,
+ 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: None,
+ keywords: None,
+ authors: None,
+ support: None,
+ funding: None,
+ time: None,
+ extra_fields: BTreeMap::new(),
+ }],
+ packages_dev: Some(vec![]),
+ aliases: vec![],
+ minimum_stability: "stable".to_string(),
+ stability_flags: serde_json::json!({}),
+ prefer_stable: false,
+ prefer_lowest: false,
+ platform: serde_json::json!({}),
+ platform_dev: serde_json::json!({}),
+ plugin_api_version: Some("2.6.0".to_string()),
+ };
+
+ lock.write_to_file(&working_dir.join("composer.lock"))
+ .unwrap();
+
+ let entries = collect_funding_from_locked(working_dir).unwrap();
+ assert!(entries.is_empty());
+
+ let grouped = group_by_vendor(&entries);
+ assert!(grouped.is_empty());
+ }
}