jtool-grep 0.3.0

notebook-specific grep tool for jtool
Documentation
//! Human-readable output formatter

use super::{OutputFormat, OutputFormatter, OutputOptions};
use crate::types::{GrepResult, Match};
use anyhow::Result;
use colored::*;

/// Context for formatting a single line
struct LineContext<'a> {
    notebook: Option<&'a str>,
    cell_index: usize,
    exec_str: &'a str,
    match_type: &'a str,
    line_num: usize,
    line_content: &'a str,
    marker: &'a str,
}

/// Human-readable output formatter
pub struct HumanFormatter {
    options: OutputOptions,
    use_color: bool,
}

impl HumanFormatter {
    pub fn new(options: OutputOptions) -> Self {
        let use_color = options.color_mode.should_use_color();

        // Set colored crate override based on color mode
        match options.color_mode {
            super::ColorMode::Always => {
                colored::control::set_override(true);
            }
            super::ColorMode::Never => {
                colored::control::set_override(false);
            }
            super::ColorMode::Auto => {
                // Let colored crate auto-detect
            }
        }

        Self { options, use_color }
    }

    /// Highlight the matched text in a line
    fn highlight_match(&self, line: &str, matched_text: &str) -> String {
        if !self.use_color || matched_text.is_empty() {
            return line.to_string();
        }

        // Find and highlight the matched text
        if let Some(pos) = line.find(matched_text) {
            let before = &line[..pos];
            let matched = &line[pos..pos + matched_text.len()];
            let after = &line[pos + matched_text.len()..];
            format!("{}{}{}", before, matched.red().bold(), after)
        } else {
            line.to_string()
        }
    }
}

impl OutputFormatter for HumanFormatter {
    fn format_result(&self, result: &GrepResult) -> Result<String> {
        let mut output = String::new();

        if self.options.count_mode {
            // Show count of matches
            output.push_str(&format!("{}:{}\n", result.notebook, result.matches.len()));
        } else if self.options.files_with_matches || self.options.format == OutputFormat::PathsOnly
        {
            // Just show notebook name if there are matches
            if !result.matches.is_empty() {
                output.push_str(&result.notebook);
                output.push('\n');
            }
        } else if self.options.format == OutputFormat::MatchesOnly {
            // Only show matched text, no metadata
            for m in &result.matches {
                let text = if self.use_color {
                    m.matched_text.red().bold().to_string()
                } else {
                    m.matched_text.clone()
                };
                output.push_str(&text);
                output.push('\n');
            }
        } else if self.options.heading_mode || self.options.format == OutputFormat::Grouped {
            // Heading/Grouped mode: show notebook name once, then all matches
            if !result.matches.is_empty() {
                output.push_str(&result.notebook);
                output.push('\n');

                if self.options.format == OutputFormat::Grouped {
                    // Add separator line
                    let sep_len = result.notebook.len().min(80);
                    output.push_str(&"─".repeat(sep_len));
                    output.push('\n');
                }

                for m in &result.matches {
                    output.push_str(&self.format_match_no_filename(m));
                }
                output.push('\n');
            }
        } else {
            // Standard/Compact modes: show matches with optional filenames
            for m in &result.matches {
                if self.options.show_filename {
                    output.push_str(&self.format_match(&result.notebook, m));
                } else {
                    output.push_str(&self.format_match_no_filename(m));
                }
            }
        }

        Ok(output)
    }

    fn format_results(&self, results: &[GrepResult]) -> Result<String> {
        let mut output = String::new();

        for result in results {
            output.push_str(&self.format_result(result)?);
        }

        Ok(output)
    }
}

impl HumanFormatter {
    /// Format a single match with notebook name
    fn format_match(&self, notebook: &str, m: &Match) -> String {
        let mut output = String::new();

        let exec_str = if let Some(count) = m.execution_count {
            format!("[{count}]")
        } else {
            String::new()
        };

        // Apply color to the matched line
        let highlighted_line = self.highlight_match(m.line_content.trim(), &m.matched_text);

        // Check if we have any context to show
        let has_context = !m.context_before.is_empty() || !m.context_after.is_empty();

        if has_context {
            // Show context before
            for (i, line) in m.context_before.iter().enumerate() {
                let line_num = m.line_index.saturating_sub(m.context_before.len() - i);
                let ctx = LineContext {
                    notebook: Some(notebook),
                    cell_index: m.cell_index,
                    exec_str: &exec_str,
                    match_type: &m.match_type.to_string(),
                    line_num,
                    line_content: line.trim(),
                    marker: "-",
                };
                output.push_str(&self.format_context_line(&ctx));
            }

            // Show the matching line with marker
            let ctx = LineContext {
                notebook: Some(notebook),
                cell_index: m.cell_index,
                exec_str: &exec_str,
                match_type: &m.match_type.to_string(),
                line_num: m.line_index,
                line_content: &highlighted_line,
                marker: ">",
            };
            output.push_str(&self.format_main_line(&ctx));

            // Show context after
            for (i, line) in m.context_after.iter().enumerate() {
                let line_num = m.line_index + i + 1;
                let ctx = LineContext {
                    notebook: Some(notebook),
                    cell_index: m.cell_index,
                    exec_str: &exec_str,
                    match_type: &m.match_type.to_string(),
                    line_num,
                    line_content: line.trim(),
                    marker: "-",
                };
                output.push_str(&self.format_context_line(&ctx));
            }

            output.push_str("--\n");
        } else {
            // No context, just show the matching line
            let ctx = LineContext {
                notebook: Some(notebook),
                cell_index: m.cell_index,
                exec_str: &exec_str,
                match_type: &m.match_type.to_string(),
                line_num: m.line_index,
                line_content: &highlighted_line,
                marker: "",
            };
            output.push_str(&self.format_main_line(&ctx));
        }

        output
    }

    /// Format a match without the notebook filename
    fn format_match_no_filename(&self, m: &Match) -> String {
        let mut output = String::new();

        let exec_str = if let Some(count) = m.execution_count {
            format!("[{count}]")
        } else {
            String::new()
        };

        // Apply color to the matched line
        let highlighted_line = self.highlight_match(m.line_content.trim(), &m.matched_text);

        // Check if we have any context to show
        let has_context = !m.context_before.is_empty() || !m.context_after.is_empty();

        if has_context {
            // Show context before
            for (i, line) in m.context_before.iter().enumerate() {
                let line_num = m.line_index.saturating_sub(m.context_before.len() - i);
                let ctx = LineContext {
                    notebook: None,
                    cell_index: m.cell_index,
                    exec_str: &exec_str,
                    match_type: &m.match_type.to_string(),
                    line_num,
                    line_content: line.trim(),
                    marker: "-",
                };
                output.push_str(&self.format_context_line(&ctx));
            }

            // Show the matching line with marker
            let ctx = LineContext {
                notebook: None,
                cell_index: m.cell_index,
                exec_str: &exec_str,
                match_type: &m.match_type.to_string(),
                line_num: m.line_index,
                line_content: &highlighted_line,
                marker: ">",
            };
            output.push_str(&self.format_main_line(&ctx));

            // Show context after
            for (i, line) in m.context_after.iter().enumerate() {
                let line_num = m.line_index + i + 1;
                let ctx = LineContext {
                    notebook: None,
                    cell_index: m.cell_index,
                    exec_str: &exec_str,
                    match_type: &m.match_type.to_string(),
                    line_num,
                    line_content: line.trim(),
                    marker: "-",
                };
                output.push_str(&self.format_context_line(&ctx));
            }

            output.push_str("    --\n");
        } else {
            // No context, just show the matching line
            let ctx = LineContext {
                notebook: None,
                cell_index: m.cell_index,
                exec_str: &exec_str,
                match_type: &m.match_type.to_string(),
                line_num: m.line_index,
                line_content: &highlighted_line,
                marker: "",
            };
            output.push_str(&self.format_main_line(&ctx));
        }

        output
    }

    /// Format a context line
    fn format_context_line(&self, ctx: &LineContext) -> String {
        self.format_line_impl(ctx)
    }

    /// Format the main matching line
    fn format_main_line(&self, ctx: &LineContext) -> String {
        self.format_line_impl(ctx)
    }

    /// Format a line based on the selected format preset
    fn format_line_impl(&self, ctx: &LineContext) -> String {
        let indent = if ctx.notebook.is_none() { "  " } else { "" };
        let marker_str = if ctx.marker.is_empty() {
            String::new()
        } else {
            format!("{} ", ctx.marker)
        };

        match self.options.format {
            OutputFormat::Standard => {
                // Standard format: notebook:cellN[exec]:type:line: content
                let nb_prefix = if let Some(nb) = ctx.notebook {
                    format!("{nb}:")
                } else {
                    String::new()
                };

                if self.options.no_line_number {
                    format!(
                        "{marker_str}{indent}{nb_prefix}cell{}{}{}: {}\n",
                        ctx.cell_index, ctx.exec_str, ctx.match_type, ctx.line_content
                    )
                } else {
                    format!(
                        "{marker_str}{indent}{nb_prefix}cell{}{}:{}:{}: {}\n",
                        ctx.cell_index,
                        ctx.exec_str,
                        ctx.match_type,
                        ctx.line_num,
                        ctx.line_content
                    )
                }
            }
            OutputFormat::Compact => {
                // Compact format: notebook:cellN:lineN: content
                let nb_prefix = if let Some(nb) = ctx.notebook {
                    format!("{nb}:")
                } else {
                    String::new()
                };

                format!(
                    "{}{}{}cell{}:line{}: {}\n",
                    marker_str,
                    indent,
                    nb_prefix,
                    ctx.cell_index + 1,
                    ctx.line_num + 1,
                    ctx.line_content
                )
            }
            OutputFormat::CompactNoCell => {
                // Ultra-compact: notebook:lineN: content (no cell info)
                let nb_prefix = if let Some(nb) = ctx.notebook {
                    format!("{nb}:")
                } else {
                    String::new()
                };

                format!(
                    "{}{}{}{}: {}\n",
                    marker_str,
                    indent,
                    nb_prefix,
                    ctx.line_num + 1,
                    ctx.line_content
                )
            }
            _ => {
                // For other formats (Grouped, PathsOnly, MatchesOnly), fall back to standard
                let nb_prefix = if let Some(nb) = ctx.notebook {
                    format!("{nb}:")
                } else {
                    String::new()
                };

                if self.options.no_line_number {
                    format!(
                        "{marker_str}{indent}{nb_prefix}cell{}{}{}: {}\n",
                        ctx.cell_index, ctx.exec_str, ctx.match_type, ctx.line_content
                    )
                } else {
                    format!(
                        "{marker_str}{indent}{nb_prefix}cell{}{}:{}:{}: {}\n",
                        ctx.cell_index,
                        ctx.exec_str,
                        ctx.match_type,
                        ctx.line_num,
                        ctx.line_content
                    )
                }
            }
        }
    }
}