logana 0.5.1

Turn any log source — files, compressed archives, Docker, or OTel streams — into structured data. Filter by pattern, field, or date range; annotate lines; bookmark findings; and export to Markdown, Jira, or AI assistants via the built-in MCP server.
Documentation
use crate::{
    config::Keybindings,
    mode::app_mode::{Mode, ModeRenderState, status_entry},
    mode::normal_mode::NormalMode,
    theme::Theme,
    ui::{KeyResult, TabState},
};
use async_trait::async_trait;
use crossterm::event::{KeyCode, KeyModifiers};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};

#[derive(Debug, Clone)]
pub struct DockerContainer {
    pub id: String,
    pub name: String,
    pub image: String,
    pub status: String,
}

#[derive(Debug)]
pub struct DockerSelectMode {
    pub containers: Vec<DockerContainer>,
    pub selected: usize,
    pub error: Option<String>,
}

impl DockerSelectMode {
    pub fn new(containers: Vec<DockerContainer>) -> Self {
        DockerSelectMode {
            containers,
            selected: 0,
            error: None,
        }
    }

    pub fn with_error(error: String) -> Self {
        DockerSelectMode {
            containers: Vec::new(),
            selected: 0,
            error: Some(error),
        }
    }
}

#[async_trait]
impl Mode for DockerSelectMode {
    async fn handle_key(
        mut self: Box<Self>,
        tab: &mut TabState,
        key: KeyCode,
        modifiers: KeyModifiers,
    ) -> (Box<dyn Mode>, KeyResult) {
        let kb = &tab.interaction.keybindings;
        if kb.navigation.scroll_down.matches(key, modifiers) {
            if !self.containers.is_empty() {
                self.selected = (self.selected + 1).min(self.containers.len() - 1);
            }
        } else if kb.navigation.scroll_up.matches(key, modifiers) {
            self.selected = self.selected.saturating_sub(1);
        } else if kb.docker_select.confirm.matches(key, modifiers) {
            if let Some(c) = self.containers.get(self.selected) {
                let id = c.id.clone();
                let name = c.name.clone();
                return (
                    Box::new(NormalMode::default()),
                    KeyResult::DockerAttach(id, name),
                );
            }
            return (Box::new(NormalMode::default()), KeyResult::Handled);
        } else if kb.docker_select.cancel.matches(key, modifiers) {
            return (Box::new(NormalMode::default()), KeyResult::Handled);
        }
        (self, KeyResult::Ignored)
    }

    fn mode_bar_content(&self, kb: &Keybindings, theme: &Theme) -> Line<'static> {
        let mut spans: Vec<Span<'static>> = vec![Span::styled(
            "[DOCKER]  ",
            Style::default()
                .fg(theme.text_highlight_fg)
                .add_modifier(Modifier::BOLD),
        )];
        // Navigate up/down
        spans.push(Span::styled("<", Style::default().fg(theme.text)));
        spans.push(Span::styled(
            kb.navigation.scroll_up.display(),
            Style::default()
                .fg(theme.text_highlight_fg)
                .add_modifier(Modifier::BOLD),
        ));
        spans.push(Span::styled("/", Style::default().fg(theme.text)));
        spans.push(Span::styled(
            kb.navigation.scroll_down.display(),
            Style::default()
                .fg(theme.text_highlight_fg)
                .add_modifier(Modifier::BOLD),
        ));
        spans.push(Span::styled(
            "> navigate  ",
            Style::default().fg(theme.text),
        ));
        status_entry(
            &mut spans,
            kb.docker_select.confirm.display(),
            "attach",
            theme,
        );
        status_entry(
            &mut spans,
            kb.docker_select.cancel.display(),
            "cancel",
            theme,
        );
        Line::from(spans)
    }

    fn render_state(&self) -> ModeRenderState {
        ModeRenderState::DockerSelect {
            containers: self.containers.clone(),
            selected: self.selected,
            error: self.error.clone(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::db::Database;
    use crate::db::LogManager;
    use crate::ingestion::FileReader;
    use crate::mode::app_mode::ModeRenderState;
    use std::sync::Arc;

    async fn make_tab() -> TabState {
        let file_reader = FileReader::from_bytes(b"line1\nline2\n".to_vec());
        let db = Arc::new(Database::in_memory().await.unwrap());
        let log_manager = LogManager::new(db, None).await;
        TabState::new(file_reader, log_manager, "test".to_string())
    }

    fn sample_containers() -> Vec<DockerContainer> {
        vec![
            DockerContainer {
                id: "abc123".to_string(),
                name: "web-app".to_string(),
                image: "nginx:latest".to_string(),
                status: "Up 2 hours".to_string(),
            },
            DockerContainer {
                id: "def456".to_string(),
                name: "db-server".to_string(),
                image: "postgres:15".to_string(),
                status: "Up 3 hours".to_string(),
            },
            DockerContainer {
                id: "ghi789".to_string(),
                name: "cache".to_string(),
                image: "redis:7".to_string(),
                status: "Up 1 hour".to_string(),
            },
        ]
    }

    async fn press(
        mode: DockerSelectMode,
        tab: &mut TabState,
        code: KeyCode,
    ) -> (Box<dyn Mode>, KeyResult) {
        Box::new(mode)
            .handle_key(tab, code, KeyModifiers::NONE)
            .await
    }

    #[tokio::test]
    async fn test_j_moves_cursor_down() {
        let mut tab = make_tab().await;
        let mode = DockerSelectMode::new(sample_containers());
        let (mode2, _) = press(mode, &mut tab, KeyCode::Char('j')).await;
        match mode2.render_state() {
            ModeRenderState::DockerSelect { selected, .. } => assert_eq!(selected, 1),
            other => panic!("expected DockerSelect, got {:?}", other),
        }
    }

    #[tokio::test]
    async fn test_k_moves_cursor_up() {
        let mut tab = make_tab().await;
        let mut mode = DockerSelectMode::new(sample_containers());
        mode.selected = 2;
        let (mode2, _) = press(mode, &mut tab, KeyCode::Char('k')).await;
        match mode2.render_state() {
            ModeRenderState::DockerSelect { selected, .. } => assert_eq!(selected, 1),
            other => panic!("expected DockerSelect, got {:?}", other),
        }
    }

    #[tokio::test]
    async fn test_k_at_zero_stays() {
        let mut tab = make_tab().await;
        let mode = DockerSelectMode::new(sample_containers());
        let (mode2, _) = press(mode, &mut tab, KeyCode::Char('k')).await;
        match mode2.render_state() {
            ModeRenderState::DockerSelect { selected, .. } => assert_eq!(selected, 0),
            other => panic!("expected DockerSelect, got {:?}", other),
        }
    }

    #[tokio::test]
    async fn test_j_at_end_stays() {
        let mut tab = make_tab().await;
        let mut mode = DockerSelectMode::new(sample_containers());
        mode.selected = 2;
        let (mode2, _) = press(mode, &mut tab, KeyCode::Char('j')).await;
        match mode2.render_state() {
            ModeRenderState::DockerSelect { selected, .. } => assert_eq!(selected, 2),
            other => panic!("expected DockerSelect, got {:?}", other),
        }
    }

    #[tokio::test]
    async fn test_down_arrow_moves_cursor() {
        let mut tab = make_tab().await;
        let mode = DockerSelectMode::new(sample_containers());
        let (mode2, _) = press(mode, &mut tab, KeyCode::Down).await;
        match mode2.render_state() {
            ModeRenderState::DockerSelect { selected, .. } => assert_eq!(selected, 1),
            other => panic!("expected DockerSelect, got {:?}", other),
        }
    }

    #[tokio::test]
    async fn test_up_arrow_moves_cursor() {
        let mut tab = make_tab().await;
        let mut mode = DockerSelectMode::new(sample_containers());
        mode.selected = 2;
        let (mode2, _) = press(mode, &mut tab, KeyCode::Up).await;
        match mode2.render_state() {
            ModeRenderState::DockerSelect { selected, .. } => assert_eq!(selected, 1),
            other => panic!("expected DockerSelect, got {:?}", other),
        }
    }

    #[tokio::test]
    async fn test_enter_returns_docker_attach() {
        let mut tab = make_tab().await;
        let mode = DockerSelectMode::new(sample_containers());
        let (mode2, result) = press(mode, &mut tab, KeyCode::Enter).await;
        assert!(matches!(
            result,
            KeyResult::DockerAttach(ref id, ref name) if id == "abc123" && name == "web-app"
        ));
        assert!(!matches!(
            mode2.render_state(),
            ModeRenderState::DockerSelect { .. }
        )); // NormalMode
    }

    #[tokio::test]
    async fn test_enter_with_selection() {
        let mut tab = make_tab().await;
        let mut mode = DockerSelectMode::new(sample_containers());
        mode.selected = 1;
        let (_, result) = press(mode, &mut tab, KeyCode::Enter).await;
        assert!(matches!(
            result,
            KeyResult::DockerAttach(ref id, ref name) if id == "def456" && name == "db-server"
        ));
    }

    #[tokio::test]
    async fn test_enter_empty_list() {
        let mut tab = make_tab().await;
        let mode = DockerSelectMode::new(vec![]);
        let (mode2, result) = press(mode, &mut tab, KeyCode::Enter).await;
        assert!(matches!(result, KeyResult::Handled));
        assert!(!matches!(
            mode2.render_state(),
            ModeRenderState::DockerSelect { .. }
        ));
    }

    #[tokio::test]
    async fn test_esc_cancels() {
        let mut tab = make_tab().await;
        let mode = DockerSelectMode::new(sample_containers());
        let (mode2, result) = press(mode, &mut tab, KeyCode::Esc).await;
        assert!(matches!(result, KeyResult::Handled));
        assert!(!matches!(
            mode2.render_state(),
            ModeRenderState::DockerSelect { .. }
        ));
    }

    #[tokio::test]
    async fn test_mode_bar_content() {
        let mode = DockerSelectMode::new(sample_containers());
        assert!(matches!(
            mode.render_state(),
            ModeRenderState::DockerSelect { .. }
        ));
    }

    #[tokio::test]
    async fn test_error_mode_shows_error() {
        let mode = DockerSelectMode::with_error("Docker not found".to_string());
        match mode.render_state() {
            ModeRenderState::DockerSelect { error, .. } => {
                assert_eq!(error.as_deref(), Some("Docker not found"));
            }
            other => panic!("expected DockerSelect, got {:?}", other),
        }
    }

    #[tokio::test]
    async fn test_error_mode_esc_cancels() {
        let mut tab = make_tab().await;
        let mode = DockerSelectMode::with_error("Docker not found".to_string());
        let (mode2, result) = press(mode, &mut tab, KeyCode::Esc).await;
        assert!(matches!(result, KeyResult::Handled));
        assert!(!matches!(
            mode2.render_state(),
            ModeRenderState::DockerSelect { .. }
        ));
    }

    #[tokio::test]
    async fn test_j_on_empty_list() {
        let mut tab = make_tab().await;
        let mode = DockerSelectMode::new(vec![]);
        let (mode2, _) = press(mode, &mut tab, KeyCode::Char('j')).await;
        match mode2.render_state() {
            ModeRenderState::DockerSelect {
                containers,
                selected,
                ..
            } => {
                assert!(containers.is_empty());
                assert_eq!(selected, 0);
            }
            other => panic!("expected DockerSelect, got {:?}", other),
        }
    }

    #[tokio::test]
    async fn test_unrecognized_key_returns_ignored() {
        let mut tab = make_tab().await;
        let mode = DockerSelectMode::new(sample_containers());
        let (_, result) = press(mode, &mut tab, KeyCode::F(2)).await;
        assert!(matches!(result, KeyResult::Ignored));
    }
}