Welcome to Chapter 8 of our journey to build a production-grade Mermaid code analyzer and fixer. In the previous chapters, we laid the foundational components: the lexer, parser, AST, validator, rule engine, and diagnostics system. These are the core engines of our tool, but without a robust command-line interface (CLI), our powerful backend remains inaccessible to users.

This chapter focuses entirely on building a user-friendly and feature-rich CLI for our mermaid-analyzer tool. We will leverage the clap crate for argument parsing, providing a familiar and intuitive experience for developers. Our CLI will support multiple output modes: lint for reporting issues, fix for applying safe transformations, and strict for enforcing the highest level of correctness. We’ll also ensure our output is clear, actionable, and visually appealing using colored terminal output, mirroring the excellent diagnostics provided by the Rust compiler itself.

By the end of this chapter, you will have a fully functional mermaid-analyzer executable that can be invoked from the terminal, taking Mermaid code as input (from files or stdin) and producing diagnostics or fixed output based on the chosen mode. This is a critical step towards making our tool practical and deployable, transforming it from a collection of libraries into a tangible, useful application.

Planning & Design

A well-designed CLI is crucial for developer experience. Our tool needs to be intuitive, provide clear feedback, and offer flexible options for different use cases.

CLI Architecture

The CLI will act as the orchestrator, taking user input (commands, flags, file paths) and coordinating the execution flow through our previously built components.

flowchart TD User[User Input via Terminal] --> CLI_App[mermaid-analyzer CLI Application] subgraph CLI_App A[Argument Parsing - Clap] --> B{Command Type?} B -->|Lint| Lint_Handler[Lint Command Handler] B -->|Fix| Fix_Handler[Fix Command Handler] B -->|Strict| Strict_Handler[Strict Command Handler] end subgraph Core_Components["Core Analyzer Components"] Lexer[Lexer] Parser[Parser] AST[Abstract Syntax Tree] Validator[Validator] Rule_Engine[Rule Engine] Diagnostics[Diagnostics System] Formatter[Formatter] end Lint_Handler -->|Input Mermaid Code| Lexer Lexer --> Parser Parser --> AST AST --> Validator Validator --> Diagnostics Diagnostics -->|Report Issues| Output_Formatter[Output Formatter - Colored] Output_Formatter --> User Fix_Handler --> Lexer Lexer --> Parser Parser --> AST AST --> Rule_Engine Rule_Engine --> Formatter Formatter -->|Fixed Mermaid Code| Output_Formatter Output_Formatter --> User Strict_Handler --> Lexer Lexer --> Parser Parser --> AST AST --> Validator Validator --> Diagnostics Diagnostics -->|Errors/Warnings?| Strict_Decision{Any Errors or Warnings?} Strict_Decision -->|Yes| Fail_Strict[Exit with Error Code] Strict_Decision -->|No, safe to fix| Rule_Engine Rule_Engine --> Formatter Formatter -->|Fixed Code| Output_Formatter Output_Formatter --> User

Explanation of the CLI Flow:

  1. User Input: The user interacts with the mermaid-analyzer executable via the command line.
  2. Argument Parsing: The clap crate parses the command-line arguments, identifying the subcommand (lint, fix, strict), input files, and any flags (e.g., --output, --format).
  3. Command Handling: Based on the subcommand, a dedicated handler function is invoked.
  4. Core Component Integration: Each handler orchestrates the calls to our lexer, parser, validator, rule_engine, diagnostics, and formatter modules.
  5. Output Formatting: A dedicated output module takes the results (diagnostics, fixed code) and formats them for terminal display, including colored output for readability.
  6. User Feedback: The formatted output is printed to the console, providing the user with the requested information or fixed code.

File Structure for the CLI Module

We’ll organize our CLI logic within a src/cli module to keep it separate from the core library components.

src/
├── main.rs                 # Entry point, calls cli::run
├── cli/
│   ├── mod.rs              # Public interface for CLI, main run logic
│   ├── args.rs             # Defines CLI arguments and subcommands using clap
│   ├── handlers.rs         # Contains logic for each subcommand (lint, fix, strict)
│   ├── output.rs           # Helpers for formatting and printing colored output
│   └── error.rs            # CLI-specific error types
├── lexer/                  # (existing)
├── parser/                 # (existing)
├── ast/                    # (existing)
├── validator/              # (existing)
├── diagnostics/            # (existing)
├── rule_engine/            # (existing)
└── formatter/              # (existing)

Command-Line Arguments

We’ll define the following structure for our CLI:

  • Main Command: mermaid-analyzer
  • Subcommands:
    • lint: Reports issues without making changes.
      • --file <PATH>: Input Mermaid file.
      • --format <FORMAT>: Output format (e.g., text (default), json).
      • --warn-as-error: Treat warnings as errors, exiting with non-zero code.
    • fix: Applies safe fixes and outputs corrected code.
      • --file <PATH>: Input Mermaid file.
      • --output <PATH>: Output file for fixed code (defaults to stdout).
      • --check: Only report if fixes would be applied, do not write.
      • --diff: Show a diff of changes.
    • strict: Combines lint and fix, failing on any issue and only applying guaranteed safe fixes.
      • --file <PATH>: Input Mermaid file.
      • --output <PATH>: Output file for fixed code (defaults to stdout).
      • --check: Only report if fixes would be applied, do not write.
      • --diff: Show a diff of changes.
  • Global Options:
    • --verbose / -v: Increase logging verbosity.
    • --quiet / -q: Suppress non-error output.
    • --color <WHEN>: Control colored output (e.g., auto, always, never).

Step-by-Step Implementation

a) Setup/Configuration

First, let’s add the necessary dependencies to our Cargo.toml. We’ll use clap for argument parsing, owo-colors for colored terminal output, anyhow for simplified error handling, tracing for structured logging, and tracing-subscriber to configure it.

Cargo.toml

[package]
name = "mermaid-analyzer"
version = "0.1.0"
edition = "2021"

[dependencies]
# CLI Argument parsing
clap = { version = "4.5", features = ["derive"] }
# Colored terminal output
owo-colors = "3.5"
# Flexible error handling
anyhow = "1.0"
# Structured logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Diffing library for fix mode
similar = "2.2"
# Text formatting for diagnostics
termwidth = "0.1" # A simple crate to get terminal width for better formatting

# Internal crates (assuming a workspace or path dependencies)
# For a single project, these would be modules within src/lib.rs or direct files.
# For this tutorial, we'll assume they are modules directly within src/.
# In a real-world multi-crate project, these would be:
# mermaid-analyzer-lexer = { path = "./crates/lexer" }
# mermaid-analyzer-parser = { path = "./crates/parser" }
# ...
# But for simplicity, we'll treat them as modules:
# (No explicit Cargo.toml entry for internal modules)

Next, let’s set up our src/main.rs to initialize logging and delegate to our cli module.

src/main.rs

use anyhow::Result;
use tracing_subscriber::{EnvFilter, FmtSubscriber};

mod cli;
mod lexer;
mod parser;
mod ast;
mod validator;
mod diagnostics;
mod rule_engine;
mod formatter;

#[tokio::main] // Assuming we might use async I/O in the future, good practice
async fn main() -> Result<()> {
    // Initialize logging
    // Reads RUST_LOG environment variable, e.g., RUST_LOG="info"
    let subscriber = FmtSubscriber::builder()
        .with_env_filter(EnvFilter::from_default_env())
        .finish();
    tracing::subscriber::set_global_default(subscriber)
        .expect("setting default subscriber failed");

    cli::run_cli().await
}

Explanation:

  • anyhow::Result: Simplifies error handling by allowing ? operator for functions returning Result.
  • tracing and tracing_subscriber: We initialize a tracing subscriber that reads the RUST_LOG environment variable. This allows users to control logging verbosity (e.g., RUST_LOG=debug mermaid-analyzer lint ...).
  • mod cli;: Declares our cli module, which will contain all CLI-specific logic.
  • #[tokio::main]: While our current CLI might not be heavily async, using tokio::main sets up an async runtime, which is a good forward-looking practice for Rust applications that might involve network requests or complex file I/O. For now, it simply allows async fn main().
  • cli::run_cli().await: Delegates the main CLI execution to our cli module.

b) Core Implementation - CLI Argument Parsing

We’ll define the CLI structure using clap’s derive macros.

src/cli/args.rs

use clap::{Parser, Subcommand, ValueEnum};
use std::path::PathBuf;

/// A strict Mermaid diagram analyzer and fixer.
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
pub struct Cli {
    /// Path to the Mermaid file to process. If not provided, reads from stdin.
    #[arg(short, long, value_name = "FILE")]
    pub file: Option<PathBuf>,

    /// Controls when to use colors.
    #[arg(long, default_value_t = ColorWhen::Auto, value_enum)]
    pub color: ColorWhen,

    /// Increase logging verbosity.
    #[arg(short, long, action = clap::ArgAction::Count)]
    pub verbose: u8,

    /// Suppress all non-error output.
    #[arg(short, long)]
    pub quiet: bool,

    #[command(subcommand)]
    pub command: Command,
}

#[derive(Subcommand, Debug)]
pub enum Command {
    /// Lints a Mermaid file for syntax and semantic errors.
    Lint(LintOptions),
    /// Fixes common Mermaid issues and outputs the corrected code.
    Fix(FixOptions),
    /// Lints and automatically fixes issues, failing on any remaining error or warning.
    Strict(StrictOptions),
}

#[derive(Parser, Debug)]
pub struct LintOptions {
    /// Output format for diagnostics.
    #[arg(long, default_value_t = LintFormat::Text, value_enum)]
    pub format: LintFormat,

    /// Treat all warnings as errors, exiting with a non-zero status code.
    #[arg(long)]
    pub warn_as_error: bool,
}

#[derive(Parser, Debug)]
pub struct FixOptions {
    /// Path to write the fixed Mermaid code. If not provided, prints to stdout.
    #[arg(short, long, value_name = "FILE")]
    pub output: Option<PathBuf>,

    /// Only check if fixes would be applied, do not write changes.
    #[arg(long)]
    pub check: bool,

    /// Show a diff of the changes applied.
    #[arg(long)]
    pub diff: bool,
}

#[derive(Parser, Debug)]
pub struct StrictOptions {
    /// Path to write the fixed Mermaid code. If not provided, prints to stdout.
    #[arg(short, long, value_name = "FILE")]
    pub output: Option<PathBuf>,

    /// Only check if fixes would be applied, do not write changes.
    #[arg(long)]
    pub check: bool,

    /// Show a diff of the changes applied.
    #[arg(long)]
    pub diff: bool,
}

#[derive(ValueEnum, Debug, Clone, Copy)]
pub enum ColorWhen {
    Auto,
    Always,
    Never,
}

#[derive(ValueEnum, Debug, Clone, Copy)]
pub enum LintFormat {
    Text,
    Json,
}

Explanation:

  • #[derive(Parser, Debug)]: clap macros to automatically generate argument parsing logic.
  • Cli struct: Represents the top-level command, holding global options and subcommands.
  • #[command(author, version, about, long_about = None)]: Automatically populates help messages from Cargo.toml.
  • #[arg(...)]: Attributes for individual arguments, defining short/long flags, default values, value names, etc.
  • Subcommand enum: Defines the lint, fix, and strict subcommands, each with its own options struct.
  • ValueEnum: Used for custom enum types that clap should parse from string inputs (e.g., ColorWhen, LintFormat).

Now, let’s create the src/cli/mod.rs file to integrate clap and define the main run_cli function.

src/cli/mod.rs

use anyhow::{Context, Result};
use clap::Parser;
use tracing::{debug, error, info, warn};

pub mod args;
pub mod handlers;
pub mod output;
pub mod error; // We'll define CLI-specific errors here later

use args::{Cli, Command};
use handlers::{handle_fix_command, handle_lint_command, handle_strict_command};

/// Entry point for the CLI application.
pub async fn run_cli() -> Result<()> {
    let cli = Cli::parse();

    // Configure logging verbosity based on CLI flags
    // This overrides RUST_LOG if specified via CLI
    let log_level = match cli.verbose {
        0 => "info",
        1 => "debug",
        _ => "trace",
    };
    if cli.quiet {
        // Quiet mode overrides verbosity, only show errors
        std::env::set_var("RUST_LOG", "error");
    } else {
        std::env::set_var("RUST_LOG", log_level);
    }
    // Re-initialize tracing subscriber with potentially updated RUST_LOG
    // NOTE: In a production app, you might want a more sophisticated way
    // to dynamically adjust logging without re-initializing the global subscriber.
    // For simplicity, we assume this is called once at startup.
    tracing_subscriber::fmt::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::from_default_env()
                .add_directive(log_level.parse().unwrap())
        )
        .init();
    info!("CLI arguments parsed: {:?}", cli);

    let input_code = match &cli.file {
        Some(path) => {
            debug!("Reading input from file: {}", path.display());
            tokio::fs::read_to_string(path)
                .await
                .with_context(|| format!("Failed to read file: {}", path.display()))?
        }
        None => {
            debug!("Reading input from stdin");
            let mut buffer = String::new();
            tokio::io::stdin()
                .read_to_string(&mut buffer)
                .await
                .context("Failed to read from stdin")?
        }
    };

    match cli.command {
        Command::Lint(options) => {
            handle_lint_command(&input_code, cli.file.as_deref(), &options, cli.color).await
        }
        Command::Fix(options) => {
            handle_fix_command(&input_code, cli.file.as_deref(), &options, cli.color).await
        }
        Command::Strict(options) => {
            handle_strict_command(&input_code, cli.file.as_deref(), &options, cli.color).await
        }
    }
}

Explanation:

  • Cli::parse(): This is where clap does its magic, parsing command-line arguments into our Cli struct.
  • Logging Configuration: We dynamically adjust the RUST_LOG environment variable based on --verbose or --quiet flags. This allows users to control the verbosity of info!, debug!, trace! messages. Note the re-initialization of the tracing subscriber for simplicity; in a more complex application, you might use a reload_handle from tracing-subscriber.
  • Input Handling: The CLI can read Mermaid code either from a specified file (--file) or from standard input (stdin). This is crucial for pipeline integration (e.g., cat diagram.mmd | mermaid-analyzer lint). We use tokio::fs::read_to_string and tokio::io::stdin().read_to_string for async file/stdin reading.
  • Command Dispatch: A match statement dispatches to the appropriate handler function based on the parsed subcommand. Each handler receives the input code, optional file path, subcommand-specific options, and color preference.

c) Core Implementation - Output Formatting

Before implementing handlers, let’s create a utility module for consistent, colored output.

src/cli/output.rs

use crate::diagnostics::{Diagnostic, DiagnosticSeverity, Span};
use crate::cli::args::ColorWhen;
use owo_colors::{OwoColorize, Style};
use std::io::{self, Write};
use std::path::Path;
use tracing::warn;
use similar::{ChangeTag, TextDiff};

/// Determines if colored output should be used.
pub fn should_color(color_when: ColorWhen) -> bool {
    match color_when {
        ColorWhen::Always => true,
        ColorWhen::Never => false,
        ColorWhen::Auto => atty::is(atty::Stream::Stdout),
    }
}

/// Styles a message based on diagnostic severity.
fn get_severity_style(severity: DiagnosticSeverity) -> Style {
    match severity {
        DiagnosticSeverity::Error => Style::new().red().bold(),
        DiagnosticSeverity::Warning => Style::new().yellow().bold(),
        DiagnosticSeverity::Info => Style::new().blue().bold(),
        DiagnosticSeverity::Hint => Style::new().cyan().bold(),
    }
}

/// Formats and prints a single diagnostic message.
pub fn print_diagnostic(
    diagnostic: &Diagnostic,
    source_code: &str,
    file_path: Option<&Path>,
    color_enabled: bool,
) {
    let mut stdout = io::stdout().lock();

    // Determine the base style for the severity label
    let severity_style = get_severity_style(diagnostic.severity);
    let severity_label = format!("{}", diagnostic.severity).to_uppercase();

    // Print header: file:line:col - SEVERITY[CODE]: Message
    let file_info = if let Some(path) = file_path {
        format!("{}:{}:{} ", path.display(), diagnostic.span.start_line, diagnostic.span.start_col)
    } else {
        String::new()
    };

    let header_message = format!(
        "{}{}{}[{}]: {}",
        file_info,
        if color_enabled { severity_label.style(severity_style) } else { severity_label.normal() },
        if color_enabled { ": ".bold() } else { ": ".normal() },
        if color_enabled { diagnostic.code.bold() } else { diagnostic.code.normal() },
        if color_enabled { diagnostic.message.bold() } else { diagnostic.message.normal() },
    );
    writeln!(stdout, "{}", header_message).expect("Failed to write to stdout");

    // Print code snippet with highlight
    if let Some(code_lines) = source_code.lines().nth(diagnostic.span.start_line - 1) {
        let line_num_str = format!("{} | ", diagnostic.span.start_line);
        let padding = " ".repeat(line_num_str.len());

        writeln!(stdout, "{} {}", padding, if color_enabled { "|".blue() } else { "|".normal() }).expect("Failed to write to stdout");
        writeln!(stdout, "{}{}", if color_enabled { line_num_str.blue() } else { line_num_str.normal() }, code_lines).expect("Failed to write to stdout");

        // Calculate highlight span within the line
        let highlight_start_col = diagnostic.span.start_col - 1;
        let highlight_end_col = if diagnostic.span.start_line == diagnostic.span.end_line {
            diagnostic.span.end_col - 1
        } else {
            // If diagnostic spans multiple lines, highlight to end of current line
            code_lines.len()
        };
        let highlight_len = highlight_end_col.saturating_sub(highlight_start_col).max(1); // Ensure at least 1 char is highlighted

        let highlight = format!(
            "{}{}",
            " ".repeat(highlight_start_col),
            "^".repeat(highlight_len)
        );

        writeln!(stdout, "{} {}", padding, if color_enabled { highlight.red().bold() } else { highlight.normal() }).expect("Failed to write to stdout");
    } else {
        warn!("Could not retrieve source line {} for diagnostic.", diagnostic.span.start_line);
    }

    // Print help message if available
    if let Some(help) = &diagnostic.help {
        let help_label = if color_enabled { "  help:".green().bold() } else { "  help:".normal() };
        writeln!(stdout, "{} {}", help_label, help).expect("Failed to write to stdout");
    }
    writeln!(stdout).expect("Failed to write to stdout"); // Empty line for separation
}

/// Prints a summary of applied fixes.
pub fn print_fix_summary(
    fixed_count: usize,
    warning_count: usize,
    error_count: usize,
    color_enabled: bool,
) {
    let mut stdout = io::stdout().lock();
    let total_issues = warning_count + error_count;

    writeln!(stdout, "--- Analysis Summary ---").expect("Failed to write to stdout");
    if fixed_count > 0 {
        writeln!(stdout, "{} {} issues fixed.", if color_enabled { fixed_count.to_string().green().bold() } else { fixed_count.to_string().normal() }, if fixed_count == 1 { "issue" } else { "issues" }).expect("Failed to write to stdout");
    }
    if warning_count > 0 {
        writeln!(stdout, "{} {} warnings.", if color_enabled { warning_count.to_string().yellow().bold() } else { warning_count.to_string().normal() }, if warning_count == 1 { "warning" } else { "warnings" }).expect("Failed to write to stdout");
    }
    if error_count > 0 {
        writeln!(stdout, "{} {} errors.", if color_enabled { error_count.to_string().red().bold() } else { error_count.to_string().normal() }, if error_count == 1 { "error" } else { "errors" }).expect("Failed to write to stdout");
    }
    if total_issues == 0 && fixed_count == 0 {
        writeln!(stdout, "No issues found. Diagram is {}!", if color_enabled { "clean".green().bold() } else { "clean".normal() }).expect("Failed to write to stdout");
    }
    writeln!(stdout, "------------------------").expect("Failed to write to stdout");
}

/// Prints a unified diff between original and fixed code.
pub fn print_diff(original: &str, fixed: &str, color_enabled: bool) {
    let diff = TextDiff::from_lines(original, fixed);
    let mut stdout = io::stdout().lock();

    writeln!(stdout, "--- Diff ---").expect("Failed to write to stdout");
    for change in diff.iter_all_changes() {
        let line = format!("{}", change.value());
        match change.tag() {
            ChangeTag::Delete => writeln!(stdout, "{}", if color_enabled { format!("-{}", line).red() } else { format!("-{}", line).normal() }),
            ChangeTag::Insert => writeln!(stdout, "{}", if color_enabled { format!("+{}", line).green() } else { format!("+{}", line).normal() }),
            ChangeTag::Equal => writeln!(stdout, "{}", format!(" {}", line).normal()), // No color for equal lines
        }.expect("Failed to write to stdout");
    }
    writeln!(stdout, "------------").expect("Failed to write to stdout");
}

Explanation:

  • should_color: Uses atty crate to detect if stdout is a TTY, enabling colors by default only for interactive terminals.
  • get_severity_style: Maps DiagnosticSeverity to owo-colors styles.
  • print_diagnostic: This is the core function for displaying diagnostic messages. It mimics rustc’s output format, including:
    • File path, line, and column.
    • Colored severity label (ERROR, WARNING).
    • Diagnostic code and message.
    • Code snippet with a highlight underneath the problematic span.
    • Optional help message.
  • print_fix_summary: Provides a concise summary of how many issues were found and fixed.
  • print_diff: Uses the similar crate to generate and print a unified diff between two strings, highlighting additions and deletions.

d) Core Implementation - CLI-specific Errors

A custom error type for the CLI can help distinguish between different failure modes.

src/cli/error.rs

use thiserror::Error;

#[derive(Error, Debug)]
pub enum CliError {
    #[error("Failed to read input: {0}")]
    InputReadError(#[from] std::io::Error),
    #[error("Failed to write output to '{}': {0}", .0.display())]
    OutputWriteError(std::path::PathBuf, #[source] std::io::Error),
    #[error("Invalid Mermaid syntax: {0}")]
    SyntaxError(String), // Simplified for now, will integrate with diagnostics
    #[error("Processing failed: {0}")]
    ProcessingError(String), // General processing error
    #[error("Strict mode failure: {0} errors and {1} warnings found.")]
    StrictModeFailure(usize, usize),
}

Explanation:

  • thiserror::Error: A derive macro that simplifies implementing the Error trait, making custom errors easy to define and use.
  • CliError: Defines various error conditions specific to the CLI’s operation, such as file I/O issues or strict mode violations.

e) Core Implementation - Handlers for lint, fix, strict

Now we’ll implement the actual logic for each subcommand in src/cli/handlers.rs. These handlers will orchestrate the calls to our lexer, parser, validator, rule engine, and formatter.

src/cli/handlers.rs

use anyhow::{Context, Result};
use std::io::{self, Write};
use std::path::Path;
use tracing::{debug, error, info, warn};

use crate::cli::args::{ColorWhen, LintFormat, LintOptions, FixOptions, StrictOptions};
use crate::cli::output::{should_color, print_diagnostic, print_fix_summary, print_diff};
use crate::cli::error::CliError;
use crate::lexer::Lexer;
use crate::parser::Parser;
use crate::validator::Validator;
use crate::diagnostics::{Diagnostic, DiagnosticSeverity, DiagnosticCollection};
use crate::rule_engine::{RuleEngine, RuleApplicationResult};
use crate::formatter::Formatter;

/// Handles the 'lint' subcommand.
pub async fn handle_lint_command(
    input_code: &str,
    file_path: Option<&Path>,
    options: &LintOptions,
    color_when: ColorWhen,
) -> Result<()> {
    info!("Executing 'lint' command.");
    let color_enabled = should_color(color_when);

    let mut diagnostics = DiagnosticCollection::new();

    // 1. Lexing
    debug!("Starting lexing.");
    let tokens = Lexer::new(input_code).tokenize(&mut diagnostics);
    debug!("Lexing complete. Found {} tokens.", tokens.len());

    // 2. Parsing
    debug!("Starting parsing.");
    let parse_result = Parser::new(tokens).parse(&mut diagnostics);
    let ast = match parse_result {
        Ok(ast) => {
            debug!("Parsing complete. AST generated.");
            Some(ast)
        },
        Err(_) => {
            error!("Parsing failed. Diagnostics collected.");
            None // Parser error means we can't build a full AST, but diagnostics are crucial
        }
    };

    // 3. Validation (only if AST was successfully built)
    if let Some(ast_root) = &ast {
        debug!("Starting validation.");
        Validator::new().validate(ast_root, &mut diagnostics);
        debug!("Validation complete.");
    } else {
        warn!("Skipping semantic validation due to parsing errors.");
    }

    // Output diagnostics
    match options.format {
        LintFormat::Text => {
            for diag in diagnostics.iter() {
                print_diagnostic(diag, input_code, file_path, color_enabled);
            }
        }
        LintFormat::Json => {
            // In a real-world scenario, you'd serialize to a proper JSON array of diagnostics.
            // For now, we'll just print a basic representation.
            let json_output = serde_json::to_string_pretty(diagnostics.iter().collect::<Vec<_>>())
                .context("Failed to serialize diagnostics to JSON")?;
            println!("{}", json_output);
        }
    }

    let error_count = diagnostics.errors().count();
    let warning_count = diagnostics.warnings().count();

    if error_count > 0 || (options.warn_as_error && warning_count > 0) {
        error!(
            "Linting failed: {} errors, {} warnings.",
            error_count, warning_count
        );
        // Exit with a non-zero status code to indicate failure in CI/CD pipelines
        std::process::exit(1);
    } else if warning_count > 0 {
        info!("Linting complete: {} warnings found.", warning_count);
    } else {
        info!("Linting complete: No issues found.");
    }

    Ok(())
}

/// Handles the 'fix' subcommand.
pub async fn handle_fix_command(
    input_code: &str,
    file_path: Option<&Path>,
    options: &FixOptions,
    color_when: ColorWhen,
) -> Result<()> {
    info!("Executing 'fix' command.");
    let color_enabled = should_color(color_when);

    let mut diagnostics = DiagnosticCollection::new();

    // 1. Lexing
    let tokens = Lexer::new(input_code).tokenize(&mut diagnostics);

    // 2. Parsing
    let mut ast = Parser::new(tokens).parse(&mut diagnostics)
        .map_err(|e| CliError::ProcessingError(format!("Parsing failed: {}", e)))?;

    // 3. Apply rules to fix AST
    debug!("Applying rule engine fixes.");
    let rule_engine = RuleEngine::new(); // In a real app, this would be configured with specific rules
    let fix_result = rule_engine.apply_fixes(&mut ast, &mut diagnostics);
    debug!("Rule engine applied {} fixes.", fix_result.fixes_applied);

    // 4. Format the fixed AST back to Mermaid code
    let formatter = Formatter::new(); // Formatter might have options later
    let fixed_code = formatter.format(&ast);

    // Output diagnostics (if any remained after fixes)
    for diag in diagnostics.iter() {
        print_diagnostic(diag, input_code, file_path, color_enabled);
    }

    if options.check {
        if fix_result.fixes_applied > 0 {
            info!("'--check' mode: {} fixes would be applied.", fix_result.fixes_applied);
            if options.diff {
                print_diff(input_code, &fixed_code, color_enabled);
            }
            std::process::exit(1); // Indicate that changes are needed
        } else {
            info!("'--check' mode: No fixes needed.");
        }
    } else {
        if options.diff {
            print_diff(input_code, &fixed_code, color_enabled);
        }

        match &options.output {
            Some(path) => {
                debug!("Writing fixed code to file: {}", path.display());
                tokio::fs::write(path, fixed_code)
                    .await
                    .map_err(|e| CliError::OutputWriteError(path.clone(), e))?;
                info!("Fixed code written to {}", path.display());
            }
            None => {
                debug!("Printing fixed code to stdout.");
                io::stdout().write_all(fixed_code.as_bytes())
                    .context("Failed to write fixed code to stdout")?;
                writeln!(io::stdout()).context("Failed to write newline")?; // Ensure a final newline
            }
        }
    }

    print_fix_summary(
        fix_result.fixes_applied,
        diagnostics.warnings().count(),
        diagnostics.errors().count(),
        color_enabled,
    );

    let error_count = diagnostics.errors().count();
    if error_count > 0 {
        error!("Fix command finished with {} errors.", error_count);
        std::process::exit(1);
    }

    Ok(())
}

/// Handles the 'strict' subcommand.
pub async fn handle_strict_command(
    input_code: &str,
    file_path: Option<&Path>,
    options: &StrictOptions,
    color_when: ColorWhen,
) -> Result<()> {
    info!("Executing 'strict' command.");
    let color_enabled = should_color(color_when);

    let mut diagnostics = DiagnosticCollection::new();

    // 1. Lexing
    let tokens = Lexer::new(input_code).tokenize(&mut diagnostics);

    // 2. Parsing
    let mut ast = Parser::new(tokens).parse(&mut diagnostics)
        .map_err(|e| CliError::ProcessingError(format!("Parsing failed: {}", e)))?;

    // Initial check for errors before applying any fixes in strict mode
    // Strict mode fails immediately on *any* error or warning that cannot be safely fixed.
    let initial_error_count = diagnostics.errors().count();
    let initial_warning_count = diagnostics.warnings().count();

    if initial_error_count > 0 {
        for diag in diagnostics.iter() {
            print_diagnostic(diag, input_code, file_path, color_enabled);
        }
        return Err(CliError::StrictModeFailure(initial_error_count, initial_warning_count).into());
    }

    // 3. Apply rules to fix AST (only if no initial errors)
    debug!("Applying rule engine fixes in strict mode.");
    let rule_engine = RuleEngine::new(); // Configured for strict, safe fixes
    let fix_result = rule_engine.apply_safe_fixes(&mut ast, &mut diagnostics); // Assuming RuleEngine has apply_safe_fixes
    debug!("Rule engine applied {} safe fixes.", fix_result.fixes_applied);

    // Re-validate and check for remaining issues after fixes
    let mut post_fix_diagnostics = DiagnosticCollection::new();
    Validator::new().validate(&ast, &mut post_fix_diagnostics); // Re-validate fixed AST

    // Combine diagnostics
    diagnostics.extend(post_fix_diagnostics);

    // Output all diagnostics
    for diag in diagnostics.iter() {
        print_diagnostic(diag, input_code, file_path, color_enabled);
    }

    let final_error_count = diagnostics.errors().count();
    let final_warning_count = diagnostics.warnings().count();

    if final_error_count > 0 || final_warning_count > 0 {
        error!(
            "Strict mode failed: {} errors and {} warnings remain after fixes.",
            final_error_count, final_warning_count
        );
        return Err(CliError::StrictModeFailure(final_error_count, final_warning_count).into());
    }

    // 4. Format the fixed AST back to Mermaid code
    let formatter = Formatter::new();
    let fixed_code = formatter.format(&ast);

    if options.check {
        if fix_result.fixes_applied > 0 {
            info!("'--check' mode (strict): {} safe fixes would be applied.", fix_result.fixes_applied);
            if options.diff {
                print_diff(input_code, &fixed_code, color_enabled);
            }
            std::process::exit(1); // Indicate that changes are needed
        } else {
            info!("'--check' mode (strict): No safe fixes needed.");
        }
    } else {
        if options.diff {
            print_diff(input_code, &fixed_code, color_enabled);
        }

        match &options.output {
            Some(path) => {
                debug!("Writing fixed code to file: {}", path.display());
                tokio::fs::write(path, fixed_code)
                    .await
                    .map_err(|e| CliError::OutputWriteError(path.clone(), e))?;
                info!("Fixed code written to {}", path.display());
            }
            None => {
                debug!("Printing fixed code to stdout.");
                io::stdout().write_all(fixed_code.as_bytes())
                    .context("Failed to write fixed code to stdout")?;
                writeln!(io::stdout()).context("Failed to write newline")?;
            }
        }
    }

    print_fix_summary(
        fix_result.fixes_applied,
        final_warning_count,
        final_error_count,
        color_enabled,
    );

    Ok(())
}

Explanation:

  • Each handler function (handle_lint_command, handle_fix_command, handle_strict_command) follows a similar pattern:
    1. Initialization: Determine color preference, create a DiagnosticCollection.
    2. Lexing: Creates a Lexer and tokenizes the input.
    3. Parsing: Creates a Parser and attempts to build an AST. If parsing fails, the AST might be None, but diagnostics are still collected.
    4. Validation: If an AST was successfully built, a Validator checks for semantic issues.
    5. Rule Application (for fix and strict): A RuleEngine applies fixes to the AST. strict mode might call a specific apply_safe_fixes method (which you would implement in your rule_engine module).
    6. Formatting (for fix and strict): A Formatter converts the (potentially modified) AST back into Mermaid code.
    7. Output:
      • Diagnostics are printed using print_diagnostic.
      • Fixed code is written to a file or stdout.
      • Diffs are printed if requested.
      • Summaries are printed.
    8. Error Handling & Exit Codes: Crucially, the handlers return Result<()> and use anyhow for error propagation. They also use std::process::exit(1) (or propagate an error that causes main to exit with 1) to indicate failure, which is vital for CI/CD pipelines.
  • LintFormat::Json: A placeholder is included for JSON output. In a real application, you would need to derive serde::Serialize for your Diagnostic struct and use serde_json to serialize the collection.
  • StrictModeFailure: A specific error type is returned for strict mode failures, clearly indicating why the tool exited.

f) Integrating with diagnostics, rule_engine, formatter (Recap and Assumed API)

For the handlers above to work, your diagnostics, rule_engine, and formatter modules (from previous chapters) need to expose specific public APIs. Let’s quickly review the assumed interfaces:

src/diagnostics/mod.rs (Expected API)

// Simplified example, actual implementation would be more robust
pub struct Diagnostic {
    pub severity: DiagnosticSeverity,
    pub code: String,
    pub message: String,
    pub span: Span,
    pub help: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagnosticSeverity {
    Error,
    Warning,
    Info,
    Hint,
}

pub struct DiagnosticCollection {
    // ...
}

impl DiagnosticCollection {
    pub fn new() -> Self { /* ... */ }
    pub fn add(&mut self, diagnostic: Diagnostic) { /* ... */ }
    pub fn iter(&self) -> impl Iterator<Item = &Diagnostic> { /* ... */ }
    pub fn errors(&self) -> impl Iterator<Item = &Diagnostic> { /* ... */ }
    pub fn warnings(&self) -> impl Iterator<Item = &Diagnostic> { /* ... */ }
    pub fn extend(&mut self, other: DiagnosticCollection) { /* ... */ }
    pub fn has_errors(&self) -> bool { /* ... */ }
    pub fn has_warnings(&self) -> bool { /* ... */ }
}

src/rule_engine/mod.rs (Expected API)

// Simplified example, actual implementation would be more robust
use crate::ast::AstNode; // Assuming AstNode is your root AST type
use crate::diagnostics::DiagnosticCollection;

pub struct RuleApplicationResult {
    pub fixes_applied: usize,
    pub issues_introduced: usize, // e.g., if a fix causes a new issue
}

pub struct RuleEngine { /* ... */ }

impl RuleEngine {
    pub fn new() -> Self { /* ... */ }
    /// Applies all rules, including potentially aggressive ones.
    pub fn apply_fixes(&self, ast: &mut AstNode, diagnostics: &mut DiagnosticCollection) -> RuleApplicationResult {
        // ... iterate through rules, call rule.apply()
        RuleApplicationResult { fixes_applied: 0, issues_introduced: 0 }
    }
    /// Applies only safe, non-controversial fixes.
    pub fn apply_safe_fixes(&self, ast: &mut AstNode, diagnostics: &mut DiagnosticCollection) -> RuleApplicationResult {
        // ... iterate through rules marked as 'safe', call rule.apply()
        RuleApplicationResult { fixes_applied: 0, issues_introduced: 0 }
    }
}

src/formatter/mod.rs (Expected API)

// Simplified example, actual implementation would be more robust
use crate::ast::AstNode; // Assuming AstNode is your root AST type

pub struct Formatter { /* ... */ }

impl Formatter {
    pub fn new() -> Self { /* ... */ }
    pub fn format(&self, ast: &AstNode) -> String {
        // ... walk the AST and reconstruct the Mermaid code
        "formatted mermaid code".to_string()
    }
}

These are simplified representations. Your actual implementations from previous chapters should match these general public interfaces for the CLI to integrate smoothly.

Testing This Component

To test the CLI, we can compile the project and run various commands.

  1. Build the project:

    cargo build --release
    

    This will create an executable at target/release/mermaid-analyzer.

  2. Create a sample Mermaid file (e.g., test.mmd):

    graph TD A[Start] --> B(Process) B -- Invalid Arrow -> C{Decision} C -- Yes --> D[End] C -- No --X E[Error]
  3. Test lint mode:

    target/release/mermaid-analyzer lint --file test.mmd
    

    Expected output: You should see diagnostic messages for the “Invalid Arrow” and potentially other issues, similar to Rust compiler output, with line and column numbers. The exit code should be 1 if errors are found, 0 otherwise.

    # Test with warnings as errors
    target/release/mermaid-analyzer lint --file test.mmd --warn-as-error
    
    # Test JSON output
    target/release/mermaid-analyzer lint --file test.mmd --format json
    
    # Test stdin input
    echo "graph TD; A-->B" | target/release/mermaid-analyzer lint
    
  4. Test fix mode:

    # This assumes your rule engine has a rule to fix "Invalid Arrow"
    target/release/mermaid-analyzer fix --file test.mmd --output fixed.mmd --diff
    

    Expected output: You should see a diff showing the changes, and a new fixed.mmd file containing the corrected Mermaid code.

    # Test --check flag
    target/release/mermaid-analyzer fix --file test.mmd --check
    # This should exit with code 1 if fixes are needed, 0 if not.
    
  5. Test strict mode:

    # If the file has unfixable errors or remaining warnings, this should fail.
    target/release/mermaid-analyzer strict --file test.mmd
    

    Expected output: If test.mmd still has issues that strict mode deems unacceptable (either unfixable errors or warnings that strict mode doesn’t allow), it should print diagnostics and exit with a non-zero status. If it successfully fixes all issues and finds no remaining problems, it should print the fixed code (or write to --output) and exit with 0.

  6. Test --color and --verbose flags:

    target/release/mermaid-analyzer lint --file test.mmd --color always
    RUST_LOG=debug target/release/mermaid-analyzer lint --file test.mmd --verbose
    

    Expected output: Colored output should always be present, and you should see debug! level logs when --verbose is used.

Production Considerations

When deploying a CLI tool, several factors ensure it’s robust, performant, and maintainable.

  1. Error Handling:

    • User-friendly messages: All errors, especially those originating from file I/O or parsing, should be presented in a clear, actionable way to the user, not just raw Rust panics or backtraces. anyhow and thiserror help achieve this.
    • Exit Codes: Crucial for CI/CD. 0 for success, 1 for general failure (e.g., linting errors, unfixable issues), and potentially 2 for configuration errors or invalid arguments. Our handlers already use std::process::exit(1).
    • Graceful Shutdown: Ensure temporary files are cleaned up and resources released even on error.
  2. Performance Optimization:

    • Efficient I/O: Reading from stdin or large files should be non-blocking where possible or use buffered readers. Our use of tokio::fs and tokio::io::stdin provides async capabilities, which can be beneficial for very large inputs.
    • Minimal Allocations: Avoid excessive string copying or vector reallocations in core loops (lexer, parser, formatter). This is generally handled by careful design in the underlying modules.
    • Lazy Loading: If the tool were to support many diagram types or complex rules, only loading necessary components could improve startup time. For our current scope, this isn’t a major concern.
  3. Security Considerations:

    • Path Traversal: When accepting file paths from user input, ensure they are properly sanitized or resolved to prevent malicious users from accessing unintended files (e.g., ../etc/passwd). std::path::PathBuf and canonicalization (path.canonicalize()) can help, though for simple read/write operations within the current directory, it’s less of a direct exploit vector.
    • Input Validation: While our lexer and parser handle invalid Mermaid syntax, ensure that the input itself isn’t excessively large, leading to resource exhaustion (DoS). Streaming input could mitigate this for extremely large files, but for typical Mermaid diagrams, direct reading is fine.
  4. Logging and Monitoring:

    • Structured Logging: tracing provides structured logs that can be easily parsed by log aggregators. This is invaluable for debugging in production or understanding tool behavior.
    • Configurable Verbosity: Our --verbose and --quiet flags, combined with RUST_LOG, allow users to control how much information the tool outputs.
    • Metrics (Future): For a highly critical tool, integrating metrics (e.g., processing time, number of fixes applied) could be useful for performance monitoring.

Code Review Checkpoint

At this point, we have significantly enhanced our mermaid-analyzer by building a robust and user-friendly CLI.

Summary of what was built:

  • A src/cli module encapsulating all CLI logic.
  • Argument parsing using clap for lint, fix, and strict subcommands, along with global options.
  • Input handling from files or stdin.
  • Handlers for each subcommand that orchestrate the lexing, parsing, validation, rule application, and formatting steps.
  • A dedicated src/cli/output.rs module for consistent, colored terminal output, including Rust-compiler-like diagnostics and diffs.
  • CLI-specific error handling using thiserror.
  • Integration with tracing for configurable logging.

Files created/modified:

  • Cargo.toml: Added clap, owo-colors, anyhow, tracing, tracing-subscriber, similar, termwidth.
  • src/main.rs: Initialized tracing, delegated to cli::run_cli.
  • src/cli/mod.rs: Main CLI entry point, argument parsing, input handling, command dispatch.
  • src/cli/args.rs: clap structs for CLI arguments.
  • src/cli/handlers.rs: Logic for lint, fix, strict subcommands.
  • src/cli/output.rs: Functions for colored diagnostics, fix summaries, and diffs.
  • src/cli/error.rs: Custom error types for CLI operations.

How it integrates with existing code: The CLI acts as the top-level orchestrator, calling the public APIs of our previously developed lexer, parser, ast, validator, diagnostics, rule_engine, and formatter modules. It provides the input, receives diagnostics or processed ASTs, and then formats the output for the user. It does not modify the core logic of these underlying components; rather, it consumes their services.

Common Issues & Solutions

  1. Issue: “command not found: mermaid-analyzer”

    • Cause: The executable is not in your system’s PATH, or you haven’t built it.
    • Solution:
      • Ensure you’ve run cargo build --release.
      • Call the executable directly using its full path: target/release/mermaid-analyzer ....
      • For easier use, add target/release to your system’s PATH, or install the tool globally: cargo install --path . (from the project root, after cargo build).
  2. Issue: clap parsing errors or unexpected argument behavior.

    • Cause: Mismatch between clap attribute configuration and how arguments are passed on the command line, or incorrect ValueEnum variants.
    • Solution:
      • Carefully review src/cli/args.rs for correct #[arg(...)] and #[command(...)] attributes.
      • Check clap’s generated help message: mermaid-analyzer --help or mermaid-analyzer lint --help. This is often the quickest way to spot discrepancies.
      • Ensure ValueEnum variants are uppercase (or match the expected string input case) if you’re passing them as command-line arguments.
  3. Issue: No colored output / unexpected color behavior.

    • Cause: owo-colors (or atty) might not detect a TTY, or the --color flag is overriding.
    • Solution:
      • Explicitly use --color always: mermaid-analyzer lint --file test.mmd --color always.
      • Check if your terminal supports ANSI escape codes. Some minimal environments might not.
      • If piping output (e.g., mermaid-analyzer lint --file test.mmd | less -R), atty will correctly detect that stdout is not a TTY and disable colors. less -R is often needed to view colored output from piped commands.
  4. Issue: “Failed to read file: No such file or directory”

    • Cause: The --file path is incorrect, or the file doesn’t exist.
    • Solution:
      • Double-check the file path for typos.
      • Ensure the file has read permissions.
      • Use an absolute path or a path relative to where you’re running the mermaid-analyzer command.

Testing & Verification

To verify the functionality implemented in this chapter, perform the following checks:

  1. Basic Linting:

    • Create a valid simple.mmd file (e.g., graph TD; A-->B;). Run mermaid-analyzer lint --file simple.mmd. Expected: Exit code 0, “No issues found.”
    • Create an invalid invalid.mmd file (e.g., graph TD; A--?B;). Run mermaid-analyzer lint --file invalid.mmd. Expected: Diagnostic error message, exit code 1.
  2. Fixing Functionality:

    • Create a needs_fix.mmd file with issues your rule_engine can fix (e.g., missing quotes around labels, non-standard arrows).
    • Run mermaid-analyzer fix --file needs_fix.mmd --output fixed_output.mmd --diff. Expected: Diff output, fixed_output.mmd contains corrected Mermaid code. Compare fixed_output.mmd manually.
    • Run mermaid-analyzer fix --file needs_fix.mmd --check. Expected: Exit code 1, summary indicating fixes would be applied.
    • Run mermaid-analyzer fix --file fixed_output.mmd --check. Expected: Exit code 0, summary indicating no fixes needed.
  3. Strict Mode:

    • Run mermaid-analyzer strict --file needs_fix.mmd. Expected: If issues are fixable and no warnings remain, it should output fixed code and exit 0. If any unfixable errors or warnings remain, it should output diagnostics and exit 1.
    • Test with a file that has a clear unfixable error. Expected: Immediate failure with diagnostics and exit code 1.
  4. Input from Stdin:

    • echo "graph TD; A-->B;" | mermaid-analyzer lint. Expected: Exit code 0.
    • echo "graph TD; A--?B;" | mermaid-analyzer lint. Expected: Diagnostic error, exit code 1.
  5. Logging:

    • Run with RUST_LOG=debug mermaid-analyzer lint --file simple.mmd. Expected: Detailed debug logs about lexing, parsing, etc.
    • Run with mermaid-analyzer lint --file simple.mmd -v. Expected: Same as above.
    • Run with mermaid-analyzer lint --file invalid.mmd -q. Expected: Only error output, no info! or debug! messages.

By thoroughly testing these scenarios, you can verify that the CLI is correctly interpreting arguments, delegating to the core logic, and producing the expected output and exit codes for various situations.

Summary & Next Steps

In this comprehensive chapter, we successfully built the command-line interface for our mermaid-analyzer tool. We leveraged the clap crate to define a robust and intuitive argument structure, enabling lint, fix, and strict modes. We implemented handlers for each mode, orchestrating the flow through our lexer, parser, AST, validator, rule engine, and formatter. Crucially, we designed a sophisticated output system using owo-colors to provide Rust-compiler-like diagnostics, diffs, and summaries, enhancing the user experience significantly. We also addressed production considerations such as error handling, performance, security, and logging.

Our mermaid-analyzer is now a fully usable executable, capable of being run from the terminal and integrated into development workflows.

In the next chapter, Chapter 9: Advanced Rule Engine Features and Custom Rule Development, we will delve deeper into the rule_engine. We’ll explore how to create more complex and context-aware rules, discuss rule configuration, and lay the groundwork for a potential plugin system, allowing users to extend the tool with their own custom Mermaid validation and fixing logic. This will further enhance the tool’s flexibility and power, making it adaptable to diverse project requirements.