use anyhow::Context;
use ratatui::style::Color;
use serde::de::Error;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use std::str::FromStr;
use crate::auto_complete::fuzzy_match;
static BUNDLED_THEMES: &[(&str, &str)] = &[
("atomic", include_str!("../themes/atomic.json")),
(
"catppuccin-latte",
include_str!("../themes/catppuccin-latte.json"),
),
(
"catppuccin-macchiato",
include_str!("../themes/catppuccin-macchiato.json"),
),
(
"catppuccin-mocha",
include_str!("../themes/catppuccin-mocha.json"),
),
("dracula", include_str!("../themes/dracula.json")),
("github-dark", include_str!("../themes/github-dark.json")),
(
"github-dark-dimmed",
include_str!("../themes/github-dark-dimmed.json"),
),
("github-light", include_str!("../themes/github-light.json")),
(
"everforest-dark",
include_str!("../themes/everforest-dark.json"),
),
(
"everforest-light",
include_str!("../themes/everforest-light.json"),
),
("gruvbox-dark", include_str!("../themes/gruvbox-dark.json")),
(
"jandedobbeleer",
include_str!("../themes/jandedobbeleer.json"),
),
("kanagawa", include_str!("../themes/kanagawa.json")),
("monokai", include_str!("../themes/monokai.json")),
("nord", include_str!("../themes/nord.json")),
("onedark", include_str!("../themes/onedark.json")),
("onelight", include_str!("../themes/onelight.json")),
("paradox", include_str!("../themes/paradox.json")),
("rose-pine", include_str!("../themes/rose-pine.json")),
(
"rose-pine-dawn",
include_str!("../themes/rose-pine-dawn.json"),
),
("solarized", include_str!("../themes/solarized.json")),
("tokyonight", include_str!("../themes/tokyonight.json")),
];
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ValueColors {
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_http_get"
)]
pub http_get: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_http_post"
)]
pub http_post: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_http_put"
)]
pub http_put: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_http_delete"
)]
pub http_delete: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_http_patch"
)]
pub http_patch: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_http_other"
)]
pub http_other: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_status_2xx"
)]
pub status_2xx: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_status_3xx"
)]
pub status_3xx: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_status_4xx"
)]
pub status_4xx: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_status_5xx"
)]
pub status_5xx: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_ip_address"
)]
pub ip_address: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_uuid"
)]
pub uuid: Color,
#[serde(skip)]
pub disabled: HashSet<String>,
}
fn default_http_get() -> Color {
Color::Rgb(80, 250, 123)
}
fn default_http_post() -> Color {
Color::Rgb(139, 233, 253)
}
fn default_http_put() -> Color {
Color::Rgb(255, 184, 108)
}
fn default_http_delete() -> Color {
Color::Rgb(255, 85, 85)
}
fn default_http_patch() -> Color {
Color::Rgb(189, 147, 249)
}
fn default_http_other() -> Color {
Color::Rgb(98, 114, 164)
}
fn default_status_2xx() -> Color {
Color::Rgb(80, 250, 123)
}
fn default_status_3xx() -> Color {
Color::Rgb(139, 233, 253)
}
fn default_status_4xx() -> Color {
Color::Rgb(255, 184, 108)
}
fn default_status_5xx() -> Color {
Color::Rgb(255, 85, 85)
}
fn default_ip_address() -> Color {
Color::Rgb(189, 147, 249)
}
fn default_uuid() -> Color {
Color::Rgb(108, 113, 196)
}
impl Default for ValueColors {
fn default() -> Self {
ValueColors {
http_get: default_http_get(),
http_post: default_http_post(),
http_put: default_http_put(),
http_delete: default_http_delete(),
http_patch: default_http_patch(),
http_other: default_http_other(),
status_2xx: default_status_2xx(),
status_3xx: default_status_3xx(),
status_4xx: default_status_4xx(),
status_5xx: default_status_5xx(),
ip_address: default_ip_address(),
uuid: default_uuid(),
disabled: HashSet::new(),
}
}
}
pub struct ValueColorGroup {
pub label: &'static str,
pub children: Vec<(&'static str, &'static str, Color)>,
}
impl ValueColors {
pub fn grouped_categories(
&self,
process_representative: Option<Color>,
) -> Vec<ValueColorGroup> {
let process_swatch = process_representative.unwrap_or(Color::Rgb(255, 85, 85));
vec![
ValueColorGroup {
label: "HTTP methods",
children: vec![
("http_get", "GET", self.http_get),
("http_post", "POST", self.http_post),
("http_put", "PUT", self.http_put),
("http_delete", "DELETE", self.http_delete),
("http_patch", "PATCH", self.http_patch),
("http_other", "HEAD/OPTIONS", self.http_other),
],
},
ValueColorGroup {
label: "Status codes",
children: vec![
("status_2xx", "2xx", self.status_2xx),
("status_3xx", "3xx", self.status_3xx),
("status_4xx", "4xx", self.status_4xx),
("status_5xx", "5xx", self.status_5xx),
],
},
ValueColorGroup {
label: "Network",
children: vec![("ip_address", "IP addresses", self.ip_address)],
},
ValueColorGroup {
label: "Identifiers",
children: vec![("uuid", "UUIDs", self.uuid)],
},
ValueColorGroup {
label: "Process",
children: vec![("process_colors", "Process / logger colors", process_swatch)],
},
]
}
pub fn is_disabled(&self, key: &str) -> bool {
self.disabled.contains(key)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Theme {
#[serde(serialize_with = "color_to_str", deserialize_with = "color_from_str")]
pub root_bg: Color,
#[serde(serialize_with = "color_to_str", deserialize_with = "color_from_str")]
pub border: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_cursor_bg"
)]
pub cursor_bg: Color,
#[serde(serialize_with = "color_to_str", deserialize_with = "color_from_str")]
pub border_title: Color,
#[serde(serialize_with = "color_to_str", deserialize_with = "color_from_str")]
pub text: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_text_highlight_fg"
)]
pub text_highlight_fg: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_text_highlight_bg"
)]
pub text_highlight_bg: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_cursor_fg"
)]
pub cursor_fg: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_trace_fg"
)]
pub trace_fg: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_debug_fg"
)]
pub debug_fg: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_info_fg"
)]
pub info_fg: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_notice_fg"
)]
pub notice_fg: Color,
#[serde(serialize_with = "color_to_str", deserialize_with = "color_from_str")]
pub error_fg: Color,
#[serde(serialize_with = "color_to_str", deserialize_with = "color_from_str")]
pub warning_fg: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_fatal_fg"
)]
pub fatal_fg: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_search_fg"
)]
pub search_fg: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_visual_select_bg"
)]
pub visual_select_bg: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_visual_select_fg"
)]
pub visual_select_fg: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_mark_bg"
)]
pub mark_bg: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_mark_fg"
)]
pub mark_fg: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_line_number_fg"
)]
pub line_number_fg: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_inactive_tab_fg"
)]
pub inactive_tab_fg: Color,
#[serde(
serialize_with = "color_to_str",
deserialize_with = "color_from_str",
default = "default_comment_fg"
)]
pub comment_fg: Color,
#[serde(
serialize_with = "colors_to_str_vec",
deserialize_with = "colors_from_str_vec"
)]
pub process_colors: Vec<Color>,
#[serde(default)]
pub value_colors: ValueColors,
}
fn color_to_str<S>(color: &Color, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&color.to_string())
}
fn color_from_str<'de, D>(deserializer: D) -> Result<Color, D::Error>
where
D: serde::Deserializer<'de>,
{
struct ColorVisitor;
impl<'de> serde::de::Visitor<'de> for ColorVisitor {
type Value = Color;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a color string (e.g., \"#RRGGBB\") or an RGB array [u8; 3]")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Color::from_str(v).map_err(E::custom)
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: serde::de::SeqAccess<'de>,
{
let r = seq
.next_element()?
.ok_or_else(|| A::Error::invalid_length(0, &self))?;
let g = seq
.next_element()?
.ok_or_else(|| A::Error::invalid_length(1, &self))?;
let b = seq
.next_element()?
.ok_or_else(|| A::Error::invalid_length(2, &self))?;
Ok(Color::Rgb(r, g, b))
}
}
deserializer.deserialize_any(ColorVisitor)
}
fn colors_to_str_vec<S>(colors: &[Color], serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let strs: Vec<String> = colors.iter().map(|c| c.to_string()).collect();
strs.serialize(serializer)
}
fn colors_from_str_vec<'de, D>(deserializer: D) -> Result<Vec<Color>, D::Error>
where
D: serde::Deserializer<'de>,
{
struct ColorVecVisitor;
impl<'de> serde::de::Visitor<'de> for ColorVecVisitor {
type Value = Vec<Color>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a sequence of color strings or RGB arrays")
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: serde::de::SeqAccess<'de>,
{
let mut colors = Vec::new();
while let Some(element) = seq.next_element_seed(ColorDeserializer)? {
colors.push(element);
}
Ok(colors)
}
}
deserializer.deserialize_seq(ColorVecVisitor)
}
struct ColorDeserializer;
impl<'de> serde::de::DeserializeSeed<'de> for ColorDeserializer {
type Value = Color;
fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
where
D: serde::Deserializer<'de>,
{
color_from_str(deserializer)
}
}
fn default_cursor_bg() -> Color {
Color::Rgb(98, 114, 164) }
fn default_text_highlight_fg() -> Color {
Color::Rgb(255, 184, 108) }
fn default_text_highlight_bg() -> Color {
Color::Rgb(122, 74, 16) }
fn default_trace_fg() -> Color {
Color::Rgb(98, 114, 164) }
fn default_debug_fg() -> Color {
Color::Rgb(139, 233, 253) }
fn default_info_fg() -> Color {
Color::Rgb(248, 248, 242) }
fn default_notice_fg() -> Color {
Color::Rgb(248, 248, 242) }
fn default_fatal_fg() -> Color {
Color::Rgb(255, 85, 85) }
fn default_cursor_fg() -> Color {
Color::Rgb(28, 28, 28)
}
fn default_search_fg() -> Color {
Color::Rgb(28, 28, 28)
}
fn default_visual_select_bg() -> Color {
Color::Rgb(68, 71, 90)
}
fn default_visual_select_fg() -> Color {
Color::Rgb(248, 248, 242)
}
fn default_mark_bg() -> Color {
Color::Rgb(70, 60, 15)
}
fn default_mark_fg() -> Color {
Color::Rgb(248, 248, 242)
}
fn default_line_number_fg() -> Color {
Color::Rgb(128, 128, 128) }
fn default_inactive_tab_fg() -> Color {
Color::Rgb(128, 128, 128) }
fn default_comment_fg() -> Color {
Color::Rgb(139, 233, 253) }
impl Theme {
pub fn list_available_themes() -> Vec<String> {
Self::list_available_themes_from(dirs::config_dir().as_deref())
}
fn list_available_themes_from(config_dir: Option<&Path>) -> Vec<String> {
let mut set: std::collections::HashSet<String> = BUNDLED_THEMES
.iter()
.map(|(name, _)| name.to_string())
.collect();
let mut add_from_dir = |dir: &Path| {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) == Some("json")
&& let Some(stem) = p.file_stem().and_then(|s| s.to_str())
{
set.insert(stem.to_string());
}
}
}
};
add_from_dir(Path::new("themes"));
if let Some(dir) = config_dir {
add_from_dir(&dir.join("logana/themes"));
}
let mut themes: Vec<String> = set.into_iter().collect();
themes.sort();
themes
}
pub fn from_file<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
Self::from_file_with_config_dir(path, dirs::config_dir().as_deref())
}
fn from_file_with_config_dir<P: AsRef<Path>>(
path: P,
config_dir: Option<&Path>,
) -> anyhow::Result<Self> {
let config_path = config_dir.map(|d| d.join("logana").join("themes").join(&path));
let local_path = Path::new("themes").join(&path);
let data = if config_path.as_ref().is_some_and(|p| p.exists()) {
let cp = config_path.unwrap();
fs::read_to_string(&cp)
.with_context(|| format!("Failed to read theme from {:?}", cp))?
} else if local_path.exists() {
fs::read_to_string(&local_path)
.with_context(|| format!("Failed to read theme from {:?}", local_path))?
} else {
let stem = path
.as_ref()
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
BUNDLED_THEMES
.iter()
.find(|(name, _)| *name == stem)
.map(|(_, json)| json.to_string())
.ok_or_else(|| {
anyhow::anyhow!(
"Theme {:?} not found in config dir, local themes/, or bundled themes",
path.as_ref()
)
})?
};
let mut json_val: serde_json::Value = serde_json::from_str(&data)?;
if json_val.get("cursor_bg").is_none_or(|v| v.is_null())
&& let Some(border) = json_val.get("border").cloned()
{
json_val["cursor_bg"] = border;
}
let config: Theme = serde_json::from_value(json_val)?;
Ok(config)
}
}
impl Default for Theme {
fn default() -> Self {
Theme::from_file("catppuccin-mocha.json").unwrap_or_else(|_| Theme {
root_bg: Color::Rgb(0x1e, 0x1e, 0x2e),
border: Color::Rgb(0x31, 0x32, 0x44),
cursor_bg: Color::Rgb(0x31, 0x32, 0x44),
border_title: Color::Rgb(0xcd, 0xd6, 0xf4),
text: Color::Rgb(0xcd, 0xd6, 0xf4),
text_highlight_fg: Color::Rgb(0xf9, 0xe2, 0xaf),
text_highlight_bg: Color::Rgb(0x4a, 0x3a, 0x0a),
cursor_fg: Color::Rgb(0xcd, 0xd6, 0xf4),
trace_fg: Color::Rgb(0x58, 0x5b, 0x70),
debug_fg: Color::Rgb(0x89, 0xdc, 0xeb),
info_fg: Color::Rgb(0xcd, 0xd6, 0xf4),
notice_fg: Color::Rgb(0xa6, 0xe3, 0xa1),
error_fg: Color::Rgb(0xf3, 0x8b, 0xa8),
warning_fg: Color::Rgb(0xf9, 0xe2, 0xaf),
fatal_fg: Color::Rgb(0xf3, 0x8b, 0xa8),
search_fg: Color::Rgb(0x1e, 0x1e, 0x2e),
visual_select_bg: Color::Rgb(0x45, 0x47, 0x5a),
visual_select_fg: Color::Rgb(0xcd, 0xd6, 0xf4),
mark_bg: Color::Rgb(0x4a, 0x38, 0x00),
mark_fg: Color::Rgb(0xcd, 0xd6, 0xf4),
line_number_fg: default_line_number_fg(),
inactive_tab_fg: Color::Rgb(0x58, 0x5b, 0x70),
comment_fg: Color::Rgb(0x89, 0xdc, 0xeb),
process_colors: vec![
Color::Rgb(0xf3, 0x8b, 0xa8),
Color::Rgb(0xa6, 0xe3, 0xa1),
Color::Rgb(0xf9, 0xe2, 0xaf),
Color::Rgb(0x89, 0xb4, 0xfa),
Color::Rgb(0xcb, 0xa6, 0xf7),
Color::Rgb(0x89, 0xdc, 0xeb),
],
value_colors: ValueColors::default(),
})
}
}
pub fn complete_theme(partial: &str) -> Vec<String> {
let themes = Theme::list_available_themes();
if partial.is_empty() {
themes
} else {
themes
.into_iter()
.filter(|t| fuzzy_match(partial, t))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_value_colors_default() {
let vc = ValueColors::default();
assert_eq!(vc.http_get, Color::Rgb(80, 250, 123));
assert_eq!(vc.http_post, Color::Rgb(139, 233, 253));
assert_eq!(vc.http_put, Color::Rgb(255, 184, 108));
assert_eq!(vc.http_delete, Color::Rgb(255, 85, 85));
assert_eq!(vc.http_patch, Color::Rgb(189, 147, 249));
assert_eq!(vc.http_other, Color::Rgb(98, 114, 164));
assert_eq!(vc.status_2xx, Color::Rgb(80, 250, 123));
assert_eq!(vc.status_3xx, Color::Rgb(139, 233, 253));
assert_eq!(vc.status_4xx, Color::Rgb(255, 184, 108));
assert_eq!(vc.status_5xx, Color::Rgb(255, 85, 85));
assert_eq!(vc.ip_address, Color::Rgb(189, 147, 249));
assert_eq!(vc.uuid, Color::Rgb(108, 113, 196));
assert!(vc.disabled.is_empty());
}
#[test]
fn test_grouped_categories_structure() {
let vc = ValueColors::default();
let groups = vc.grouped_categories(None);
assert_eq!(groups.len(), 5);
assert_eq!(groups[0].label, "HTTP methods");
assert_eq!(groups[0].children.len(), 6);
assert_eq!(groups[1].label, "Status codes");
assert_eq!(groups[1].children.len(), 4);
assert_eq!(groups[2].label, "Network");
assert_eq!(groups[2].children.len(), 1);
assert_eq!(groups[3].label, "Identifiers");
assert_eq!(groups[3].children.len(), 1);
assert_eq!(groups[4].label, "Process");
assert_eq!(groups[4].children.len(), 1);
assert_eq!(groups[4].children[0].0, "process_colors");
}
#[test]
fn test_grouped_categories_keys_and_labels() {
let vc = ValueColors::default();
let groups = vc.grouped_categories(None);
let http = &groups[0].children;
assert_eq!(http[0].0, "http_get");
assert_eq!(http[0].1, "GET");
assert_eq!(http[5].0, "http_other");
assert_eq!(http[5].1, "HEAD/OPTIONS");
let status = &groups[1].children;
assert_eq!(status[0].0, "status_2xx");
assert_eq!(status[3].0, "status_5xx");
assert_eq!(groups[2].children[0].0, "ip_address");
assert_eq!(groups[3].children[0].0, "uuid");
assert_eq!(groups[4].children[0].0, "process_colors");
}
#[test]
fn test_grouped_categories_uses_current_colors() {
let mut vc = ValueColors::default();
vc.http_get = Color::Rgb(1, 2, 3);
let groups = vc.grouped_categories(None);
assert_eq!(groups[0].children[0].2, Color::Rgb(1, 2, 3));
}
#[test]
fn test_grouped_categories_process_representative() {
let vc = ValueColors::default();
let custom = Color::Rgb(10, 20, 30);
let groups = vc.grouped_categories(Some(custom));
assert_eq!(groups[4].children[0].2, custom);
let groups_none = vc.grouped_categories(None);
assert_eq!(groups_none[4].children[0].2, Color::Rgb(255, 85, 85));
}
#[test]
fn test_is_disabled_false_by_default() {
let vc = ValueColors::default();
assert!(!vc.is_disabled("http_get"));
assert!(!vc.is_disabled("uuid"));
}
#[test]
fn test_is_disabled_true_when_in_set() {
let mut vc = ValueColors::default();
vc.disabled.insert("http_get".to_string());
assert!(vc.is_disabled("http_get"));
assert!(!vc.is_disabled("http_post"));
}
#[test]
fn test_value_colors_serde_roundtrip() {
let original = ValueColors::default();
let json = serde_json::to_string(&original).unwrap();
let deserialized: ValueColors = serde_json::from_str(&json).unwrap();
assert_eq!(original, deserialized);
}
#[test]
fn test_value_colors_disabled_not_serialized() {
let mut vc = ValueColors::default();
vc.disabled.insert("http_get".to_string());
let json = serde_json::to_string(&vc).unwrap();
assert!(!json.contains("disabled"));
let deserialized: ValueColors = serde_json::from_str(&json).unwrap();
assert!(deserialized.disabled.is_empty());
}
#[test]
fn test_value_colors_partial_json_uses_defaults() {
let json = r##"{"http_get": "#FF0000"}"##;
let vc: ValueColors = serde_json::from_str(json).unwrap();
assert_eq!(vc.http_get, Color::Rgb(255, 0, 0));
assert_eq!(vc.http_post, default_http_post());
assert_eq!(vc.uuid, default_uuid());
}
#[test]
fn test_theme_default_loads_successfully() {
let theme = Theme::default();
assert!(!theme.process_colors.is_empty());
let _ = (theme.root_bg, theme.text, theme.error_fg, theme.warning_fg);
}
#[test]
fn test_default_fallback_level_colors() {
assert_eq!(default_trace_fg(), Color::Rgb(98, 114, 164));
assert_eq!(default_debug_fg(), Color::Rgb(139, 233, 253));
assert_eq!(default_info_fg(), Color::Rgb(248, 248, 242));
assert_eq!(default_notice_fg(), Color::Rgb(248, 248, 242));
assert_eq!(default_fatal_fg(), Color::Rgb(255, 85, 85));
assert_eq!(default_cursor_fg(), Color::Rgb(28, 28, 28));
assert_eq!(default_search_fg(), Color::Rgb(28, 28, 28));
assert_eq!(default_visual_select_bg(), Color::Rgb(68, 71, 90));
assert_eq!(default_visual_select_fg(), Color::Rgb(248, 248, 242));
assert_eq!(default_mark_bg(), Color::Rgb(70, 60, 15));
assert_eq!(default_mark_fg(), Color::Rgb(248, 248, 242));
assert_eq!(default_inactive_tab_fg(), Color::Rgb(128, 128, 128));
}
#[test]
fn test_theme_serde_roundtrip() {
let original = Theme::default();
let json = serde_json::to_string(&original).unwrap();
let deserialized: Theme = serde_json::from_str(&json).unwrap();
assert_eq!(original, deserialized);
}
#[test]
fn test_theme_deserialize_hex_color() {
let json = r##"{
"root_bg": "#1e1e2e",
"border": "#313244",
"border_title": "#cdd6f4",
"text": "#cdd6f4",
"error_fg": "#f38ba8",
"warning_fg": "#f9e2af",
"process_colors": ["#f38ba8"]
}"##;
let theme: Theme = serde_json::from_str(json).unwrap();
assert_eq!(theme.root_bg, Color::Rgb(0x1e, 0x1e, 0x2e));
assert_eq!(theme.border, Color::Rgb(0x31, 0x32, 0x44));
}
#[test]
fn test_theme_deserialize_rgb_array() {
let mut json_value: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&Theme::default()).unwrap()).unwrap();
json_value["root_bg"] = serde_json::json!([100, 200, 50]);
let theme: Theme = serde_json::from_value(json_value).unwrap();
assert_eq!(theme.root_bg, Color::Rgb(100, 200, 50));
}
#[test]
fn test_theme_deserialize_missing_optional_fields_use_defaults() {
let json = r##"{
"root_bg": "#282a36",
"border": "#6272a4",
"border_title": "#f8f8f2",
"text": "#f8f8f2",
"text_highlight_fg": "#ffb86c",
"error_fg": "#ff5555",
"warning_fg": "#f1fa8c",
"process_colors": ["#ff5555", "#50fa7b"]
}"##;
let theme: Theme = serde_json::from_str(json).unwrap();
assert_eq!(theme.cursor_bg, default_cursor_bg());
assert_eq!(theme.trace_fg, default_trace_fg());
assert_eq!(theme.debug_fg, default_debug_fg());
assert_eq!(theme.info_fg, default_info_fg());
assert_eq!(theme.notice_fg, default_notice_fg());
assert_eq!(theme.fatal_fg, default_fatal_fg());
assert_eq!(theme.cursor_fg, default_cursor_fg());
assert_eq!(theme.search_fg, default_search_fg());
assert_eq!(theme.visual_select_bg, default_visual_select_bg());
assert_eq!(theme.visual_select_fg, default_visual_select_fg());
assert_eq!(theme.mark_bg, default_mark_bg());
assert_eq!(theme.mark_fg, default_mark_fg());
assert_eq!(theme.inactive_tab_fg, default_inactive_tab_fg());
assert_eq!(theme.comment_fg, default_comment_fg());
assert_eq!(theme.value_colors, ValueColors::default());
}
#[test]
fn test_theme_deserialize_process_colors_rgb_arrays() {
let mut json_value: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&Theme::default()).unwrap()).unwrap();
json_value["process_colors"] = serde_json::json!([[10, 20, 30], [40, 50, 60]]);
let theme: Theme = serde_json::from_value(json_value).unwrap();
assert_eq!(theme.process_colors.len(), 2);
assert_eq!(theme.process_colors[0], Color::Rgb(10, 20, 30));
assert_eq!(theme.process_colors[1], Color::Rgb(40, 50, 60));
}
#[test]
fn test_theme_from_file_nonexistent() {
let result = Theme::from_file("nonexistent_theme_xyz123.json");
assert!(result.is_err());
}
#[test]
fn test_theme_from_file_valid() {
let temp = tempdir().unwrap();
let theme_dir = temp.path().join("logana").join("themes");
fs::create_dir_all(&theme_dir).unwrap();
let original = Theme::default();
let theme_json = serde_json::to_string(&original).unwrap();
fs::write(theme_dir.join("test_theme.json"), &theme_json).unwrap();
let loaded = Theme::from_file_with_config_dir("test_theme.json", Some(temp.path()));
assert!(loaded.is_ok());
assert_eq!(loaded.unwrap(), original);
}
#[test]
fn test_theme_from_file_cursor_bg_backfilled_from_border() {
let temp = tempdir().unwrap();
let theme_dir = temp.path().join("logana").join("themes");
fs::create_dir_all(&theme_dir).unwrap();
let json = r##"{
"root_bg": "#282a36",
"border": "#6272a4",
"border_title": "#f8f8f2",
"text": "#f8f8f2",
"error_fg": "#ff5555",
"warning_fg": "#f1fa8c",
"process_colors": ["#ff5555"]
}"##;
fs::write(theme_dir.join("minimal.json"), json).unwrap();
let theme = Theme::from_file_with_config_dir("minimal.json", Some(temp.path())).unwrap();
assert_eq!(theme.cursor_bg, Color::Rgb(98, 114, 164)); }
#[test]
fn test_theme_from_file_cursor_bg_explicit_overrides_border() {
let temp = tempdir().unwrap();
let theme_dir = temp.path().join("logana").join("themes");
fs::create_dir_all(&theme_dir).unwrap();
let json = r##"{
"root_bg": "#fafafa",
"border": "#d0d0d0",
"cursor_bg": "#aaaaaa",
"border_title": "#383a42",
"text": "#383a42",
"error_fg": "#e45649",
"warning_fg": "#c18401",
"process_colors": ["#e45649"]
}"##;
fs::write(theme_dir.join("explicit.json"), json).unwrap();
let theme = Theme::from_file_with_config_dir("explicit.json", Some(temp.path())).unwrap();
assert_eq!(theme.border, Color::Rgb(0xd0, 0xd0, 0xd0));
assert_eq!(theme.cursor_bg, Color::Rgb(0xaa, 0xaa, 0xaa)); }
#[test]
fn test_theme_from_file_invalid_json() {
let temp = tempdir().unwrap();
let theme_dir = temp.path().join("logana").join("themes");
fs::create_dir_all(&theme_dir).unwrap();
fs::write(theme_dir.join("broken.json"), "not valid json {{{").unwrap();
let result = Theme::from_file_with_config_dir("broken.json", Some(temp.path()));
assert!(result.is_err());
}
#[test]
fn test_theme_loading_from_config_dir() {
let temp_dir = tempdir().unwrap();
let themes_dir = temp_dir.path().join("logana/themes");
fs::create_dir_all(&themes_dir).unwrap();
fs::write(themes_dir.join("mytheme.json"), "{}").unwrap();
let themes = Theme::list_available_themes_from(Some(temp_dir.path()));
assert!(themes.contains(&"mytheme".to_string()));
}
#[test]
fn test_list_available_themes_ignores_non_json() {
let temp_dir = tempdir().unwrap();
let themes_dir = temp_dir.path().join("logana/themes");
fs::create_dir_all(&themes_dir).unwrap();
fs::write(themes_dir.join("readme.txt"), "not a theme").unwrap();
fs::write(themes_dir.join("valid.json"), "{}").unwrap();
let themes = Theme::list_available_themes_from(Some(temp_dir.path()));
assert!(themes.contains(&"valid".to_string()));
assert!(!themes.contains(&"readme".to_string()));
assert!(!themes.contains(&"readme.txt".to_string()));
}
#[test]
fn test_complete_theme_empty_returns_available_themes() {
let themes = complete_theme("");
for t in &themes {
assert!(!t.is_empty());
}
}
#[test]
fn test_complete_theme_no_match_returns_empty() {
let results = complete_theme("zzznomatch9999");
assert!(results.is_empty());
}
#[test]
fn test_complete_theme_fuzzy_match() {
let temp_dir = tempdir().unwrap();
let themes_dir = temp_dir.path().join("logana/themes");
fs::create_dir_all(&themes_dir).unwrap();
fs::write(themes_dir.join("monokai.json"), "{}").unwrap();
fs::write(themes_dir.join("solarized.json"), "{}").unwrap();
let all = Theme::list_available_themes_from(Some(temp_dir.path()));
let results: Vec<String> = all.into_iter().filter(|t| fuzzy_match("mono", t)).collect();
assert!(results.contains(&"monokai".to_string()));
assert!(!results.contains(&"solarized".to_string()));
}
#[test]
fn test_color_deserialize_string() {
#[derive(Deserialize)]
struct Wrapper {
#[serde(deserialize_with = "color_from_str")]
color: Color,
}
let w: Wrapper = serde_json::from_str(r##"{"color": "#ff0000"}"##).unwrap();
assert_eq!(w.color, Color::Rgb(255, 0, 0));
}
#[test]
fn test_color_deserialize_rgb_array() {
#[derive(Deserialize)]
struct Wrapper {
#[serde(deserialize_with = "color_from_str")]
color: Color,
}
let w: Wrapper = serde_json::from_str(r#"{"color": [10, 20, 30]}"#).unwrap();
assert_eq!(w.color, Color::Rgb(10, 20, 30));
}
#[test]
fn test_color_deserialize_incomplete_array() {
#[derive(Deserialize)]
struct Wrapper {
#[serde(deserialize_with = "color_from_str")]
_color: Color,
}
let result: Result<Wrapper, _> = serde_json::from_str(r#"{"_color": [10, 20]}"#);
assert!(result.is_err());
}
#[test]
fn test_colors_vec_roundtrip() {
#[derive(Serialize, Deserialize, PartialEq, Debug)]
struct Wrapper {
#[serde(
serialize_with = "colors_to_str_vec",
deserialize_with = "colors_from_str_vec"
)]
colors: Vec<Color>,
}
let original = Wrapper {
colors: vec![Color::Rgb(1, 2, 3), Color::Rgb(4, 5, 6)],
};
let json = serde_json::to_string(&original).unwrap();
let deserialized: Wrapper = serde_json::from_str(&json).unwrap();
assert_eq!(original, deserialized);
}
}