bearask 0.5.0

A really fast and featureful CLI prompting lib
Documentation
use {
    crate::{
        style::EditorStyle,
        util::CursorGuard,
        validation::{Validate, run_validator},
    },
    crossterm::{
        event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
        terminal,
    },
    miette::IntoDiagnostic,
    owo_colors::OwoColorize,
    std::{
        env,
        io::{Write, stdout},
        process::Command,
    },
};

#[derive(Clone)]
pub struct Editor {
    prompt: String,
    prompt_prefix: String,
    help_message: Option<String>,
    editor_command: Option<String>,
    file_extension: String,
    predefined_message: Option<String>,
    require_changes: bool,
    show_hints: bool,
    allow_escape: bool,
    style: EditorStyle,
    validation: Option<Box<dyn Validate<str>>>,
    _cursor_guard: CursorGuard,
}

impl Editor {
    pub fn new(prompt: impl Into<String>) -> Self {
        let _cursor_guard = CursorGuard::new().expect("Failed to initialize cursor guard");

        Self {
            prompt: prompt.into(),
            prompt_prefix: "📝".into(),
            help_message: None,
            editor_command: None,
            file_extension: "txt".into(),
            predefined_message: None,
            require_changes: false,
            show_hints: true,
            allow_escape: true,
            style: EditorStyle::default(),
            validation: None,
            _cursor_guard,
        }
    }

    pub fn prompt(&self) -> &str {
        &self.prompt
    }

    pub fn with_prompt_prefix(mut self, prefix: impl Into<String>) -> Self {
        self.prompt_prefix = prefix.into();
        self
    }

    pub fn with_help_message(mut self, message: impl Into<String>) -> Self {
        self.help_message = Some(message.into());
        self
    }

    pub fn with_editor(mut self, command: impl Into<String>) -> Self {
        self.editor_command = Some(command.into());
        self
    }

    pub fn with_file_extension(mut self, ext: impl Into<String>) -> Self {
        self.file_extension = ext.into();
        self
    }

    pub fn with_predefined_message(mut self, message: impl Into<String>) -> Self {
        self.predefined_message = Some(message.into());
        self
    }

    pub fn with_require_changes(mut self, require: bool) -> Self {
        self.require_changes = require;
        self
    }

    pub fn with_hints(mut self, enabled: bool) -> Self {
        self.show_hints = enabled;
        self
    }

    pub fn with_escape(mut self, allow: bool) -> Self {
        self.allow_escape = allow;
        self
    }

    pub fn with_style(mut self, style: EditorStyle) -> Self {
        self.style = style;
        self
    }

    pub fn with_validation(mut self, validation: impl Validate<str> + 'static) -> Self {
        self.validation = Some(Box::new(validation));
        self
    }

    fn detect_editor(&self) -> String {
        if let Some(ref cmd) = self.editor_command {
            return cmd.clone();
        }

        if let Ok(editor) = env::var("VISUAL") {
            return editor;
        }
        if let Ok(editor) = env::var("EDITOR") {
            return editor;
        }

        if cfg!(windows) {
            "notepad".into()
        } else {
            "nano".into()
        }
    }

    pub fn ask(&self) -> miette::Result<String> {
        let original_hook = std::panic::take_hook();
        std::panic::set_hook(Box::new(move |panic_info| {
            let _ = terminal::disable_raw_mode();
            std::panic::take_hook()(panic_info);
        }));

        let result = self.ask_internal();

        let _ = std::panic::take_hook();
        std::panic::set_hook(original_hook);

        result
    }

    fn ask_internal(&self) -> miette::Result<String> {
        let mut out = stdout();
        let editor = self.detect_editor();
        let tw = crate::util::term_width();

        let line = format!(
            "{} {}",
            self.prompt_prefix.style(self.style.prompt_prefix),
            self.prompt.style(self.style.prompt),
        );
        crate::util::writeln_physical(&mut out, &line, tw)?;

        if let Some(ref help) = self.help_message {
            let line = format!("  {}", help.style(self.style.hint));
            crate::util::writeln_physical(&mut out, &line, tw)?;
        }

        let line = format!(
            "  {} {}",
            "Editor:".style(self.style.hint),
            editor.style(self.style.editor_command),
        );
        crate::util::writeln_physical(&mut out, &line, tw)?;

        if self.show_hints {
            let mut hints = vec!["Enter to open editor"];
            if self.allow_escape {
                hints.push("Esc to cancel");
            }
            let line = format!("  {}", hints.join(", ").style(self.style.hint));
            crate::util::writeln_physical(&mut out, &line, tw)?;
        }

        out.flush().into_diagnostic()?;

        terminal::enable_raw_mode().into_diagnostic()?;

        while event::poll(std::time::Duration::from_millis(0)).into_diagnostic()? {
            event::read().into_diagnostic()?;
        }

        loop {
            if let Event::Key(key_event) = event::read().into_diagnostic()? {
                if key_event.kind != KeyEventKind::Press {
                    continue;
                }

                if key_event.modifiers.contains(KeyModifiers::CONTROL)
                    && matches!(key_event.code, KeyCode::Char('c'))
                {
                    let _ = terminal::disable_raw_mode();
                    std::process::exit(130);
                }

                match key_event.code {
                    KeyCode::Enter => {
                        terminal::disable_raw_mode().into_diagnostic()?;
                        break;
                    }
                    KeyCode::Esc if self.allow_escape => {
                        terminal::disable_raw_mode().into_diagnostic()?;
                        return Err(miette::miette!("Cancelled"));
                    }
                    _ => continue,
                }
            }
        }

        let temp_dir = env::temp_dir();
        let file_name = format!("bearask_{}.{}", std::process::id(), self.file_extension);
        let temp_path = temp_dir.join(file_name);

        if let Some(ref message) = self.predefined_message {
            std::fs::write(&temp_path, message).into_diagnostic()?;
        } else {
            std::fs::write(&temp_path, "").into_diagnostic()?;
        }

        let editor_parts: Vec<&str> = editor.split_whitespace().collect();
        let (program, args) = editor_parts
            .split_first()
            .ok_or_else(|| miette::miette!("Empty editor command"))?;

        let status = Command::new(program)
            .args(args)
            .arg(&temp_path)
            .status()
            .into_diagnostic()?;

        if !status.success() {
            let _ = std::fs::remove_file(&temp_path);
            return Err(miette::miette!("Editor exited with status: {}", status));
        }

        let content = std::fs::read_to_string(&temp_path).into_diagnostic()?;
        let _ = std::fs::remove_file(&temp_path);

        let trimmed = content.trim().to_string();

        if self.require_changes {
            let original = self.predefined_message.as_deref().unwrap_or("").trim();
            if trimmed == original {
                return Err(miette::miette!("No changes were made"));
            }
        }

        if let Some(ref validator) = self.validation {
            run_validator(validator.as_ref(), &trimmed).map_err(|e| miette::miette!(e))?;
        }

        let line_count = trimmed.lines().count();
        let line = format!(
            "{} {} {}",
            self.prompt_prefix.style(self.style.prompt_prefix),
            self.prompt.style(self.style.prompt),
            format!(
                "({} line{})",
                line_count,
                if line_count == 1 { "" } else { "s" }
            )
            .style(self.style.success)
            .bold(),
        );
        crate::util::writeln_physical(&mut out, &line, tw)?;
        out.flush().into_diagnostic()?;

        Ok(trimmed)
    }
}