rumdl 0.3.0

A fast Markdown linter and formatter written in Rust
Documentation
//! Handler for the `init` command.

use colored::*;
use std::fs;
use std::io::{self, IsTerminal, Write};
use std::path::Path;

use rumdl_lib::config as rumdl_config;
use rumdl_lib::exit_codes::exit;

/// Handle the init command: create a new configuration file.
///
/// Prompts only when stdin is a terminal. Without one (CI, scripts, agents)
/// nothing is asked: the editor-extension offer is skipped, and appending to an
/// existing pyproject.toml needs `yes`.
pub fn handle_init(pyproject: bool, preset: Option<&str>, output: Option<String>, yes: bool) {
    if pyproject {
        handle_pyproject_init(preset, yes);
    } else {
        let output_path = output.as_deref().unwrap_or(".rumdl.toml");
        let preset_name = preset.unwrap_or("default");

        match rumdl_config::create_preset_config(preset_name, output_path) {
            Ok(()) => {
                if preset_name == "default" {
                    println!("Created default configuration file: {output_path}");
                } else {
                    println!("Created {preset_name} configuration file: {output_path}");
                }

                // Offer to install VS Code extension
                offer_vscode_extension_install();

                println!("\nSetup complete! You can now:");
                println!("  - Run {} to lint your Markdown files", "rumdl check .".cyan());
                println!("  - Open your editor to see real-time linting");
            }
            Err(e) => {
                eprintln!("{}: Failed to create config file: {}", "Error".red().bold(), e);
                exit::tool_error();
            }
        }
    }
}

fn handle_pyproject_init(preset: Option<&str>, yes: bool) {
    let preset_name = preset.unwrap_or("default");
    let config_content = match rumdl_config::generate_pyproject_preset_config(preset_name) {
        Ok(content) => content,
        Err(e) => {
            eprintln!("{}: {}", "Error".red().bold(), e);
            exit::tool_error();
        }
    };

    if Path::new("pyproject.toml").exists() {
        let content = match fs::read_to_string("pyproject.toml") {
            Ok(content) => content,
            Err(e) => {
                eprintln!("{}: Failed to read pyproject.toml: {}", "Error".red().bold(), e);
                exit::tool_error();
            }
        };
        if content.contains("[tool.rumdl]") {
            println!("The pyproject.toml file already contains a [tool.rumdl] section.");
            println!("Please edit the file manually to avoid overwriting existing configuration.");
            return;
        }

        if !yes {
            println!("pyproject.toml already exists. Would you like to append rumdl configuration? [y/N]");
            let answer = prompt_user("> ");
            // A script that supplies no answer has not consented, and must not
            // mistake the refusal for success.
            if answer.is_none() && !io::stdin().is_terminal() {
                eprintln!(
                    "{}: pyproject.toml already exists. Pass --yes to append the rumdl configuration to it.",
                    "Error".red().bold()
                );
                exit::tool_error();
            }
            if !accepts(answer.as_deref(), false) {
                println!("Aborted. No changes made to pyproject.toml");
                return;
            }
        }

        // Append with a blank line for separation
        let new_content = format!("{}\n\n{}", content.trim_end(), config_content);
        match fs::write("pyproject.toml", new_content) {
            Ok(()) => {
                println!("Added rumdl configuration to pyproject.toml");
            }
            Err(e) => {
                eprintln!("{}: Failed to update pyproject.toml: {}", "Error".red().bold(), e);
                exit::tool_error();
            }
        }
    } else {
        // Create new pyproject.toml with basic structure
        let basic_content = r#"[build-system]
requires = ["setuptools>=42", "wheel"]
build-backend = "setuptools.build_meta"

"#;
        let content = basic_content.to_owned() + config_content.as_str();

        match fs::write("pyproject.toml", content) {
            Ok(()) => {
                println!("Created pyproject.toml with rumdl configuration");
            }
            Err(e) => {
                eprintln!("{}: Failed to create pyproject.toml: {}", "Error".red().bold(), e);
                exit::tool_error();
            }
        }
    }
}

/// Prompt user for input and read their response.
/// Returns None when there is no answer: end of input (stdin closed, Ctrl-D)
/// or an I/O error.
fn prompt_user(prompt: &str) -> Option<String> {
    print!("{prompt}");
    if io::stdout().flush().is_err() {
        return None;
    }

    let mut answer = String::new();
    match io::stdin().read_line(&mut answer) {
        Ok(0) | Err(_) => None,
        Ok(_) => Some(answer),
    }
}

/// Whether a yes/no answer means yes. An empty line takes the prompt's
/// default, but no answer at all (end of input) is never consent.
fn accepts(answer: Option<&str>, default_yes: bool) -> bool {
    match answer.map(str::trim) {
        None => false,
        Some("") => default_yes,
        Some(answer) => answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"),
    }
}

/// Offer to install the VS Code extension during init
fn offer_vscode_extension_install() {
    use rumdl_lib::vscode::VsCodeExtension;

    if !io::stdin().is_terminal() {
        return;
    }

    // Check if we're in an integrated terminal
    if let Some((cmd, editor_name)) = VsCodeExtension::current_editor_from_env() {
        println!("\nDetected you're using {}.", editor_name.green());
        println!("Would you like to install the rumdl extension? [Y/n]");

        if accepts(prompt_user("> ").as_deref(), true) {
            match VsCodeExtension::with_command(cmd) {
                Ok(vscode) => {
                    if let Err(e) = vscode.install(false) {
                        eprintln!("{}: {}", "Error".red().bold(), e);
                    }
                }
                Err(e) => {
                    eprintln!("{}: {}", "Error".red().bold(), e);
                }
            }
        }
    } else {
        // Check for available editors
        let available_editors = VsCodeExtension::find_all_editors();

        match available_editors.len() {
            0 => {
                // No editors found, skip silently
            }
            1 => {
                // Single editor found
                let (cmd, editor_name) = available_editors[0];
                println!("\n{} detected.", editor_name.green());
                println!("Would you like to install the rumdl extension for real-time linting? [y/N]");

                if accepts(prompt_user("> ").as_deref(), false) {
                    match VsCodeExtension::with_command(cmd) {
                        Ok(vscode) => {
                            if let Err(e) = vscode.install(false) {
                                eprintln!("{}: {}", "Error".red().bold(), e);
                            }
                        }
                        Err(e) => {
                            eprintln!("{}: {}", "Error".red().bold(), e);
                        }
                    }
                }
            }
            _ => {
                // Multiple editors found
                println!("\nMultiple VS Code-compatible editors found:");
                for (i, (_, editor_name)) in available_editors.iter().enumerate() {
                    println!("  {}. {}", i + 1, editor_name);
                }
                println!(
                    "\nInstall the rumdl extension? [1-{}/a=all/n=none]:",
                    available_editors.len()
                );

                let Some(response) = prompt_user("> ") else {
                    return; // I/O error, exit gracefully
                };
                let answer = response.trim().to_lowercase();

                if answer == "a" || answer == "all" {
                    // Install in all editors
                    for (cmd, editor_name) in &available_editors {
                        println!("\nInstalling for {editor_name}...");
                        match VsCodeExtension::with_command(cmd) {
                            Ok(vscode) => {
                                if let Err(e) = vscode.install(false) {
                                    eprintln!("{}: {}", "Error".red().bold(), e);
                                }
                            }
                            Err(e) => {
                                eprintln!("{}: {}", "Error".red().bold(), e);
                            }
                        }
                    }
                } else if let Ok(num) = answer.parse::<usize>()
                    && num > 0
                    && num <= available_editors.len()
                {
                    let (cmd, editor_name) = available_editors[num - 1];
                    println!("\nInstalling for {editor_name}...");
                    match VsCodeExtension::with_command(cmd) {
                        Ok(vscode) => {
                            if let Err(e) = vscode.install(false) {
                                eprintln!("{}: {}", "Error".red().bold(), e);
                            }
                        }
                        Err(e) => {
                            eprintln!("{}: {}", "Error".red().bold(), e);
                        }
                    }
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::accepts;

    #[test]
    fn end_of_input_is_never_consent() {
        assert!(!accepts(None, true));
        assert!(!accepts(None, false));
    }

    #[test]
    fn an_empty_line_takes_the_prompt_default() {
        assert!(accepts(Some("\n"), true));
        assert!(!accepts(Some("\n"), false));
    }

    #[test]
    fn explicit_answers_win_over_the_default() {
        for yes in ["y", "Y", "yes", " YES \n"] {
            assert!(accepts(Some(yes), false), "{yes:?}");
        }
        for no in ["n", "no", "nope", "q"] {
            assert!(!accepts(Some(no), true), "{no:?}");
        }
    }
}