Skip to main content

ripsed_json/
detect.rs

1use std::io::{self, BufRead, Read};
2
3/// The detected input mode based on stdin content.
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub enum InputMode {
6    /// Valid ripsed JSON request detected.
7    Json(String),
8    /// Plain text (pipe mode).
9    Pipe(Vec<u8>),
10}
11
12/// Peek at stdin to determine whether the input is a JSON request or plain text.
13///
14/// Reads the first chunk of stdin, checks if it starts with `{` and contains
15/// an `"operations"` key, and returns the appropriate mode.
16pub fn detect_stdin(stdin: &mut impl Read) -> io::Result<InputMode> {
17    let mut buffer = Vec::new();
18    stdin.read_to_end(&mut buffer)?;
19
20    if buffer.is_empty() {
21        return Ok(InputMode::Pipe(buffer));
22    }
23
24    // Find first non-whitespace byte
25    let first_nonws = buffer.iter().position(|&b| !b.is_ascii_whitespace());
26
27    match first_nonws {
28        Some(pos) if buffer[pos] == b'{' => {
29            // Try to parse as JSON
30            if let Ok(text) = std::str::from_utf8(&buffer)
31                && is_ripsed_json(text)
32            {
33                return Ok(InputMode::Json(text.to_string()));
34            }
35            Ok(InputMode::Pipe(buffer))
36        }
37        _ => Ok(InputMode::Pipe(buffer)),
38    }
39}
40
41/// Check if a JSON string looks like a ripsed request (has "operations" key).
42///
43/// This is a lightweight heuristic check — it does NOT fully parse the JSON.
44/// Full validation happens later in `JsonRequest::parse`, so a false positive
45/// here is harmless (it will be caught downstream), while avoiding the cost
46/// of double-deserializing valid requests.
47fn is_ripsed_json(text: &str) -> bool {
48    let trimmed = text.trim_start();
49    trimmed.starts_with('{') && trimmed.contains("\"operations\"")
50}
51
52/// Detect input mode from a buffered reader (for streaming stdin).
53pub fn detect_buffered(reader: &mut impl BufRead) -> io::Result<InputMode> {
54    let buf = reader.fill_buf()?;
55    if buf.is_empty() {
56        return Ok(InputMode::Pipe(vec![]));
57    }
58
59    // Peek at first non-whitespace
60    let first_nonws = buf.iter().position(|&b| !b.is_ascii_whitespace());
61
62    if first_nonws.is_some_and(|pos| buf[pos] == b'{') {
63        // Read everything and try to parse
64        let mut full = Vec::new();
65        reader.read_to_end(&mut full)?;
66        if let Ok(text) = std::str::from_utf8(&full)
67            && is_ripsed_json(text)
68        {
69            return Ok(InputMode::Json(text.to_string()));
70        }
71        Ok(InputMode::Pipe(full))
72    } else {
73        let mut full = Vec::new();
74        reader.read_to_end(&mut full)?;
75        Ok(InputMode::Pipe(full))
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn test_detect_json() {
85        let input = r#"{"operations": [{"op": "replace", "find": "a", "replace": "b"}]}"#;
86        let mut cursor = io::Cursor::new(input.as_bytes());
87        let mode = detect_stdin(&mut cursor).unwrap();
88        assert!(matches!(mode, InputMode::Json(_)));
89    }
90
91    #[test]
92    fn test_detect_plain_text() {
93        let input = "just some plain text\n";
94        let mut cursor = io::Cursor::new(input.as_bytes());
95        let mode = detect_stdin(&mut cursor).unwrap();
96        assert!(matches!(mode, InputMode::Pipe(_)));
97    }
98
99    #[test]
100    fn test_detect_json_without_operations() {
101        let input = r#"{"key": "value"}"#;
102        let mut cursor = io::Cursor::new(input.as_bytes());
103        let mode = detect_stdin(&mut cursor).unwrap();
104        assert!(matches!(mode, InputMode::Pipe(_)));
105    }
106
107    #[test]
108    fn test_detect_empty() {
109        let input = "";
110        let mut cursor = io::Cursor::new(input.as_bytes());
111        let mode = detect_stdin(&mut cursor).unwrap();
112        assert!(matches!(mode, InputMode::Pipe(_)));
113    }
114}