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 file = std::fs::File::open(path).map_err(|err| LoadError::Io {
55 field: FieldName::new("file"),
56 message: format!("could not read path '{}': {err}", path.display()),
57 source: Some(Box::new(err)),
58 })?;
59 let text = read_capped(
60 file,
61 MAX_CLI_INPUT_BYTES,
62 "file",
63 &path.display().to_string(),
64 )?;
65 parse_json(&text)
66}
67
68fn load_stdin(stdin: impl Read) -> Result<Value, LoadError> {
69 let buf = read_capped(stdin, MAX_CLI_INPUT_BYTES, "stdin", "stdin")?;
70 if buf.trim().is_empty() {
71 return Err(LoadError::Usage {
72 kind: crate::error::UsageErrorKind::Generic,
73 message: usage_message(),
74 });
75 }
76 parse_json(&buf)
77}
78
79fn read_capped(
80 reader: impl Read,
81 max: usize,
82 field: &str,
83 origin: &str,
84) -> Result<String, LoadError> {
85 let mut buf = Vec::new();
86 let n = reader
87 .take(max as u64 + 1)
88 .read_to_end(&mut buf)
89 .map_err(|err| LoadError::Io {
90 field: FieldName::new(field),
91 message: format!("could not read {origin}: {err}"),
92 source: Some(Box::new(err)),
93 })?;
94 if n > max {
95 return Err(LoadError::Io {
96 field: FieldName::new(field),
97 message: format!("{origin} exceeds maximum of {max} bytes"),
98 source: None,
99 });
100 }
101 String::from_utf8(buf).map_err(|err| LoadError::Io {
102 field: FieldName::new(field),
103 message: format!("{origin} is not valid UTF-8: {err}"),
104 source: Some(Box::new(err)),
105 })
106}
107
108fn parse_json(text: &str) -> Result<Value, LoadError> {
109 serde_json::from_str(text).map_err(|err| LoadError::Parse {
110 message: err.to_string(),
111 })
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use crate::error::{emit_io_error, emit_parse_error};
118 use std::io::Cursor;
119
120 fn args(items: &[&str]) -> Vec<String> {
121 items.iter().map(|s| (*s).to_string()).collect()
122 }
123
124 #[test]
125 fn input_inline_json_loads() {
126 let value = load_command_input(
127 &args(&[r#"{"type":"chrome","title":"Hi"}"#]),
128 Cursor::new(""),
129 )
130 .expect("inline JSON");
131 assert_eq!(value["type"], "chrome");
132 assert_eq!(value["title"], "Hi");
133 }
134
135 #[test]
136 fn input_json_file_loads() {
137 let dir = tempfile::tempdir().unwrap();
138 let path = dir.path().join("cmd.json");
139 std::fs::write(&path, r#"{"type":"chrome","title":"FromFile"}"#).unwrap();
140
141 let value = load_command_input(&args(&[path.to_str().unwrap()]), Cursor::new(""))
142 .expect("json file");
143 assert_eq!(value["type"], "chrome");
144 assert_eq!(value["title"], "FromFile");
145 }
146
147 #[test]
148 fn input_stdin_loads_json() {
149 let value = load_command_input(&[], Cursor::new(r#"{"type":"chrome","title":"Stdin"}"#))
150 .expect("stdin JSON");
151 assert_eq!(value["type"], "chrome");
152 assert_eq!(value["title"], "Stdin");
153 }
154
155 #[test]
156 fn input_no_args_empty_stdin_is_usage() {
157 let err = load_command_input(&[], Cursor::new("")).expect_err("empty stdin");
158 assert!(matches!(err, LoadError::Usage { .. }));
159 }
160
161 #[test]
162 fn input_two_positional_args_is_usage() {
163 let err = load_command_input(&args(&["a", "b"]), Cursor::new("")).expect_err("two args");
164 assert!(matches!(err, LoadError::Usage { .. }));
165 }
166
167 #[test]
168 fn input_unknown_flag_is_usage() {
169 let err =
170 load_command_input(&args(&["--unknown-flag"]), Cursor::new("")).expect_err("flag");
171 assert!(matches!(err, LoadError::Usage { .. }));
172 }
173
174 #[test]
175 fn input_two_file_paths_is_usage() {
176 let err = load_command_input(&args(&["file.json", "other.json"]), Cursor::new(""))
177 .expect_err("two files");
178 assert!(matches!(err, LoadError::Usage { .. }));
179 }
180
181 #[test]
182 fn input_inline_parse_error() {
183 let err =
184 load_command_input(&args(&["{not-json"]), Cursor::new("")).expect_err("bad inline");
185 assert!(matches!(err, LoadError::Parse { .. }));
186 }
187
188 #[test]
189 fn input_missing_json_file_is_io() {
190 let dir = tempfile::tempdir().unwrap();
191 let missing = dir.path().join("definitely-missing-wyvern-a3.json");
192 let err = load_command_input(&args(&[missing.to_str().unwrap()]), Cursor::new(""))
193 .expect_err("missing file");
194 match err {
195 LoadError::Io { field, .. } => assert_eq!(field, "file"),
196 other => panic!("expected Io, got {other:?}"),
197 }
198 }
199
200 #[test]
201 fn input_parse_error_with_quotes_emits_valid_json() {
202 let err =
203 load_command_input(&args(&[r#"{ "bad": }"#]), Cursor::new("")).expect_err("parse");
204 let out = emit_parse_error(&err).expect("emit");
205 let value: Value = serde_json::from_str(&out).expect("valid JSON stderr");
206 assert_eq!(value["error"], "parse");
207 assert!(value["message"].is_string());
208 }
209
210 #[test]
211 fn input_io_error_with_quotes_in_path_emits_valid_json() {
212 let dir = tempfile::tempdir().unwrap();
213 let path = dir.path().join(r#"say "hi".json"#);
215 let err = load_command_input(&args(&[path.to_str().unwrap()]), Cursor::new(""))
216 .expect_err("missing quoted path");
217 let out = emit_io_error(&err).expect("emit");
218 let value: Value = serde_json::from_str(&out).expect("valid JSON stderr");
219 assert_eq!(value["error"], "io");
220 assert_eq!(value["field"], "file");
221 assert!(value["message"].as_str().unwrap().contains('"'));
222 }
223
224 #[test]
225 fn input_stdin_rejects_oversize() {
226 let huge = format!(
227 r#"{{"type":"chrome","title":"{}"}}"#,
228 "x".repeat(MAX_CLI_INPUT_BYTES)
229 );
230 let err = load_command_input(&[], Cursor::new(huge)).expect_err("oversize stdin");
231 match err {
232 LoadError::Io { field, message, .. } => {
233 assert_eq!(field, "stdin");
234 assert!(message.contains("exceeds maximum"), "{message}");
235 }
236 other => panic!("expected Io, got {other:?}"),
237 }
238 }
239}