rusticity-term 0.1.10

Terminal UI components for Rusticity
use crate::app::App;
use crate::common::{
    filter_by_field, filter_by_fields, render_pagination_text, CyclicEnum, InputFocus,
    SortDirection,
};
use crate::ecr::image::{self, Image as EcrImage};
use crate::ecr::repo::{self, Repository as EcrRepository};
use crate::keymap::Mode;
use crate::table::{TableState, TableWindow};
use crate::ui::filter::{render_simple_filter, SimpleFilterConfig};
use crate::ui::table::{expanded_from_columns, render_table, Column, TableConfig};
use crate::ui::{format_title, render_tabs};
use ratatui::{prelude::*, widgets::*};

pub const FILTER_CONTROLS: [InputFocus; 2] = [InputFocus::Filter, InputFocus::Pagination];

pub struct State {
    pub repositories: TableState<EcrRepository>,
    pub tab: Tab,
    pub current_repository: Option<String>,
    pub current_repository_uri: Option<String>,
    pub images: TableState<EcrImage>,
    pub input_focus: InputFocus,
    pub scan: crate::ecr::scan::State,
}

impl Default for State {
    fn default() -> Self {
        Self::new()
    }
}

impl State {
    pub fn new() -> Self {
        Self {
            repositories: TableState::new(),
            tab: Tab::Private,
            current_repository: None,
            current_repository_uri: None,
            images: TableState::new(),
            input_focus: InputFocus::Filter,
            scan: crate::ecr::scan::State::default(),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Tab {
    Private,
    Public,
}

impl CyclicEnum for Tab {
    const ALL: &'static [Self] = &[Self::Private, Self::Public];
}

impl Tab {
    pub fn name(&self) -> &'static str {
        match self {
            Tab::Private => "Private",
            Tab::Public => "Public",
        }
    }
}

pub fn filtered_ecr_repositories(app: &App) -> Vec<&EcrRepository> {
    filter_by_field(
        &app.ecr_state.repositories.items,
        &app.ecr_state.repositories.filter,
        |r| &r.name,
    )
}

pub fn filtered_ecr_images(app: &App) -> Vec<&EcrImage> {
    filter_by_fields(
        &app.ecr_state.images.items,
        &app.ecr_state.images.filter,
        |img| [&img.tag, &img.digest],
    )
}

pub fn render_repositories(frame: &mut Frame, app: &App, area: Rect) {
    frame.render_widget(Clear, area);

    if app.ecr_state.current_repository.is_some() {
        if !crate::ecr::scan::render(frame, app, area) {
            render_images(frame, app, area);
        }
    } else {
        render_repository_list(frame, app, area);
    }
}

pub fn render_repository_list(frame: &mut Frame, app: &App, area: Rect) {
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(1), // Tabs
            Constraint::Length(3), // Filter
            Constraint::Min(0),    // Table
        ])
        .split(area);

    let tabs: Vec<(&str, Tab)> = Tab::ALL.iter().map(|tab| (tab.name(), *tab)).collect();
    render_tabs(frame, chunks[0], &tabs, &app.ecr_state.tab);

    let view = app
        .ecr_state
        .repositories
        .prepare_view(filtered_ecr_repositories(app), TableWindow::SelectedPage);
    let pagination = render_pagination_text(view.current_page, view.total_pages);

    render_simple_filter(
        frame,
        chunks[1],
        SimpleFilterConfig {
            filter_text: &app.ecr_state.repositories.filter,
            placeholder: "Search by repository substring",
            pagination: &pagination,
            mode: app.mode,
            is_input_focused: app.ecr_state.input_focus == InputFocus::Filter,
            is_pagination_focused: app.ecr_state.input_focus == InputFocus::Pagination,
        },
    );

    let tab_label = match app.ecr_state.tab {
        Tab::Private => "Private",
        Tab::Public => "Public",
    };
    let title = format_title(&format!(
        "{} repositories ({})",
        tab_label, view.total_items
    ));

    let columns: Vec<Box<dyn Column<EcrRepository>>> = app
        .ecr_repo_visible_column_ids
        .iter()
        .filter_map(|col_id| {
            repo::Column::from_id(col_id).map(|col| Box::new(col) as Box<dyn Column<EcrRepository>>)
        })
        .collect();

    let config = TableConfig {
        items: view.items,
        selected_index: view.selected_index,
        expanded_index: view.expanded_index,
        columns: &columns,
        sort_column: "Repository name",
        sort_direction: SortDirection::Asc,
        title,
        area: chunks[2],
        get_expanded_content: Some(Box::new(|repo: &EcrRepository| {
            expanded_from_columns(&columns, repo)
        })),
        is_active: app.mode != Mode::FilterInput,
    };

    render_table(frame, config);
}

pub fn render_images(frame: &mut Frame, app: &App, area: Rect) {
    let chunks = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).split(area);
    let view = app
        .ecr_state
        .images
        .prepare_view(filtered_ecr_images(app), TableWindow::SelectedPage);
    let pagination = render_pagination_text(view.current_page, view.total_pages);
    render_simple_filter(
        frame,
        chunks[0],
        SimpleFilterConfig {
            filter_text: &app.ecr_state.images.filter,
            placeholder: "Search artifacts",
            pagination: &pagination,
            mode: app.mode,
            is_input_focused: true,
            is_pagination_focused: false,
        },
    );
    let columns: Vec<Box<dyn Column<EcrImage>>> = app
        .ecr_image_visible_column_ids
        .iter()
        .filter_map(|id| {
            image::Column::from_id(id).map(|col| Box::new(col) as Box<dyn Column<EcrImage>>)
        })
        .collect();
    render_table(
        frame,
        TableConfig {
            items: view.items,
            selected_index: view.selected_index,
            expanded_index: view.expanded_index,
            columns: &columns,
            sort_column: "Pushed at",
            sort_direction: SortDirection::Desc,
            title: format_title(&format!("Images ({})", view.total_items)),
            area: chunks[1],
            get_expanded_content: Some(Box::new(|image| expanded_from_columns(&columns, image))),
            is_active: app.mode != Mode::FilterInput,
        },
    );
}

#[cfg(test)]
mod prepared_image_tests {
    use super::*;
    use crate::common::PageSize;
    use ratatui::{backend::TestBackend, Terminal};

    #[test]
    fn images_use_their_own_page_size_and_safe_relative_selection() {
        let mut app = App::new_without_client("test".into(), None);
        app.ecr_state.repositories.page_size = PageSize::OneHundred;
        app.ecr_state.images.page_size = PageSize::Ten;
        app.ecr_state.images.items = (0..25)
            .map(|i| EcrImage {
                tag: format!("tag{i:02}"),
                digest: format!("digest{i:02}"),
                artifact_type: "Image".into(),
                pushed_at: String::new(),
                size_bytes: 0,
                uri: String::new(),
                last_pull_time: String::new(),
            })
            .collect();
        app.ecr_state.images.selected = 12;
        app.ecr_state.images.expanded_item = Some(11);
        app.ecr_state.images.scroll_offset = 200; // Legacy scroll offsets must not underflow.
        let mut terminal = Terminal::new(TestBackend::new(240, 40)).unwrap();
        terminal.draw(|f| render_images(f, &app, f.area())).unwrap();
        let text: String = terminal
            .backend()
            .buffer()
            .content()
            .iter()
            .map(|c| c.symbol())
            .collect();
        assert!(text.contains("1 [2] 3"));
        assert!(text.contains("► tag12"));
        assert!(text.contains("▼ tag11"));
        assert!(!text.contains("tag09"));
        assert!(!text.contains("tag20"));
        app.ecr_state.images.filter = "no match".into();
        terminal.draw(|f| render_images(f, &app, f.area())).unwrap();
    }
}

#[cfg(test)]
mod image_filter_tests {
    use super::*;

    fn image(tag: &str, digest: &str) -> EcrImage {
        EcrImage {
            tag: tag.to_string(),
            digest: digest.to_string(),
            artifact_type: "Image".into(),
            pushed_at: String::new(),
            size_bytes: 0,
            uri: String::new(),
            last_pull_time: String::new(),
        }
    }

    fn app_with(images: Vec<EcrImage>, filter: &str) -> App {
        let mut app = App::new_without_client("test".into(), None);
        app.ecr_state.images.items = images;
        app.ecr_state.images.filter = filter.to_string();
        app
    }

    #[test]
    fn matches_tag_or_digest_case_insensitively_and_preserves_order() {
        let app = app_with(
            vec![
                image("Latest", "sha256:AAA"),
                image("stable", "sha256:bbb"),
                image("dev", "sha256:ccc"),
            ],
            "SHA256:BBB",
        );
        let filtered = filtered_ecr_images(&app);
        assert_eq!(filtered.len(), 1);
        assert_eq!(filtered[0].tag, "stable");

        // Only tag/digest are searched — no new fields leaked in.
        let app = app_with(vec![image("v1", "sha256:ddd")], "LATEST");
        assert!(filtered_ecr_images(&app).is_empty());
    }

    #[test]
    fn empty_filter_returns_all_in_source_order() {
        let app = app_with(vec![image("b", "d2"), image("a", "d1")], "");
        let filtered = filtered_ecr_images(&app);
        assert_eq!(filtered.len(), 2);
        assert_eq!(filtered[0].tag, "b");
        assert_eq!(filtered[1].tag, "a");
    }

    #[test]
    fn unicode_case_insensitive_and_whitespace_is_significant() {
        let app = app_with(vec![image("café-build", "sha256:x")], "CAFÉ");
        assert_eq!(filtered_ecr_images(&app).len(), 1);
        // Whitespace is not trimmed — a padded query must not match a tight tag.
        let app = app_with(vec![image("cafe", "sha256:x")], " cafe ");
        assert!(filtered_ecr_images(&app).is_empty());
    }
}