1use std::{
4 fmt,
5 fs::{self, File},
6 io::{self, Read as _, Write as _},
7 path::{Path, PathBuf},
8 process::ExitCode,
9};
10
11use clap::Parser;
12
13use crate::{
14 ContentTreeLimits, ContentValidationCode, ContentValidationErrors, PostCollection,
15 PostDocument,
16 cli::{Arguments, JsonError, JsonErrorReport, JsonReport},
17 validate_post_document_bytes,
18};
19
20const SUCCESS: u8 = 0;
21const INVALID_DOCUMENT: u8 = 65;
22const INPUT_UNAVAILABLE: u8 = 66;
23const INTERNAL_ERROR: u8 = 70;
24const PERMISSION_DENIED: u8 = 77;
25
26#[derive(Debug)]
27enum CliError {
28 InputUnavailable { path: PathBuf, detail: String },
29 PermissionDenied { path: PathBuf, source: io::Error },
30 Input { path: PathBuf, source: io::Error },
31 Output(io::Error),
32 Json(serde_json::Error),
33 Internal(&'static str),
34}
35
36impl CliError {
37 const fn exit_code(&self) -> u8 {
38 match self {
39 Self::InputUnavailable { .. } => INPUT_UNAVAILABLE,
40 Self::PermissionDenied { .. } => PERMISSION_DENIED,
41 Self::Input { .. } | Self::Output(_) | Self::Json(_) | Self::Internal(_) => {
42 INTERNAL_ERROR
43 }
44 }
45 }
46
47 const fn json_code(&self) -> &'static str {
48 match self {
49 Self::InputUnavailable { .. } => "input_unavailable",
50 Self::PermissionDenied { .. } => "permission_denied",
51 Self::Input { .. } | Self::Output(_) | Self::Json(_) | Self::Internal(_) => {
52 "internal_error"
53 }
54 }
55 }
56
57 fn path(&self) -> Option<&Path> {
58 match self {
59 Self::InputUnavailable { path, .. }
60 | Self::PermissionDenied { path, .. }
61 | Self::Input { path, .. } => Some(path.as_path()),
62 Self::Output(_) | Self::Json(_) | Self::Internal(_) => None,
63 }
64 }
65}
66
67impl fmt::Display for CliError {
68 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
69 match self {
70 Self::InputUnavailable { path, detail } => {
71 write!(formatter, "{}: input unavailable: {detail}", path.display())
72 }
73 Self::PermissionDenied { path, source } => {
74 write!(formatter, "{}: permission denied: {source}", path.display())
75 }
76 Self::Input { path, source } => {
77 write!(
78 formatter,
79 "{}: failed to read input: {source}",
80 path.display()
81 )
82 }
83 Self::Output(source) => write!(formatter, "failed to write output: {source}"),
84 Self::Json(source) => write!(formatter, "failed to encode JSON output: {source}"),
85 Self::Internal(message) => formatter.write_str(message),
86 }
87 }
88}
89
90pub fn run() -> ExitCode {
92 let arguments = Arguments::parse();
93 let json = arguments.json;
94 match execute(arguments) {
95 Ok(exit) => ExitCode::from(exit),
96 Err(error) => {
97 let exit = error.exit_code();
98 let reported = if json {
99 write_json_error(&error)
100 } else {
101 write_human_error(&error)
102 };
103 if reported.is_err() {
104 return ExitCode::from(INTERNAL_ERROR);
105 }
106 ExitCode::from(exit)
107 }
108 }
109}
110
111fn execute(arguments: Arguments) -> Result<u8, CliError> {
112 let contents = read_markdown(&arguments.markdown)?;
113 let path_label = arguments.markdown.to_string_lossy();
114 let validation = validate_post_document_bytes(
115 path_label.as_ref(),
116 &contents,
117 PostCollection::from(arguments.collection),
118 );
119
120 if arguments.json {
121 return write_json_result(path_label.as_ref(), validation);
122 }
123
124 match validation {
125 Ok(_) => {
126 let stdout = io::stdout();
127 let mut stdout = stdout.lock();
128 writeln!(stdout, "{path_label}: valid")
129 .and_then(|()| stdout.flush())
130 .map_err(CliError::Output)?;
131 Ok(SUCCESS)
132 }
133 Err(diagnostics) => {
134 let stderr = io::stderr();
135 let mut stderr = stderr.lock();
136 write_human_diagnostics(&mut stderr, path_label.as_ref(), &diagnostics)?;
137 Ok(INVALID_DOCUMENT)
138 }
139 }
140}
141
142fn read_markdown(path: &Path) -> Result<Vec<u8>, CliError> {
143 let metadata = fs::metadata(path).map_err(|source| classify_input_error(path, source))?;
144 if !metadata.is_file() {
145 return Err(CliError::InputUnavailable {
146 path: path.to_owned(),
147 detail: "path is not a regular file".to_owned(),
148 });
149 }
150
151 let file = File::open(path).map_err(|source| classify_input_error(path, source))?;
152 let limit = ContentTreeLimits::default().post_file_bytes.get();
153 let read_limit = limit.saturating_add(1);
154 let capacity = usize::try_from(metadata.len().min(read_limit)).map_err(|_| {
155 CliError::Internal("the configured post byte limit does not fit this platform")
156 })?;
157 let mut contents = Vec::with_capacity(capacity);
158 file.take(read_limit)
159 .read_to_end(&mut contents)
160 .map_err(|source| classify_input_error(path, source))?;
161 Ok(contents)
162}
163
164fn classify_input_error(path: &Path, source: io::Error) -> CliError {
165 match source.kind() {
166 io::ErrorKind::NotFound => CliError::InputUnavailable {
167 path: path.to_owned(),
168 detail: source.to_string(),
169 },
170 io::ErrorKind::PermissionDenied => CliError::PermissionDenied {
171 path: path.to_owned(),
172 source,
173 },
174 _ => CliError::Input {
175 path: path.to_owned(),
176 source,
177 },
178 }
179}
180
181fn write_json_result(
182 path: &str,
183 validation: Result<PostDocument, ContentValidationErrors>,
184) -> Result<u8, CliError> {
185 let stdout = io::stdout();
186 let mut stdout = stdout.lock();
187 match validation {
188 Ok(_) => {
189 write_json_report(
190 &mut stdout,
191 &JsonReport {
192 path,
193 valid: true,
194 diagnostics: &[],
195 },
196 )?;
197 Ok(SUCCESS)
198 }
199 Err(diagnostics) => {
200 write_json_report(
201 &mut stdout,
202 &JsonReport {
203 path,
204 valid: false,
205 diagnostics: diagnostics.errors(),
206 },
207 )?;
208 Ok(INVALID_DOCUMENT)
209 }
210 }
211}
212
213fn write_json_report(writer: &mut impl io::Write, report: &JsonReport<'_>) -> Result<(), CliError> {
214 serde_json::to_writer(&mut *writer, report).map_err(CliError::Json)?;
215 writer.write_all(b"\n").map_err(CliError::Output)?;
216 writer.flush().map_err(CliError::Output)
217}
218
219fn write_json_error(error: &CliError) -> Result<(), CliError> {
220 let path = error.path().map(|path| path.to_string_lossy().into_owned());
221 let report = JsonErrorReport {
222 error: JsonError {
223 path: path.as_deref(),
224 code: error.json_code(),
225 message: error.to_string(),
226 },
227 };
228 let stdout = io::stdout();
229 let mut stdout = stdout.lock();
230 serde_json::to_writer(&mut stdout, &report).map_err(CliError::Json)?;
231 stdout.write_all(b"\n").map_err(CliError::Output)?;
232 stdout.flush().map_err(CliError::Output)
233}
234
235fn write_human_error(error: &CliError) -> Result<(), CliError> {
236 let stderr = io::stderr();
237 let mut stderr = stderr.lock();
238 writeln!(stderr, "markdowncompiler: {error}")
239 .and_then(|()| stderr.flush())
240 .map_err(CliError::Output)
241}
242
243fn write_human_diagnostics(
244 writer: &mut impl io::Write,
245 path: &str,
246 diagnostics: &ContentValidationErrors,
247) -> Result<(), CliError> {
248 for diagnostic in diagnostics.errors() {
249 let code = validation_code_name(diagnostic.code)?;
250 writeln!(
251 writer,
252 "{path}: {} [{code}]: {}",
253 diagnostic.field, diagnostic.message
254 )
255 .map_err(CliError::Output)?;
256 if let Some(related) = &diagnostic.related {
257 writeln!(writer, " related: {}: {}", related.path, related.field)
258 .map_err(CliError::Output)?;
259 }
260 }
261
262 let count = diagnostics.errors().len();
263 let noun = if count == 1 {
264 "diagnostic"
265 } else {
266 "diagnostics"
267 };
268 writeln!(writer, "{path}: invalid ({count} {noun})").map_err(CliError::Output)?;
269 writer.flush().map_err(CliError::Output)
270}
271
272fn validation_code_name(code: ContentValidationCode) -> Result<String, CliError> {
273 match serde_json::to_value(code).map_err(CliError::Json)? {
274 serde_json::Value::String(value) => Ok(value),
275 _ => Err(CliError::Internal(
276 "validation code did not serialize as a JSON string",
277 )),
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 fn io_error(kind: io::ErrorKind) -> io::Error {
286 io::Error::new(kind, "fixture")
287 }
288
289 #[test]
290 fn cli_errors_have_stable_exit_and_json_categories() {
291 let path = PathBuf::from("post.md");
292 let errors = [
293 (
294 CliError::InputUnavailable {
295 path: path.clone(),
296 detail: "missing".to_owned(),
297 },
298 INPUT_UNAVAILABLE,
299 "input_unavailable",
300 ),
301 (
302 CliError::PermissionDenied {
303 path: path.clone(),
304 source: io_error(io::ErrorKind::PermissionDenied),
305 },
306 PERMISSION_DENIED,
307 "permission_denied",
308 ),
309 (
310 CliError::Input {
311 path,
312 source: io_error(io::ErrorKind::Other),
313 },
314 INTERNAL_ERROR,
315 "internal_error",
316 ),
317 (
318 CliError::Output(io_error(io::ErrorKind::BrokenPipe)),
319 INTERNAL_ERROR,
320 "internal_error",
321 ),
322 (
323 CliError::Json(
324 serde_json::from_str::<serde_json::Value>("{")
325 .expect_err("fixture JSON must be incomplete"),
326 ),
327 INTERNAL_ERROR,
328 "internal_error",
329 ),
330 (
331 CliError::Internal("fixture"),
332 INTERNAL_ERROR,
333 "internal_error",
334 ),
335 ];
336
337 for (error, exit, json_code) in errors {
338 assert_eq!(error.exit_code(), exit);
339 assert_eq!(error.json_code(), json_code);
340 }
341 }
342}