1use crate::{cli::ContentArgs, error::Error};
2use serde_json::{Value, json};
3use std::io::{IsTerminal, Read};
4
5pub const MAX_TEXT: usize = 1024 * 1024;
6
7pub fn validate(text: &str, title: bool) -> Result<(), Error> {
8 let max = if title { 512 } else { MAX_TEXT };
9 if text.trim().is_empty() || text.len() > max {
10 return Err(Error::invalid(format!(
11 "{} must contain 1–{max} bytes of text",
12 if title { "Title" } else { "Content" }
13 )));
14 }
15 if text.chars().any(|c| {
16 matches!(c, '\u{fffe}' | '\u{ffff}')
17 || (c.is_control() && (title || !matches!(c, '\n' | '\r' | '\t')))
18 }) {
19 return Err(Error::invalid(
20 "Text contains unsupported control or XML characters",
21 ));
22 }
23 Ok(())
24}
25
26pub fn content(args: &ContentArgs) -> Result<String, Error> {
27 let text = if let Some(text) = &args.text {
28 text.clone()
29 } else {
30 let path = args
31 .file
32 .as_ref()
33 .ok_or_else(|| Error::invalid("Use --text or --file"))?;
34 let reader: Box<dyn Read> = if path.as_os_str() == "-" {
35 if std::io::stdin().is_terminal() {
36 return Err(Error::invalid("Pipe UTF-8 text into --file -"));
37 }
38 Box::new(std::io::stdin())
39 } else {
40 Box::new(std::fs::File::open(path)?)
41 };
42 let mut bytes = Vec::new();
43 reader.take((MAX_TEXT + 1) as u64).read_to_end(&mut bytes)?;
44 if bytes.len() > MAX_TEXT {
45 return Err(Error::invalid("Content exceeds 1 MiB"));
46 }
47 String::from_utf8(bytes).map_err(|_| Error::invalid("Content must be UTF-8"))?
48 };
49 let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
50 validate(text, false)?;
51 Ok(text.replace("\r\n", "\n").replace('\r', "\n"))
52}
53
54pub fn prepare(
55 operation: &str,
56 target: &str,
57 title: Option<&str>,
58 args: &ContentArgs,
59) -> Result<Value, Error> {
60 if let Some(title) = title {
61 validate(title, true)?;
62 }
63 let text = content(args)?;
64 Ok(json!({"operation":operation,"target":target,"title":title,"text":text}))
65}