cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
use std::io::IsTerminal as _;
use std::io::Read as _;

use clap::ArgMatches;

use crate::infra::driving::cli::errors::{die1, CliError};
use crate::infra::driving::cli::OutputFormat;

/// Extract a required `String` argument from clap matches as a `&str`.
///
/// `clap` populates the value when the argument is declared `required(true)`
/// and the parser succeeded; if either invariant is broken the binary is
/// already in an unrecoverable state. The panic carries the arg name for
/// diagnosis. This helper keeps the call sites readable and centralises the
/// (impossible-by-construction) panic site.
pub(in super::super) fn required_str<'a>(matches: &'a ArgMatches, name: &str) -> &'a str {
    matches
        .get_one::<String>(name)
        .map(String::as_str)
        .unwrap_or_else(|| panic!("clap requires '{name}' to be present"))
}

/// Like [`required_str`], for multi-value arguments declared with
/// `ArgAction::Append` or `num_args(1..)` and `required(true)`.
pub(in super::super) fn required_many<'a>(
    matches: &'a ArgMatches,
    name: &str,
) -> impl Iterator<Item = &'a str> {
    matches
        .get_many::<String>(name)
        .unwrap_or_else(|| panic!("clap requires '{name}' to be present"))
        .map(String::as_str)
}

pub(super) const DR_BODY_TEMPLATE: &str = "## Context\n\n## Decision\n\n## Consequences\n";
pub(super) const ISSUE_BODY_TEMPLATE: &str = "## Description\n\n## Definition of done\n\n- [ ] \n";

/// Capitalise the first character of a string.
pub(super) fn capitalise(s: &str) -> String {
    let mut chars = s.chars();
    match chars.next() {
        None => String::new(),
        Some(c) => c.to_uppercase().to_string() + chars.as_str(),
    }
}

/// Maximum bytes accepted from stdin when reading a record body.
///
/// 10 MiB is several orders of magnitude above any plausible Markdown body
/// (a long ADR is ~50 KiB) and small enough that a runaway `cat huge.bin |
/// cartu issue new ...` cannot OOM the process.
const MAX_STDIN_BYTES: usize = 10 * 1024 * 1024;

/// Read up to `max_bytes` from `reader` into a `String`, refusing input that
/// exceeds the cap.
///
/// Returns `Err` with a message containing "exceeds" if the reader produces
/// more than `max_bytes`. Extracted from `read_body_from_stdin_or_editor` so
/// the cap is unit-testable without a real stdin handle.
fn read_capped<R: std::io::Read>(reader: R, max_bytes: usize) -> std::io::Result<String> {
    let mut buf = String::new();
    let bytes_read = reader
        .take((max_bytes as u64).saturating_add(1))
        .read_to_string(&mut buf)?;
    if bytes_read > max_bytes {
        return Err(std::io::Error::other(format!(
            "input exceeds {max_bytes} bytes — refusing to read further to avoid OOM"
        )));
    }
    Ok(buf)
}

/// Read the body from stdin (if piped) or open `$EDITOR` with a template.
pub(super) fn read_body_from_stdin_or_editor(template: &str, output_fmt: OutputFormat) -> String {
    if !std::io::stdin().is_terminal() {
        // Stdin is piped — read it with a hard cap to prevent OOM.
        return read_capped(std::io::stdin(), MAX_STDIN_BYTES).unwrap_or_else(|e| {
            die1(
                CliError::new(format!("failed to read stdin: {e}")).kind("io"),
                output_fmt,
            );
        });
    }

    // Interactive: open editor with template
    let editor = std::env::var("EDITOR")
        .or_else(|_| std::env::var("VISUAL"))
        .unwrap_or_else(|_| String::new());

    if editor.is_empty() {
        let cmd = std::env::args().nth(1).unwrap_or_default();
        die1(
            CliError::new("no editor configured and stdin is not a pipe")
                .kind("config")
                .hint(format!(
                    "Pipe the body via stdin: `echo 'body' | cartu {cmd} ...`, \
                     or set $EDITOR / $VISUAL to open an interactive editor."
                )),
            output_fmt,
        );
    }

    let mut tmp = tempfile::Builder::new()
        .suffix(".md")
        .tempfile()
        .unwrap_or_else(|e| {
            die1(
                CliError::new(format!("failed to create temp file: {e}")).kind("io"),
                output_fmt,
            );
        });
    std::io::Write::write_all(&mut tmp, template.as_bytes()).unwrap_or_else(|e| {
        die1(
            CliError::new(format!("failed to write template: {e}")).kind("io"),
            output_fmt,
        );
    });

    let status = std::process::Command::new(&editor)
        .arg(tmp.path())
        .status()
        .unwrap_or_else(|e| {
            die1(
                CliError::new(format!("failed to launch editor '{editor}': {e}")).kind("io"),
                output_fmt,
            );
        });

    if !status.success() {
        die1(
            CliError::new("editor exited with non-zero status").kind("io"),
            output_fmt,
        );
    }

    std::fs::read_to_string(tmp.path()).unwrap_or_else(|e| {
        die1(
            CliError::new(format!("failed to read edited file: {e}")).kind("io"),
            output_fmt,
        );
    })
}

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

    #[test]
    fn read_capped_returns_input_when_under_limit() {
        let input: &[u8] = b"hello world";
        let result = read_capped(input, 1024).unwrap();
        assert_eq!(result, "hello world");
    }

    #[test]
    fn read_capped_accepts_input_exactly_at_limit() {
        let input = vec![b'x'; 100];
        let result = read_capped(input.as_slice(), 100).unwrap();
        assert_eq!(result.len(), 100);
    }

    #[test]
    fn required_str_returns_argument_value() {
        let cmd = clap::Command::new("test").arg(
            clap::Arg::new("id")
                .long("id")
                .required(true)
                .value_name("ID"),
        );
        let matches = cmd
            .try_get_matches_from(["test", "--id", "ISSUE-0042"])
            .unwrap();
        assert_eq!(required_str(&matches, "id"), "ISSUE-0042");
    }

    #[test]
    #[should_panic(expected = "clap requires 'absent'")]
    fn required_str_panics_when_argument_is_absent() {
        let cmd = clap::Command::new("test").arg(clap::Arg::new("absent").long("absent"));
        let matches = cmd.try_get_matches_from(["test"]).unwrap();
        let _ = required_str(&matches, "absent");
    }

    #[test]
    fn read_capped_rejects_input_over_limit() {
        let input = vec![b'x'; 101];
        let err = read_capped(input.as_slice(), 100).unwrap_err();
        assert!(
            err.to_string().contains("exceeds"),
            "expected 'exceeds' in error, got: {err}"
        );
    }
}