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;
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"))
}
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";
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(),
}
}
const MAX_STDIN_BYTES: usize = 10 * 1024 * 1024;
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)
}
pub(super) fn read_body_from_stdin_or_editor(template: &str, output_fmt: OutputFormat) -> String {
if !std::io::stdin().is_terminal() {
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,
);
});
}
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}"
);
}
}