Skip to main content

wyvern/
input.rs

1//! Argv/stdin command input loaders.
2
3use std::io::Read;
4use std::path::Path;
5
6use serde_json::Value;
7
8use wyvern_schema::FieldName;
9
10use crate::cli_args::usage_message;
11use crate::error::LoadError;
12
13/// 1 MiB cap for stdin and `.json` file loads (RSH-001 / RSH-002).
14const MAX_CLI_INPUT_BYTES: usize = 1024 * 1024;
15
16/// Load a command [`Value`] from positional args or stdin.
17///
18/// Called after extension dispatch fails (no argv match). Remaining cases:
19/// - `.json` → read file and parse JSON
20/// - otherwise → parse the argument as inline JSON
21///
22/// `.md` shorthand is handled by the shipped `markdown-suffix` registry in
23/// `main` before this function runs.
24///
25/// # Errors
26///
27/// Returns [`LoadError::Usage`] for invalid argv shapes or empty stdin,
28/// [`LoadError::Parse`] for invalid JSON, and [`LoadError::Io`] for read
29/// failures.
30pub fn load_command_input(args: &[String], stdin: impl Read) -> Result<Value, LoadError> {
31    match args {
32        [] => load_stdin(stdin),
33        [arg] if arg.starts_with('-') => Err(LoadError::Usage {
34            kind: crate::error::UsageErrorKind::Generic,
35            message: usage_message(),
36        }),
37        [arg] => load_positional(arg),
38        _ => Err(LoadError::Usage {
39            kind: crate::error::UsageErrorKind::Generic,
40            message: usage_message(),
41        }),
42    }
43}
44
45fn load_positional(arg: &str) -> Result<Value, LoadError> {
46    let path = Path::new(arg);
47    match path.extension().and_then(|ext| ext.to_str()) {
48        Some(ext) if ext.eq_ignore_ascii_case("json") => load_json_file(path),
49        _ => parse_json(arg),
50    }
51}
52
53fn load_json_file(path: &Path) -> Result<Value, LoadError> {
54    let text = read_file_capped(path)?;
55    parse_json(&text)
56}
57
58/// Read a filesystem path with the CLI 1 MiB cap (RSH-001 / RSH-003).
59pub(crate) fn read_file_capped(path: &Path) -> Result<String, LoadError> {
60    let file = std::fs::File::open(path).map_err(|err| LoadError::Io {
61        field: FieldName::new("file"),
62        message: format!("could not read path '{}': {err}", path.display()),
63        source: Some(Box::new(err)),
64    })?;
65    read_capped(
66        file,
67        MAX_CLI_INPUT_BYTES,
68        "file",
69        &path.display().to_string(),
70    )
71}
72
73fn load_stdin(stdin: impl Read) -> Result<Value, LoadError> {
74    let buf = read_capped(stdin, MAX_CLI_INPUT_BYTES, "stdin", "stdin")?;
75    if buf.trim().is_empty() {
76        return Err(LoadError::Usage {
77            kind: crate::error::UsageErrorKind::Generic,
78            message: usage_message(),
79        });
80    }
81    parse_json(&buf)
82}
83
84fn read_capped(
85    reader: impl Read,
86    max: usize,
87    field: &str,
88    origin: &str,
89) -> Result<String, LoadError> {
90    let mut buf = Vec::new();
91    let n = reader
92        .take(max as u64 + 1)
93        .read_to_end(&mut buf)
94        .map_err(|err| LoadError::Io {
95            field: FieldName::new(field),
96            message: format!("could not read {origin}: {err}"),
97            source: Some(Box::new(err)),
98        })?;
99    if n > max {
100        return Err(LoadError::Io {
101            field: FieldName::new(field),
102            message: format!("{origin} exceeds maximum of {max} bytes"),
103            source: None,
104        });
105    }
106    String::from_utf8(buf).map_err(|err| LoadError::Io {
107        field: FieldName::new(field),
108        message: format!("{origin} is not valid UTF-8: {err}"),
109        source: Some(Box::new(err)),
110    })
111}
112
113fn parse_json(text: &str) -> Result<Value, LoadError> {
114    serde_json::from_str(text).map_err(|err| LoadError::Parse {
115        message: err.to_string(),
116    })
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::error::{emit_io_error, emit_parse_error};
123    use std::io::Cursor;
124
125    fn args(items: &[&str]) -> Vec<String> {
126        items.iter().map(|s| (*s).to_string()).collect()
127    }
128
129    #[test]
130    fn input_inline_json_loads() {
131        let value = load_command_input(
132            &args(&[r#"{"type":"chrome","title":"Hi"}"#]),
133            Cursor::new(""),
134        )
135        .expect("inline JSON");
136        assert_eq!(value["type"], "chrome");
137        assert_eq!(value["title"], "Hi");
138    }
139
140    #[test]
141    fn input_json_file_loads() {
142        let dir = tempfile::tempdir().unwrap();
143        let path = dir.path().join("cmd.json");
144        std::fs::write(&path, r#"{"type":"chrome","title":"FromFile"}"#).unwrap();
145
146        let value = load_command_input(&args(&[path.to_str().unwrap()]), Cursor::new(""))
147            .expect("json file");
148        assert_eq!(value["type"], "chrome");
149        assert_eq!(value["title"], "FromFile");
150    }
151
152    #[test]
153    fn input_stdin_loads_json() {
154        let value = load_command_input(&[], Cursor::new(r#"{"type":"chrome","title":"Stdin"}"#))
155            .expect("stdin JSON");
156        assert_eq!(value["type"], "chrome");
157        assert_eq!(value["title"], "Stdin");
158    }
159
160    #[test]
161    fn input_no_args_empty_stdin_is_usage() {
162        let err = load_command_input(&[], Cursor::new("")).expect_err("empty stdin");
163        assert!(matches!(err, LoadError::Usage { .. }));
164    }
165
166    #[test]
167    fn input_two_positional_args_is_usage() {
168        let err = load_command_input(&args(&["a", "b"]), Cursor::new("")).expect_err("two args");
169        assert!(matches!(err, LoadError::Usage { .. }));
170    }
171
172    #[test]
173    fn input_unknown_flag_is_usage() {
174        let err =
175            load_command_input(&args(&["--unknown-flag"]), Cursor::new("")).expect_err("flag");
176        assert!(matches!(err, LoadError::Usage { .. }));
177    }
178
179    #[test]
180    fn input_two_file_paths_is_usage() {
181        let err = load_command_input(&args(&["file.json", "other.json"]), Cursor::new(""))
182            .expect_err("two files");
183        assert!(matches!(err, LoadError::Usage { .. }));
184    }
185
186    #[test]
187    fn input_inline_parse_error() {
188        let err =
189            load_command_input(&args(&["{not-json"]), Cursor::new("")).expect_err("bad inline");
190        assert!(matches!(err, LoadError::Parse { .. }));
191    }
192
193    #[test]
194    fn input_missing_json_file_is_io() {
195        let dir = tempfile::tempdir().unwrap();
196        let missing = dir.path().join("definitely-missing-wyvern-a3.json");
197        let err = load_command_input(&args(&[missing.to_str().unwrap()]), Cursor::new(""))
198            .expect_err("missing file");
199        match err {
200            LoadError::Io { field, .. } => assert_eq!(field, "file"),
201            other => panic!("expected Io, got {other:?}"),
202        }
203    }
204
205    #[test]
206    fn input_parse_error_with_quotes_emits_valid_json() {
207        let err =
208            load_command_input(&args(&[r#"{ "bad": }"#]), Cursor::new("")).expect_err("parse");
209        let out = emit_parse_error(&err).expect("emit");
210        let value: Value = serde_json::from_str(&out).expect("valid JSON stderr");
211        assert_eq!(value["error"], "parse");
212        assert!(value["message"].is_string());
213    }
214
215    #[test]
216    fn input_io_error_with_quotes_in_path_emits_valid_json() {
217        let dir = tempfile::tempdir().unwrap();
218        // Path that does not exist; message will include the path string.
219        let path = dir.path().join(r#"say "hi".json"#);
220        let err = load_command_input(&args(&[path.to_str().unwrap()]), Cursor::new(""))
221            .expect_err("missing quoted path");
222        let out = emit_io_error(&err).expect("emit");
223        let value: Value = serde_json::from_str(&out).expect("valid JSON stderr");
224        assert_eq!(value["error"], "io");
225        assert_eq!(value["field"], "file");
226        assert!(value["message"].as_str().unwrap().contains('"'));
227    }
228
229    #[test]
230    fn input_stdin_rejects_oversize() {
231        let huge = format!(
232            r#"{{"type":"chrome","title":"{}"}}"#,
233            "x".repeat(MAX_CLI_INPUT_BYTES)
234        );
235        let err = load_command_input(&[], Cursor::new(huge)).expect_err("oversize stdin");
236        match err {
237            LoadError::Io { field, message, .. } => {
238                assert_eq!(field, "stdin");
239                assert!(message.contains("exceeds maximum"), "{message}");
240            }
241            other => panic!("expected Io, got {other:?}"),
242        }
243    }
244}