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
13pub fn load_command_input(args: &[String], stdin: impl Read) -> Result<Value, LoadError> {
25 match args {
26 [] => load_stdin(stdin),
27 [arg] if arg.starts_with('-') => Err(LoadError::Usage {
28 message: usage_message(),
29 }),
30 [arg] => load_positional(arg),
31 _ => Err(LoadError::Usage {
32 message: usage_message(),
33 }),
34 }
35}
36
37fn load_positional(arg: &str) -> Result<Value, LoadError> {
38 let path = Path::new(arg);
39 match path.extension().and_then(|ext| ext.to_str()) {
40 Some(ext) if ext.eq_ignore_ascii_case("md") => Ok(serde_json::json!({
41 "type": "markdown",
42 "file": arg,
43 })),
44 Some(ext) if ext.eq_ignore_ascii_case("json") => load_json_file(path),
45 _ => parse_json(arg),
46 }
47}
48
49fn load_json_file(path: &Path) -> Result<Value, LoadError> {
50 let text = std::fs::read_to_string(path).map_err(|err| LoadError::Io {
51 field: FieldName::new("file"),
52 message: format!("could not read path '{}': {err}", path.display()),
53 })?;
54 parse_json(&text)
55}
56
57fn load_stdin(mut stdin: impl Read) -> Result<Value, LoadError> {
58 let mut buf = String::new();
59 stdin
60 .read_to_string(&mut buf)
61 .map_err(|err| LoadError::Io {
62 field: FieldName::new("stdin"),
63 message: format!("could not read stdin: {err}"),
64 })?;
65 if buf.trim().is_empty() {
66 return Err(LoadError::Usage {
67 message: usage_message(),
68 });
69 }
70 parse_json(&buf)
71}
72
73fn parse_json(text: &str) -> Result<Value, LoadError> {
74 serde_json::from_str(text).map_err(|err| LoadError::Parse {
75 message: err.to_string(),
76 })
77}
78
79#[cfg(test)]
80mod tests {
81 use super::*;
82 use crate::error::{emit_io_error, emit_parse_error};
83 use std::io::Cursor;
84
85 fn args(items: &[&str]) -> Vec<String> {
86 items.iter().map(|s| (*s).to_string()).collect()
87 }
88
89 #[test]
90 fn input_inline_json_loads() {
91 let value = load_command_input(
92 &args(&[r#"{"type":"chrome","title":"Hi"}"#]),
93 Cursor::new(""),
94 )
95 .expect("inline JSON");
96 assert_eq!(value["type"], "chrome");
97 assert_eq!(value["title"], "Hi");
98 }
99
100 #[test]
101 fn input_json_file_loads() {
102 let dir = std::env::temp_dir().join(format!("wyvern-a3-json-{}", std::process::id()));
103 std::fs::create_dir_all(&dir).unwrap();
104 let path = dir.join("cmd.json");
105 std::fs::write(&path, r#"{"type":"chrome","title":"FromFile"}"#).unwrap();
106
107 let value = load_command_input(&args(&[path.to_str().unwrap()]), Cursor::new(""))
108 .expect("json file");
109 assert_eq!(value["type"], "chrome");
110 assert_eq!(value["title"], "FromFile");
111
112 let _ = std::fs::remove_dir_all(&dir);
113 }
114
115 #[test]
116 fn input_md_path_loads_markdown_value() {
117 let value =
118 load_command_input(&args(&["docs/readme.md"]), Cursor::new("")).expect("md path");
119 assert_eq!(value["type"], "markdown");
120 assert_eq!(value["file"], "docs/readme.md");
121 }
122
123 #[test]
124 fn input_stdin_loads_json() {
125 let value = load_command_input(&[], Cursor::new(r#"{"type":"chrome","title":"Stdin"}"#))
126 .expect("stdin JSON");
127 assert_eq!(value["type"], "chrome");
128 assert_eq!(value["title"], "Stdin");
129 }
130
131 #[test]
132 fn input_no_args_empty_stdin_is_usage() {
133 let err = load_command_input(&[], Cursor::new("")).expect_err("empty stdin");
134 assert!(matches!(err, LoadError::Usage { .. }));
135 }
136
137 #[test]
138 fn input_two_positional_args_is_usage() {
139 let err = load_command_input(&args(&["a", "b"]), Cursor::new("")).expect_err("two args");
140 assert!(matches!(err, LoadError::Usage { .. }));
141 }
142
143 #[test]
144 fn input_unknown_flag_is_usage() {
145 let err =
146 load_command_input(&args(&["--unknown-flag"]), Cursor::new("")).expect_err("flag");
147 assert!(matches!(err, LoadError::Usage { .. }));
148 }
149
150 #[test]
151 fn input_two_file_paths_is_usage() {
152 let err = load_command_input(&args(&["file.json", "other.json"]), Cursor::new(""))
153 .expect_err("two files");
154 assert!(matches!(err, LoadError::Usage { .. }));
155 }
156
157 #[test]
158 fn input_inline_parse_error() {
159 let err =
160 load_command_input(&args(&["{not-json"]), Cursor::new("")).expect_err("bad inline");
161 assert!(matches!(err, LoadError::Parse { .. }));
162 }
163
164 #[test]
165 fn input_missing_json_file_is_io() {
166 let err = load_command_input(
167 &args(&["/definitely/missing/wyvern-a3.json"]),
168 Cursor::new(""),
169 )
170 .expect_err("missing file");
171 match err {
172 LoadError::Io { field, .. } => assert_eq!(field, "file"),
173 other => panic!("expected Io, got {other:?}"),
174 }
175 }
176
177 #[test]
178 fn input_parse_error_with_quotes_emits_valid_json() {
179 let err =
180 load_command_input(&args(&[r#"{ "bad": }"#]), Cursor::new("")).expect_err("parse");
181 let out = emit_parse_error(&err).expect("emit");
182 let value: Value = serde_json::from_str(&out).expect("valid JSON stderr");
183 assert_eq!(value["error"], "parse");
184 assert!(value["message"].is_string());
185 }
186
187 #[test]
188 fn input_io_error_with_quotes_in_path_emits_valid_json() {
189 let dir = std::env::temp_dir().join(format!("wyvern-a3-quote-{}", std::process::id()));
190 std::fs::create_dir_all(&dir).unwrap();
191 let path = dir.join(r#"say "hi".json"#);
193 let err = load_command_input(&args(&[path.to_str().unwrap()]), Cursor::new(""))
194 .expect_err("missing quoted path");
195 let out = emit_io_error(&err).expect("emit");
196 let value: Value = serde_json::from_str(&out).expect("valid JSON stderr");
197 assert_eq!(value["error"], "io");
198 assert_eq!(value["field"], "file");
199 assert!(value["message"].as_str().unwrap().contains('"'));
200 let _ = std::fs::remove_dir_all(&dir);
201 }
202}