use serde::{Deserialize, Serialize};
use crate::api::{ApiError, GitHub};
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Topic {
pub name: String,
pub display_name: Option<String>,
pub short_description: Option<String>,
pub description: Option<String>,
pub created_by: Option<String>,
pub released: Option<String>,
pub featured: bool,
pub curated: bool,
pub stars: Option<u64>,
pub related: Vec<String>,
}
impl Topic {
pub fn title(&self) -> &str {
self.display_name.as_deref().unwrap_or(&self.name)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Topics {
pub topics: Vec<Topic>,
pub total: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub enum Since {
#[default]
Daily,
Weekly,
Monthly,
}
impl Since {
pub const ALL: [Since; 3] = [Since::Daily, Since::Weekly, Since::Monthly];
pub fn slug(self) -> &'static str {
match self {
Since::Daily => "daily",
Since::Weekly => "weekly",
Since::Monthly => "monthly",
}
}
pub fn from_slug(slug: &str) -> Option<Since> {
Since::ALL.into_iter().find(|s| s.slug() == slug)
}
pub fn label(self) -> &'static str {
match self {
Since::Daily => "Today",
Since::Weekly => "This week",
Since::Monthly => "This month",
}
}
pub fn span(self) -> &'static str {
match self {
Since::Daily => "the past day",
Since::Weekly => "the past week",
Since::Monthly => "the past month",
}
}
fn days(self) -> u64 {
match self {
Since::Daily => 1,
Since::Weekly => 7,
Since::Monthly => 30,
}
}
}
pub fn new_and_starred(language: Option<&str>, since: Since, now: u64) -> String {
let from = now.saturating_sub(since.days() * 86_400);
let day = crate::ui::time::iso8601(from);
let day = day.get(..10).unwrap_or(&day);
let mut query = format!("created:>{day}");
if let Some(language) = language.filter(|l| !l.trim().is_empty()) {
let language = language.trim();
if language.contains(' ') {
query.push_str(&format!(" language:\"{language}\""));
} else {
query.push_str(&format!(" language:{language}"));
}
}
query.push_str(" sort:stars");
query
}
pub fn topic_repos(topic: &str) -> String {
format!("topic:{topic} sort:stars")
}
impl GitHub {
pub async fn featured_topics(&self) -> Result<Topics, ApiError> {
let body = self
.rest_get(
"/search/topics?q=is:featured&per_page=100",
"GitHub's featured topics",
)
.await?;
let found: wire::Found = serde_json::from_str(&body)?;
let mut topics: Vec<Topic> = found
.items
.into_iter()
.map(wire::Topic::into_topic)
.collect();
topics.sort_by_key(|t| t.title().to_lowercase());
let topics = Topics {
topics,
total: found.total_count,
};
Ok(topics)
}
pub async fn topic(&self, name: &str) -> Result<Topic, ApiError> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Graph {
stargazer_count: u64,
related_topics: Vec<crate::api::browse::wire::Name>,
}
let path = format!(
"/search/topics?q={}&per_page=10",
url::form_urlencoded::byte_serialize(name.as_bytes()).collect::<String>()
);
let found: wire::Found = serde_json::from_str(&self.rest_get(&path, name).await?)?;
let mut topic = (found.items.into_iter())
.find(|t| t.name.eq_ignore_ascii_case(name))
.map_or_else(
|| Topic {
name: name.to_owned(),
..Topic::default()
},
wire::Topic::into_topic,
);
let vars = serde_json::json!({ "name": name });
let graph: Result<Graph, _> = self
.graphql_json(crate::api::raw::TOPIC, vars, name, "/topic")
.await;
match graph {
Ok(g) => {
topic.stars = Some(g.stargazer_count);
topic.related = g.related_topics.into_iter().map(|t| t.name).collect();
}
Err(err) => tracing::debug!(%err, "no stars or related topics for the topic"),
}
Ok(topic)
}
}
fn text_of(html: &str) -> String {
let mut out = String::new();
let mut tag = false;
for c in html.chars() {
match c {
'<' => tag = true,
'>' => tag = false,
c if !tag => out.push(c),
_ => {}
}
}
let out = out
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'");
out.split_whitespace().collect::<Vec<_>>().join(" ")
}
mod wire {
use serde::Deserialize;
#[derive(Deserialize)]
pub struct Found {
pub total_count: u64,
pub items: Vec<Topic>,
}
#[derive(Deserialize)]
pub struct Topic {
pub name: String,
pub display_name: Option<String>,
pub short_description: Option<String>,
pub description: Option<String>,
pub created_by: Option<String>,
pub released: Option<String>,
#[serde(default)]
pub featured: bool,
#[serde(default)]
pub curated: bool,
}
impl Topic {
pub fn into_topic(self) -> super::Topic {
let some = |s: Option<String>| s.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty());
super::Topic {
name: self.name,
display_name: some(self.display_name),
short_description: some(self.short_description),
description: some(self.description.map(|d| super::text_of(&d))),
created_by: some(self.created_by),
released: some(self.released),
featured: self.featured,
curated: self.curated,
stars: None,
related: Vec::new(),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_and_starred_searches_from_a_day() {
let now = crate::ui::time::parse_iso8601("2026-10-12T15:00:00Z").unwrap();
assert_eq!(
new_and_starred(Some("rust"), Since::Weekly, now),
"created:>2026-10-05 language:rust sort:stars"
);
assert_eq!(
new_and_starred(Some("Visual Basic .NET"), Since::Daily, now),
"created:>2026-10-11 language:\"Visual Basic .NET\" sort:stars"
);
assert_eq!(
new_and_starred(None, Since::Monthly, now),
"created:>2026-09-12 sort:stars"
);
}
#[test]
fn a_description_is_its_text() {
assert_eq!(
text_of("<p>Rust is a <em>systems</em> language & more.</p>\n"),
"Rust is a systems language & more."
);
}
}