use crate::watch::Engine;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Intent {
Release,
Price,
Stock,
Jobs,
News,
Generic,
}
impl Intent {
pub fn detect(prompt: &str) -> Self {
let lower = prompt.to_lowercase();
if lower.contains("release")
|| lower.contains("changelog")
|| lower.contains("version")
|| lower.contains("update")
|| lower.contains("new version")
{
return Intent::Release;
}
if lower.contains("price")
|| lower.contains("deal")
|| lower.contains("discount")
|| lower.contains("sale")
|| lower.contains("cost")
|| lower.contains("$")
{
return Intent::Price;
}
if lower.contains("stock")
|| lower.contains("available")
|| lower.contains("availability")
|| lower.contains("back in")
|| lower.contains("restock")
|| lower.contains("inventory")
{
return Intent::Stock;
}
if lower.contains("job")
|| lower.contains("career")
|| lower.contains("hiring")
|| lower.contains("position")
|| lower.contains("opening")
{
return Intent::Jobs;
}
if lower.contains("news")
|| lower.contains("article")
|| lower.contains("blog")
|| lower.contains("post")
|| lower.contains("feed")
{
return Intent::News;
}
Intent::Generic
}
}
#[derive(Debug, Clone)]
pub enum UrlTransform {
AppendPath(&'static str),
ReplacePath(&'static str),
AppendSuffix(&'static str),
}
impl UrlTransform {
pub fn apply(&self, url: &url::Url) -> url::Url {
let mut result = url.clone();
match self {
UrlTransform::AppendPath(path) => {
let current_path = result.path().trim_end_matches('/');
result.set_path(&format!("{}{}", current_path, path));
}
UrlTransform::ReplacePath(path) => {
result.set_path(path);
}
UrlTransform::AppendSuffix(suffix) => {
let current_path = result.path().to_string();
result.set_path(&format!("{}{}", current_path, suffix));
}
}
result
}
}
#[derive(Debug, Clone)]
pub struct TransformRule {
pub host: &'static str,
pub path_pattern: Option<&'static str>,
pub intent: Intent,
pub transform: UrlTransform,
pub engine: Engine,
pub confidence: f32,
pub description: &'static str,
}
impl TransformRule {
pub fn matches(&self, url: &url::Url, intent: Intent) -> bool {
if url.host_str() != Some(self.host) {
return false;
}
if self.intent != intent {
return false;
}
if let Some(pattern) = self.path_pattern {
if !matches_path_pattern(url.path(), pattern) {
return false;
}
}
true
}
}
#[derive(Debug, Clone)]
pub struct TransformMatch {
pub url: url::Url,
pub engine: Engine,
pub confidence: f32,
pub description: &'static str,
}
fn matches_path_pattern(path: &str, pattern: &str) -> bool {
let path_segments: Vec<&str> = path.trim_matches('/').split('/').filter(|s| !s.is_empty()).collect();
let pattern_segments: Vec<&str> = pattern.trim_matches('/').split('/').filter(|s| !s.is_empty()).collect();
if path_segments.len() != pattern_segments.len() {
return false;
}
for (path_seg, pattern_seg) in path_segments.iter().zip(pattern_segments.iter()) {
if *pattern_seg != "*" && *pattern_seg != *path_seg {
return false;
}
}
true
}
pub static TRANSFORM_RULES: &[TransformRule] = &[
TransformRule {
host: "github.com",
path_pattern: Some("*/*"),
intent: Intent::Release,
transform: UrlTransform::AppendPath("/releases.atom"),
engine: Engine::Rss,
confidence: 0.95,
description: "GitHub releases Atom feed",
},
TransformRule {
host: "gitlab.com",
path_pattern: Some("*/*"),
intent: Intent::Release,
transform: UrlTransform::AppendPath("/-/releases.atom"),
engine: Engine::Rss,
confidence: 0.95,
description: "GitLab releases Atom feed",
},
TransformRule {
host: "codeberg.org",
path_pattern: Some("*/*"),
intent: Intent::Release,
transform: UrlTransform::AppendPath("/releases.rss"),
engine: Engine::Rss,
confidence: 0.95,
description: "Codeberg releases RSS feed",
},
TransformRule {
host: "sr.ht",
path_pattern: Some("~*/*"),
intent: Intent::Release,
transform: UrlTransform::AppendPath("/refs/rss.xml"),
engine: Engine::Rss,
confidence: 0.90,
description: "SourceHut refs RSS feed",
},
TransformRule {
host: "news.ycombinator.com",
path_pattern: None,
intent: Intent::News,
transform: UrlTransform::ReplacePath("/rss"),
engine: Engine::Rss,
confidence: 0.95,
description: "Hacker News RSS feed",
},
TransformRule {
host: "www.reddit.com",
path_pattern: Some("r/*"),
intent: Intent::News,
transform: UrlTransform::AppendSuffix(".rss"),
engine: Engine::Rss,
confidence: 0.95,
description: "Reddit subreddit RSS feed",
},
TransformRule {
host: "reddit.com",
path_pattern: Some("r/*"),
intent: Intent::News,
transform: UrlTransform::AppendSuffix(".rss"),
engine: Engine::Rss,
confidence: 0.95,
description: "Reddit subreddit RSS feed",
},
TransformRule {
host: "old.reddit.com",
path_pattern: Some("r/*"),
intent: Intent::News,
transform: UrlTransform::AppendSuffix(".rss"),
engine: Engine::Rss,
confidence: 0.95,
description: "Reddit subreddit RSS feed",
},
TransformRule {
host: "pypi.org",
path_pattern: Some("project/*"),
intent: Intent::Release,
transform: UrlTransform::AppendPath("/rss"),
engine: Engine::Rss,
confidence: 0.90,
description: "PyPI package RSS feed",
},
TransformRule {
host: "crates.io",
path_pattern: Some("crates/*"),
intent: Intent::Release,
transform: UrlTransform::AppendPath("/versions"),
engine: Engine::Http,
confidence: 0.80,
description: "crates.io versions page",
},
TransformRule {
host: "www.npmjs.com",
path_pattern: Some("package/*"),
intent: Intent::Release,
transform: UrlTransform::AppendPath("?activeTab=versions"),
engine: Engine::Playwright,
confidence: 0.75,
description: "npm package versions (requires JS)",
},
TransformRule {
host: "hub.docker.com",
path_pattern: Some("r/*/*"),
intent: Intent::Release,
transform: UrlTransform::AppendPath("/tags"),
engine: Engine::Playwright,
confidence: 0.80,
description: "Docker Hub tags page",
},
TransformRule {
host: "hub.docker.com",
path_pattern: Some("_/*"),
intent: Intent::Release,
transform: UrlTransform::AppendPath("/tags"),
engine: Engine::Playwright,
confidence: 0.80,
description: "Docker Hub official image tags",
},
];
pub fn match_transform(url: &url::Url, intent: Intent) -> Option<TransformMatch> {
if url.scheme() != "http" && url.scheme() != "https" {
return None;
}
for rule in TRANSFORM_RULES {
if rule.matches(url, intent) {
let transformed_url = rule.transform.apply(url);
return Some(TransformMatch {
url: transformed_url,
engine: rule.engine.clone(),
confidence: rule.confidence,
description: rule.description,
});
}
}
None
}
pub fn detect_and_match(prompt: &str, url: &url::Url) -> Option<TransformMatch> {
let intent = Intent::detect(prompt);
if intent == Intent::Generic {
return None;
}
match_transform(url, intent)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_intent_detection() {
assert_eq!(Intent::detect("watch for new releases"), Intent::Release);
assert_eq!(Intent::detect("alert on price drops"), Intent::Price);
assert_eq!(Intent::detect("notify when back in stock"), Intent::Stock);
assert_eq!(Intent::detect("track job postings"), Intent::Jobs);
assert_eq!(Intent::detect("follow the news"), Intent::News);
assert_eq!(Intent::detect("just watch this page"), Intent::Generic);
}
#[test]
fn test_github_releases_transform() {
let url = url::Url::parse("https://github.com/astral-sh/ruff").unwrap();
let result = match_transform(&url, Intent::Release).unwrap();
assert_eq!(result.url.as_str(), "https://github.com/astral-sh/ruff/releases.atom");
assert_eq!(result.engine, Engine::Rss);
assert!(result.confidence > 0.9);
}
#[test]
fn test_gitlab_releases_transform() {
let url = url::Url::parse("https://gitlab.com/inkscape/inkscape").unwrap();
let result = match_transform(&url, Intent::Release).unwrap();
assert_eq!(result.url.as_str(), "https://gitlab.com/inkscape/inkscape/-/releases.atom");
assert_eq!(result.engine, Engine::Rss);
}
#[test]
fn test_reddit_news_transform() {
let url = url::Url::parse("https://www.reddit.com/r/rust").unwrap();
let result = match_transform(&url, Intent::News).unwrap();
assert_eq!(result.url.as_str(), "https://www.reddit.com/r/rust.rss");
assert_eq!(result.engine, Engine::Rss);
}
#[test]
fn test_hn_news_transform() {
let url = url::Url::parse("https://news.ycombinator.com").unwrap();
let result = match_transform(&url, Intent::News).unwrap();
assert_eq!(result.url.as_str(), "https://news.ycombinator.com/rss");
assert_eq!(result.engine, Engine::Rss);
}
#[test]
fn test_pypi_releases_transform() {
let url = url::Url::parse("https://pypi.org/project/requests").unwrap();
let result = match_transform(&url, Intent::Release).unwrap();
assert_eq!(result.url.as_str(), "https://pypi.org/project/requests/rss");
assert_eq!(result.engine, Engine::Rss);
}
#[test]
fn test_no_match_wrong_intent() {
let url = url::Url::parse("https://github.com/astral-sh/ruff").unwrap();
let result = match_transform(&url, Intent::Price);
assert!(result.is_none());
}
#[test]
fn test_no_match_unknown_host() {
let url = url::Url::parse("https://example.com/some/path").unwrap();
let result = match_transform(&url, Intent::Release);
assert!(result.is_none());
}
#[test]
fn test_combined_detect_and_match() {
let url = url::Url::parse("https://github.com/tokio-rs/tokio").unwrap();
let result = detect_and_match("watch for new releases", &url).unwrap();
assert_eq!(result.url.as_str(), "https://github.com/tokio-rs/tokio/releases.atom");
}
#[test]
fn test_path_pattern_matching() {
assert!(matches_path_pattern("/owner/repo", "*/*"));
assert!(matches_path_pattern("/r/rust", "r/*"));
assert!(!matches_path_pattern("/owner/repo/extra", "*/*"));
assert!(!matches_path_pattern("/owner", "*/*"));
}
}