use axum::{Json, extract::State};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use std::sync::Mutex;
use crate::api::routes::AppState;
static INSTALLED_CATALOG_IDS: Mutex<Vec<String>> = Mutex::new(Vec::new());
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct CatalogEntry {
#[serde(flatten)]
pub manifest: ThemeManifest,
pub version: String,
pub preview_swatch: String,
pub installed: bool,
}
fn catalog_themes() -> Vec<CatalogEntry> {
let installed = INSTALLED_CATALOG_IDS
.lock()
.unwrap_or_else(|e| e.into_inner());
let mut entries = vec![
CatalogEntry {
manifest: ThemeManifest {
id: "parchment".into(),
name: "Parchment".into(),
description: "Warm parchment tones with elegant serif typography feel".into(),
author: "Roboticus".into(),
swatch: "#d4a574".into(),
variables: HashMap::from([
("--bg".into(), "#2a2118".into()),
("--surface".into(), "#352a1f".into()),
("--surface-2".into(), "#3f3326".into()),
("--accent".into(), "#c17f3a".into()),
("--text".into(), "#f5e6c8".into()),
("--muted".into(), "#b8a080".into()),
("--border".into(), "#6b5540".into()),
("--theme-body-texture".into(), "repeating-linear-gradient(0deg, transparent, transparent 3px, rgba(139,94,60,0.03) 3px, rgba(139,94,60,0.03) 4px)".into()),
("--theme-separator".into(), "linear-gradient(90deg, transparent, #8b5e3c 20%, #c17f3a 50%, #8b5e3c 80%, transparent)".into()),
("--theme-separator-height".into(), "2px".into()),
("--theme-scrollbar".into(), "rgba(193,127,58,0.3)".into()),
("--theme-card-border".into(), "linear-gradient(to bottom, #6b5540, #3f3326) 1".into()),
]),
textures: HashMap::from([
("body".into(), ThemeTexture {
kind: "css".into(),
value: "repeating-linear-gradient(0deg, transparent, transparent 3px, rgba(139,94,60,0.03) 3px, rgba(139,94,60,0.03) 4px)".into(),
}),
("surface".into(), ThemeTexture {
kind: "css".into(),
value: "radial-gradient(ellipse at 20% 50%, rgba(193,127,58,0.06) 0%, transparent 70%)".into(),
}),
]),
fonts: vec![],
thumbnail: String::new(),
source: "catalog".into(),
},
version: "1.0.0".into(),
preview_swatch: "linear-gradient(135deg, #f5e6c8, #d4a574)".into(),
installed: false,
},
CatalogEntry {
manifest: ThemeManifest {
id: "midnight-ocean".into(),
name: "Midnight Ocean".into(),
description: "Deep navy depths with teal accents and wave-inspired separators".into(),
author: "Roboticus".into(),
swatch: "#0d9488".into(),
variables: HashMap::from([
("--bg".into(), "#0a1628".into()),
("--surface".into(), "#0e1f3a".into()),
("--surface-2".into(), "#132848".into()),
("--accent".into(), "#0d9488".into()),
("--text".into(), "#c8e1f5".into()),
("--muted".into(), "#6b8ab0".into()),
("--border".into(), "#1e3a5f".into()),
("--theme-body-texture".into(), "radial-gradient(ellipse at 50% 0%, rgba(13,148,136,0.08) 0%, transparent 60%)".into()),
("--theme-separator".into(), "linear-gradient(90deg, transparent, #0d9488 15%, #1e3a5f 50%, #0d9488 85%, transparent)".into()),
("--theme-separator-height".into(), "2px".into()),
("--theme-scrollbar".into(), "rgba(13,148,136,0.3)".into()),
]),
textures: HashMap::from([
("body".into(), ThemeTexture {
kind: "css".into(),
value: "radial-gradient(ellipse at 50% 0%, rgba(13,148,136,0.08) 0%, transparent 60%)".into(),
}),
]),
fonts: vec![],
thumbnail: String::new(),
source: "catalog".into(),
},
version: "1.0.0".into(),
preview_swatch: "linear-gradient(135deg, #0a1628, #0d9488)".into(),
installed: false,
},
CatalogEntry {
manifest: ThemeManifest {
id: "solarized-dark".into(),
name: "Solarized Dark".into(),
description: "Ethan Schoonover's precision-engineered dark palette for low-fatigue reading".into(),
author: "Roboticus".into(),
swatch: "#268bd2".into(),
variables: HashMap::from([
("--bg".into(), "#002b36".into()),
("--surface".into(), "#073642".into()),
("--surface-2".into(), "#0a4050".into()),
("--accent".into(), "#268bd2".into()),
("--text".into(), "#93a1a1".into()),
("--muted".into(), "#657b83".into()),
("--border".into(), "#2aa198".into()),
]),
textures: HashMap::new(),
fonts: vec![],
thumbnail: String::new(),
source: "catalog".into(),
},
version: "1.0.0".into(),
preview_swatch: "linear-gradient(135deg, #002b36, #268bd2)".into(),
installed: false,
},
CatalogEntry {
manifest: ThemeManifest {
id: "dracula".into(),
name: "Dracula".into(),
description: "The beloved dark theme with purple, pink, and green highlights".into(),
author: "Roboticus".into(),
swatch: "#bd93f9".into(),
variables: HashMap::from([
("--bg".into(), "#282a36".into()),
("--surface".into(), "#2d303e".into()),
("--surface-2".into(), "#343746".into()),
("--accent".into(), "#bd93f9".into()),
("--text".into(), "#f8f8f2".into()),
("--muted".into(), "#6272a4".into()),
("--border".into(), "#44475a".into()),
("--theme-scrollbar".into(), "rgba(189,147,249,0.25)".into()),
]),
textures: HashMap::new(),
fonts: vec![],
thumbnail: String::new(),
source: "catalog".into(),
},
version: "1.0.0".into(),
preview_swatch: "linear-gradient(135deg, #282a36, #bd93f9, #ff79c6)".into(),
installed: false,
},
CatalogEntry {
manifest: ThemeManifest {
id: "nord".into(),
name: "Nord".into(),
description: "Arctic blue-gray palette inspired by the cold beauty of the Nordic wilderness".into(),
author: "Roboticus".into(),
swatch: "#88c0d0".into(),
variables: HashMap::from([
("--bg".into(), "#2e3440".into()),
("--surface".into(), "#3b4252".into()),
("--surface-2".into(), "#434c5e".into()),
("--accent".into(), "#88c0d0".into()),
("--text".into(), "#eceff4".into()),
("--muted".into(), "#81a1c1".into()),
("--border".into(), "#4c566a".into()),
]),
textures: HashMap::new(),
fonts: vec![],
thumbnail: String::new(),
source: "catalog".into(),
},
version: "1.0.0".into(),
preview_swatch: "linear-gradient(135deg, #2e3440, #88c0d0)".into(),
installed: false,
},
];
for entry in &mut entries {
if installed.contains(&entry.manifest.id) {
entry.installed = true;
}
}
entries
}
pub(crate) async fn list_theme_catalog() -> Json<serde_json::Value> {
let catalog = catalog_themes();
Json(serde_json::json!({ "catalog": catalog }))
}
#[derive(Debug, Deserialize)]
pub(crate) struct InstallRequest {
pub id: String,
}
pub(crate) async fn install_catalog_theme(
Json(req): Json<InstallRequest>,
) -> Json<serde_json::Value> {
let catalog = catalog_themes();
let Some(entry) = catalog.iter().find(|e| e.manifest.id == req.id) else {
return Json(serde_json::json!({
"ok": false,
"error": format!("theme '{}' not found in catalog", req.id)
}));
};
if entry.installed {
return Json(serde_json::json!({
"ok": true,
"message": "already installed"
}));
}
{
let mut ids = INSTALLED_CATALOG_IDS
.lock()
.unwrap_or_else(|e| e.into_inner());
if !ids.contains(&req.id) {
ids.push(req.id.clone());
}
}
tracing::info!(theme_id = %req.id, "catalog theme installed");
Json(serde_json::json!({
"ok": true,
"theme": entry.manifest
}))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ThemeManifest {
pub id: String,
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub author: String,
#[serde(default)]
pub swatch: String,
#[serde(default)]
pub variables: HashMap<String, String>,
#[serde(default)]
pub textures: HashMap<String, ThemeTexture>,
#[serde(default)]
pub fonts: Vec<String>,
#[serde(default)]
pub thumbnail: String,
#[serde(skip_deserializing, default)]
pub source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ThemeTexture {
#[serde(rename = "type")]
pub kind: String,
pub value: String,
}
pub(crate) async fn list_themes(State(state): State<AppState>) -> Json<Vec<ThemeManifest>> {
let mut themes = Vec::new();
let builtin_dir = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.join("themes")));
if let Some(dir) = &builtin_dir {
load_themes_from_dir(dir, "builtin", &mut themes);
}
let project_themes = Path::new("themes");
if project_themes.is_dir() {
load_themes_from_dir(project_themes, "project", &mut themes);
}
let config = state.config.read().await;
let workspace = config.skills.skills_dir.parent().unwrap_or(Path::new("."));
let workspace_themes = workspace.join("themes");
drop(config);
if workspace_themes.is_dir() {
load_themes_from_dir(&workspace_themes, "workspace", &mut themes);
}
if let Ok(home) = std::env::var("HOME") {
let home_themes = std::path::PathBuf::from(home)
.join(".roboticus")
.join("workspace")
.join("themes");
if home_themes.is_dir() && home_themes != workspace_themes {
load_themes_from_dir(&home_themes, "workspace", &mut themes);
}
}
{
let installed = INSTALLED_CATALOG_IDS
.lock()
.unwrap_or_else(|e| e.into_inner());
if !installed.is_empty() {
let catalog = catalog_themes();
for entry in &catalog {
if installed.contains(&entry.manifest.id) {
themes.push(entry.manifest.clone());
}
}
}
}
let mut seen = std::collections::HashSet::new();
themes.reverse();
themes.retain(|t| seen.insert(t.id.clone()));
themes.reverse();
Json(themes)
}
fn load_themes_from_dir(dir: &Path, source: &str, out: &mut Vec<ThemeManifest>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
match std::fs::read_to_string(&path) {
Ok(content) => match serde_json::from_str::<ThemeManifest>(&content) {
Ok(mut manifest) => {
manifest.source = source.to_string();
tracing::debug!(
theme_id = %manifest.id,
source,
path = %path.display(),
"loaded theme manifest"
);
out.push(manifest);
}
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"failed to parse theme manifest"
);
}
},
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"failed to read theme file"
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn parse_theme_with_textures() {
let json = r##"{
"id": "parchment",
"name": "Parchment & Ink",
"description": "Warm tones",
"author": "Test",
"swatch": "#8b4513",
"variables": { "--bg": "#1a1410", "--accent": "#c17f3a" },
"textures": {
"body": { "type": "css", "value": "repeating-linear-gradient(0deg, transparent, transparent 2px)" }
},
"fonts": ["https://fonts.googleapis.com/css2?family=Cinzel"]
}"##;
let manifest: ThemeManifest = serde_json::from_str(json).unwrap();
assert_eq!(manifest.id, "parchment");
assert_eq!(manifest.name, "Parchment & Ink");
assert!(!manifest.variables.is_empty());
assert!(manifest.textures.contains_key("body"));
assert!(!manifest.fonts.is_empty());
}
#[test]
fn theme_without_textures_parses() {
let json = r##"{
"id": "minimal",
"name": "Minimal",
"variables": { "--accent": "#ff0000" }
}"##;
let manifest: ThemeManifest = serde_json::from_str(json).unwrap();
assert_eq!(manifest.id, "minimal");
assert!(manifest.textures.is_empty());
assert!(manifest.fonts.is_empty());
}
#[test]
fn parse_theme_minimal_fields_only() {
let json = r#"{"id": "bare", "name": "Bare"}"#;
let manifest: ThemeManifest = serde_json::from_str(json).unwrap();
assert_eq!(manifest.id, "bare");
assert_eq!(manifest.name, "Bare");
assert!(manifest.description.is_empty());
assert!(manifest.author.is_empty());
assert!(manifest.swatch.is_empty());
assert!(manifest.variables.is_empty());
assert!(manifest.textures.is_empty());
assert!(manifest.fonts.is_empty());
assert!(manifest.thumbnail.is_empty());
}
#[test]
fn parse_theme_with_thumbnail() {
let json = r#"{
"id": "preview",
"name": "Preview Theme",
"thumbnail": "data:image/png;base64,iVBOR"
}"#;
let manifest: ThemeManifest = serde_json::from_str(json).unwrap();
assert_eq!(manifest.id, "preview");
assert!(manifest.thumbnail.starts_with("data:image/png"));
}
#[test]
fn source_field_not_deserialized() {
let json = r#"{
"id": "src-test",
"name": "Source Test",
"source": "should-be-ignored"
}"#;
let manifest: ThemeManifest = serde_json::from_str(json).unwrap();
assert!(manifest.source.is_empty());
}
#[test]
fn source_field_serializes() {
let mut manifest = ThemeManifest {
id: "s".into(),
name: "S".into(),
description: String::new(),
author: String::new(),
swatch: String::new(),
variables: HashMap::new(),
textures: HashMap::new(),
fonts: vec![],
thumbnail: String::new(),
source: "builtin".into(),
};
let json = serde_json::to_string(&manifest).unwrap();
assert!(json.contains(r#""source":"builtin""#));
manifest.source = "workspace".into();
let json = serde_json::to_string(&manifest).unwrap();
assert!(json.contains(r#""source":"workspace""#));
}
#[test]
fn texture_kind_rename() {
let json = r#"{"type": "url", "value": "https://example.com/bg.png"}"#;
let tex: ThemeTexture = serde_json::from_str(json).unwrap();
assert_eq!(tex.kind, "url");
assert_eq!(tex.value, "https://example.com/bg.png");
}
#[test]
fn load_themes_from_dir_picks_up_json_files() {
let dir = TempDir::new().unwrap();
let theme_json = r#"{"id": "loaded", "name": "Loaded Theme"}"#;
fs::write(dir.path().join("cool.json"), theme_json).unwrap();
fs::write(dir.path().join("readme.txt"), "not a theme").unwrap();
let mut themes = Vec::new();
load_themes_from_dir(dir.path(), "test-source", &mut themes);
assert_eq!(themes.len(), 1);
assert_eq!(themes[0].id, "loaded");
assert_eq!(themes[0].name, "Loaded Theme");
assert_eq!(themes[0].source, "test-source");
}
#[test]
fn load_themes_from_dir_skips_invalid_json() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("bad.json"), "{ not valid json }").unwrap();
fs::write(
dir.path().join("good.json"),
r#"{"id": "ok", "name": "OK"}"#,
)
.unwrap();
let mut themes = Vec::new();
load_themes_from_dir(dir.path(), "test", &mut themes);
assert_eq!(themes.len(), 1);
assert_eq!(themes[0].id, "ok");
}
#[test]
fn load_themes_from_dir_handles_missing_dir() {
let mut themes = Vec::new();
load_themes_from_dir(Path::new("/nonexistent/themes/dir"), "ghost", &mut themes);
assert!(themes.is_empty());
}
#[test]
fn load_themes_from_dir_empty_directory() {
let dir = TempDir::new().unwrap();
let mut themes = Vec::new();
load_themes_from_dir(dir.path(), "empty", &mut themes);
assert!(themes.is_empty());
}
#[test]
fn load_themes_from_dir_multiple_themes() {
let dir = TempDir::new().unwrap();
for i in 0..3 {
let json = format!(r#"{{"id": "theme-{i}", "name": "Theme {i}"}}"#);
fs::write(dir.path().join(format!("theme-{i}.json")), json).unwrap();
}
let mut themes = Vec::new();
load_themes_from_dir(dir.path(), "multi", &mut themes);
assert_eq!(themes.len(), 3);
let ids: Vec<&str> = themes.iter().map(|t| t.id.as_str()).collect();
for i in 0..3 {
assert!(ids.contains(&format!("theme-{i}").as_str()));
}
assert!(themes.iter().all(|t| t.source == "multi"));
}
#[test]
fn load_themes_from_dir_skips_missing_required_fields() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("no-name.json"), r#"{"id": "orphan"}"#).unwrap();
fs::write(
dir.path().join("valid.json"),
r#"{"id": "v", "name": "Valid"}"#,
)
.unwrap();
let mut themes = Vec::new();
load_themes_from_dir(dir.path(), "test", &mut themes);
assert_eq!(themes.len(), 1);
assert_eq!(themes[0].id, "v");
}
#[test]
fn theme_manifest_clone_and_debug() {
let manifest = ThemeManifest {
id: "clone-test".into(),
name: "Clone".into(),
description: String::new(),
author: String::new(),
swatch: String::new(),
variables: HashMap::new(),
textures: HashMap::new(),
fonts: vec![],
thumbnail: String::new(),
source: String::new(),
};
let cloned = manifest.clone();
assert_eq!(cloned.id, manifest.id);
let debug = format!("{:?}", manifest);
assert!(debug.contains("clone-test"));
}
#[test]
fn catalog_themes_returns_five_entries() {
let catalog = catalog_themes();
assert_eq!(catalog.len(), 5);
let ids: Vec<&str> = catalog.iter().map(|e| e.manifest.id.as_str()).collect();
assert!(ids.contains(&"parchment"));
assert!(ids.contains(&"midnight-ocean"));
assert!(ids.contains(&"solarized-dark"));
assert!(ids.contains(&"dracula"));
assert!(ids.contains(&"nord"));
}
#[test]
fn catalog_themes_have_required_variables() {
let catalog = catalog_themes();
for entry in &catalog {
let v = &entry.manifest.variables;
assert!(v.contains_key("--bg"), "{} missing --bg", entry.manifest.id);
assert!(
v.contains_key("--accent"),
"{} missing --accent",
entry.manifest.id
);
assert!(
v.contains_key("--text"),
"{} missing --text",
entry.manifest.id
);
assert!(
!entry.version.is_empty(),
"{} missing version",
entry.manifest.id
);
assert!(
!entry.preview_swatch.is_empty(),
"{} missing swatch",
entry.manifest.id
);
assert_eq!(entry.manifest.source, "catalog");
}
}
#[test]
fn catalog_entry_serializes_with_flatten() {
let catalog = catalog_themes();
let json = serde_json::to_value(&catalog[0]).unwrap();
assert!(json.get("id").is_some());
assert!(json.get("name").is_some());
assert!(json.get("variables").is_some());
assert!(json.get("version").is_some());
assert!(json.get("preview_swatch").is_some());
assert!(json.get("installed").is_some());
}
#[test]
fn parchment_has_deep_theme_decorations() {
let catalog = catalog_themes();
let parchment = catalog
.iter()
.find(|e| e.manifest.id == "parchment")
.unwrap();
let v = &parchment.manifest.variables;
assert!(v.contains_key("--theme-body-texture"));
assert!(v.contains_key("--theme-separator"));
assert!(v.contains_key("--theme-separator-height"));
assert!(v.contains_key("--theme-scrollbar"));
assert!(v.contains_key("--theme-card-border"));
}
#[test]
fn midnight_ocean_has_deep_theme_decorations() {
let catalog = catalog_themes();
let ocean = catalog
.iter()
.find(|e| e.manifest.id == "midnight-ocean")
.unwrap();
let v = &ocean.manifest.variables;
assert!(v.contains_key("--theme-body-texture"));
assert!(v.contains_key("--theme-separator"));
assert!(v.contains_key("--theme-scrollbar"));
}
}