use std::collections::HashMap;
use std::path::PathBuf;
use async_trait::async_trait;
use sha2::{Digest, Sha256};
use crate::options::OptionSchema;
use crate::paths;
use crate::payload::{Body, ImageData, Payload};
use crate::render::Shape;
use super::super::git::{open_repo, payload, repo_cache_key};
use super::super::{FetchContext, FetchError, Fetcher, Safety};
use super::logo_assets;
use super::scan::classify_path;
use super::scan::for_each_tracked_path;
const SHAPES: &[Shape] = &[Shape::Image];
const OPTION_SCHEMAS: &[OptionSchema] = &[OptionSchema {
name: "language",
type_hint: "string",
required: false,
default: Some("auto-detected from the dominant language in the repo"),
description: "Override the detected language. Accepts the labels emitted by the shared `code_*` classifier (`Rust`, `Go`, `Python`, …); unknown values fall back to the generic `</>` glyph.",
}];
pub struct CodeLanguageLogo;
#[derive(Debug, Default, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Options {
#[serde(default)]
language: Option<String>,
}
#[async_trait]
impl Fetcher for CodeLanguageLogo {
fn name(&self) -> &str {
"code_language_logo"
}
fn safety(&self) -> Safety {
Safety::Safe
}
fn description(&self) -> &'static str {
"Detects the dominant language across the repo's tracked files and emits a bundled Devicon PNG (covers ~35 languages; falls back to a generic `</>` glyph). Pair with the `media_image` renderer for a `project_codebase`-style hero that scales to whatever cell the layout assigns."
}
fn refresh_interval(&self) -> u64 {
60 * 60
}
fn shapes(&self) -> &[Shape] {
SHAPES
}
fn cache_key(&self, ctx: &FetchContext) -> String {
let base = repo_cache_key(self.name(), ctx);
let opts = ctx
.options
.as_ref()
.map(toml::Value::to_string)
.unwrap_or_default();
if opts.is_empty() {
return base;
}
let digest = Sha256::digest(opts.as_bytes());
let hex: String = digest.iter().take(4).map(|b| format!("{b:02x}")).collect();
format!("{base}-{hex}")
}
fn option_schemas(&self) -> &[OptionSchema] {
OPTION_SCHEMAS
}
async fn fetch(&self, ctx: &FetchContext) -> Result<Payload, FetchError> {
let opts: Options = parse_options(ctx.options.as_ref())?;
let language = match opts.language {
Some(label) => Some(label),
None => detect_dominant_language()?.map(String::from),
};
let (slug, bytes) = logo_assets::asset_for(language.as_deref());
let path = ensure_extracted(slug, bytes)?;
Ok(payload(Body::Image(ImageData {
path: path.to_string_lossy().into_owned(),
})))
}
}
fn parse_options(raw: Option<&toml::Value>) -> Result<Options, FetchError> {
match raw {
None => Ok(Options::default()),
Some(value) => value
.clone()
.try_into::<Options>()
.map_err(|e| FetchError::Failed(format!("invalid options: {e}"))),
}
}
fn detect_dominant_language() -> Result<Option<&'static str>, FetchError> {
let repo = open_repo()?;
detect_dominant_in(&repo)
}
fn detect_dominant_in(repo: &gix::Repository) -> Result<Option<&'static str>, FetchError> {
let mut counts: HashMap<&'static str, u32> = HashMap::new();
for_each_tracked_path(repo, |path| {
if let Some(lang) = classify_path(path) {
*counts.entry(lang).or_insert(0) += 1;
}
})?;
Ok(counts
.into_iter()
.max_by(|a, b| a.1.cmp(&b.1).then_with(|| b.0.cmp(a.0)))
.map(|(lang, _)| lang))
}
fn ensure_extracted(slug: &str, bytes: &[u8]) -> Result<PathBuf, FetchError> {
let dir = logos_cache_dir()?;
std::fs::create_dir_all(&dir)
.map_err(|e| FetchError::Failed(format!("create logos cache dir: {e}")))?;
let path = dir.join(format!("{slug}.png"));
if !path.exists() {
std::fs::write(&path, bytes)
.map_err(|e| FetchError::Failed(format!("write logo {slug}: {e}")))?;
}
Ok(path)
}
fn logos_cache_dir() -> Result<PathBuf, FetchError> {
paths::cache_dir()
.map(|d| d.join("logos"))
.ok_or_else(|| FetchError::Failed("no SPLASHBOARD_HOME / HOME".into()))
}
#[cfg(test)]
mod tests {
use super::super::super::git::test_support::{commit_touching, make_repo};
use super::*;
use crate::paths;
fn with_home<F: FnOnce()>(f: F) {
let _lock = paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let previous = std::env::var("SPLASHBOARD_HOME").ok();
unsafe {
std::env::set_var("SPLASHBOARD_HOME", tmp.path());
}
f();
unsafe {
match previous {
Some(v) => std::env::set_var("SPLASHBOARD_HOME", v),
None => std::env::remove_var("SPLASHBOARD_HOME"),
}
}
}
fn image_path(payload: Payload) -> String {
serde_json::to_value(&payload.body).unwrap()["data"]["path"]
.as_str()
.unwrap()
.to_string()
}
#[test]
fn dominant_language_picks_max_file_count() {
let (_tmp, repo) = make_repo();
commit_touching(&repo, "src/a.rs", "fn a() {}");
commit_touching(&repo, "src/b.rs", "fn b() {}");
commit_touching(&repo, "src/c.rs", "fn c() {}");
commit_touching(&repo, "doc.md", "# hi");
let dominant = detect_dominant_in(&repo).unwrap();
assert_eq!(dominant, Some("Rust"));
}
#[test]
fn dominant_language_returns_none_for_empty_repo() {
let (_tmp, repo) = make_repo();
assert!(detect_dominant_in(&repo).unwrap().is_none());
}
#[test]
fn dominant_language_ignores_unclassified_paths() {
let (_tmp, repo) = make_repo();
commit_touching(&repo, "src/main.go", "package x");
commit_touching(&repo, "config.weird", "noise");
commit_touching(&repo, "another.weird", "more noise");
assert_eq!(detect_dominant_in(&repo).unwrap(), Some("Go"));
}
#[test]
fn ensure_extracted_writes_bundled_bytes_once() {
with_home(|| {
let (slug, bytes) = logo_assets::asset_for(Some("Rust"));
let path = ensure_extracted(slug, bytes).unwrap();
assert!(path.exists());
let written = std::fs::read(&path).unwrap();
assert_eq!(written.len(), bytes.len());
let path2 = ensure_extracted(slug, bytes).unwrap();
assert_eq!(path, path2);
});
}
#[test]
fn parse_options_rejects_unknown_keys() {
let bad: toml::Value = toml::from_str(r#"unknown = 1"#).unwrap();
assert!(parse_options(Some(&bad)).is_err());
}
#[test]
fn parse_options_accepts_language_override() {
let v: toml::Value = toml::from_str(r#"language = "Python""#).unwrap();
let opts = parse_options(Some(&v)).unwrap();
assert_eq!(opts.language.as_deref(), Some("Python"));
}
#[test]
fn cache_key_changes_with_options() {
let mut ctx = FetchContext {
widget_id: "w".into(),
..Default::default()
};
let k0 = CodeLanguageLogo.cache_key(&ctx);
ctx.options = Some(toml::from_str(r#"language = "Rust""#).unwrap());
let k1 = CodeLanguageLogo.cache_key(&ctx);
ctx.options = Some(toml::from_str(r#"language = "Go""#).unwrap());
let k2 = CodeLanguageLogo.cache_key(&ctx);
assert_ne!(k0, k1);
assert_ne!(k1, k2);
}
#[test]
fn fetcher_contract_exposes_catalog_surface() {
assert_eq!(CodeLanguageLogo.name(), "code_language_logo");
assert_eq!(CodeLanguageLogo.safety(), Safety::Safe);
assert_eq!(CodeLanguageLogo.shapes(), &[Shape::Image]);
let schemas = CodeLanguageLogo.option_schemas();
assert_eq!(schemas.len(), 1);
assert_eq!(schemas[0].name, "language");
assert_eq!(schemas[0].type_hint, "string");
assert_eq!(
schemas[0].default,
Some("auto-detected from the dominant language in the repo")
);
assert!(CodeLanguageLogo.description().contains("Devicon PNG"));
}
#[test]
fn fetch_with_language_override_emits_bundled_logo_path() {
with_home(|| {
let payload = tokio::runtime::Runtime::new()
.unwrap()
.block_on(CodeLanguageLogo.fetch(&FetchContext {
widget_id: "logo".into(),
options: Some(toml::from_str(r#"language = "Rust""#).unwrap()),
..Default::default()
}))
.unwrap();
let path = image_path(payload);
assert!(
path.ends_with("rust.png"),
"expected rust logo path, got {path}"
);
assert!(std::path::Path::new(&path).exists());
});
}
#[test]
fn fetch_with_unknown_language_override_falls_back_to_generic_logo() {
with_home(|| {
let payload = tokio::runtime::Runtime::new()
.unwrap()
.block_on(CodeLanguageLogo.fetch(&FetchContext {
widget_id: "logo".into(),
options: Some(toml::from_str(r#"language = "Whitespace""#).unwrap()),
..Default::default()
}))
.unwrap();
let path = image_path(payload);
assert!(
path.ends_with("_generic.png"),
"expected generic logo path, got {path}"
);
assert!(std::path::Path::new(&path).exists());
});
}
#[test]
fn fetch_without_override_uses_detected_repo_language() {
with_home(|| {
let payload = tokio::runtime::Runtime::new()
.unwrap()
.block_on(CodeLanguageLogo.fetch(&FetchContext {
widget_id: "logo".into(),
..Default::default()
}))
.unwrap();
let path = image_path(payload);
let (slug, _) = logo_assets::asset_for(detect_dominant_language().unwrap());
assert!(
path.ends_with(&format!("{slug}.png")),
"expected detected logo path for {slug}, got {path}"
);
});
}
#[test]
fn fetch_surfaces_logo_cache_dir_creation_failures() {
let _lock = paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let broken_home = tmp.path().join("home-file");
std::fs::write(&broken_home, "not-a-directory").unwrap();
let previous = std::env::var("SPLASHBOARD_HOME").ok();
unsafe {
std::env::set_var("SPLASHBOARD_HOME", &broken_home);
}
let err = tokio::runtime::Runtime::new()
.unwrap()
.block_on(CodeLanguageLogo.fetch(&FetchContext {
widget_id: "logo".into(),
options: Some(toml::from_str(r#"language = "Rust""#).unwrap()),
..Default::default()
}))
.unwrap_err();
unsafe {
match previous {
Some(v) => std::env::set_var("SPLASHBOARD_HOME", v),
None => std::env::remove_var("SPLASHBOARD_HOME"),
}
}
assert!(matches!(err, FetchError::Failed(msg) if msg.contains("create logos cache dir")));
}
}