Skip to main content

rumdl_lib/code_block_tools/
processor.rs

1//! Main processor for code block linting and formatting.
2//!
3//! This module coordinates language resolution, tool lookup, execution,
4//! and result collection for processing code blocks in markdown files.
5
6#[cfg(test)]
7use super::config::LanguageToolConfig;
8use super::config::{CodeBlockToolsConfig, NormalizeLanguage, OnError, OnMissing};
9use super::executor::{ExecutorError, ToolExecutor, ToolOutput};
10use super::linguist::LinguistResolver;
11use super::registry::ToolRegistry;
12use crate::config::MarkdownFlavor;
13use crate::rule::{LintWarning, Severity};
14use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
15
16/// Special built-in tool name for rumdl's own markdown linting.
17/// When this tool is configured for markdown blocks, the processor skips
18/// external execution since it's handled by embedded markdown linting.
19pub const RUMDL_BUILTIN_TOOL: &str = "rumdl";
20
21/// Check if a language is markdown (handles common variations).
22fn is_markdown_language(lang: &str) -> bool {
23    matches!(lang.to_lowercase().as_str(), "markdown" | "md")
24}
25
26/// Information about a fenced code block for processing.
27#[derive(Debug, Clone)]
28pub struct FencedCodeBlockInfo {
29    /// 0-indexed line number where opening fence starts.
30    pub start_line: usize,
31    /// 0-indexed line number where closing fence ends.
32    pub end_line: usize,
33    /// Byte offset where code content starts (after opening fence line).
34    pub content_start: usize,
35    /// Byte offset where code content ends (before closing fence line).
36    pub content_end: usize,
37    /// Language tag extracted from info string (first token).
38    pub language: String,
39    /// Full info string from the fence.
40    pub info_string: String,
41    /// The fence character used (` or ~).
42    pub fence_char: char,
43    /// Length of the fence (3 or more).
44    pub fence_length: usize,
45    /// Leading whitespace on the fence line.
46    pub indent: usize,
47    /// Exact leading whitespace prefix from the fence line.
48    pub indent_prefix: String,
49}
50
51/// A diagnostic message from an external tool.
52#[derive(Debug, Clone)]
53pub struct CodeBlockDiagnostic {
54    /// Line number in the original markdown file (1-indexed).
55    pub file_line: usize,
56    /// Column number (1-indexed, if available).
57    pub column: Option<usize>,
58    /// Message from the tool.
59    pub message: String,
60    /// Severity (error, warning, info).
61    pub severity: DiagnosticSeverity,
62    /// Name of the tool that produced this.
63    pub tool: String,
64    /// Line where the code block starts (1-indexed, for context).
65    pub code_block_start: usize,
66}
67
68/// Severity level for diagnostics.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum DiagnosticSeverity {
71    Error,
72    Warning,
73    Info,
74}
75
76impl CodeBlockDiagnostic {
77    /// Convert to a LintWarning for integration with rumdl's warning system.
78    pub fn to_lint_warning(&self) -> LintWarning {
79        let severity = match self.severity {
80            DiagnosticSeverity::Error => Severity::Error,
81            DiagnosticSeverity::Warning => Severity::Warning,
82            DiagnosticSeverity::Info => Severity::Info,
83        };
84
85        LintWarning {
86            message: self.message.clone(),
87            line: self.file_line,
88            column: self.column.unwrap_or(1),
89            end_line: self.file_line,
90            end_column: self.column.unwrap_or(1),
91            severity,
92            fix: None, // External tool diagnostics don't provide fixes
93            rule_name: Some(self.tool.clone()),
94        }
95    }
96}
97
98/// Error during code block processing.
99#[derive(Debug, Clone)]
100pub enum ProcessorError {
101    /// Tool execution failed.
102    ToolError(ExecutorError),
103    /// No tools configured for language.
104    NoToolsConfigured { language: String },
105    /// Tool binary not found.
106    ToolBinaryNotFound { tool: String, language: String },
107    /// Processing was aborted due to on_error = fail.
108    Aborted { message: String },
109}
110
111impl std::fmt::Display for ProcessorError {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        match self {
114            Self::ToolError(e) => write!(f, "{e}"),
115            Self::NoToolsConfigured { language } => {
116                write!(f, "No tools configured for language '{language}'")
117            }
118            Self::ToolBinaryNotFound { tool, language } => {
119                write!(f, "Tool '{tool}' binary not found for language '{language}'")
120            }
121            Self::Aborted { message } => write!(f, "Processing aborted: {message}"),
122        }
123    }
124}
125
126impl std::error::Error for ProcessorError {}
127
128impl From<ExecutorError> for ProcessorError {
129    fn from(e: ExecutorError) -> Self {
130        Self::ToolError(e)
131    }
132}
133
134/// Result of processing a single code block.
135#[derive(Debug)]
136pub struct CodeBlockResult {
137    /// Diagnostics from linting.
138    pub diagnostics: Vec<CodeBlockDiagnostic>,
139    /// Formatted content (if formatting was requested and succeeded).
140    pub formatted_content: Option<String>,
141    /// Whether the code block was modified.
142    pub was_modified: bool,
143}
144
145/// Result of formatting code blocks in a document.
146#[derive(Debug)]
147pub struct FormatOutput {
148    /// The formatted content (may be partially formatted if errors occurred).
149    pub content: String,
150    /// Whether any errors occurred during formatting.
151    pub had_errors: bool,
152    /// Error messages for blocks that couldn't be formatted.
153    pub error_messages: Vec<String>,
154}
155
156/// Main processor for code block tools.
157pub struct CodeBlockToolProcessor<'a> {
158    config: &'a CodeBlockToolsConfig,
159    flavor: MarkdownFlavor,
160    linguist: LinguistResolver,
161    registry: ToolRegistry,
162    executor: ToolExecutor,
163    user_aliases: std::collections::HashMap<String, String>,
164}
165
166impl<'a> CodeBlockToolProcessor<'a> {
167    /// Create a new processor with the given configuration and markdown flavor.
168    pub fn new(config: &'a CodeBlockToolsConfig, flavor: MarkdownFlavor) -> Self {
169        let user_aliases = config
170            .language_aliases
171            .iter()
172            .map(|(k, v)| (k.to_lowercase(), v.to_lowercase()))
173            .collect();
174        Self {
175            config,
176            flavor,
177            linguist: LinguistResolver::new(),
178            registry: ToolRegistry::new(config.tools.clone()),
179            executor: ToolExecutor::new(config.timeout),
180            user_aliases,
181        }
182    }
183
184    /// Extract all fenced code blocks from content.
185    pub fn extract_code_blocks(&self, content: &str) -> Vec<FencedCodeBlockInfo> {
186        let mut blocks = Vec::new();
187        let mut current_block: Option<FencedCodeBlockBuilder> = None;
188
189        let options = Options::all();
190        let parser = Parser::new_ext(content, options).into_offset_iter();
191
192        let lines: Vec<&str> = content.lines().collect();
193
194        for (event, range) in parser {
195            match event {
196                Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) => {
197                    let info_string = info.to_string();
198                    let language = info_string.split_whitespace().next().unwrap_or("").to_string();
199
200                    // Find start line
201                    let start_line = content[..range.start].chars().filter(|&c| c == '\n').count();
202
203                    // Find content start (after opening fence line)
204                    let content_start = content[range.start..]
205                        .find('\n')
206                        .map(|i| range.start + i + 1)
207                        .unwrap_or(content.len());
208
209                    // Detect fence character and length from the line
210                    let fence_line = lines.get(start_line).unwrap_or(&"");
211                    let trimmed = fence_line.trim_start();
212                    let indent = fence_line.len() - trimmed.len();
213                    let indent_prefix = fence_line.get(..indent).unwrap_or("").to_string();
214                    let (fence_char, fence_length) = if trimmed.starts_with('~') {
215                        ('~', trimmed.chars().take_while(|&c| c == '~').count())
216                    } else {
217                        ('`', trimmed.chars().take_while(|&c| c == '`').count())
218                    };
219
220                    current_block = Some(FencedCodeBlockBuilder {
221                        start_line,
222                        content_start,
223                        language,
224                        info_string,
225                        fence_char,
226                        fence_length,
227                        indent,
228                        indent_prefix,
229                    });
230                }
231                Event::End(TagEnd::CodeBlock) => {
232                    if let Some(builder) = current_block.take() {
233                        // Find end line
234                        let end_line = content[..range.end].chars().filter(|&c| c == '\n').count();
235
236                        // Find content end (before closing fence line)
237                        let search_start = builder.content_start.min(range.end);
238                        let content_end = if search_start < range.end {
239                            content[search_start..range.end]
240                                .rfind('\n')
241                                .map(|i| search_start + i)
242                                .unwrap_or(search_start)
243                        } else {
244                            search_start
245                        };
246
247                        if content_end >= builder.content_start {
248                            blocks.push(FencedCodeBlockInfo {
249                                start_line: builder.start_line,
250                                end_line,
251                                content_start: builder.content_start,
252                                content_end,
253                                language: builder.language,
254                                info_string: builder.info_string,
255                                fence_char: builder.fence_char,
256                                fence_length: builder.fence_length,
257                                indent: builder.indent,
258                                indent_prefix: builder.indent_prefix,
259                            });
260                        }
261                    }
262                }
263                _ => {}
264            }
265        }
266
267        // For MkDocs flavor, also extract code blocks inside admonitions and tabs
268        if self.flavor == MarkdownFlavor::MkDocs {
269            let mkdocs_blocks = self.extract_mkdocs_code_blocks(content);
270            for mb in mkdocs_blocks {
271                // Deduplicate: only add if no existing block starts at the same line
272                if !blocks.iter().any(|b| b.start_line == mb.start_line) {
273                    blocks.push(mb);
274                }
275            }
276            blocks.sort_by_key(|b| b.start_line);
277        }
278
279        blocks
280    }
281
282    /// Extract fenced code blocks that are inside MkDocs admonitions or tabs.
283    ///
284    /// pulldown_cmark doesn't parse MkDocs-specific constructs, so indented
285    /// code blocks inside `!!!`/`???` admonitions or `===` tabs are missed.
286    /// This method manually scans for them.
287    fn extract_mkdocs_code_blocks(&self, content: &str) -> Vec<FencedCodeBlockInfo> {
288        use crate::utils::mkdocs_admonitions;
289        use crate::utils::mkdocs_tabs;
290
291        let mut blocks = Vec::new();
292        let lines: Vec<&str> = content.lines().collect();
293
294        // Track current MkDocs context indent level
295        // We only need to know if we're inside any MkDocs block, so a simple stack suffices.
296        let mut context_indent_stack: Vec<usize> = Vec::new();
297
298        // Track fence state inside MkDocs context
299        let mut in_fence = false;
300        let mut fence_start_line: usize = 0;
301        let mut fence_content_start: usize = 0;
302        let mut fence_char: char = '`';
303        let mut fence_length: usize = 0;
304        let mut fence_indent: usize = 0;
305        let mut fence_indent_prefix = String::new();
306        let mut fence_language = String::new();
307        let mut fence_info_string = String::new();
308
309        // Compute byte offsets via pointer arithmetic.
310        // `content.lines()` returns slices into the original string,
311        // so each line's pointer offset from `content` gives its byte position.
312        // This correctly handles \n, \r\n, and empty lines.
313        let content_start_ptr = content.as_ptr() as usize;
314        let line_offsets: Vec<usize> = lines
315            .iter()
316            .map(|line| line.as_ptr() as usize - content_start_ptr)
317            .collect();
318
319        for (i, line) in lines.iter().enumerate() {
320            let line_indent = crate::utils::mkdocs_common::get_line_indent(line);
321            let is_admonition = mkdocs_admonitions::is_admonition_start(line);
322            let is_tab = mkdocs_tabs::is_tab_marker(line);
323
324            // Pop contexts when the current line is not indented enough to be content.
325            // This runs for ALL lines (including new admonition/tab starts) to clean
326            // up stale entries before potentially pushing a new context.
327            if !line.trim().is_empty() {
328                while let Some(&ctx_indent) = context_indent_stack.last() {
329                    if line_indent < ctx_indent + 4 {
330                        context_indent_stack.pop();
331                        if in_fence {
332                            in_fence = false;
333                        }
334                    } else {
335                        break;
336                    }
337                }
338            }
339
340            // Check for admonition start — push new context
341            if is_admonition && let Some(indent) = mkdocs_admonitions::get_admonition_indent(line) {
342                context_indent_stack.push(indent);
343                continue;
344            }
345
346            // Check for tab marker — push new context
347            if is_tab && let Some(indent) = mkdocs_tabs::get_tab_indent(line) {
348                context_indent_stack.push(indent);
349                continue;
350            }
351
352            // Only look for fences inside a MkDocs context
353            if context_indent_stack.is_empty() {
354                continue;
355            }
356
357            let trimmed = line.trim_start();
358            let leading_spaces = line.len() - trimmed.len();
359
360            if !in_fence {
361                // Check for fence opening
362                let (fc, fl) = if trimmed.starts_with("```") {
363                    ('`', trimmed.chars().take_while(|&c| c == '`').count())
364                } else if trimmed.starts_with("~~~") {
365                    ('~', trimmed.chars().take_while(|&c| c == '~').count())
366                } else {
367                    continue;
368                };
369
370                if fl >= 3 {
371                    in_fence = true;
372                    fence_start_line = i;
373                    fence_char = fc;
374                    fence_length = fl;
375                    fence_indent = leading_spaces;
376                    fence_indent_prefix = line.get(..leading_spaces).unwrap_or("").to_string();
377
378                    let after_fence = &trimmed[fl..];
379                    fence_info_string = after_fence.trim().to_string();
380                    fence_language = fence_info_string.split_whitespace().next().unwrap_or("").to_string();
381
382                    // Content starts at the next line's byte offset
383                    fence_content_start = line_offsets.get(i + 1).copied().unwrap_or(content.len());
384                }
385            } else {
386                // Check for fence closing
387                let is_closing = if fence_char == '`' {
388                    trimmed.starts_with("```")
389                        && trimmed.chars().take_while(|&c| c == '`').count() >= fence_length
390                        && trimmed.trim_start_matches('`').trim().is_empty()
391                } else {
392                    trimmed.starts_with("~~~")
393                        && trimmed.chars().take_while(|&c| c == '~').count() >= fence_length
394                        && trimmed.trim_start_matches('~').trim().is_empty()
395                };
396
397                if is_closing {
398                    let content_end = line_offsets.get(i).copied().unwrap_or(content.len());
399
400                    if content_end >= fence_content_start {
401                        blocks.push(FencedCodeBlockInfo {
402                            start_line: fence_start_line,
403                            end_line: i,
404                            content_start: fence_content_start,
405                            content_end,
406                            language: fence_language.clone(),
407                            info_string: fence_info_string.clone(),
408                            fence_char,
409                            fence_length,
410                            indent: fence_indent,
411                            indent_prefix: fence_indent_prefix.clone(),
412                        });
413                    }
414
415                    in_fence = false;
416                }
417            }
418        }
419
420        blocks
421    }
422
423    /// Resolve a language tag to its canonical name.
424    fn resolve_language(&self, language: &str) -> String {
425        let lower = language.to_lowercase();
426        if let Some(mapped) = self.user_aliases.get(&lower) {
427            return mapped.clone();
428        }
429        match self.config.normalize_language {
430            NormalizeLanguage::Linguist => self.linguist.resolve(&lower),
431            NormalizeLanguage::Exact => lower,
432        }
433    }
434
435    /// Get the effective on_error setting for a language.
436    fn get_on_error(&self, language: &str) -> OnError {
437        self.config
438            .languages
439            .get(language)
440            .and_then(|lc| lc.on_error)
441            .unwrap_or(self.config.on_error)
442    }
443
444    /// Strip the fence indentation prefix from each line of a code block.
445    fn strip_indent_from_block(&self, content: &str, indent_prefix: &str) -> String {
446        if indent_prefix.is_empty() {
447            return content.to_string();
448        }
449
450        let mut out = String::with_capacity(content.len());
451        for line in content.split_inclusive('\n') {
452            if let Some(stripped) = line.strip_prefix(indent_prefix) {
453                out.push_str(stripped);
454            } else {
455                out.push_str(line);
456            }
457        }
458        out
459    }
460
461    /// Re-apply the fence indentation prefix to each line of a code block.
462    fn apply_indent_to_block(&self, content: &str, indent_prefix: &str) -> String {
463        if indent_prefix.is_empty() {
464            return content.to_string();
465        }
466        if content.is_empty() {
467            return String::new();
468        }
469
470        let mut out = String::with_capacity(content.len() + indent_prefix.len());
471        for line in content.split_inclusive('\n') {
472            if line == "\n" {
473                out.push_str(line);
474            } else {
475                out.push_str(indent_prefix);
476                out.push_str(line);
477            }
478        }
479        out
480    }
481
482    /// Lint all code blocks in the content.
483    ///
484    /// Returns diagnostics from all configured linters.
485    pub fn lint(&self, content: &str) -> Result<Vec<CodeBlockDiagnostic>, ProcessorError> {
486        let mut all_diagnostics = Vec::new();
487        let blocks = self.extract_code_blocks(content);
488
489        for block in blocks {
490            if block.language.is_empty() {
491                continue; // Skip blocks without language tag
492            }
493
494            let canonical_lang = self.resolve_language(&block.language);
495
496            // Get lint tools for this language
497            let lang_config = self.config.languages.get(&canonical_lang);
498
499            // If language is explicitly configured with enabled=false, skip silently
500            if let Some(lc) = lang_config
501                && !lc.enabled
502            {
503                continue;
504            }
505
506            let lint_tools = match lang_config {
507                Some(lc) if !lc.lint.is_empty() => &lc.lint,
508                _ => {
509                    // No tools configured for this language in lint mode
510                    match self.config.on_missing_language_definition {
511                        OnMissing::Ignore => continue,
512                        OnMissing::Fail => {
513                            all_diagnostics.push(CodeBlockDiagnostic {
514                                file_line: block.start_line + 1,
515                                column: None,
516                                message: format!("No lint tools configured for language '{canonical_lang}'"),
517                                severity: DiagnosticSeverity::Error,
518                                tool: "code-block-tools".to_string(),
519                                code_block_start: block.start_line + 1,
520                            });
521                            continue;
522                        }
523                        OnMissing::FailFast => {
524                            return Err(ProcessorError::NoToolsConfigured {
525                                language: canonical_lang,
526                            });
527                        }
528                    }
529                }
530            };
531
532            // Extract code block content
533            let code_content_raw = if block.content_start < block.content_end && block.content_end <= content.len() {
534                &content[block.content_start..block.content_end]
535            } else {
536                continue;
537            };
538            let code_content = self.strip_indent_from_block(code_content_raw, &block.indent_prefix);
539
540            // Run each lint tool
541            for tool_id in lint_tools {
542                // Skip built-in "rumdl" tool for markdown - handled separately by embedded markdown linting
543                if tool_id == RUMDL_BUILTIN_TOOL && is_markdown_language(&canonical_lang) {
544                    continue;
545                }
546
547                let tool_def = match self.registry.get(tool_id) {
548                    Some(t) => t,
549                    None => {
550                        log::warn!("Unknown tool '{tool_id}' configured for language '{canonical_lang}'");
551                        continue;
552                    }
553                };
554
555                // Check if tool binary exists before running
556                let tool_name = tool_def.command.first().map(String::as_str).unwrap_or("");
557                if !tool_name.is_empty() && !self.executor.is_tool_available(tool_name) {
558                    match self.config.on_missing_tool_binary {
559                        OnMissing::Ignore => {
560                            log::debug!("Tool binary '{tool_name}' not found, skipping");
561                            continue;
562                        }
563                        OnMissing::Fail => {
564                            all_diagnostics.push(CodeBlockDiagnostic {
565                                file_line: block.start_line + 1,
566                                column: None,
567                                message: format!("Tool binary '{tool_name}' not found in PATH"),
568                                severity: DiagnosticSeverity::Error,
569                                tool: "code-block-tools".to_string(),
570                                code_block_start: block.start_line + 1,
571                            });
572                            continue;
573                        }
574                        OnMissing::FailFast => {
575                            return Err(ProcessorError::ToolBinaryNotFound {
576                                tool: tool_name.to_string(),
577                                language: canonical_lang.clone(),
578                            });
579                        }
580                    }
581                }
582
583                match self.executor.lint(tool_def, &code_content, Some(self.config.timeout)) {
584                    Ok(output) => {
585                        // Parse tool output into diagnostics
586                        let diagnostics = self.parse_tool_output(
587                            &output,
588                            tool_id,
589                            block.start_line + 1, // Convert to 1-indexed
590                        );
591                        all_diagnostics.extend(diagnostics);
592                    }
593                    Err(e) => {
594                        let on_error = self.get_on_error(&canonical_lang);
595                        match on_error {
596                            OnError::Fail => return Err(e.into()),
597                            OnError::Warn => {
598                                log::warn!("Tool '{tool_id}' failed: {e}");
599                            }
600                            OnError::Skip => {
601                                // Silently skip
602                            }
603                        }
604                    }
605                }
606            }
607        }
608
609        Ok(all_diagnostics)
610    }
611
612    /// Format all code blocks in the content.
613    ///
614    /// Returns the modified content with formatted code blocks and any errors that occurred.
615    /// With `on-missing-*` = `fail`, errors are collected but formatting continues.
616    /// With `on-missing-*` = `fail-fast`, returns Err immediately on first error.
617    pub fn format(&self, content: &str) -> Result<FormatOutput, ProcessorError> {
618        let blocks = self.extract_code_blocks(content);
619
620        if blocks.is_empty() {
621            return Ok(FormatOutput {
622                content: content.to_string(),
623                had_errors: false,
624                error_messages: Vec::new(),
625            });
626        }
627
628        // Process blocks in reverse order to maintain byte offsets
629        let mut result = content.to_string();
630        let mut error_messages: Vec<String> = Vec::new();
631
632        for block in blocks.into_iter().rev() {
633            if block.language.is_empty() {
634                continue;
635            }
636
637            let canonical_lang = self.resolve_language(&block.language);
638
639            // Get format tools for this language
640            let lang_config = self.config.languages.get(&canonical_lang);
641
642            // If language is explicitly configured with enabled=false, skip silently
643            if let Some(lc) = lang_config
644                && !lc.enabled
645            {
646                continue;
647            }
648
649            let format_tools = match lang_config {
650                Some(lc) if !lc.format.is_empty() => &lc.format,
651                _ => {
652                    // No tools configured for this language in format mode
653                    match self.config.on_missing_language_definition {
654                        OnMissing::Ignore => continue,
655                        OnMissing::Fail => {
656                            error_messages.push(format!(
657                                "No format tools configured for language '{canonical_lang}' at line {}",
658                                block.start_line + 1
659                            ));
660                            continue;
661                        }
662                        OnMissing::FailFast => {
663                            return Err(ProcessorError::NoToolsConfigured {
664                                language: canonical_lang,
665                            });
666                        }
667                    }
668                }
669            };
670
671            // Extract code block content
672            if block.content_start >= block.content_end || block.content_end > result.len() {
673                continue;
674            }
675            let code_content_raw = result[block.content_start..block.content_end].to_string();
676            let code_content = self.strip_indent_from_block(&code_content_raw, &block.indent_prefix);
677
678            // Run format tools (use first successful one)
679            let mut formatted = code_content.clone();
680            let mut tool_ran = false;
681            for tool_id in format_tools {
682                // Skip built-in "rumdl" tool for markdown - handled separately by embedded markdown formatting
683                if tool_id == RUMDL_BUILTIN_TOOL && is_markdown_language(&canonical_lang) {
684                    continue;
685                }
686
687                let tool_def = match self.registry.get(tool_id) {
688                    Some(t) => t,
689                    None => {
690                        log::warn!("Unknown tool '{tool_id}' configured for language '{canonical_lang}'");
691                        continue;
692                    }
693                };
694
695                // Check if tool binary exists before running
696                let tool_name = tool_def.command.first().map(String::as_str).unwrap_or("");
697                if !tool_name.is_empty() && !self.executor.is_tool_available(tool_name) {
698                    match self.config.on_missing_tool_binary {
699                        OnMissing::Ignore => {
700                            log::debug!("Tool binary '{tool_name}' not found, skipping");
701                            continue;
702                        }
703                        OnMissing::Fail => {
704                            error_messages.push(format!(
705                                "Tool binary '{tool_name}' not found in PATH for language '{canonical_lang}' at line {}",
706                                block.start_line + 1
707                            ));
708                            continue;
709                        }
710                        OnMissing::FailFast => {
711                            return Err(ProcessorError::ToolBinaryNotFound {
712                                tool: tool_name.to_string(),
713                                language: canonical_lang.clone(),
714                            });
715                        }
716                    }
717                }
718
719                match self.executor.format(tool_def, &formatted, Some(self.config.timeout)) {
720                    Ok(output) => {
721                        // Ensure trailing newline matches original (unindented)
722                        formatted = output;
723                        if code_content.ends_with('\n') && !formatted.ends_with('\n') {
724                            formatted.push('\n');
725                        } else if !code_content.ends_with('\n') && formatted.ends_with('\n') {
726                            formatted.pop();
727                        }
728                        tool_ran = true;
729                        break; // Use first successful formatter
730                    }
731                    Err(e) => {
732                        let on_error = self.get_on_error(&canonical_lang);
733                        match on_error {
734                            OnError::Fail => return Err(e.into()),
735                            OnError::Warn => {
736                                log::warn!("Formatter '{tool_id}' failed: {e}");
737                            }
738                            OnError::Skip => {}
739                        }
740                    }
741                }
742            }
743
744            // Replace content if changed and a tool actually ran
745            if tool_ran && formatted != code_content {
746                let reindented = self.apply_indent_to_block(&formatted, &block.indent_prefix);
747                if reindented != code_content_raw {
748                    result.replace_range(block.content_start..block.content_end, &reindented);
749                }
750            }
751        }
752
753        Ok(FormatOutput {
754            content: result,
755            had_errors: !error_messages.is_empty(),
756            error_messages,
757        })
758    }
759
760    /// Parse tool output into diagnostics.
761    ///
762    /// This is a basic parser that handles common output formats.
763    /// Tools vary widely in their output format, so this is best-effort.
764    fn parse_tool_output(
765        &self,
766        output: &ToolOutput,
767        tool_id: &str,
768        code_block_start_line: usize,
769    ) -> Vec<CodeBlockDiagnostic> {
770        let mut diagnostics = Vec::new();
771        let mut shellcheck_line: Option<usize> = None;
772
773        // Combine stdout and stderr for parsing
774        let stdout = &output.stdout;
775        let stderr = &output.stderr;
776        let combined = format!("{stdout}\n{stderr}");
777
778        // Look for common line:column:message patterns
779        // Examples:
780        // - ruff: "_.py:1:1: E501 Line too long"
781        // - shellcheck: "In - line 1: ..."
782        // - eslint: "1:10 error Description"
783
784        for line in combined.lines() {
785            let line = line.trim();
786            if line.is_empty() {
787                continue;
788            }
789
790            if let Some(line_num) = self.parse_shellcheck_header(line) {
791                shellcheck_line = Some(line_num);
792                continue;
793            }
794
795            if let Some(line_num) = shellcheck_line
796                && let Some(diag) = self.parse_shellcheck_message(line, tool_id, code_block_start_line, line_num)
797            {
798                diagnostics.push(diag);
799                continue;
800            }
801
802            // Try pattern: "file:line:col: message" or "file:line: message"
803            if let Some(diag) = self.parse_standard_format(line, tool_id, code_block_start_line) {
804                diagnostics.push(diag);
805                continue;
806            }
807
808            // Try pattern: "line:col message" (eslint style)
809            if let Some(diag) = self.parse_eslint_format(line, tool_id, code_block_start_line) {
810                diagnostics.push(diag);
811                continue;
812            }
813
814            // Try single-line shellcheck format fallback
815            if let Some(diag) = self.parse_shellcheck_format(line, tool_id, code_block_start_line) {
816                diagnostics.push(diag);
817            }
818        }
819
820        // If no diagnostics parsed but tool failed, create one diagnostic per output line
821        if diagnostics.is_empty() && !output.success {
822            let raw_output = if !output.stderr.is_empty() {
823                &output.stderr
824            } else if !output.stdout.is_empty() {
825                &output.stdout
826            } else {
827                ""
828            };
829
830            let lines: Vec<&str> = raw_output.lines().map(|l| l.trim()).filter(|l| !l.is_empty()).collect();
831
832            if lines.is_empty() {
833                let exit_code = output.exit_code;
834                diagnostics.push(CodeBlockDiagnostic {
835                    file_line: code_block_start_line,
836                    column: None,
837                    message: format!("Tool exited with code {exit_code}"),
838                    severity: DiagnosticSeverity::Error,
839                    tool: tool_id.to_string(),
840                    code_block_start: code_block_start_line,
841                });
842            } else {
843                for line_text in lines {
844                    diagnostics.push(CodeBlockDiagnostic {
845                        file_line: code_block_start_line,
846                        column: None,
847                        message: line_text.to_string(),
848                        severity: DiagnosticSeverity::Error,
849                        tool: tool_id.to_string(),
850                        code_block_start: code_block_start_line,
851                    });
852                }
853            }
854        }
855
856        diagnostics
857    }
858
859    /// Parse standard "file:line:col: message" format.
860    fn parse_standard_format(
861        &self,
862        line: &str,
863        tool_id: &str,
864        code_block_start_line: usize,
865    ) -> Option<CodeBlockDiagnostic> {
866        // Match patterns like "file.py:1:10: E501 message"
867        let mut parts = line.rsplitn(4, ':');
868        let message = parts.next()?.trim().to_string();
869        let part1 = parts.next()?.trim().to_string();
870        let part2 = parts.next()?.trim().to_string();
871        let part3 = parts.next().map(|s| s.trim().to_string());
872
873        let (line_part, col_part) = if part3.is_some() {
874            (part2, Some(part1))
875        } else {
876            (part1, None)
877        };
878
879        if let Ok(line_num) = line_part.parse::<usize>() {
880            let column = col_part.and_then(|s| s.parse::<usize>().ok());
881            let message = Self::strip_fixable_markers(&message);
882            if !message.is_empty() {
883                let severity = self.infer_severity(&message);
884                return Some(CodeBlockDiagnostic {
885                    file_line: code_block_start_line + line_num,
886                    column,
887                    message,
888                    severity,
889                    tool: tool_id.to_string(),
890                    code_block_start: code_block_start_line,
891                });
892            }
893        }
894        None
895    }
896
897    /// Parse eslint-style "line:col severity message" format.
898    fn parse_eslint_format(
899        &self,
900        line: &str,
901        tool_id: &str,
902        code_block_start_line: usize,
903    ) -> Option<CodeBlockDiagnostic> {
904        // Match "1:10 error Message"
905        let parts: Vec<&str> = line.splitn(3, ' ').collect();
906        if parts.len() >= 2 {
907            let loc_parts: Vec<&str> = parts[0].split(':').collect();
908            if loc_parts.len() == 2
909                && let (Ok(line_num), Ok(col)) = (loc_parts[0].parse::<usize>(), loc_parts[1].parse::<usize>())
910            {
911                let (sev_part, msg_part) = if parts.len() >= 3 {
912                    (parts[1], parts[2])
913                } else {
914                    (parts[1], "")
915                };
916                let message = if msg_part.is_empty() {
917                    sev_part.to_string()
918                } else {
919                    msg_part.to_string()
920                };
921                let message = Self::strip_fixable_markers(&message);
922                let severity = match sev_part.to_lowercase().as_str() {
923                    "error" => DiagnosticSeverity::Error,
924                    "warning" | "warn" => DiagnosticSeverity::Warning,
925                    "info" => DiagnosticSeverity::Info,
926                    _ => self.infer_severity(&message),
927                };
928                return Some(CodeBlockDiagnostic {
929                    file_line: code_block_start_line + line_num,
930                    column: Some(col),
931                    message,
932                    severity,
933                    tool: tool_id.to_string(),
934                    code_block_start: code_block_start_line,
935                });
936            }
937        }
938        None
939    }
940
941    /// Parse shellcheck-style "In - line N: message" format.
942    fn parse_shellcheck_format(
943        &self,
944        line: &str,
945        tool_id: &str,
946        code_block_start_line: usize,
947    ) -> Option<CodeBlockDiagnostic> {
948        // Match "In - line 5:" pattern
949        if line.starts_with("In ")
950            && line.contains(" line ")
951            && let Some(line_start) = line.find(" line ")
952        {
953            let after_line = &line[line_start + 6..];
954            if let Some(colon_pos) = after_line.find(':')
955                && let Ok(line_num) = after_line[..colon_pos].trim().parse::<usize>()
956            {
957                let message = Self::strip_fixable_markers(after_line[colon_pos + 1..].trim());
958                if !message.is_empty() {
959                    let severity = self.infer_severity(&message);
960                    return Some(CodeBlockDiagnostic {
961                        file_line: code_block_start_line + line_num,
962                        column: None,
963                        message,
964                        severity,
965                        tool: tool_id.to_string(),
966                        code_block_start: code_block_start_line,
967                    });
968                }
969            }
970        }
971        None
972    }
973
974    /// Parse shellcheck header line to capture line number context.
975    fn parse_shellcheck_header(&self, line: &str) -> Option<usize> {
976        if line.starts_with("In ")
977            && line.contains(" line ")
978            && let Some(line_start) = line.find(" line ")
979        {
980            let after_line = &line[line_start + 6..];
981            if let Some(colon_pos) = after_line.find(':') {
982                return after_line[..colon_pos].trim().parse::<usize>().ok();
983            }
984        }
985        None
986    }
987
988    /// Parse shellcheck message line containing SCXXXX codes.
989    fn parse_shellcheck_message(
990        &self,
991        line: &str,
992        tool_id: &str,
993        code_block_start_line: usize,
994        line_num: usize,
995    ) -> Option<CodeBlockDiagnostic> {
996        let sc_pos = line.find("SC")?;
997        let after_sc = &line[sc_pos + 2..];
998        let code_len = after_sc.chars().take_while(|c| c.is_ascii_digit()).count();
999        if code_len == 0 {
1000            return None;
1001        }
1002        let after_code = &after_sc[code_len..];
1003        let sev_start = after_code.find('(')? + 1;
1004        let sev_end = after_code[sev_start..].find(')')? + sev_start;
1005        let sev = after_code[sev_start..sev_end].trim().to_lowercase();
1006        let message_start = after_code.find("):")? + 2;
1007        let message = Self::strip_fixable_markers(after_code[message_start..].trim());
1008        if message.is_empty() {
1009            return None;
1010        }
1011
1012        let severity = match sev.as_str() {
1013            "error" => DiagnosticSeverity::Error,
1014            "warning" | "warn" => DiagnosticSeverity::Warning,
1015            "info" | "style" => DiagnosticSeverity::Info,
1016            _ => self.infer_severity(&message),
1017        };
1018
1019        Some(CodeBlockDiagnostic {
1020            file_line: code_block_start_line + line_num,
1021            column: None,
1022            message,
1023            severity,
1024            tool: tool_id.to_string(),
1025            code_block_start: code_block_start_line,
1026        })
1027    }
1028
1029    /// Infer severity from message content.
1030    fn infer_severity(&self, message: &str) -> DiagnosticSeverity {
1031        let lower = message.to_lowercase();
1032        if lower.contains("error")
1033            || lower.starts_with("e") && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1034            || lower.starts_with("f") && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1035        {
1036            DiagnosticSeverity::Error
1037        } else if lower.contains("warning")
1038            || lower.contains("warn")
1039            || lower.starts_with("w") && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1040        {
1041            DiagnosticSeverity::Warning
1042        } else {
1043            DiagnosticSeverity::Info
1044        }
1045    }
1046
1047    /// Strip "fixable" markers from external tool messages.
1048    ///
1049    /// External tools like ruff show `[*]` to indicate fixable issues, but in rumdl's
1050    /// context these markers can be misleading - the lint tool's fix capability may
1051    /// differ from what our configured formatter can fix. We strip these markers
1052    /// to avoid making promises we can't keep.
1053    fn strip_fixable_markers(message: &str) -> String {
1054        message
1055            .replace(" [*]", "")
1056            .replace("[*] ", "")
1057            .replace("[*]", "")
1058            .replace(" (fixable)", "")
1059            .replace("(fixable) ", "")
1060            .replace("(fixable)", "")
1061            .replace(" [fix available]", "")
1062            .replace("[fix available] ", "")
1063            .replace("[fix available]", "")
1064            .replace(" [autofix]", "")
1065            .replace("[autofix] ", "")
1066            .replace("[autofix]", "")
1067            .trim()
1068            .to_string()
1069    }
1070}
1071
1072/// Builder for FencedCodeBlockInfo during parsing.
1073struct FencedCodeBlockBuilder {
1074    start_line: usize,
1075    content_start: usize,
1076    language: String,
1077    info_string: String,
1078    fence_char: char,
1079    fence_length: usize,
1080    indent: usize,
1081    indent_prefix: String,
1082}
1083
1084#[cfg(test)]
1085mod tests {
1086    use super::*;
1087
1088    fn default_config() -> CodeBlockToolsConfig {
1089        CodeBlockToolsConfig::default()
1090    }
1091
1092    #[test]
1093    fn test_extract_code_blocks() {
1094        let config = default_config();
1095        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1096
1097        let content = r#"# Example
1098
1099```python
1100def hello():
1101    print("Hello")
1102```
1103
1104Some text
1105
1106```rust
1107fn main() {}
1108```
1109"#;
1110
1111        let blocks = processor.extract_code_blocks(content);
1112
1113        assert_eq!(blocks.len(), 2);
1114
1115        assert_eq!(blocks[0].language, "python");
1116        assert_eq!(blocks[0].fence_char, '`');
1117        assert_eq!(blocks[0].fence_length, 3);
1118        assert_eq!(blocks[0].start_line, 2);
1119        assert_eq!(blocks[0].indent, 0);
1120        assert_eq!(blocks[0].indent_prefix, "");
1121
1122        assert_eq!(blocks[1].language, "rust");
1123        assert_eq!(blocks[1].fence_char, '`');
1124        assert_eq!(blocks[1].fence_length, 3);
1125    }
1126
1127    #[test]
1128    fn test_extract_code_blocks_with_info_string() {
1129        let config = default_config();
1130        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1131
1132        let content = "```python title=\"example.py\"\ncode\n```";
1133        let blocks = processor.extract_code_blocks(content);
1134
1135        assert_eq!(blocks.len(), 1);
1136        assert_eq!(blocks[0].language, "python");
1137        assert_eq!(blocks[0].info_string, "python title=\"example.py\"");
1138    }
1139
1140    #[test]
1141    fn test_extract_code_blocks_tilde_fence() {
1142        let config = default_config();
1143        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1144
1145        let content = "~~~bash\necho hello\n~~~";
1146        let blocks = processor.extract_code_blocks(content);
1147
1148        assert_eq!(blocks.len(), 1);
1149        assert_eq!(blocks[0].language, "bash");
1150        assert_eq!(blocks[0].fence_char, '~');
1151        assert_eq!(blocks[0].fence_length, 3);
1152        assert_eq!(blocks[0].indent_prefix, "");
1153    }
1154
1155    #[test]
1156    fn test_extract_code_blocks_with_indent_prefix() {
1157        let config = default_config();
1158        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1159
1160        let content = "  - item\n    ```python\n    print('hi')\n    ```";
1161        let blocks = processor.extract_code_blocks(content);
1162
1163        assert_eq!(blocks.len(), 1);
1164        assert_eq!(blocks[0].indent_prefix, "    ");
1165    }
1166
1167    #[test]
1168    fn test_extract_code_blocks_no_language() {
1169        let config = default_config();
1170        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1171
1172        let content = "```\nplain code\n```";
1173        let blocks = processor.extract_code_blocks(content);
1174
1175        assert_eq!(blocks.len(), 1);
1176        assert_eq!(blocks[0].language, "");
1177    }
1178
1179    #[test]
1180    fn test_resolve_language_linguist() {
1181        let mut config = default_config();
1182        config.normalize_language = NormalizeLanguage::Linguist;
1183        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1184
1185        assert_eq!(processor.resolve_language("py"), "python");
1186        assert_eq!(processor.resolve_language("bash"), "shell");
1187        assert_eq!(processor.resolve_language("js"), "javascript");
1188    }
1189
1190    #[test]
1191    fn test_resolve_language_exact() {
1192        let mut config = default_config();
1193        config.normalize_language = NormalizeLanguage::Exact;
1194        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1195
1196        assert_eq!(processor.resolve_language("py"), "py");
1197        assert_eq!(processor.resolve_language("BASH"), "bash");
1198    }
1199
1200    #[test]
1201    fn test_resolve_language_user_alias_override() {
1202        let mut config = default_config();
1203        config.language_aliases.insert("py".to_string(), "python".to_string());
1204        config.normalize_language = NormalizeLanguage::Exact;
1205        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1206
1207        assert_eq!(processor.resolve_language("PY"), "python");
1208    }
1209
1210    #[test]
1211    fn test_indent_strip_and_reapply_roundtrip() {
1212        let config = default_config();
1213        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1214
1215        let raw = "    def hello():\n        print('hi')";
1216        let stripped = processor.strip_indent_from_block(raw, "    ");
1217        assert_eq!(stripped, "def hello():\n    print('hi')");
1218
1219        let reapplied = processor.apply_indent_to_block(&stripped, "    ");
1220        assert_eq!(reapplied, raw);
1221    }
1222
1223    #[test]
1224    fn test_infer_severity() {
1225        let config = default_config();
1226        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1227
1228        assert_eq!(
1229            processor.infer_severity("E501 line too long"),
1230            DiagnosticSeverity::Error
1231        );
1232        assert_eq!(
1233            processor.infer_severity("W291 trailing whitespace"),
1234            DiagnosticSeverity::Warning
1235        );
1236        assert_eq!(
1237            processor.infer_severity("error: something failed"),
1238            DiagnosticSeverity::Error
1239        );
1240        assert_eq!(
1241            processor.infer_severity("warning: unused variable"),
1242            DiagnosticSeverity::Warning
1243        );
1244        assert_eq!(
1245            processor.infer_severity("note: consider using"),
1246            DiagnosticSeverity::Info
1247        );
1248    }
1249
1250    #[test]
1251    fn test_parse_standard_format_windows_path() {
1252        let config = default_config();
1253        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1254
1255        let output = ToolOutput {
1256            stdout: "C:\\path\\file.py:2:5: E123 message".to_string(),
1257            stderr: String::new(),
1258            exit_code: 1,
1259            success: false,
1260        };
1261
1262        let diags = processor.parse_tool_output(&output, "ruff:check", 10);
1263        assert_eq!(diags.len(), 1);
1264        assert_eq!(diags[0].file_line, 12);
1265        assert_eq!(diags[0].column, Some(5));
1266        assert_eq!(diags[0].message, "E123 message");
1267    }
1268
1269    #[test]
1270    fn test_parse_eslint_severity() {
1271        let config = default_config();
1272        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1273
1274        let output = ToolOutput {
1275            stdout: "1:2 error Unexpected token".to_string(),
1276            stderr: String::new(),
1277            exit_code: 1,
1278            success: false,
1279        };
1280
1281        let diags = processor.parse_tool_output(&output, "eslint", 5);
1282        assert_eq!(diags.len(), 1);
1283        assert_eq!(diags[0].file_line, 6);
1284        assert_eq!(diags[0].column, Some(2));
1285        assert_eq!(diags[0].severity, DiagnosticSeverity::Error);
1286        assert_eq!(diags[0].message, "Unexpected token");
1287    }
1288
1289    #[test]
1290    fn test_parse_shellcheck_multiline() {
1291        let config = default_config();
1292        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1293
1294        let output = ToolOutput {
1295            stdout: "In - line 3:\necho $var\n ^-- SC2086 (info): Double quote to prevent globbing".to_string(),
1296            stderr: String::new(),
1297            exit_code: 1,
1298            success: false,
1299        };
1300
1301        let diags = processor.parse_tool_output(&output, "shellcheck", 10);
1302        assert_eq!(diags.len(), 1);
1303        assert_eq!(diags[0].file_line, 13);
1304        assert_eq!(diags[0].severity, DiagnosticSeverity::Info);
1305        assert_eq!(diags[0].message, "Double quote to prevent globbing");
1306    }
1307
1308    #[test]
1309    fn test_lint_no_config() {
1310        let config = default_config();
1311        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1312
1313        let content = "```python\nprint('hello')\n```";
1314        let result = processor.lint(content);
1315
1316        // Should succeed with no diagnostics (no tools configured)
1317        assert!(result.is_ok());
1318        assert!(result.unwrap().is_empty());
1319    }
1320
1321    #[test]
1322    fn test_format_no_config() {
1323        let config = default_config();
1324        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1325
1326        let content = "```python\nprint('hello')\n```";
1327        let result = processor.format(content);
1328
1329        // Should succeed with unchanged content (no tools configured)
1330        assert!(result.is_ok());
1331        let output = result.unwrap();
1332        assert_eq!(output.content, content);
1333        assert!(!output.had_errors);
1334        assert!(output.error_messages.is_empty());
1335    }
1336
1337    #[test]
1338    fn test_lint_on_missing_language_definition_fail() {
1339        let mut config = default_config();
1340        config.on_missing_language_definition = OnMissing::Fail;
1341        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1342
1343        let content = "```python\nprint('hello')\n```\n\n```javascript\nconsole.log('hi');\n```";
1344        let result = processor.lint(content);
1345
1346        // Should succeed but return diagnostics for both missing language definitions
1347        assert!(result.is_ok());
1348        let diagnostics = result.unwrap();
1349        assert_eq!(diagnostics.len(), 2);
1350        assert!(diagnostics[0].message.contains("No lint tools configured"));
1351        assert!(diagnostics[0].message.contains("python"));
1352        assert!(diagnostics[1].message.contains("javascript"));
1353    }
1354
1355    #[test]
1356    fn test_lint_on_missing_language_definition_fail_fast() {
1357        let mut config = default_config();
1358        config.on_missing_language_definition = OnMissing::FailFast;
1359        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1360
1361        let content = "```python\nprint('hello')\n```\n\n```javascript\nconsole.log('hi');\n```";
1362        let result = processor.lint(content);
1363
1364        // Should fail immediately on first missing language
1365        assert!(result.is_err());
1366        let err = result.unwrap_err();
1367        assert!(matches!(err, ProcessorError::NoToolsConfigured { .. }));
1368    }
1369
1370    #[test]
1371    fn test_format_on_missing_language_definition_fail() {
1372        let mut config = default_config();
1373        config.on_missing_language_definition = OnMissing::Fail;
1374        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1375
1376        let content = "```python\nprint('hello')\n```";
1377        let result = processor.format(content);
1378
1379        // Should succeed but report errors
1380        assert!(result.is_ok());
1381        let output = result.unwrap();
1382        assert_eq!(output.content, content); // Content unchanged
1383        assert!(output.had_errors);
1384        assert!(!output.error_messages.is_empty());
1385        assert!(output.error_messages[0].contains("No format tools configured"));
1386    }
1387
1388    #[test]
1389    fn test_format_on_missing_language_definition_fail_fast() {
1390        let mut config = default_config();
1391        config.on_missing_language_definition = OnMissing::FailFast;
1392        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1393
1394        let content = "```python\nprint('hello')\n```";
1395        let result = processor.format(content);
1396
1397        // Should fail immediately
1398        assert!(result.is_err());
1399        let err = result.unwrap_err();
1400        assert!(matches!(err, ProcessorError::NoToolsConfigured { .. }));
1401    }
1402
1403    #[test]
1404    fn test_lint_on_missing_tool_binary_fail() {
1405        use super::super::config::{LanguageToolConfig, ToolDefinition};
1406
1407        let mut config = default_config();
1408        config.on_missing_tool_binary = OnMissing::Fail;
1409
1410        // Configure a tool with a non-existent binary
1411        let lang_config = LanguageToolConfig {
1412            lint: vec!["nonexistent-linter".to_string()],
1413            ..Default::default()
1414        };
1415        config.languages.insert("python".to_string(), lang_config);
1416
1417        let tool_def = ToolDefinition {
1418            command: vec!["nonexistent-binary-xyz123".to_string()],
1419            ..Default::default()
1420        };
1421        config.tools.insert("nonexistent-linter".to_string(), tool_def);
1422
1423        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1424
1425        let content = "```python\nprint('hello')\n```";
1426        let result = processor.lint(content);
1427
1428        // Should succeed but return diagnostic for missing binary
1429        assert!(result.is_ok());
1430        let diagnostics = result.unwrap();
1431        assert_eq!(diagnostics.len(), 1);
1432        assert!(diagnostics[0].message.contains("not found in PATH"));
1433    }
1434
1435    #[test]
1436    fn test_lint_on_missing_tool_binary_fail_fast() {
1437        use super::super::config::{LanguageToolConfig, ToolDefinition};
1438
1439        let mut config = default_config();
1440        config.on_missing_tool_binary = OnMissing::FailFast;
1441
1442        // Configure a tool with a non-existent binary
1443        let lang_config = LanguageToolConfig {
1444            lint: vec!["nonexistent-linter".to_string()],
1445            ..Default::default()
1446        };
1447        config.languages.insert("python".to_string(), lang_config);
1448
1449        let tool_def = ToolDefinition {
1450            command: vec!["nonexistent-binary-xyz123".to_string()],
1451            ..Default::default()
1452        };
1453        config.tools.insert("nonexistent-linter".to_string(), tool_def);
1454
1455        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1456
1457        let content = "```python\nprint('hello')\n```";
1458        let result = processor.lint(content);
1459
1460        // Should fail immediately
1461        assert!(result.is_err());
1462        let err = result.unwrap_err();
1463        assert!(matches!(err, ProcessorError::ToolBinaryNotFound { .. }));
1464    }
1465
1466    #[test]
1467    fn test_format_on_missing_tool_binary_fail() {
1468        use super::super::config::{LanguageToolConfig, ToolDefinition};
1469
1470        let mut config = default_config();
1471        config.on_missing_tool_binary = OnMissing::Fail;
1472
1473        // Configure a tool with a non-existent binary
1474        let lang_config = LanguageToolConfig {
1475            format: vec!["nonexistent-formatter".to_string()],
1476            ..Default::default()
1477        };
1478        config.languages.insert("python".to_string(), lang_config);
1479
1480        let tool_def = ToolDefinition {
1481            command: vec!["nonexistent-binary-xyz123".to_string()],
1482            ..Default::default()
1483        };
1484        config.tools.insert("nonexistent-formatter".to_string(), tool_def);
1485
1486        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1487
1488        let content = "```python\nprint('hello')\n```";
1489        let result = processor.format(content);
1490
1491        // Should succeed but report errors
1492        assert!(result.is_ok());
1493        let output = result.unwrap();
1494        assert_eq!(output.content, content); // Content unchanged
1495        assert!(output.had_errors);
1496        assert!(!output.error_messages.is_empty());
1497        assert!(output.error_messages[0].contains("not found in PATH"));
1498    }
1499
1500    #[test]
1501    fn test_format_on_missing_tool_binary_fail_fast() {
1502        use super::super::config::{LanguageToolConfig, ToolDefinition};
1503
1504        let mut config = default_config();
1505        config.on_missing_tool_binary = OnMissing::FailFast;
1506
1507        // Configure a tool with a non-existent binary
1508        let lang_config = LanguageToolConfig {
1509            format: vec!["nonexistent-formatter".to_string()],
1510            ..Default::default()
1511        };
1512        config.languages.insert("python".to_string(), lang_config);
1513
1514        let tool_def = ToolDefinition {
1515            command: vec!["nonexistent-binary-xyz123".to_string()],
1516            ..Default::default()
1517        };
1518        config.tools.insert("nonexistent-formatter".to_string(), tool_def);
1519
1520        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1521
1522        let content = "```python\nprint('hello')\n```";
1523        let result = processor.format(content);
1524
1525        // Should fail immediately
1526        assert!(result.is_err());
1527        let err = result.unwrap_err();
1528        assert!(matches!(err, ProcessorError::ToolBinaryNotFound { .. }));
1529    }
1530
1531    #[test]
1532    fn test_lint_rumdl_builtin_skipped_for_markdown() {
1533        // Configure the built-in "rumdl" tool for markdown
1534        // The processor should skip it (handled by embedded markdown linting)
1535        let mut config = default_config();
1536        config.languages.insert(
1537            "markdown".to_string(),
1538            LanguageToolConfig {
1539                lint: vec![RUMDL_BUILTIN_TOOL.to_string()],
1540                ..Default::default()
1541            },
1542        );
1543        config.on_missing_language_definition = OnMissing::Fail;
1544        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1545
1546        let content = "```markdown\n# Hello\n```";
1547        let result = processor.lint(content);
1548
1549        // Should succeed with no diagnostics - "rumdl" tool is skipped, not treated as unknown
1550        assert!(result.is_ok());
1551        assert!(result.unwrap().is_empty());
1552    }
1553
1554    #[test]
1555    fn test_format_rumdl_builtin_skipped_for_markdown() {
1556        // Configure the built-in "rumdl" tool for markdown
1557        let mut config = default_config();
1558        config.languages.insert(
1559            "markdown".to_string(),
1560            LanguageToolConfig {
1561                format: vec![RUMDL_BUILTIN_TOOL.to_string()],
1562                ..Default::default()
1563            },
1564        );
1565        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1566
1567        let content = "```markdown\n# Hello\n```";
1568        let result = processor.format(content);
1569
1570        // Should succeed with unchanged content - "rumdl" tool is skipped
1571        assert!(result.is_ok());
1572        let output = result.unwrap();
1573        assert_eq!(output.content, content);
1574        assert!(!output.had_errors);
1575    }
1576
1577    #[test]
1578    fn test_is_markdown_language() {
1579        // Test the helper function
1580        assert!(is_markdown_language("markdown"));
1581        assert!(is_markdown_language("Markdown"));
1582        assert!(is_markdown_language("MARKDOWN"));
1583        assert!(is_markdown_language("md"));
1584        assert!(is_markdown_language("MD"));
1585        assert!(!is_markdown_language("python"));
1586        assert!(!is_markdown_language("rust"));
1587        assert!(!is_markdown_language(""));
1588    }
1589
1590    // Issue #423: MkDocs admonition code block detection
1591
1592    #[test]
1593    fn test_extract_mkdocs_admonition_code_block() {
1594        let config = default_config();
1595        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1596
1597        let content = "!!! note\n    Some text\n\n    ```python\n    def hello():\n        pass\n    ```\n";
1598        let blocks = processor.extract_code_blocks(content);
1599
1600        assert_eq!(blocks.len(), 1, "Should detect code block inside MkDocs admonition");
1601        assert_eq!(blocks[0].language, "python");
1602    }
1603
1604    #[test]
1605    fn test_extract_mkdocs_tab_code_block() {
1606        let config = default_config();
1607        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1608
1609        let content = "=== \"Python\"\n\n    ```python\n    print(\"hello\")\n    ```\n";
1610        let blocks = processor.extract_code_blocks(content);
1611
1612        assert_eq!(blocks.len(), 1, "Should detect code block inside MkDocs tab");
1613        assert_eq!(blocks[0].language, "python");
1614    }
1615
1616    #[test]
1617    fn test_standard_flavor_ignores_admonition_indented_content() {
1618        let config = default_config();
1619        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1620
1621        // With standard flavor, pulldown_cmark parses this differently;
1622        // our MkDocs extraction should NOT run
1623        let content = "!!! note\n    Some text\n\n    ```python\n    def hello():\n        pass\n    ```\n";
1624        let blocks = processor.extract_code_blocks(content);
1625
1626        // Standard flavor relies on pulldown_cmark only, which may or may not detect
1627        // indented fenced blocks. The key assertion is that we don't double-detect.
1628        // With standard flavor, the MkDocs extraction path is skipped entirely.
1629        for (i, b) in blocks.iter().enumerate() {
1630            for (j, b2) in blocks.iter().enumerate() {
1631                if i != j {
1632                    assert_ne!(b.start_line, b2.start_line, "No duplicate blocks should exist");
1633                }
1634            }
1635        }
1636    }
1637
1638    #[test]
1639    fn test_mkdocs_top_level_blocks_alongside_admonition() {
1640        let config = default_config();
1641        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1642
1643        let content =
1644            "```rust\nfn main() {}\n```\n\n!!! note\n    Some text\n\n    ```python\n    print(\"hello\")\n    ```\n";
1645        let blocks = processor.extract_code_blocks(content);
1646
1647        assert_eq!(
1648            blocks.len(),
1649            2,
1650            "Should detect both top-level and admonition code blocks"
1651        );
1652        assert_eq!(blocks[0].language, "rust");
1653        assert_eq!(blocks[1].language, "python");
1654    }
1655
1656    #[test]
1657    fn test_mkdocs_nested_admonition_code_block() {
1658        let config = default_config();
1659        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1660
1661        let content = "\
1662!!! note
1663    Some text
1664
1665    !!! warning
1666        Nested content
1667
1668        ```python
1669        x = 1
1670        ```
1671";
1672        let blocks = processor.extract_code_blocks(content);
1673        assert_eq!(blocks.len(), 1, "Should detect code block inside nested admonition");
1674        assert_eq!(blocks[0].language, "python");
1675    }
1676
1677    #[test]
1678    fn test_mkdocs_consecutive_admonitions_no_stale_context() {
1679        let config = default_config();
1680        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1681
1682        // Two consecutive admonitions at the same indent level.
1683        // The first has no code block, the second does.
1684        let content = "\
1685!!! note
1686    First admonition content
1687
1688!!! warning
1689    Second admonition content
1690
1691    ```python
1692    y = 2
1693    ```
1694";
1695        let blocks = processor.extract_code_blocks(content);
1696        assert_eq!(blocks.len(), 1, "Should detect code block in second admonition only");
1697        assert_eq!(blocks[0].language, "python");
1698    }
1699
1700    #[test]
1701    fn test_mkdocs_crlf_line_endings() {
1702        let config = default_config();
1703        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1704
1705        // Use \r\n line endings
1706        let content = "!!! note\r\n    Some text\r\n\r\n    ```python\r\n    x = 1\r\n    ```\r\n";
1707        let blocks = processor.extract_code_blocks(content);
1708
1709        assert_eq!(blocks.len(), 1, "Should detect code block with CRLF line endings");
1710        assert_eq!(blocks[0].language, "python");
1711
1712        // Verify byte offsets point to valid content
1713        let extracted = &content[blocks[0].content_start..blocks[0].content_end];
1714        assert!(
1715            extracted.contains("x = 1"),
1716            "Extracted content should contain code. Got: {extracted:?}"
1717        );
1718    }
1719
1720    #[test]
1721    fn test_mkdocs_unclosed_fence_in_admonition() {
1722        let config = default_config();
1723        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1724
1725        // Unclosed fence should not produce a block
1726        let content = "!!! note\n    ```python\n    x = 1\n    no closing fence\n";
1727        let blocks = processor.extract_code_blocks(content);
1728        assert_eq!(blocks.len(), 0, "Unclosed fence should not produce a block");
1729    }
1730
1731    #[test]
1732    fn test_mkdocs_tilde_fence_in_admonition() {
1733        let config = default_config();
1734        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1735
1736        let content = "!!! note\n    ~~~ruby\n    puts 'hi'\n    ~~~\n";
1737        let blocks = processor.extract_code_blocks(content);
1738        assert_eq!(blocks.len(), 1, "Should detect tilde-fenced code block");
1739        assert_eq!(blocks[0].language, "ruby");
1740    }
1741
1742    #[test]
1743    fn test_mkdocs_empty_lines_in_code_block() {
1744        let config = default_config();
1745        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1746
1747        // Code block with empty lines inside — verifies byte offsets are correct
1748        // across empty lines (the previous find("") approach would break here)
1749        let content = "!!! note\n    ```python\n    x = 1\n\n    y = 2\n    ```\n";
1750        let blocks = processor.extract_code_blocks(content);
1751        assert_eq!(blocks.len(), 1);
1752
1753        let extracted = &content[blocks[0].content_start..blocks[0].content_end];
1754        assert!(
1755            extracted.contains("x = 1") && extracted.contains("y = 2"),
1756            "Extracted content should span across the empty line. Got: {extracted:?}"
1757        );
1758    }
1759
1760    #[test]
1761    fn test_mkdocs_content_byte_offsets_lf() {
1762        let config = default_config();
1763        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1764
1765        let content = "!!! note\n    ```python\n    print('hi')\n    ```\n";
1766        let blocks = processor.extract_code_blocks(content);
1767        assert_eq!(blocks.len(), 1);
1768
1769        // Verify the extracted content is exactly the code body
1770        let extracted = &content[blocks[0].content_start..blocks[0].content_end];
1771        assert_eq!(extracted, "    print('hi')\n", "Content offsets should be exact for LF");
1772    }
1773
1774    #[test]
1775    fn test_mkdocs_content_byte_offsets_crlf() {
1776        let config = default_config();
1777        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1778
1779        let content = "!!! note\r\n    ```python\r\n    print('hi')\r\n    ```\r\n";
1780        let blocks = processor.extract_code_blocks(content);
1781        assert_eq!(blocks.len(), 1);
1782
1783        let extracted = &content[blocks[0].content_start..blocks[0].content_end];
1784        assert_eq!(
1785            extracted, "    print('hi')\r\n",
1786            "Content offsets should be exact for CRLF"
1787        );
1788    }
1789
1790    #[test]
1791    fn test_lint_enabled_false_skips_language_in_strict_mode() {
1792        // With on-missing-language-definition = "fail", a language configured
1793        // with enabled=false should be silently skipped (no error).
1794        let mut config = default_config();
1795        config.normalize_language = NormalizeLanguage::Exact;
1796        config.on_missing_language_definition = OnMissing::Fail;
1797
1798        // Python has tools, plaintext is disabled
1799        config.languages.insert(
1800            "python".to_string(),
1801            LanguageToolConfig {
1802                lint: vec!["ruff:check".to_string()],
1803                ..Default::default()
1804            },
1805        );
1806        config.languages.insert(
1807            "plaintext".to_string(),
1808            LanguageToolConfig {
1809                enabled: false,
1810                ..Default::default()
1811            },
1812        );
1813
1814        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1815
1816        let content = "```plaintext\nsome text\n```";
1817        let result = processor.lint(content);
1818
1819        // No error for plaintext: enabled=false satisfies strict mode
1820        assert!(result.is_ok());
1821        let diagnostics = result.unwrap();
1822        assert!(
1823            diagnostics.is_empty(),
1824            "Expected no diagnostics for disabled language, got: {diagnostics:?}"
1825        );
1826    }
1827
1828    #[test]
1829    fn test_format_enabled_false_skips_language_in_strict_mode() {
1830        // Same test but for format mode
1831        let mut config = default_config();
1832        config.normalize_language = NormalizeLanguage::Exact;
1833        config.on_missing_language_definition = OnMissing::Fail;
1834
1835        config.languages.insert(
1836            "plaintext".to_string(),
1837            LanguageToolConfig {
1838                enabled: false,
1839                ..Default::default()
1840            },
1841        );
1842
1843        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1844
1845        let content = "```plaintext\nsome text\n```";
1846        let result = processor.format(content);
1847
1848        // No error for plaintext: enabled=false satisfies strict mode
1849        assert!(result.is_ok());
1850        let output = result.unwrap();
1851        assert!(!output.had_errors, "Expected no errors for disabled language");
1852        assert!(
1853            output.error_messages.is_empty(),
1854            "Expected no error messages, got: {:?}",
1855            output.error_messages
1856        );
1857    }
1858
1859    #[test]
1860    fn test_enabled_false_default_true_preserved() {
1861        // Verify that when enabled is not set, it defaults to true (existing behavior)
1862        let mut config = default_config();
1863        config.on_missing_language_definition = OnMissing::Fail;
1864
1865        // Configure python without explicitly setting enabled
1866        config.languages.insert(
1867            "python".to_string(),
1868            LanguageToolConfig {
1869                lint: vec!["ruff:check".to_string()],
1870                ..Default::default()
1871            },
1872        );
1873
1874        let lang_config = config.languages.get("python").unwrap();
1875        assert!(lang_config.enabled, "enabled should default to true");
1876    }
1877
1878    #[test]
1879    fn test_enabled_false_with_fail_fast_no_error() {
1880        // Even with fail-fast, enabled=false should skip silently
1881        let mut config = default_config();
1882        config.normalize_language = NormalizeLanguage::Exact;
1883        config.on_missing_language_definition = OnMissing::FailFast;
1884
1885        config.languages.insert(
1886            "unknown".to_string(),
1887            LanguageToolConfig {
1888                enabled: false,
1889                ..Default::default()
1890            },
1891        );
1892
1893        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1894
1895        let content = "```unknown\nsome content\n```";
1896        let result = processor.lint(content);
1897
1898        // Should not return an error: enabled=false takes precedence over fail-fast
1899        assert!(result.is_ok(), "Expected Ok but got Err: {result:?}");
1900        assert!(result.unwrap().is_empty());
1901    }
1902
1903    #[test]
1904    fn test_enabled_false_format_with_fail_fast_no_error() {
1905        // Same for format mode
1906        let mut config = default_config();
1907        config.normalize_language = NormalizeLanguage::Exact;
1908        config.on_missing_language_definition = OnMissing::FailFast;
1909
1910        config.languages.insert(
1911            "unknown".to_string(),
1912            LanguageToolConfig {
1913                enabled: false,
1914                ..Default::default()
1915            },
1916        );
1917
1918        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1919
1920        let content = "```unknown\nsome content\n```";
1921        let result = processor.format(content);
1922
1923        assert!(result.is_ok(), "Expected Ok but got Err: {result:?}");
1924        let output = result.unwrap();
1925        assert!(!output.had_errors);
1926    }
1927
1928    #[test]
1929    fn test_enabled_false_with_tools_still_skips() {
1930        // If enabled=false but tools are listed, the language should still be skipped
1931        let mut config = default_config();
1932        config.on_missing_language_definition = OnMissing::Fail;
1933
1934        config.languages.insert(
1935            "python".to_string(),
1936            LanguageToolConfig {
1937                enabled: false,
1938                lint: vec!["ruff:check".to_string()],
1939                format: vec!["ruff:format".to_string()],
1940                on_error: None,
1941            },
1942        );
1943
1944        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1945
1946        let content = "```python\nprint('hello')\n```";
1947
1948        // Lint should skip
1949        let lint_result = processor.lint(content);
1950        assert!(lint_result.is_ok());
1951        assert!(lint_result.unwrap().is_empty());
1952
1953        // Format should skip
1954        let format_result = processor.format(content);
1955        assert!(format_result.is_ok());
1956        let output = format_result.unwrap();
1957        assert!(!output.had_errors);
1958        assert_eq!(output.content, content, "Content should be unchanged");
1959    }
1960
1961    #[test]
1962    fn test_enabled_true_without_tools_triggers_strict_mode() {
1963        // A language configured with enabled=true (default) but no tools
1964        // should still trigger strict mode errors
1965        let mut config = default_config();
1966        config.on_missing_language_definition = OnMissing::Fail;
1967
1968        config.languages.insert(
1969            "python".to_string(),
1970            LanguageToolConfig {
1971                // enabled defaults to true, no tools
1972                ..Default::default()
1973            },
1974        );
1975
1976        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1977
1978        let content = "```python\nprint('hello')\n```";
1979        let result = processor.lint(content);
1980
1981        // Should report an error because enabled=true but no lint tools configured
1982        assert!(result.is_ok());
1983        let diagnostics = result.unwrap();
1984        assert_eq!(diagnostics.len(), 1);
1985        assert!(diagnostics[0].message.contains("No lint tools configured"));
1986    }
1987
1988    #[test]
1989    fn test_mixed_enabled_and_disabled_languages() {
1990        // Multiple languages: one disabled, one unconfigured
1991        let mut config = default_config();
1992        config.normalize_language = NormalizeLanguage::Exact;
1993        config.on_missing_language_definition = OnMissing::Fail;
1994
1995        config.languages.insert(
1996            "plaintext".to_string(),
1997            LanguageToolConfig {
1998                enabled: false,
1999                ..Default::default()
2000            },
2001        );
2002
2003        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2004
2005        let content = "\
2006```plaintext
2007some text
2008```
2009
2010```javascript
2011console.log('hi');
2012```
2013";
2014
2015        let result = processor.lint(content);
2016        assert!(result.is_ok());
2017        let diagnostics = result.unwrap();
2018
2019        // plaintext: skipped (enabled=false), no error
2020        // javascript: not configured at all, should trigger strict mode error
2021        assert_eq!(diagnostics.len(), 1, "Expected 1 diagnostic, got: {diagnostics:?}");
2022        assert!(
2023            diagnostics[0].message.contains("javascript"),
2024            "Error should be about javascript, got: {}",
2025            diagnostics[0].message
2026        );
2027    }
2028
2029    #[test]
2030    fn test_generic_fallback_includes_all_stderr_lines() {
2031        let config = default_config();
2032        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2033
2034        // Use output that won't be parsed by any structured format parser
2035        let output = ToolOutput {
2036            stdout: String::new(),
2037            stderr: "Parse error at position 42\nUnexpected token '::'\n3 errors found".to_string(),
2038            exit_code: 1,
2039            success: false,
2040        };
2041
2042        let diags = processor.parse_tool_output(&output, "tombi", 5);
2043        assert_eq!(diags.len(), 3, "Expected one diagnostic per non-empty stderr line");
2044        assert_eq!(diags[0].message, "Parse error at position 42");
2045        assert_eq!(diags[1].message, "Unexpected token '::'");
2046        assert_eq!(diags[2].message, "3 errors found");
2047        assert!(diags.iter().all(|d| d.tool == "tombi"));
2048        assert!(diags.iter().all(|d| d.file_line == 5));
2049    }
2050
2051    #[test]
2052    fn test_generic_fallback_includes_all_stdout_lines_when_stderr_empty() {
2053        let config = default_config();
2054        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2055
2056        let output = ToolOutput {
2057            stdout: "Line 1 error\nLine 2 detail\nLine 3 summary".to_string(),
2058            stderr: String::new(),
2059            exit_code: 1,
2060            success: false,
2061        };
2062
2063        let diags = processor.parse_tool_output(&output, "some-tool", 10);
2064        assert_eq!(diags.len(), 3);
2065        assert_eq!(diags[0].message, "Line 1 error");
2066        assert_eq!(diags[1].message, "Line 2 detail");
2067        assert_eq!(diags[2].message, "Line 3 summary");
2068    }
2069
2070    #[test]
2071    fn test_generic_fallback_skips_blank_lines() {
2072        let config = default_config();
2073        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2074
2075        let output = ToolOutput {
2076            stdout: String::new(),
2077            stderr: "error: bad input\n\n  \n\ndetail: see above\n".to_string(),
2078            exit_code: 1,
2079            success: false,
2080        };
2081
2082        let diags = processor.parse_tool_output(&output, "tool", 1);
2083        assert_eq!(diags.len(), 2);
2084        assert_eq!(diags[0].message, "error: bad input");
2085        assert_eq!(diags[1].message, "detail: see above");
2086    }
2087
2088    #[test]
2089    fn test_generic_fallback_exit_code_when_no_output() {
2090        let config = default_config();
2091        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2092
2093        let output = ToolOutput {
2094            stdout: String::new(),
2095            stderr: String::new(),
2096            exit_code: 42,
2097            success: false,
2098        };
2099
2100        let diags = processor.parse_tool_output(&output, "tool", 1);
2101        assert_eq!(diags.len(), 1);
2102        assert_eq!(diags[0].message, "Tool exited with code 42");
2103    }
2104
2105    #[test]
2106    fn test_generic_fallback_not_triggered_on_success() {
2107        let config = default_config();
2108        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2109
2110        let output = ToolOutput {
2111            stdout: "some informational output".to_string(),
2112            stderr: String::new(),
2113            exit_code: 0,
2114            success: true,
2115        };
2116
2117        let diags = processor.parse_tool_output(&output, "tool", 1);
2118        assert!(
2119            diags.is_empty(),
2120            "Successful tool runs should produce no fallback diagnostics"
2121        );
2122    }
2123}