use std::collections::HashSet;
use std::time::Duration;
use futures::future::join_all;
use crate::model::{Match, Query};
use crate::Result;
pub mod artifacthub;
pub mod aur;
pub mod crates_io;
pub mod docker_hub;
pub mod github;
pub mod go;
pub mod hackage;
pub mod hacker_news;
pub mod hex;
pub mod homebrew;
pub mod maven;
pub mod npm;
pub mod nuget;
pub mod packagist;
pub mod pypi;
pub mod rubygems;
pub mod vscode;
#[async_trait::async_trait]
pub trait SourceAdapter: Send + Sync {
fn id(&self) -> crate::model::Source;
async fn search(&self, query: &Query) -> Result<Vec<Match>>;
}
use crate::model::Source as S;
fn http_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.connect_timeout(Duration::from_secs(5))
.user_agent(concat!(
"patent/",
env!("CARGO_PKG_VERSION"),
" (prior-art search; https://github.com/r14dd/patent)"
))
.build()
.map_err(crate::Error::HttpClient)
}
fn idea_contains(idea: &str, terms: &[&str]) -> bool {
let lower = idea.to_lowercase();
let bytes = lower.as_bytes();
terms.iter().any(|t| {
lower.match_indices(t).any(|(pos, _)| {
let before = pos == 0 || !bytes[pos - 1].is_ascii_alphanumeric();
let after_pos = pos + t.len();
let after = after_pos >= bytes.len() || !bytes[after_pos].is_ascii_alphanumeric();
before && after
})
})
}
fn add(set: &mut HashSet<S>, sources: &[S]) {
set.extend(sources);
}
fn detect_sources(idea: &str) -> HashSet<S> {
let mut s = HashSet::new();
s.insert(S::GitHub);
s.insert(S::HackerNews);
if idea_contains(idea, &["rust", "crate", "cargo"]) {
s.insert(S::CratesIo);
}
if idea_contains(idea, &["brew", "homebrew", "macos", "cask"]) {
s.insert(S::Homebrew);
}
if idea_contains(
idea,
&["npm", "node", "javascript", "typescript", "deno", "bun"],
) {
s.insert(S::Npm);
}
if idea_contains(
idea,
&["python", "pip", "django", "flask", "pytorch", "pandas"],
) {
s.insert(S::PyPI);
}
if idea_contains(idea, &["go", "golang", "goroutine"]) {
s.insert(S::Go);
}
if idea_contains(
idea,
&["java", "kotlin", "spring", "maven", "gradle", "scala"],
) {
s.insert(S::Maven);
}
if idea_contains(idea, &["ruby", "rails", "sinatra", "gem"]) {
s.insert(S::RubyGems);
}
if idea_contains(
idea,
&["c#", ".net", "csharp", "dotnet", "nuget", "blazor", "unity"],
) {
s.insert(S::NuGet);
}
if idea_contains(idea, &["php", "composer", "laravel", "symfony"]) {
s.insert(S::Packagist);
}
if idea_contains(idea, &["elixir", "erlang", "phoenix", "hex", "mix"]) {
s.insert(S::Hex);
}
if idea_contains(
idea,
&[
"helm",
"kubernetes",
"k8s",
"cncf",
"cloud-native",
"operator",
"kubectl",
"crd",
],
) {
s.insert(S::ArtifactHub);
}
if idea_contains(idea, &["arch", "aur", "pacman", "archlinux"]) {
s.insert(S::Aur);
}
if idea_contains(idea, &["haskell", "cabal", "hackage", "ghc", "stack"]) {
s.insert(S::Hackage);
}
if idea_contains(
idea,
&[
"ai",
"llm",
"machine learning",
"deep learning",
"neural",
"model training",
"inference",
"embedding",
"nlp",
"computer vision",
"data science",
"data pipeline",
],
) {
add(&mut s, &[S::PyPI, S::Npm]);
}
if idea_contains(idea, &["cli", "command line", "terminal tool", "shell"]) {
add(&mut s, &[S::CratesIo, S::Go, S::Npm, S::PyPI, S::Homebrew]);
}
if idea_contains(
idea,
&[
"frontend",
"react",
"vue",
"angular",
"svelte",
"browser",
"css",
"ui component",
"web component",
"spa",
],
) {
s.insert(S::Npm);
}
if idea_contains(
idea,
&[
"api",
"backend",
"rest",
"graphql",
"microservice",
"web server",
],
) {
add(&mut s, &[S::Npm, S::PyPI, S::Go]);
}
if idea_contains(
idea,
&[
"mobile",
"ios",
"android",
"react native",
"flutter",
"swift",
"swiftui",
],
) {
add(&mut s, &[S::Npm, S::Maven]);
}
if idea_contains(
idea,
&[
"game",
"graphics",
"rendering",
"opengl",
"vulkan",
"bevy",
"godot",
],
) {
add(&mut s, &[S::CratesIo, S::NuGet]);
}
if idea_contains(idea, &["embedded", "firmware", "microcontroller", "rtos"]) {
s.insert(S::CratesIo);
}
if idea_contains(
idea,
&[
"docker",
"container",
"kubernetes",
"k8s",
"helm",
"deploy",
"infrastructure",
],
) {
add(&mut s, &[S::DockerHub, S::Go]);
}
if idea_contains(idea, &["vscode", "extension", "plugin", "ide", "editor"]) {
add(&mut s, &[S::VsCodeMarketplace, S::Npm]);
}
const ALWAYS_ON: usize = 2; if s.len() <= ALWAYS_ON {
add(&mut s, &[S::Npm, S::PyPI, S::CratesIo]);
}
s
}
fn build_source(id: S, client: reqwest::Client) -> Box<dyn SourceAdapter> {
match id {
S::CratesIo => Box::new(crates_io::CratesIo::new(client)),
S::GitHub => Box::new(github::GitHub::new(client)),
S::Npm => Box::new(npm::Npm::new(client)),
S::PyPI => Box::new(pypi::PyPI::new(client)),
S::HackerNews => Box::new(hacker_news::HackerNews::new(client)),
S::Go => Box::new(go::GoPkgDev::new(client)),
S::Maven => Box::new(maven::Maven::new(client)),
S::RubyGems => Box::new(rubygems::RubyGems::new(client)),
S::DockerHub => Box::new(docker_hub::DockerHub::new(client)),
S::VsCodeMarketplace => Box::new(vscode::VsCodeMarketplace::new(client)),
S::NuGet => Box::new(nuget::NuGet::new(client)),
S::Homebrew => Box::new(homebrew::Homebrew::new(client)),
S::Packagist => Box::new(packagist::Packagist::new(client)),
S::Hex => Box::new(hex::Hex::new(client)),
S::ArtifactHub => Box::new(artifacthub::ArtifactHub::new(client)),
S::Aur => Box::new(aur::Aur::new(client)),
S::Hackage => Box::new(hackage::Hackage::new(client)),
}
}
fn sources_for(query: &Query) -> Result<Vec<Box<dyn SourceAdapter>>> {
let client = http_client()?;
let ids = detect_sources(&query.idea);
Ok(ids
.into_iter()
.map(|id| build_source(id, client.clone()))
.collect())
}
pub struct SearchOutcome {
pub matches: Vec<Match>,
pub reached: Vec<crate::model::Source>,
pub failed: Vec<crate::model::Source>,
}
pub async fn search_all(query: &Query) -> Result<SearchOutcome> {
Ok(search_sources(&sources_for(query)?, query).await)
}
fn is_retryable(e: &crate::Error) -> bool {
!matches!(
e,
crate::Error::Unavailable(_) | crate::Error::HttpClient(_)
)
}
const SOURCE_TIMEOUT: Duration = Duration::from_secs(15);
pub async fn search_sources(sources: &[Box<dyn SourceAdapter>], query: &Query) -> SearchOutcome {
let results = join_all(sources.iter().map(|s| {
let id = s.id();
async move {
let outcome = tokio::time::timeout(SOURCE_TIMEOUT, async {
let first = s.search(query).await;
match &first {
Ok(_) => return (id, first),
Err(e) if !is_retryable(e) => return (id, first),
Err(_) => {}
}
tokio::time::sleep(Duration::from_millis(800)).await;
(id, s.search(query).await)
})
.await;
match outcome {
Ok(r) => r,
Err(_) => (id, Err(crate::Error::Parse(format!("{id} timed out")))),
}
}
}))
.await;
let mut reached = Vec::new();
let mut failed = Vec::new();
let mut all = Vec::new();
for (id, result) in results {
match result {
Ok(matches) => {
reached.push(id);
all.extend(matches);
}
Err(e) => {
eprintln!("⚠ {id} not reached: {e}");
failed.push(id);
}
}
}
SearchOutcome {
matches: dedup(all),
reached,
failed,
}
}
pub fn dedup(matches: Vec<Match>) -> Vec<Match> {
let mut seen_urls: HashSet<String> = HashSet::new();
let mut seen_name_source: HashSet<(String, crate::model::Source)> = HashSet::new();
matches
.into_iter()
.filter(|m| {
if m.url.trim().is_empty() {
seen_name_source.insert((m.name.clone(), m.source))
} else {
seen_urls.insert(m.url.clone())
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn idea_contains_respects_word_boundaries() {
assert!(idea_contains("a fast async runtime", &["async"]));
assert!(!idea_contains("rainbow trains", &["ai"]));
assert!(!idea_contains("googol", &["go"]));
assert!(!idea_contains("django framework", &["go"]));
}
#[test]
fn idea_contains_checks_all_occurrences_not_just_the_first() {
assert!(idea_contains(
"a tool for cargo packages written in go",
&["go"]
));
assert!(idea_contains("email summarizer that uses ai", &["ai"]));
assert!(idea_contains("a good way to go fast", &["go"]));
}
#[test]
fn github_and_hacker_news_are_always_selected() {
for idea in ["a ruby gem for parsing csv", "asdf qwer zxcv", "rust crate"] {
let s = detect_sources(idea);
assert!(s.contains(&S::GitHub), "GitHub missing for {idea:?}");
assert!(
s.contains(&S::HackerNews),
"Hacker News missing for {idea:?}"
);
}
}
#[test]
fn every_built_source_is_reachable_from_some_idea() {
let ideas = [
"rust crate for embedded firmware",
"a python pandas data pipeline",
"a typescript react frontend component",
"a golang microservice",
"a java spring boot service",
"a ruby on rails gem",
"a c# dotnet unity game",
"a docker container for kubernetes",
"a vscode extension for editors",
"a macos homebrew tool",
"a php composer package for laravel",
"an elixir phoenix library for caching",
"a helm chart to deploy a kubernetes operator",
"a pacman helper for installing arch linux aur packages",
"a haskell cabal library for parsing",
"anything at all with no signal",
];
let mut seen: HashSet<S> = HashSet::new();
for idea in ideas {
seen.extend(detect_sources(idea));
}
for variant in [
S::CratesIo,
S::GitHub,
S::Npm,
S::PyPI,
S::HackerNews,
S::Go,
S::Maven,
S::RubyGems,
S::DockerHub,
S::VsCodeMarketplace,
S::NuGet,
S::Homebrew,
S::Packagist,
S::Hex,
S::ArtifactHub,
S::Aur,
S::Hackage,
] {
assert!(
seen.contains(&variant),
"{variant} is built but never selected by detect_sources"
);
}
}
#[test]
fn language_mentions_select_their_registry() {
assert!(detect_sources("a rust crate for parsing").contains(&S::CratesIo));
assert!(detect_sources("a python library for parsing").contains(&S::PyPI));
assert!(detect_sources("a docker image for caching").contains(&S::DockerHub));
assert!(detect_sources("a ruby gem for parsing").contains(&S::RubyGems));
}
#[test]
fn go_and_ai_match_natural_phrasings() {
assert!(detect_sources("a fast Go library for parsing json").contains(&S::Go));
assert!(detect_sources("a library that uses AI to summarize text").contains(&S::PyPI));
assert!(detect_sources("a cargo workspace tool also written in go").contains(&S::Go));
}
#[test]
fn port_killer_demo_searches_npm() {
for idea in [
"interactive cli to kill whatever's on a port",
"CLI tool that kills whatever's on a port",
] {
let s = detect_sources(idea);
assert!(s.contains(&S::Npm), "npm missing for {idea:?}: {s:?}");
}
}
#[test]
fn no_signal_falls_back_to_broad_sweep() {
let s = detect_sources("asdf qwer zxcv hjkl");
assert!(s.contains(&S::Npm));
assert!(s.contains(&S::PyPI));
assert!(s.contains(&S::CratesIo));
}
#[test]
fn http_client_builds() {
assert!(http_client().is_ok());
}
#[test]
fn sources_for_builds_selected_adapters() {
let q = Query {
idea: "a rust crate for parsing".to_string(),
keywords: vec![],
};
let sources = sources_for(&q).expect("client should build");
assert!(!sources.is_empty());
}
}