use async_trait::async_trait;
use serde::Deserialize;
use crate::options::OptionSchema;
use crate::payload::{Body, EntriesData, Entry, Payload, TextBlockData, TextData};
use crate::render::Shape;
use crate::samples;
use super::super::{FetchContext, FetchError, Fetcher, Safety};
use super::client::{resolve_authenticated_user, rest_get};
use super::common::{cache_key, parse_options, payload};
const SHAPES: &[Shape] = &[Shape::TextBlock, Shape::Text, Shape::Entries];
const OPTION_SCHEMAS: &[OptionSchema] = &[OptionSchema {
name: "user",
type_hint: "string (github login)",
required: false,
default: Some("authenticated token user"),
description: "GitHub login to fetch. Falls back to the `GITHUB_USER` env var, then to the login that owns `GH_TOKEN` / `GITHUB_TOKEN`.",
}];
pub struct GithubUser;
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Options {
#[serde(default)]
pub user: Option<String>,
}
#[async_trait]
impl Fetcher for GithubUser {
fn name(&self) -> &str {
"github_user"
}
fn safety(&self) -> Safety {
Safety::Safe
}
fn description(&self) -> &'static str {
"GitHub profile data for a user (display name, bio, location, join year, follower counts), aimed at the hero / subtitle band of a home preset. Pair with `github_avatar` for the matching image."
}
fn refresh_interval(&self) -> u64 {
60 * 60
}
fn shapes(&self) -> &[Shape] {
SHAPES
}
fn option_schemas(&self) -> &[OptionSchema] {
OPTION_SCHEMAS
}
fn cache_key(&self, ctx: &FetchContext) -> String {
let extra = user_for_key(ctx);
cache_key(self.name(), ctx, &extra)
}
fn sample_body(&self, shape: Shape) -> Option<Body> {
Some(match shape {
Shape::Text => samples::text("@unhappychoice · Tokyo, Japan"),
Shape::TextBlock => samples::text_block(&[
"Yuji Ueki",
"Terminal splash renderer maintainer",
"Tokyo, Japan · member since 2013",
]),
Shape::Entries => samples::entries(&[
("name", "Yuji Ueki"),
("bio", "Terminal splash renderer maintainer"),
("location", "Tokyo, Japan"),
("company", ""),
("member_since", "2013"),
("followers", "420"),
("following", "69"),
("public_repos", "48"),
]),
_ => return None,
})
}
async fn fetch(&self, ctx: &FetchContext) -> Result<Payload, FetchError> {
let opts: Options = parse_options(ctx.options.as_ref()).map_err(FetchError::Failed)?;
let user = resolve_user(opts.user.as_deref())
.await
.map_err(FetchError::Failed)?;
let info: UserInfo = rest_get(&format!("/users/{user}")).await?;
let body = match ctx.shape.unwrap_or(Shape::TextBlock) {
Shape::Text => Body::Text(TextData {
value: one_liner(&info),
}),
Shape::Entries => Body::Entries(EntriesData {
items: entries(&info),
}),
_ => Body::TextBlock(TextBlockData {
lines: text_block_lines(&info),
}),
};
Ok(payload(body))
}
}
fn one_liner(info: &UserInfo) -> String {
let mut parts: Vec<String> = Vec::new();
parts.push(format!("@{}", info.login));
if let Some(loc) = nonempty(&info.location) {
parts.push(loc.clone());
}
if let Some(co) = nonempty(&info.company) {
parts.push(co.clone());
}
parts.join(" · ")
}
fn text_block_lines(info: &UserInfo) -> Vec<String> {
let mut lines = vec![format!("@{}", info.login)];
if let Some(name) = nonempty(&info.name) {
lines.push(name.clone());
}
if let Some(bio) = nonempty(&info.bio) {
lines.push(bio.clone());
}
let mut tail = Vec::new();
if let Some(loc) = nonempty(&info.location) {
tail.push(loc.clone());
}
if let Some(year) = join_year(&info.created_at) {
tail.push(format!("member since {year}"));
}
if !tail.is_empty() {
lines.push(tail.join(" · "));
}
lines
}
fn entries(info: &UserInfo) -> Vec<Entry> {
vec![
entry("name", nonempty(&info.name).cloned().unwrap_or_default()),
entry("bio", nonempty(&info.bio).cloned().unwrap_or_default()),
entry(
"location",
nonempty(&info.location).cloned().unwrap_or_default(),
),
entry(
"company",
nonempty(&info.company).cloned().unwrap_or_default(),
),
entry(
"member_since",
join_year(&info.created_at).unwrap_or_default(),
),
entry("followers", info.followers.to_string()),
entry("following", info.following.to_string()),
entry("public_repos", info.public_repos.to_string()),
]
}
fn entry(key: &str, value: String) -> Entry {
Entry {
key: key.into(),
value: Some(value),
status: None,
}
}
fn nonempty(s: &Option<String>) -> Option<&String> {
s.as_ref().filter(|v| !v.is_empty())
}
fn join_year(created_at: &Option<String>) -> Option<String> {
created_at
.as_deref()
.and_then(|s| s.get(0..4))
.map(String::from)
}
async fn resolve_user(explicit: Option<&str>) -> Result<String, String> {
if let Some(u) = explicit.filter(|s| !s.is_empty()) {
return Ok(u.into());
}
if let Ok(u) = std::env::var("GITHUB_USER")
&& !u.is_empty()
{
return Ok(u);
}
resolve_authenticated_user()
.await
.map_err(|e| format!("resolve github user: {e}"))
}
fn user_for_key(ctx: &FetchContext) -> String {
ctx.options
.as_ref()
.and_then(|v| v.get("user"))
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| std::env::var("GITHUB_USER").ok())
.unwrap_or_default()
}
#[derive(Debug, Deserialize)]
struct UserInfo {
login: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
bio: Option<String>,
#[serde(default)]
location: Option<String>,
#[serde(default)]
company: Option<String>,
#[serde(default)]
created_at: Option<String>,
#[serde(default)]
followers: u64,
#[serde(default)]
following: u64,
#[serde(default)]
public_repos: u64,
}
#[cfg(test)]
mod tests {
use std::future::Future;
use std::time::Duration;
use super::*;
struct EnvGuard {
_lock: std::sync::MutexGuard<'static, ()>,
restore: Vec<(&'static str, Option<String>)>,
}
impl EnvGuard {
fn set(pairs: &[(&'static str, Option<&str>)]) -> Self {
let lock = crate::paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let restore = pairs
.iter()
.map(|(key, value)| {
let previous = std::env::var(key).ok();
match value {
Some(current) => unsafe { std::env::set_var(key, current) },
None => unsafe { std::env::remove_var(key) },
}
(*key, previous)
})
.collect();
Self {
_lock: lock,
restore,
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
self.restore.iter().for_each(|(key, value)| match value {
Some(previous) => unsafe { std::env::set_var(key, previous) },
None => unsafe { std::env::remove_var(key) },
});
}
}
fn info(
name: Option<&str>,
bio: Option<&str>,
location: Option<&str>,
company: Option<&str>,
created_at: Option<&str>,
) -> UserInfo {
UserInfo {
login: "unhappychoice".into(),
name: name.map(String::from),
bio: bio.map(String::from),
location: location.map(String::from),
company: company.map(String::from),
created_at: created_at.map(String::from),
followers: 420,
following: 69,
public_repos: 48,
}
}
fn ctx(options: Option<&str>, shape: Option<Shape>, format: Option<&str>) -> FetchContext {
FetchContext {
widget_id: "github-user".into(),
format: format.map(str::to_string),
timeout: Duration::from_secs(1),
file_format: None,
shape,
options: options.map(|raw| toml::from_str(raw).unwrap()),
timezone: None,
locale: None,
}
}
fn run_async<T>(future: impl Future<Output = T>) -> T {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(future)
}
#[test]
fn fetcher_metadata_and_samples_cover_supported_shapes() {
let fetcher = GithubUser;
assert_eq!(fetcher.name(), "github_user");
assert_eq!(fetcher.safety(), Safety::Safe);
assert!(fetcher.description().contains("GitHub profile data"));
assert_eq!(fetcher.shapes(), SHAPES);
assert_eq!(fetcher.option_schemas().len(), 1);
assert_eq!(fetcher.option_schemas()[0].name, "user");
assert_eq!(
fetcher.option_schemas()[0].default,
Some("authenticated token user")
);
let Some(Body::Text(text)) = fetcher.sample_body(Shape::Text) else {
panic!("expected text sample");
};
assert_eq!(text.value, "@unhappychoice · Tokyo, Japan");
let Some(Body::TextBlock(block)) = fetcher.sample_body(Shape::TextBlock) else {
panic!("expected text block sample");
};
assert_eq!(block.lines[0], "Yuji Ueki");
assert_eq!(block.lines[2], "Tokyo, Japan · member since 2013");
let Some(Body::Entries(entries)) = fetcher.sample_body(Shape::Entries) else {
panic!("expected entries sample");
};
assert_eq!(entries.items[0].key, "name");
assert_eq!(entries.items[5].value.as_deref(), Some("420"));
assert!(fetcher.sample_body(Shape::Bars).is_none());
}
#[test]
fn one_liner_joins_nonempty_fields() {
let i = info(None, None, Some("Tokyo"), Some("Acme"), None);
assert_eq!(one_liner(&i), "@unhappychoice · Tokyo · Acme");
}
#[test]
fn one_liner_drops_empty_segments() {
let i = info(None, None, None, None, None);
assert_eq!(one_liner(&i), "@unhappychoice");
}
#[test]
fn text_block_leads_with_login_then_name_bio_tail() {
let i = info(
Some("Yuji Ueki"),
Some("TUI hacker"),
Some("Tokyo"),
None,
Some("2013-04-12T13:57:32Z"),
);
assert_eq!(
text_block_lines(&i),
vec![
"@unhappychoice".to_string(),
"Yuji Ueki".into(),
"TUI hacker".into(),
"Tokyo · member since 2013".into(),
]
);
}
#[test]
fn text_block_collapses_to_just_login_when_all_else_missing() {
let i = info(None, None, None, None, None);
assert_eq!(text_block_lines(&i), vec!["@unhappychoice".to_string()]);
}
#[test]
fn entries_fill_missing_optional_fields_with_empty_strings() {
let rows = entries(&info(
None,
None,
Some("Tokyo"),
None,
Some("2013-04-12T13:57:32Z"),
));
assert_eq!(rows[0].value.as_deref(), Some(""));
assert_eq!(rows[2].value.as_deref(), Some("Tokyo"));
assert_eq!(rows[4].value.as_deref(), Some("2013"));
assert_eq!(rows[7].value.as_deref(), Some("48"));
}
#[test]
fn join_year_parses_iso_timestamp() {
assert_eq!(
join_year(&Some("2013-04-12T13:57:32Z".into())),
Some("2013".into())
);
}
#[test]
fn join_year_none_when_empty() {
assert_eq!(join_year(&None), None);
}
#[test]
fn join_year_rejects_short_values() {
assert_eq!(join_year(&Some("202".into())), None);
}
#[test]
fn user_for_key_prefers_options_over_env() {
let _guard = EnvGuard::set(&[("GITHUB_USER", Some("from-env"))]);
assert_eq!(
user_for_key(&ctx(Some("user = \"from-options\""), None, None)),
"from-options"
);
}
#[test]
fn user_for_key_falls_back_to_env() {
let _guard = EnvGuard::set(&[("GITHUB_USER", Some("from-env"))]);
assert_eq!(
user_for_key(&ctx(None, Some(Shape::Text), None)),
"from-env"
);
}
#[test]
fn cache_key_is_stable_for_equivalent_context_and_changes_with_user() {
let fetcher = GithubUser;
let alice = ctx(
Some("user = \"alice\""),
Some(Shape::Entries),
Some("compact"),
);
let alice_again = ctx(
Some("user = \"alice\""),
Some(Shape::Entries),
Some("compact"),
);
let bob = ctx(
Some("user = \"bob\""),
Some(Shape::Entries),
Some("compact"),
);
assert_eq!(fetcher.cache_key(&alice), fetcher.cache_key(&alice_again));
assert_ne!(fetcher.cache_key(&alice), fetcher.cache_key(&bob));
}
#[test]
fn resolve_user_prefers_explicit_value() {
let _guard = EnvGuard::set(&[("GITHUB_USER", Some("from-env"))]);
assert_eq!(
run_async(resolve_user(Some("explicit"))).unwrap(),
"explicit"
);
}
#[test]
fn resolve_user_falls_back_to_env() {
let _guard = EnvGuard::set(&[
("GITHUB_USER", Some("from-env")),
("GH_TOKEN", None),
("GITHUB_TOKEN", None),
]);
crate::fetcher::github::client::clear_authenticated_user_cache();
assert_eq!(run_async(resolve_user(None)).unwrap(), "from-env");
}
#[test]
fn resolve_user_reports_missing_auth_without_sources() {
let _guard = EnvGuard::set(&[
("GITHUB_USER", None),
("GH_TOKEN", None),
("GITHUB_TOKEN", None),
]);
crate::fetcher::github::client::clear_authenticated_user_cache();
let err = run_async(resolve_user(None)).expect_err("expected missing auth");
assert_eq!(
err,
"resolve github user: fetch failed: GH_TOKEN / GITHUB_TOKEN not set"
);
}
#[test]
fn fetch_rejects_invalid_options_before_resolving_user() {
let err = run_async(GithubUser.fetch(&ctx(
Some("user = \"octocat\"\nbogus = true"),
Some(Shape::Text),
None,
)))
.expect_err("invalid options should fail");
let FetchError::Failed(message) = err else {
panic!("expected fetch failure");
};
assert!(message.contains("invalid options"));
}
#[test]
fn fetch_without_user_or_token_surfaces_resolution_error() {
let _guard = EnvGuard::set(&[
("GITHUB_USER", None),
("GH_TOKEN", None),
("GITHUB_TOKEN", None),
]);
crate::fetcher::github::client::clear_authenticated_user_cache();
let err = run_async(GithubUser.fetch(&ctx(None, Some(Shape::TextBlock), None)))
.expect_err("missing user sources should fail");
let FetchError::Failed(message) = err else {
panic!("expected fetch failure");
};
assert_eq!(
message,
"resolve github user: fetch failed: GH_TOKEN / GITHUB_TOKEN not set"
);
}
#[test]
fn fetch_with_explicit_user_surfaces_auth_error_before_network() {
let _guard = EnvGuard::set(&[
("GITHUB_USER", None),
("GH_TOKEN", None),
("GITHUB_TOKEN", None),
]);
crate::fetcher::github::client::clear_authenticated_user_cache();
let err = run_async(GithubUser.fetch(&ctx(
Some("user = \"octocat\""),
Some(Shape::Entries),
Some("compact"),
)))
.expect_err("missing token should fail");
let FetchError::Failed(message) = err else {
panic!("expected fetch failure");
};
assert_eq!(message, "GH_TOKEN / GITHUB_TOKEN not set");
}
}