kdash 2.1.0

A fast and simple dashboard for Kubernetes
use std::{
  collections::BTreeMap,
  env,
  ffi::OsString,
  fs,
  io::ErrorKind,
  path::{Path, PathBuf},
};

use log::warn;
use regex::Regex;
use serde::Deserialize;

#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct KdashConfig {
  pub keybindings: Option<KeybindingOverrides>,
  /// Legacy `theme: { dark, light }` colour-override map. Kept for backward
  /// compatibility — the overrides tint the built-in Macchiato (dark) and
  /// Latte (light) palettes.
  pub theme: Option<ThemeConfig>,
  /// Name of the theme to start on (e.g. `gruvbox`, `solarized-dark`).
  /// Defaults to Macchiato. See [`crate::ui::theme::ThemeName`].
  pub default_theme: Option<String>,
  /// Full user-defined palette that joins the `t` / `Alt+t` theme cycle.
  pub custom_theme: Option<crate::ui::theme::CustomThemeConfig>,
  pub log_tail_lines: Option<u32>,
  pub cli_info: Option<CliInfoConfig>,
  pub hide_logo: bool,
  pub hide_info_on_start: bool,
}

#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
pub struct KeybindingOverrides {
  #[serde(flatten)]
  pub values: BTreeMap<String, String>,
}

#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
pub struct ThemeConfig {
  pub dark: Option<BTreeMap<String, String>>,
  pub light: Option<BTreeMap<String, String>>,
}

#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(default)]
pub struct CliInfoConfig {
  pub hide_missing_binaries: bool,
  pub disable_defaults: Vec<String>,
  pub custom: Vec<CliInfoEntry>,
}

impl Default for CliInfoConfig {
  fn default() -> Self {
    Self {
      hide_missing_binaries: true,
      disable_defaults: vec![],
      custom: vec![],
    }
  }
}

#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct CliInfoEntry {
  pub label: String,
  pub command: Vec<String>,
  #[serde(with = "serde_regex")]
  pub regex: Option<Regex>,
}

impl PartialEq for CliInfoEntry {
  fn eq(&self, other: &Self) -> bool {
    self.label == other.label && self.command == other.command
  }
}

#[derive(Clone, Debug, Default, PartialEq)]
pub struct LoadedConfig {
  pub config: KdashConfig,
  pub warning: Option<String>,
}

fn config_path_from(
  env_override: Option<OsString>,
  config_dir: Option<PathBuf>,
) -> Option<PathBuf> {
  env_override
    .filter(|path| !path.is_empty())
    .map(PathBuf::from)
    .or_else(|| config_dir.map(|dir| dir.join("kdash").join("config.yaml")))
}

pub fn config_path() -> Option<PathBuf> {
  config_path_from(env::var_os("KDASH_CONFIG"), dirs::config_dir())
}

fn parse_config(contents: &str, path: &Path) -> LoadedConfig {
  // Restrict boolean resolution to `true`/`false` (YAML 1.2 core schema, matching
  // the old serde_yaml). Without this, serde_saphyr resolves bare `n`/`y`/`t`/`f`
  // as booleans, breaking existing unquoted single-letter keybindings.
  let options = serde_saphyr::options! {
    strict_booleans: true
  };
  match serde_saphyr::from_str_with_options::<KdashConfig>(contents, options) {
    Ok(config) => LoadedConfig {
      config,
      warning: None,
    },
    Err(error) => LoadedConfig {
      config: KdashConfig::default(),
      warning: Some(format!(
        "Failed to parse config file {}: {}. Using defaults.",
        path.display(),
        error
      )),
    },
  }
}

pub fn load_config_from_path(path: &Path) -> LoadedConfig {
  match fs::read_to_string(path) {
    Ok(contents) => parse_config(&contents, path),
    Err(error) if error.kind() == ErrorKind::NotFound => LoadedConfig::default(),
    Err(error) => LoadedConfig {
      config: KdashConfig::default(),
      warning: Some(format!(
        "Failed to read config file {}: {}. Using defaults.",
        path.display(),
        error
      )),
    },
  }
}

pub fn load_config() -> LoadedConfig {
  let loaded = config_path()
    .map(|path| load_config_from_path(&path))
    .unwrap_or_default();

  if let Some(warning) = &loaded.warning {
    warn!("{}", warning);
  }

  loaded
}

#[cfg(test)]
mod tests {
  use super::*;
  use std::{
    fs,
    time::{SystemTime, UNIX_EPOCH},
  };

  fn temp_test_dir(name: &str) -> PathBuf {
    let suffix = SystemTime::now()
      .duration_since(UNIX_EPOCH)
      .expect("system time should be after epoch")
      .as_nanos();
    let path = env::temp_dir().join(format!(
      "kdash-config-tests-{}-{}-{}",
      name,
      std::process::id(),
      suffix
    ));
    fs::create_dir_all(&path).expect("temp test dir should be created");
    path
  }

  #[test]
  fn test_config_path_from_prefers_env_override() {
    let path = config_path_from(
      Some(OsString::from("/tmp/custom-kdash.yaml")),
      Some(PathBuf::from("/tmp/ignored")),
    );

    assert_eq!(path, Some(PathBuf::from("/tmp/custom-kdash.yaml")));
  }

  #[test]
  fn test_config_path_from_uses_xdg_path() {
    let path = config_path_from(None, Some(PathBuf::from("/tmp/config-home")));

    assert_eq!(
      path,
      Some(PathBuf::from("/tmp/config-home/kdash/config.yaml"))
    );
  }

  #[test]
  fn test_load_config_from_path_reads_valid_config() {
    let dir = temp_test_dir("valid");
    let path = dir.join("config.yaml");
    fs::write(
      &path,
      "keybindings:\n  quit: ctrl+q\nlog_tail_lines: 250\nhide_logo: true\nhide_info_on_start: true\ncli_info:\n  disable_defaults:\n    - docker\n  custom:\n    - label: istioctl\n      command: [\"istioctl\", \"version\"]\n      regex: '\\b(v?[0-9]+\\.[0-9]+\\.[0-9]+)\\b'\ntheme:\n  dark:\n    primary: green\n  light:\n    primary: blue\n",
    )
    .expect("config fixture should be written");

    let loaded = load_config_from_path(&path);

    assert_eq!(
      loaded
        .config
        .keybindings
        .as_ref()
        .map(|overrides| &overrides.values),
      Some(&BTreeMap::from([(
        "quit".to_string(),
        "ctrl+q".to_string()
      )]))
    );
    assert_eq!(
      loaded
        .config
        .theme
        .as_ref()
        .and_then(|theme| theme.dark.as_ref()),
      Some(&BTreeMap::from([(
        "primary".to_string(),
        "green".to_string()
      )]))
    );
    assert_eq!(
      loaded
        .config
        .theme
        .as_ref()
        .and_then(|theme| theme.light.as_ref()),
      Some(&BTreeMap::from([(
        "primary".to_string(),
        "blue".to_string()
      )]))
    );
    assert_eq!(loaded.config.log_tail_lines, Some(250));
    assert!(loaded.config.hide_logo);
    assert!(loaded.config.hide_info_on_start);
    assert_eq!(
      loaded.config.cli_info,
      Some(CliInfoConfig {
        hide_missing_binaries: true,
        disable_defaults: vec!["docker".to_string()],
        custom: vec![CliInfoEntry {
          label: "istioctl".to_string(),
          command: vec!["istioctl".to_string(), "version".to_string()],
          regex: None, // regex is excluded from PartialEq
        }],
      })
    );
    assert!(loaded.warning.is_none());

    fs::remove_dir_all(dir).expect("temp test dir should be removed");
  }

  #[test]
  fn test_load_config_from_path_missing_file_uses_defaults() {
    let dir = temp_test_dir("missing");
    let path = dir.join("missing.yaml");

    let loaded = load_config_from_path(&path);

    assert_eq!(loaded.config, KdashConfig::default());
    assert!(loaded.warning.is_none());

    fs::remove_dir_all(dir).expect("temp test dir should be removed");
  }

  #[test]
  fn test_load_config_from_path_malformed_yaml_uses_defaults_with_warning() {
    let dir = temp_test_dir("malformed");
    let path = dir.join("config.yaml");
    fs::write(&path, "keybindings: [").expect("config fixture should be written");

    let loaded = load_config_from_path(&path);

    assert_eq!(loaded.config, KdashConfig::default());
    assert!(loaded
      .warning
      .as_deref()
      .is_some_and(|warning| warning.contains("Failed to parse config file")));

    fs::remove_dir_all(dir).expect("temp test dir should be removed");
  }

  #[test]
  fn test_load_config_from_path_partial_config_uses_missing_defaults() {
    let dir = temp_test_dir("partial");
    let path = dir.join("config.yaml");
    fs::write(&path, "keybindings:\n  quit: ctrl+q\n").expect("config fixture should be written");

    let loaded = load_config_from_path(&path);

    assert!(loaded.config.keybindings.is_some());
    assert!(loaded.config.theme.is_none());
    assert!(loaded.config.log_tail_lines.is_none());
    assert!(loaded.config.cli_info.is_none());
    assert!(loaded.warning.is_none());

    fs::remove_dir_all(dir).expect("temp test dir should be removed");
  }

  #[test]
  fn test_cli_info_defaults_hide_missing_binaries() {
    let config: KdashConfig =
      serde_saphyr::from_str("cli_info:\n  disable_defaults:\n    - docker\n")
        .expect("config should parse");

    assert_eq!(
      config.cli_info,
      Some(CliInfoConfig {
        hide_missing_binaries: true,
        disable_defaults: vec!["docker".to_string()],
        custom: vec![],
      })
    );
  }

  #[test]
  fn test_cli_info_can_show_missing_binaries() {
    let config: KdashConfig = serde_saphyr::from_str("cli_info:\n  hide_missing_binaries: false\n")
      .expect("config should parse");

    assert_eq!(
      config.cli_info,
      Some(CliInfoConfig {
        hide_missing_binaries: false,
        disable_defaults: vec![],
        custom: vec![],
      })
    );
  }

  #[test]
  fn test_shipped_sample_config_parses_cleanly() {
    // Guards the documented sample config (and the new default_theme /
    // custom_theme keys) against schema drift.
    let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/kdash.sample-config.yaml");
    let loaded = load_config_from_path(&path);

    assert!(
      loaded.warning.is_none(),
      "sample config should parse without warning: {:?}",
      loaded.warning
    );
    assert_eq!(loaded.config.default_theme.as_deref(), Some("macchiato"));
    assert!(loaded.config.custom_theme.is_some());
    assert!(loaded.config.theme.is_some());
  }

  #[test]
  fn test_default_theme_and_custom_theme_parse() {
    let config: KdashConfig = serde_saphyr::from_str(
      "default_theme: gruvbox\ncustom_theme:\n  base: latte\n  accent: \"#FF00AA\"\n",
    )
    .expect("config should parse");

    assert_eq!(config.default_theme.as_deref(), Some("gruvbox"));
    let custom = config.custom_theme.expect("custom_theme present");
    assert_eq!(custom.accent.as_deref(), Some("#FF00AA"));
  }

  #[test]
  fn test_hide_logo_and_info_default_to_false() {
    let config: KdashConfig = serde_saphyr::from_str("").expect("empty config should parse");

    assert!(!config.hide_logo);
    assert!(!config.hide_info_on_start);
  }

  #[test]
  fn test_hide_logo_and_info_can_be_enabled() {
    let config: KdashConfig = serde_saphyr::from_str("hide_logo: true\nhide_info_on_start: true\n")
      .expect("config should parse");

    assert!(config.hide_logo);
    assert!(config.hide_info_on_start);
  }

  #[test]
  fn test_cli_info_custom_regex_defaults_to_none() {
    let config: KdashConfig =
      serde_saphyr::from_str(
        "cli_info:\n  custom:\n    - label: containerd\n      command: [\"containerd\", \"--version\"]\n",
      )
      .expect("config should parse");

    assert_eq!(
      config.cli_info,
      Some(CliInfoConfig {
        hide_missing_binaries: true,
        disable_defaults: vec![],
        custom: vec![CliInfoEntry {
          label: "containerd".to_string(),
          command: vec!["containerd".to_string(), "--version".to_string()],
          regex: None,
        }],
      })
    );
  }
}