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/// Load a command [`Value`] from positional args or stdin.
14///
15/// Detection for a single positional arg:
16/// - `.md` → `{ "type": "markdown", "file": <path> }` (path only; file not read)
17/// - `.json` → read file and parse JSON
18/// - otherwise → parse the argument as inline JSON
19///
20/// # Errors
21///
22/// Returns [`LoadError::Usage`] for invalid argv shapes or empty stdin,
23/// [`LoadError::Parse`] for invalid JSON, and [`LoadError::Io`] for read failures.
24pub 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        // Path that does not exist; message will include the path string.
192        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}