Skip to main content

markdown_compiler/
startup.rs

1//! Standalone validator process startup, execution, and output handling.
2
3use 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
90/// Runs the standalone single-document validator to completion.
91pub 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}