kdash 1.1.1

A fast and simple dashboard for Kubernetes
use async_trait::async_trait;
use kube::config::{Context, Kubeconfig, NamedContext};
use ratatui::{
  layout::{Constraint, Rect},
  widgets::{Cell, Row, Table},
  Frame,
};

use super::{
  models::{AppResource, FilterableTable},
  ActiveBlock, App,
};
use crate::{
  network::Network,
  ui::{
    utils::{
      default_part, filter_cursor_position, filter_status_parts, layout_block_active_line, loading,
      mixed_bold_line, style_highlight, style_primary, style_secondary, table_header_style,
      text_matches_filter,
    },
    HIGHLIGHT,
  },
};

#[derive(Clone, Default)]
pub struct KubeContext {
  pub name: String,
  pub cluster: String,
  pub user: Option<String>,
  pub namespace: Option<String>,
  pub is_active: bool,
}

impl KubeContext {
  pub fn from_api(ctx: &NamedContext, is_active: bool) -> Self {
    let def_context = Context::default();
    let context = ctx.context.as_ref().unwrap_or(&def_context);
    KubeContext {
      name: ctx.name.clone(),
      cluster: context.cluster.clone(),
      user: context.user.clone(),
      namespace: context.namespace.clone(),
      is_active,
    }
  }
}

pub fn get_contexts(config: &Kubeconfig, selected_ctx: Option<String>) -> Vec<KubeContext> {
  config
    .contexts
    .iter()
    .map(|ctx| {
      KubeContext::from_api(
        ctx,
        is_active_context(&ctx.name, &config.current_context, selected_ctx.to_owned()),
      )
    })
    .collect::<Vec<KubeContext>>()
}

fn is_active_context(
  name: &str,
  current_ctx: &Option<String>,
  selected_ctx: Option<String>,
) -> bool {
  match selected_ctx {
    Some(ctx) => name == ctx,
    None => match current_ctx {
      Some(ctx) => name == ctx,
      None => false,
    },
  }
}

pub struct ContextResource {}

#[async_trait]
impl AppResource for ContextResource {
  fn render(_block: ActiveBlock, f: &mut Frame<'_>, app: &mut App, area: Rect) {
    let title = format!(" Contexts [{}] ", app.data.contexts.count_label());
    let mut title_parts = vec![default_part(&title)];
    title_parts.extend(filter_status_parts(
      &app.data.contexts.filter,
      app.data.contexts.filter_active,
    ));
    title_parts.push(default_part(" ".to_string()));
    let block = layout_block_active_line(
      mixed_bold_line(title_parts, app.light_theme),
      app.light_theme,
    );
    if !app.data.contexts.items.is_empty() {
      let filter = app.data.contexts.filter.to_lowercase();
      let has_filter = !filter.is_empty();
      let mut filtered_indices = Vec::new();
      let rows: Vec<_> = app
        .data
        .contexts
        .items
        .iter()
        .enumerate()
        .filter_map(|(idx, c)| {
          if !context_matches_filter(&filter, c) {
            return None;
          }

          let style = if c.is_active {
            style_secondary(app.light_theme)
          } else {
            style_primary(app.light_theme)
          };
          if has_filter {
            filtered_indices.push(idx);
          }

          Some(
            Row::new(vec![
              Cell::from(c.name.to_owned()),
              Cell::from(c.cluster.to_owned()),
              Cell::from(c.user.clone().unwrap_or("<none>".to_string())),
            ])
            .style(style),
          )
        })
        .collect();

      if has_filter {
        let max = filtered_indices.len().saturating_sub(1);
        if let Some(sel) = app.data.contexts.state.selected() {
          if sel > max {
            app.data.contexts.state.select(Some(max));
          }
        }
      }
      app.data.contexts.filtered_indices = filtered_indices;

      let table = Table::new(
        rows,
        [
          Constraint::Percentage(34),
          Constraint::Percentage(33),
          Constraint::Percentage(33),
        ],
      )
      .header(table_header_style(
        vec!["Context", "Cluster", "User"],
        app.light_theme,
      ))
      .block(block)
      .row_highlight_style(style_highlight())
      .highlight_symbol(HIGHLIGHT);

      f.render_stateful_widget(table, area, &mut app.data.contexts.state);
    } else {
      loading(f, block, area, app.is_loading(), app.light_theme);
    }

    if app.data.contexts.filter_active {
      f.set_cursor_position(filter_cursor_position(
        area,
        title.chars().count() + 1,
        &app.data.contexts.filter,
      ));
    }
  }

  async fn get_resource(_nw: &Network<'_>) {
    // not required
    unimplemented!()
  }
}

fn context_matches_filter(filter: &str, ctx: &KubeContext) -> bool {
  text_matches_filter(filter, &ctx.name)
    || text_matches_filter(filter, &ctx.cluster)
    || ctx
      .user
      .as_deref()
      .is_some_and(|user| text_matches_filter(filter, user))
    || ctx
      .namespace
      .as_deref()
      .is_some_and(|namespace| text_matches_filter(filter, namespace))
}

#[cfg(test)]
mod tests {
  use super::*;
  use kube::config::{Context, NamedContext};

  fn make_named_context(name: &str, cluster: &str, namespace: Option<&str>) -> NamedContext {
    NamedContext {
      name: name.to_string(),
      context: Some(Context {
        cluster: cluster.to_string(),
        user: Some("user".to_string()),
        namespace: namespace.map(String::from),
        ..Default::default()
      }),
    }
  }

  #[test]
  fn test_from_api_extracts_namespace() {
    let ctx = make_named_context("prod", "prod-cluster", Some("kube-system"));
    let kube_ctx = KubeContext::from_api(&ctx, true);

    assert_eq!(kube_ctx.name, "prod");
    assert_eq!(kube_ctx.cluster, "prod-cluster");
    assert_eq!(kube_ctx.namespace, Some("kube-system".to_string()));
    assert!(kube_ctx.is_active);
  }

  #[test]
  fn test_from_api_namespace_none_when_absent() {
    let ctx = make_named_context("dev", "dev-cluster", None);
    let kube_ctx = KubeContext::from_api(&ctx, false);

    assert_eq!(kube_ctx.namespace, None);
    assert!(!kube_ctx.is_active);
  }

  #[test]
  fn test_get_contexts_marks_active_from_kubeconfig() {
    let config = Kubeconfig {
      current_context: Some("ctx-b".to_string()),
      contexts: vec![
        make_named_context("ctx-a", "c1", Some("ns-a")),
        make_named_context("ctx-b", "c2", Some("ns-b")),
      ],
      ..Default::default()
    };

    let contexts = get_contexts(&config, None);
    assert_eq!(contexts.len(), 2);
    assert!(!contexts[0].is_active);
    assert!(contexts[1].is_active);
    assert_eq!(contexts[1].namespace, Some("ns-b".to_string()));
  }

  #[test]
  fn test_get_contexts_selected_overrides_current_context() {
    let config = Kubeconfig {
      current_context: Some("ctx-b".to_string()),
      contexts: vec![
        make_named_context("ctx-a", "c1", None),
        make_named_context("ctx-b", "c2", None),
      ],
      ..Default::default()
    };

    let contexts = get_contexts(&config, Some("ctx-a".to_string()));
    assert!(contexts[0].is_active);
    assert!(!contexts[1].is_active);
  }

  #[test]
  fn test_context_matches_filter() {
    let ctx = KubeContext {
      name: "prod".into(),
      cluster: "cluster-a".into(),
      user: Some("operator".into()),
      namespace: Some("kube-system".into()),
      is_active: false,
    };

    assert!(context_matches_filter("", &ctx));
    assert!(context_matches_filter("prod", &ctx));
    assert!(context_matches_filter("cluster-*", &ctx));
    assert!(context_matches_filter("operator", &ctx));
    assert!(context_matches_filter("kube-system", &ctx));
    assert!(!context_matches_filter("staging", &ctx));
  }
}