1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
#[allow(unused_imports)]
use crate::errors::InputSpecError;
use crate::formats::UrlSpec;
#[cfg(target_family = "wasm")]
use crate::wasm_stubs::{Client, ClientBuilder, Response};
use either::Either;
use regex::Regex;
#[cfg(not(target_family = "wasm"))]
use reqwest::blocking::{Client, Response};
use reqwest::header::{ACCEPT, HeaderValue, USER_AGENT};
use rudof_iri::IriS;
use std::io::Cursor;
use std::{
fmt::Display,
fs,
io::{self, BufReader, StdinLock},
path::{Path, PathBuf},
str::FromStr,
};
use url::Url;
/// Specification for different input sources in Rudof.
///
/// Represents the various ways data can be provided to Rudof:
/// from files, standard input, URLs, or raw strings.
#[derive(Debug, Clone)]
pub enum InputSpec {
/// Input from a file path on the filesystem
Path(PathBuf),
/// Input from standard input (stdin)
Stdin,
/// Input from a raw string in memory
Str(String),
/// Input from a URL (HTTP/HTTPS)
Url(UrlSpec),
}
/// Reader type that can handle input from multiple sources.
///
/// This type implements [`io::BufRead`] and uses Either types to represent
/// the different possible reader implementations (stdin, file, HTTP response, or string).
pub type InputSpecReader =
Either<StdinLock<'static>, Either<BufReader<fs::File>, Either<BufReader<Response>, BufReader<Cursor<Vec<u8>>>>>>;
// ============================================================================
// InputSpec
// ============================================================================
impl InputSpec {
/// Creates an InputSpec from a file path.
pub fn path<P: AsRef<Path>>(path: P) -> InputSpec {
InputSpec::Path(PathBuf::from(path.as_ref()))
}
/// Returns a human-readable name for the input source.
pub fn source_name(&self) -> String {
match self {
InputSpec::Path(path_buf) => path_buf.display().to_string(),
InputSpec::Stdin => "stdin".to_string(),
InputSpec::Url(url_spec) => url_spec.to_string(),
InputSpec::Str(_) => "string".to_string(),
}
}
pub fn str(s: &str) -> InputSpec {
InputSpec::Str(s.to_string())
}
/// Converts the InputSpec to an IRI (Internationalized Resource Identifier).
///
/// File paths are converted to file:// URLs, and other sources get appropriate IRI representations.
pub fn as_iri(&self) -> Result<IriS, InputSpecError> {
match self {
#[cfg(not(target_family = "wasm"))]
InputSpec::Path(path) => {
let path_absolute = path.canonicalize().map_err(|err| InputSpecError::AbsolutePathError {
path: path.to_string_lossy().to_string(),
error: err,
})?;
let url = Url::from_file_path(path_absolute)
.map_err(|_| InputSpecError::FromFilePath { path: path.clone() })?;
Ok(IriS::new_unchecked(url.as_str()))
},
#[cfg(target_family = "wasm")]
InputSpec::Path(_) => Err(InputSpecError::WasmNotSupported {
operation: "File path operations".to_string(),
}),
InputSpec::Stdin => Ok(IriS::new_unchecked("file://stdin")),
InputSpec::Str(_s) => Ok(IriS::new_unchecked("file://str")),
InputSpec::Url(url) => Ok(IriS::new_unchecked(url.to_string().as_str())),
}
}
/// Opens the input source for reading.
///
/// # Parameters
/// - `accept`: Optional HTTP Accept header value for URL requests
/// - `context_error`: Context string for error messages
///
/// # Returns
/// A reader that implements `BufRead` for the input source.
///
/// # Note
/// The initial version of this code was inspired by [patharg](https://github.com/jwodder/patharg/blob/edd912e865143646fd7bb4c7796aa919fa5622b3/src/lib.rs#L264)
pub fn open_read(&self, accept: Option<&str>, context_error: &str) -> Result<InputSpecReader, InputSpecError> {
match self {
InputSpec::Stdin => Ok(Either::Left(io::stdin().lock())),
InputSpec::Path(p) => match fs::File::open(p) {
Ok(reader) => Ok(Either::Right(Either::Left(BufReader::new(reader)))),
Err(e) => Err(InputSpecError::OpenPathError {
msg: context_error.to_string(),
path: p.to_path_buf(),
err: e,
}),
},
InputSpec::Url(url_spec) => {
let url = url_spec.url().clone();
let resp = match accept {
None => url_spec.client().get(url_spec.url().as_str()),
Some(accept_str) => {
let mut headers = reqwest::header::HeaderMap::new();
let accept_value =
HeaderValue::from_str(accept_str).map_err(|e| InputSpecError::AcceptValue {
context: context_error.to_string(),
str: accept_str.to_string(),
error: e.to_string(),
})?;
headers.insert(ACCEPT, accept_value);
let user_agent_rudof = HeaderValue::from_str("rudof")
.map_err(|e| InputSpecError::UserAgentValue { error: e.to_string() })?;
headers.insert(USER_AGENT, user_agent_rudof);
let client = Client::builder()
.default_headers(headers)
.build()
.map_err(|e| InputSpecError::ClientBuilderError { error: format!("{e}") })?;
client.get(url_spec.url().as_str())
},
}
.send()
.map_err(|e| InputSpecError::UrlDerefError {
url,
error: format!("{e}"),
})?;
let reader = BufReader::new(resp);
Ok(Either::Right(Either::Right(Either::Left(reader))))
},
InputSpec::Str(s) => {
// The following code had the problem that it didn't detect properly if the file didn't exist
let cursor = Cursor::new(s.clone().into_bytes());
let reader = BufReader::new(cursor);
/*let path = Path::new(s);
let file = File::open(&path).map_err(|e| InputSpecError::OpenPathError {
msg: context_error.to_string(),
path: path.to_path_buf(),
err: e,
})?; */
// let reader = BufReader::new(file);
// Ok(Either::Right(Either::Left(reader)))
Ok(Either::Right(Either::Right(Either::Right(reader))))
},
}
}
/// Attempts to guess a base IRI for the input source.
///
/// Used when no explicit base IRI is provided for relative IRI resolution.
pub fn guess_base(&self) -> Result<String, InputSpecError> {
match self {
#[cfg(not(target_family = "wasm"))]
InputSpec::Path(path) => {
let absolute_path = fs::canonicalize(path).map_err(|err| InputSpecError::AbsolutePathError {
path: path.to_string_lossy().to_string(),
error: err,
})?;
let url: Url = Url::from_file_path(absolute_path).map_err(|_| InputSpecError::GuessBaseFromPath {
path: path.to_path_buf(),
})?;
Ok(url.to_string())
},
#[cfg(target_family = "wasm")]
InputSpec::Path(_) => Err(InputSpecError::WasmNotSupported {
operation: "File path operations".to_string(),
}),
InputSpec::Stdin => Ok("stdin://".to_string()),
InputSpec::Url(url_spec) => Ok(url_spec.url().to_string()),
InputSpec::Str(_) => Ok("string://".to_string()),
}
}
}
impl Display for InputSpec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self {
InputSpec::Path(path_buf) => write!(f, "Path: {}", path_buf.display()),
InputSpec::Stdin => write!(f, "Stdin"),
InputSpec::Url(url_spec) => write!(f, "Url: {url_spec}"),
InputSpec::Str(s) => write!(f, "String: {s}"),
}
}
}
impl FromStr for InputSpec {
type Err = InputSpecError;
/// Parses a string into an InputSpec.
///
/// # Logic
/// - "-" becomes Stdin
/// - Strings starting with "http://" or "https://" become URLs
/// - Existing file paths become Path
/// - Everything else becomes a raw string (Str)
fn from_str(s: &str) -> Result<Self, Self::Err> {
// TODO - This probably needs some improvements
// `~` must also be allowed beyond the first character: Windows 8.3
// short names (e.g. `C:\Users\RUNNER~1\AppData\Local\Temp\...`, as
// used by GitHub Actions' Windows runners for temp directories) put
// it in the middle of a path component, not just at the start.
let filepath_regex = Regex::new(r"^[.~/a-zA-Z0-9]?[/\\a-zA-Z0-9._:~-]*$").unwrap();
// A CURIE-like token (`alias:local`, e.g. `es:E10`) matches the loose
// filepath regex above (it allows ':') but is never a legitimate
// filesystem path — unlike a Windows drive-letter path, its "local"
// part contains no path separator. Let it fall through to `Str`
// instead of demanding it exist as a file, so callers that know
// about prefixes (e.g. the CLI's `--schema`/`--shapemap`, which can
// expand it against an endpoint's prefix map) get a chance to
// resolve it before it's treated as a literal string.
let curie_like_regex = Regex::new(r"^[A-Za-z][A-Za-z0-9._-]*:[^/\\]+$").unwrap();
if s.is_empty() {
return Err(InputSpecError::InvalidInput {
error: "The provided input is empty".to_string(),
});
}
match s {
_ if s == "-" => Ok(InputSpec::Stdin),
_ if s.starts_with("http://") => {
let url_spec = UrlSpec::parse(s)?;
Ok(InputSpec::Url(url_spec))
},
_ if s.starts_with("https://") => {
let url_spec = UrlSpec::parse(s)?;
Ok(InputSpec::Url(url_spec))
},
_ if curie_like_regex.is_match(s) && !Path::new(s).exists() => Ok(InputSpec::Str(s.to_string())),
_ if filepath_regex.is_match(s) => {
let path = Path::new(s);
if !path.exists() {
return Err(InputSpecError::FileDoesntExist { str: s.to_string() });
}
if path.is_dir() {
return Err(InputSpecError::PathIsDir { str: s.to_string() });
}
let pb: PathBuf = PathBuf::from_str(s).map_err(|e| InputSpecError::ParsingPathError {
str: s.to_string(),
error: format!("{e}"),
})?;
Ok(InputSpec::Path(pb))
},
_ if Path::new(s).is_file() || Path::new(s).is_dir() => {
let path = Path::new(s);
if path.is_dir() {
return Err(InputSpecError::PathIsDir { str: s.to_string() });
}
Ok(InputSpec::Path(PathBuf::from(s)))
},
_ => Ok(InputSpec::Str(s.to_string())),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn path_with_tilde_component_is_recognized_as_path() {
// Regression test: Windows 8.3 short names (e.g. the temp dirs
// GitHub Actions' Windows runners use) put a `~` in the middle of a
// path component, like `RUNNER~1`. That must still resolve to
// `InputSpec::Path`, not fall through to `InputSpec::Str`.
let dir = std::env::temp_dir().join("input_spec_test_RUNNER~1");
fs::create_dir_all(&dir).unwrap();
let file_path = dir.join("cache.bin");
fs::write(&file_path, b"content").unwrap();
let s = file_path.to_str().unwrap();
let spec = InputSpec::from_str(s).unwrap();
assert!(matches!(spec, InputSpec::Path(_)), "expected Path, got {spec:?}");
fs::remove_dir_all(&dir).unwrap();
}
}