Skip to main content

jtool_grep/output/
human.rs

1//! Human-readable output formatter
2
3use super::{OutputFormat, OutputFormatter, OutputOptions};
4use crate::types::{GrepResult, Match};
5use anyhow::Result;
6use colored::*;
7
8/// Context for formatting a single line
9struct LineContext<'a> {
10    notebook: Option<&'a str>,
11    cell_index: usize,
12    exec_str: &'a str,
13    match_type: &'a str,
14    line_num: usize,
15    line_content: &'a str,
16    marker: &'a str,
17}
18
19/// Human-readable output formatter
20pub struct HumanFormatter {
21    options: OutputOptions,
22    use_color: bool,
23}
24
25impl HumanFormatter {
26    pub fn new(options: OutputOptions) -> Self {
27        let use_color = options.color_mode.should_use_color();
28
29        // Set colored crate override based on color mode
30        match options.color_mode {
31            super::ColorMode::Always => {
32                colored::control::set_override(true);
33            }
34            super::ColorMode::Never => {
35                colored::control::set_override(false);
36            }
37            super::ColorMode::Auto => {
38                // Let colored crate auto-detect
39            }
40        }
41
42        Self { options, use_color }
43    }
44
45    /// Highlight the matched text in a line
46    fn highlight_match(&self, line: &str, matched_text: &str) -> String {
47        if !self.use_color || matched_text.is_empty() {
48            return line.to_string();
49        }
50
51        // Find and highlight the matched text
52        if let Some(pos) = line.find(matched_text) {
53            let before = &line[..pos];
54            let matched = &line[pos..pos + matched_text.len()];
55            let after = &line[pos + matched_text.len()..];
56            format!("{}{}{}", before, matched.red().bold(), after)
57        } else {
58            line.to_string()
59        }
60    }
61}
62
63impl OutputFormatter for HumanFormatter {
64    fn format_result(&self, result: &GrepResult) -> Result<String> {
65        let mut output = String::new();
66
67        if self.options.count_mode {
68            // Show count of matches
69            output.push_str(&format!("{}:{}\n", result.notebook, result.matches.len()));
70        } else if self.options.files_with_matches || self.options.format == OutputFormat::PathsOnly
71        {
72            // Just show notebook name if there are matches
73            if !result.matches.is_empty() {
74                output.push_str(&result.notebook);
75                output.push('\n');
76            }
77        } else if self.options.format == OutputFormat::MatchesOnly {
78            // Only show matched text, no metadata
79            for m in &result.matches {
80                let text = if self.use_color {
81                    m.matched_text.red().bold().to_string()
82                } else {
83                    m.matched_text.clone()
84                };
85                output.push_str(&text);
86                output.push('\n');
87            }
88        } else if self.options.heading_mode || self.options.format == OutputFormat::Grouped {
89            // Heading/Grouped mode: show notebook name once, then all matches
90            if !result.matches.is_empty() {
91                output.push_str(&result.notebook);
92                output.push('\n');
93
94                if self.options.format == OutputFormat::Grouped {
95                    // Add separator line
96                    let sep_len = result.notebook.len().min(80);
97                    output.push_str(&"─".repeat(sep_len));
98                    output.push('\n');
99                }
100
101                for m in &result.matches {
102                    output.push_str(&self.format_match_no_filename(m));
103                }
104                output.push('\n');
105            }
106        } else {
107            // Standard/Compact modes: show matches with optional filenames
108            for m in &result.matches {
109                if self.options.show_filename {
110                    output.push_str(&self.format_match(&result.notebook, m));
111                } else {
112                    output.push_str(&self.format_match_no_filename(m));
113                }
114            }
115        }
116
117        Ok(output)
118    }
119
120    fn format_results(&self, results: &[GrepResult]) -> Result<String> {
121        let mut output = String::new();
122
123        for result in results {
124            output.push_str(&self.format_result(result)?);
125        }
126
127        Ok(output)
128    }
129}
130
131impl HumanFormatter {
132    /// Format a single match with notebook name
133    fn format_match(&self, notebook: &str, m: &Match) -> String {
134        let mut output = String::new();
135
136        let exec_str = if let Some(count) = m.execution_count {
137            format!("[{count}]")
138        } else {
139            String::new()
140        };
141
142        // Apply color to the matched line
143        let highlighted_line = self.highlight_match(m.line_content.trim(), &m.matched_text);
144
145        // Check if we have any context to show
146        let has_context = !m.context_before.is_empty() || !m.context_after.is_empty();
147
148        if has_context {
149            // Show context before
150            for (i, line) in m.context_before.iter().enumerate() {
151                let line_num = m.line_index.saturating_sub(m.context_before.len() - i);
152                let ctx = LineContext {
153                    notebook: Some(notebook),
154                    cell_index: m.cell_index,
155                    exec_str: &exec_str,
156                    match_type: &m.match_type.to_string(),
157                    line_num,
158                    line_content: line.trim(),
159                    marker: "-",
160                };
161                output.push_str(&self.format_context_line(&ctx));
162            }
163
164            // Show the matching line with marker
165            let ctx = LineContext {
166                notebook: Some(notebook),
167                cell_index: m.cell_index,
168                exec_str: &exec_str,
169                match_type: &m.match_type.to_string(),
170                line_num: m.line_index,
171                line_content: &highlighted_line,
172                marker: ">",
173            };
174            output.push_str(&self.format_main_line(&ctx));
175
176            // Show context after
177            for (i, line) in m.context_after.iter().enumerate() {
178                let line_num = m.line_index + i + 1;
179                let ctx = LineContext {
180                    notebook: Some(notebook),
181                    cell_index: m.cell_index,
182                    exec_str: &exec_str,
183                    match_type: &m.match_type.to_string(),
184                    line_num,
185                    line_content: line.trim(),
186                    marker: "-",
187                };
188                output.push_str(&self.format_context_line(&ctx));
189            }
190
191            output.push_str("--\n");
192        } else {
193            // No context, just show the matching line
194            let ctx = LineContext {
195                notebook: Some(notebook),
196                cell_index: m.cell_index,
197                exec_str: &exec_str,
198                match_type: &m.match_type.to_string(),
199                line_num: m.line_index,
200                line_content: &highlighted_line,
201                marker: "",
202            };
203            output.push_str(&self.format_main_line(&ctx));
204        }
205
206        output
207    }
208
209    /// Format a match without the notebook filename
210    fn format_match_no_filename(&self, m: &Match) -> String {
211        let mut output = String::new();
212
213        let exec_str = if let Some(count) = m.execution_count {
214            format!("[{count}]")
215        } else {
216            String::new()
217        };
218
219        // Apply color to the matched line
220        let highlighted_line = self.highlight_match(m.line_content.trim(), &m.matched_text);
221
222        // Check if we have any context to show
223        let has_context = !m.context_before.is_empty() || !m.context_after.is_empty();
224
225        if has_context {
226            // Show context before
227            for (i, line) in m.context_before.iter().enumerate() {
228                let line_num = m.line_index.saturating_sub(m.context_before.len() - i);
229                let ctx = LineContext {
230                    notebook: None,
231                    cell_index: m.cell_index,
232                    exec_str: &exec_str,
233                    match_type: &m.match_type.to_string(),
234                    line_num,
235                    line_content: line.trim(),
236                    marker: "-",
237                };
238                output.push_str(&self.format_context_line(&ctx));
239            }
240
241            // Show the matching line with marker
242            let ctx = LineContext {
243                notebook: None,
244                cell_index: m.cell_index,
245                exec_str: &exec_str,
246                match_type: &m.match_type.to_string(),
247                line_num: m.line_index,
248                line_content: &highlighted_line,
249                marker: ">",
250            };
251            output.push_str(&self.format_main_line(&ctx));
252
253            // Show context after
254            for (i, line) in m.context_after.iter().enumerate() {
255                let line_num = m.line_index + i + 1;
256                let ctx = LineContext {
257                    notebook: None,
258                    cell_index: m.cell_index,
259                    exec_str: &exec_str,
260                    match_type: &m.match_type.to_string(),
261                    line_num,
262                    line_content: line.trim(),
263                    marker: "-",
264                };
265                output.push_str(&self.format_context_line(&ctx));
266            }
267
268            output.push_str("    --\n");
269        } else {
270            // No context, just show the matching line
271            let ctx = LineContext {
272                notebook: None,
273                cell_index: m.cell_index,
274                exec_str: &exec_str,
275                match_type: &m.match_type.to_string(),
276                line_num: m.line_index,
277                line_content: &highlighted_line,
278                marker: "",
279            };
280            output.push_str(&self.format_main_line(&ctx));
281        }
282
283        output
284    }
285
286    /// Format a context line
287    fn format_context_line(&self, ctx: &LineContext) -> String {
288        self.format_line_impl(ctx)
289    }
290
291    /// Format the main matching line
292    fn format_main_line(&self, ctx: &LineContext) -> String {
293        self.format_line_impl(ctx)
294    }
295
296    /// Format a line based on the selected format preset
297    fn format_line_impl(&self, ctx: &LineContext) -> String {
298        let indent = if ctx.notebook.is_none() { "  " } else { "" };
299        let marker_str = if ctx.marker.is_empty() {
300            String::new()
301        } else {
302            format!("{} ", ctx.marker)
303        };
304
305        match self.options.format {
306            OutputFormat::Standard => {
307                // Standard format: notebook:cellN[exec]:type:line: content
308                let nb_prefix = if let Some(nb) = ctx.notebook {
309                    format!("{nb}:")
310                } else {
311                    String::new()
312                };
313
314                if self.options.no_line_number {
315                    format!(
316                        "{marker_str}{indent}{nb_prefix}cell{}{}{}: {}\n",
317                        ctx.cell_index, ctx.exec_str, ctx.match_type, ctx.line_content
318                    )
319                } else {
320                    format!(
321                        "{marker_str}{indent}{nb_prefix}cell{}{}:{}:{}: {}\n",
322                        ctx.cell_index,
323                        ctx.exec_str,
324                        ctx.match_type,
325                        ctx.line_num,
326                        ctx.line_content
327                    )
328                }
329            }
330            OutputFormat::Compact => {
331                // Compact format: notebook:cellN:lineN: content
332                let nb_prefix = if let Some(nb) = ctx.notebook {
333                    format!("{nb}:")
334                } else {
335                    String::new()
336                };
337
338                format!(
339                    "{}{}{}cell{}:line{}: {}\n",
340                    marker_str,
341                    indent,
342                    nb_prefix,
343                    ctx.cell_index + 1,
344                    ctx.line_num + 1,
345                    ctx.line_content
346                )
347            }
348            OutputFormat::CompactNoCell => {
349                // Ultra-compact: notebook:lineN: content (no cell info)
350                let nb_prefix = if let Some(nb) = ctx.notebook {
351                    format!("{nb}:")
352                } else {
353                    String::new()
354                };
355
356                format!(
357                    "{}{}{}{}: {}\n",
358                    marker_str,
359                    indent,
360                    nb_prefix,
361                    ctx.line_num + 1,
362                    ctx.line_content
363                )
364            }
365            _ => {
366                // For other formats (Grouped, PathsOnly, MatchesOnly), fall back to standard
367                let nb_prefix = if let Some(nb) = ctx.notebook {
368                    format!("{nb}:")
369                } else {
370                    String::new()
371                };
372
373                if self.options.no_line_number {
374                    format!(
375                        "{marker_str}{indent}{nb_prefix}cell{}{}{}: {}\n",
376                        ctx.cell_index, ctx.exec_str, ctx.match_type, ctx.line_content
377                    )
378                } else {
379                    format!(
380                        "{marker_str}{indent}{nb_prefix}cell{}{}:{}:{}: {}\n",
381                        ctx.cell_index,
382                        ctx.exec_str,
383                        ctx.match_type,
384                        ctx.line_num,
385                        ctx.line_content
386                    )
387                }
388            }
389        }
390    }
391}