1use 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
13const MAX_CLI_INPUT_BYTES: usize = 1024 * 1024;
15
16pub 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
58pub(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 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}