jj-cz 1.1.0

Conventional commits for Jujutsu
Documentation
//! Prompt abstraction for the interactive commit workflow
//!
//! This module provides the [`Prompter`] trait and its production
//! implementation [`RealPrompts`].  The trait is the seam that allows
//! [`CommitWorkflow`](super::CommitWorkflow) to use real interactive prompts
//! in production while accepting mock implementations in tests.

use inquire::{Confirm, Text};
use unicode_width::UnicodeWidthStr;

use crate::{
    commit::types::{Body, BreakingChange, CommitType, Description, References, Scope},
    error::Error,
};

/// Abstraction over prompt operations used by the commit workflow
///
/// Implement this trait to supply a custom front-end (interactive TUI, mock,
/// headless, etc.) to [`CommitWorkflow`](super::CommitWorkflow).
pub trait Prompter {
    /// Prompt the user to select a commit type
    fn select_commit_type(&self) -> Result<CommitType, Error>;

    /// Prompt the user to input an optional scope
    fn input_scope(&self) -> Result<Scope, Error>;

    /// Prompt the user to input a required description
    fn input_description(&self) -> Result<Description, Error>;

    /// Prompt the user for breaking change
    fn input_breaking_change(&self) -> Result<BreakingChange, Error>;

    /// Prompt the user to optionally add a free-form body via an external editor
    fn input_body(&self) -> Result<Body, Error>;

    /// Prompt the user to optionally add comma-separated ticket references
    fn input_references(&self) -> Result<References, Error>;

    /// Prompt the user to confirm applying the commit message
    fn confirm_apply(&self, message: &str) -> Result<bool, Error>;

    /// Display a message to the user (errors, feedback, status)
    ///
    /// In production this prints to stdout.  In tests, implementations
    /// typically record the message for later assertion.
    fn emit_message(&self, msg: &str);
}

fn format_message_box(message: &str) -> String {
    let preview_width = message
        .split('\n')
        .map(|line| line.width())
        .max()
        .unwrap_or(0)
        .max(72);
    let mut lines: Vec<String> = Vec::new();
    lines.push(format!("{}", "".repeat(preview_width + 2)));
    for line in message.split('\n') {
        let padding = preview_width.saturating_sub(line.width());
        lines.push(format!("{line}{:padding$} │", ""));
    }
    lines.push(format!("{}", "".repeat(preview_width + 2)));
    lines.join("\n")
}

/// Production implementation of [`Prompter`] using the `inquire` crate
#[derive(Debug)]
pub struct RealPrompts;

impl Prompter for RealPrompts {
    fn select_commit_type(&self) -> Result<CommitType, Error> {
        inquire::Select::new("Select commit type:", CommitType::all().to_vec())
            .with_page_size(11)
            .with_help_message(
                "Use arrow keys to navigate, Enter to select. See https://www.conventionalcommits.org/ for details.",
            )
            .with_formatter(&|option| format!("{}: {}", option.value.as_str(), option.value.description()))
            .prompt()
            .map_err(|_| Error::Cancelled)
    }

    fn input_scope(&self) -> Result<Scope, Error> {
        let answer = inquire::Text::new("Enter scope (optional):")
            .with_help_message(
                "Scope is optional. If provided, it should be a noun representing the section of code affected (e.g., 'api', 'ui', 'config'). Max 30 characters.",
            )
            .with_placeholder("Leave empty if no scope")
            .prompt_skippable()
            .map_err(|_| Error::Cancelled)?;
        match answer {
            Some(s) if s.trim().is_empty() => Ok(Scope::empty()),
            Some(s) => Scope::parse(s.trim()).map_err(|e| Error::InvalidScope(e.to_string())),
            None => Ok(Scope::empty()),
        }
    }

    fn input_references(&self) -> Result<References, Error> {
        let answer = inquire::Text::new("Enter comma-separated references (optional):")
            .with_help_message("References are optional. If provided, will become footer(s) in the commit message. References must be comma-separated.")
            .with_placeholder("Leave empty if no references")
            .prompt_skippable()
            .map_err(|_| Error::Cancelled)?;
        match answer {
            None => Ok(References::default()),
            Some(s) if s.trim().is_empty() => Ok(References::default()),
            Some(s) => Ok(References::from(s)),
        }
    }

    fn input_description(&self) -> Result<Description, Error> {
        loop {
            let answer = Text::new("Enter description (required):")
                .with_help_message(
                    "Description is required. Short summary in imperative mood \
                     (e.g., 'add feature', 'fix bug'). Soft limit: 50 characters.",
                )
                .prompt()
                .map_err(|_| Error::Cancelled)?;

            let trimmed = answer.trim();
            if trimmed.is_empty() {
                println!("❌ Description cannot be empty. Please provide a description.");
                continue;
            }

            // parse() only fails on empty - already handled above
            let Ok(desc) = Description::parse(trimmed) else {
                println!("❌ Description cannot be empty. Please provide a description.");
                continue;
            };

            // Soft limit warning: over 50 chars is allowed but may push the
            // combined first line over 72 characters.
            if desc.len() > Description::MAX_LENGTH {
                println!(
                    "⚠️  Description is {} characters (soft limit is {}). \
                     The combined commit line must still be ≤ 72 characters.",
                    desc.len(),
                    Description::MAX_LENGTH
                );
            }

            return Ok(desc);
        }
    }

    fn input_breaking_change(&self) -> Result<BreakingChange, Error> {
        if !Confirm::new("Does this revision include a breaking change?")
            .with_default(false)
            .prompt()
            .map_err(|_| Error::Cancelled)?
        {
            return Ok(BreakingChange::No);
        }
        let answer = Text::new("Enter the description of the breaking change:")
            .with_help_message("Enter an empty message to skip creating a message footer")
            .prompt()
            .map_err(|_| Error::Cancelled)?;
        let trimmed = answer.trim();
        Ok(trimmed.into())
    }

    fn input_body(&self) -> Result<Body, Error> {
        let wants_body = Confirm::new("Add a body?")
            .with_default(false)
            .prompt()
            .map_err(|_| Error::Cancelled)?;
        if !wants_body {
            return Ok(Body::default());
        }

        let template = "\
JJ: Body (optional). Markdown is supported.\n\
JJ: Wrap prose lines at 72 characters where possible.\n\
JJ: Lines starting with \"JJ:\" will be removed.\n";

        let raw = inquire::Editor::new("Body:")
            .with_predefined_text(template)
            .with_file_extension(".md")
            .prompt()
            .map_err(|_| Error::Cancelled)?;
        let stripped: String = raw
            .lines()
            .filter(|line| !line.starts_with("JJ:"))
            .collect::<Vec<_>>()
            .join("\n");

        Ok(Body::from(stripped))
    }

    fn confirm_apply(&self, message: &str) -> Result<bool, Error> {
        println!(
            "\n📝 Commit Message Preview:\n{}\n",
            format_message_box(message)
        );
        inquire::Confirm::new("Apply this commit message?")
            .with_default(true)
            .with_help_message("Select 'No' to cancel and start over")
            .prompt()
            .map_err(|_| Error::Cancelled)
    }

    fn emit_message(&self, msg: &str) {
        println!("{}", msg);
    }
}

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

    /// Test RealPrompts implements Prompter trait
    #[test]
    fn real_prompts_implements_trait() {
        let real = RealPrompts;
        fn _accepts_prompter(_p: impl Prompter) {}
        _accepts_prompter(real);
    }

    /// Top border uses exactly preview_width (74) dashes; bottom likewise
    #[test]
    fn format_message_box_borders() {
        let result = format_message_box("hello");
        let lines: Vec<&str> = result.split('\n').collect();
        let dashes = "".repeat(74);
        assert_eq!(lines[0], format!("{dashes}"));
        assert_eq!(lines[lines.len() - 1], format!("{dashes}"));
    }

    /// A single-line message produces exactly 3 rows: top, content, bottom
    #[test]
    fn format_message_box_single_line_row_count() {
        let result = format_message_box("feat: add login");
        assert_eq!(result.split('\n').count(), 3);
    }

    /// A message with one `\n` produces 4 rows: top, two content, bottom
    #[test]
    fn format_message_box_multi_line_row_count() {
        let result = format_message_box("feat: add login\nsecond line");
        assert_eq!(result.split('\n').count(), 4);
    }

    /// A breaking-change message (`\n\n`) produces an empty content row for the blank line
    #[test]
    fn format_message_box_blank_separator_line() {
        let msg = "feat!: drop old API\n\nBREAKING CHANGE: removed";
        let result = format_message_box(msg);
        assert_eq!(result.split('\n').count(), 5); // top + 3 content + bottom
    }

    /// All output rows have identical char counts (the box is rectangular)
    #[test]
    fn format_message_box_all_rows_same_width() {
        let msg = "feat(auth): add login\n\nBREAKING CHANGE: old API removed";
        let result = format_message_box(msg);
        let widths: Vec<usize> = result.split('\n').map(|l| l.chars().count()).collect();
        let expected = widths[0];
        assert!(
            widths.iter().all(|&w| w == expected),
            "rows have differing widths: {:?}",
            widths
        );
    }

    /// An empty message produces a single fully-padded content row
    #[test]
    fn format_message_box_empty_message() {
        let result = format_message_box("");
        let lines: Vec<&str> = result.split('\n').collect();
        assert_eq!(lines.len(), 3);
        // "│ " + 72 spaces + " │" = 76 chars
        let expected = format!("│ {:72} │", "");
        assert_eq!(lines[1], expected);
    }

    /// A line of exactly 72 characters leaves no right-hand padding
    #[test]
    fn format_message_box_line_exactly_72_chars() {
        let line_72 = "a".repeat(72);
        let result = format_message_box(&line_72);
        let lines: Vec<&str> = result.split('\n').collect();
        let expected = format!("{line_72}");
        assert_eq!(lines[1], expected);
    }

    /// A single CJK character (display width 2) is padded as if it occupies 2 columns,
    /// not 1 - so the right-hand padding is 70 spaces, not 71
    #[test]
    fn format_message_box_single_cjk_char() {
        let result = format_message_box("");
        let lines: Vec<&str> = result.split('\n').collect();
        let expected = format!("│ 字{:70} │", "");
        assert_eq!(lines[1], expected);
    }

    /// A single emoji (display width 2) is padded as if it occupies 2 columns
    #[test]
    fn format_message_box_single_emoji() {
        let result = format_message_box("🦀");
        let lines: Vec<&str> = result.split('\n').collect();
        let expected = format!("│ 🦀{:70} │", "");
        assert_eq!(lines[1], expected);
    }

    /// Mixed ASCII and CJK: padding accounts for the display width of the whole line
    ///
    /// "feat: " = 6 display cols, "漢字" = 4 display cols → total 10, padding = 62
    #[test]
    fn format_message_box_mixed_ascii_and_cjk() {
        let result = format_message_box("feat: 漢字");
        let lines: Vec<&str> = result.split('\n').collect();
        let expected = format!("│ feat: 漢字{:62} │", "");
        assert_eq!(lines[1], expected);
    }

    /// When a line exceeds 72 display columns the border expands to fit (width + 2 dashes)
    #[test]
    fn format_message_box_border_expands_beyond_72() {
        let line_73 = "a".repeat(73);
        let result = format_message_box(&line_73);
        let lines: Vec<&str> = result.split('\n').collect();
        let dashes = "".repeat(75); // 73 + 2
        assert_eq!(lines[0], format!("{dashes}"));
        assert_eq!(lines[lines.len() - 1], format!("{dashes}"));
    }

    /// A line that sets the box width gets zero right-hand padding
    #[test]
    fn format_message_box_widest_line_has_no_padding() {
        let line_73 = "a".repeat(73);
        let result = format_message_box(&line_73);
        let lines: Vec<&str> = result.split('\n').collect();
        assert_eq!(lines[1], format!("{line_73}"));
    }

    /// In a multi-line message, shorter lines are padded out to match the widest line
    #[test]
    fn format_message_box_shorter_lines_padded_to_widest() {
        let long_line = "a".repeat(80);
        let result = format_message_box(&format!("{long_line}\nshort"));
        let lines: Vec<&str> = result.split('\n').collect();
        assert_eq!(lines[1], format!("{long_line}"));
        assert_eq!(lines[2], format!("│ short{:75} │", "")); // 80 - 5 = 75
    }

    /// All rows have equal char count when the box expands beyond 72
    #[test]
    fn format_message_box_all_rows_same_width_when_expanded() {
        let long_line = "a".repeat(80);
        let result = format_message_box(&format!("{long_line}\nshort"));
        let widths: Vec<usize> = result.split('\n').map(|l| l.chars().count()).collect();
        let expected = widths[0];
        assert!(
            widths.iter().all(|&w| w == expected),
            "rows have differing widths: {:?}",
            widths
        );
    }

    /// Wide characters can also trigger box expansion beyond 72 columns
    ///
    /// 37 CJK characters × 2 display columns = 74 display columns → border uses 76 dashes
    #[test]
    fn format_message_box_wide_chars_expand_box() {
        let wide_line = "".repeat(37); // 74 display cols
        let result = format_message_box(&wide_line);
        let lines: Vec<&str> = result.split('\n').collect();
        let dashes = "".repeat(76); // 74 + 2
        assert_eq!(lines[0], format!("{dashes}"));
        assert_eq!(lines[1], format!("{wide_line}")); // no padding
    }
}