1mod args;
4mod render;
5
6use std::{
7 ffi::OsString,
8 io::{self, Write},
9 process::ExitCode,
10};
11
12use args::{Cli, Command};
13use clap::{Parser, error::ErrorKind};
14use render::{
15 RenderError, render_analysis, render_cluster, render_comparison_with_profile, render_inventory,
16 render_profiles, render_prosody,
17};
18
19pub fn run<I, T, W, E>(args: I, mut stdout: W, mut stderr: E) -> ExitCode
21where
22 I: IntoIterator<Item = T>,
23 T: Into<OsString> + Clone,
24 W: Write,
25 E: Write,
26{
27 let cli = match Cli::try_parse_from(args) {
28 Ok(cli) => cli,
29 Err(error) => {
30 let code = if matches!(
31 error.kind(),
32 ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
33 ) {
34 0
35 } else {
36 2
37 };
38 let mut text = error.render().to_string();
39 if !text.ends_with('\n') {
40 text.push('\n');
41 }
42 let writer: &mut dyn Write = if error.use_stderr() {
43 &mut stderr
44 } else {
45 &mut stdout
46 };
47 return match writer.write_all(text.as_bytes()) {
48 Ok(()) => ExitCode::from(code),
49 Err(error) if error.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
50 Err(_) => ExitCode::from(1),
51 };
52 }
53 };
54 match cli.command {
55 Command::Inventory { kind } => output_result(render_inventory(kind, &mut stdout), 1),
56 Command::Compare {
57 left,
58 right,
59 profile,
60 } => match render_comparison_with_profile(&left, &right, profile.into(), &mut stdout) {
61 Ok(()) => ExitCode::SUCCESS,
62 Err(RenderError::Io(error)) => output_result(Err(error), 1),
63 Err(RenderError::Parse(error)) => report_domain(&mut stderr, error),
64 Err(RenderError::Compare(error)) => report_domain(&mut stderr, error),
65 Err(RenderError::Analysis(error)) => report_domain(&mut stderr, error),
66 Err(RenderError::Cluster(error)) => report_domain(&mut stderr, error),
67 Err(RenderError::Stress(error)) => report_domain(&mut stderr, error),
68 Err(RenderError::Realization(error)) => report_domain(&mut stderr, error),
69 },
70 Command::Profiles => output_result(render_profiles(&mut stdout), 1),
71 Command::Analyze {
72 word,
73 kind,
74 syllabic_at,
75 } => match render_analysis(&word, kind, &syllabic_at, &mut stdout) {
76 Ok(()) => ExitCode::SUCCESS,
77 Err(RenderError::Io(error)) => output_result(Err(error), 1),
78 Err(RenderError::Analysis(error)) => report_domain(&mut stderr, error),
79 Err(RenderError::Cluster(error)) => report_domain(&mut stderr, error),
80 Err(RenderError::Parse(error)) => report_domain(&mut stderr, error),
81 Err(RenderError::Compare(error)) => report_domain(&mut stderr, error),
82 Err(RenderError::Stress(error)) => report_domain(&mut stderr, error),
83 Err(RenderError::Realization(error)) => report_domain(&mut stderr, error),
84 },
85 Command::Cluster {
86 cluster,
87 position,
88 kind,
89 } => match render_cluster(&cluster, position, kind, &mut stdout) {
90 Ok(()) => ExitCode::SUCCESS,
91 Err(RenderError::Io(error)) => output_result(Err(error), 1),
92 Err(RenderError::Cluster(error)) => report_domain(&mut stderr, error),
93 Err(RenderError::Analysis(error)) => report_domain(&mut stderr, error),
94 Err(RenderError::Parse(error)) => report_domain(&mut stderr, error),
95 Err(RenderError::Compare(error)) => report_domain(&mut stderr, error),
96 Err(RenderError::Stress(error)) => report_domain(&mut stderr, error),
97 Err(RenderError::Realization(error)) => report_domain(&mut stderr, error),
98 },
99 Command::Prosody {
100 word,
101 kind,
102 syllabic_at,
103 buffer_after,
104 syllables,
105 primary,
106 unstressed,
107 following,
108 } => match render_prosody(
109 render::ProsodyRequest {
110 input: &word,
111 kind,
112 syllabic_positions: &syllabic_at,
113 buffer_after: &buffer_after,
114 mode: syllables,
115 primary,
116 unstressed,
117 following,
118 },
119 &mut stdout,
120 ) {
121 Ok(()) => ExitCode::SUCCESS,
122 Err(RenderError::Io(error)) => output_result(Err(error), 1),
123 Err(RenderError::Analysis(error)) => report_domain(&mut stderr, error),
124 Err(RenderError::Stress(error)) => report_domain(&mut stderr, error),
125 Err(RenderError::Realization(error)) => report_domain(&mut stderr, error),
126 Err(RenderError::Parse(error)) => report_domain(&mut stderr, error),
127 Err(RenderError::Compare(error)) => report_domain(&mut stderr, error),
128 Err(RenderError::Cluster(error)) => report_domain(&mut stderr, error),
129 },
130 }
131}
132
133fn output_result(result: io::Result<()>, failure_code: u8) -> ExitCode {
134 match result {
135 Ok(()) => ExitCode::SUCCESS,
136 Err(error) if error.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
137 Err(_) => ExitCode::from(failure_code),
138 }
139}
140
141fn report_domain<W: Write>(stderr: &mut W, error: impl std::fmt::Display) -> ExitCode {
142 match writeln!(stderr, "error: {error}") {
143 Ok(()) => ExitCode::from(2),
144 Err(error) if error.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
145 Err(_) => ExitCode::from(1),
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use std::io;
153
154 struct BrokenWriter(io::ErrorKind);
155 impl Write for BrokenWriter {
156 fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
157 Err(io::Error::new(self.0, "injected writer failure"))
158 }
159 fn flush(&mut self) -> io::Result<()> {
160 Ok(())
161 }
162 }
163
164 #[test]
165 fn non_broken_output_failure_returns_one() {
166 let mut stderr = Vec::new();
167 let code = run(
168 ["relojban", "inventory"],
169 BrokenWriter(io::ErrorKind::Other),
170 &mut stderr,
171 );
172 assert_eq!(code, ExitCode::from(1));
173 }
174
175 #[test]
176 fn prosody_profiles_and_alternate_compare_writer_failures_return_one() {
177 for args in [
178 vec!["relojban", "prosody", "klama", "--kind", "brivla"],
179 vec!["relojban", "profiles"],
180 vec![
181 "relojban",
182 "compare",
183 "e",
184 "o",
185 "--profile",
186 "close-mid-e-v1",
187 ],
188 ] {
189 let code = run(
190 args.iter().copied(),
191 BrokenWriter(io::ErrorKind::Other),
192 Vec::new(),
193 );
194 assert_eq!(code, ExitCode::from(1), "args={args:?}");
195 }
196 }
197
198 #[test]
199 fn broken_pipe_is_a_successful_exit() {
200 let code = run(
201 ["relojban", "inventory"],
202 BrokenWriter(io::ErrorKind::BrokenPipe),
203 Vec::new(),
204 );
205 assert_eq!(code, ExitCode::SUCCESS);
206 }
207
208 #[test]
209 fn help_writer_failure_is_handled() {
210 let code = run(
211 ["relojban", "--help"],
212 BrokenWriter(io::ErrorKind::BrokenPipe),
213 Vec::new(),
214 );
215 assert_eq!(code, ExitCode::SUCCESS);
216 }
217
218 #[test]
219 fn renderers_surface_io_failures_at_the_writer_boundary() {
220 let ordinary = crate::render::render_inventory(
221 crate::args::InventoryKind::Alphabet,
222 BrokenWriter(io::ErrorKind::Other),
223 )
224 .unwrap_err();
225 assert_eq!(ordinary.kind(), io::ErrorKind::Other);
226
227 let broken = crate::render::render_comparison_with_profile(
228 "p",
229 "b",
230 bauske_phonology::PronunciationProfile::RepresentativesV1,
231 BrokenWriter(io::ErrorKind::BrokenPipe),
232 )
233 .unwrap_err();
234 assert!(
235 matches!(broken, RenderError::Io(error) if error.kind() == io::ErrorKind::BrokenPipe)
236 );
237 }
238 #[test]
239 fn new_renderers_surface_io_failures_and_commands_handle_broken_pipe() {
240 let analysis = crate::render::render_analysis(
241 "ai",
242 crate::args::WordKindArg::Cmavo,
243 &[],
244 BrokenWriter(io::ErrorKind::Other),
245 )
246 .unwrap_err();
247 assert!(matches!(analysis, RenderError::Io(error) if error.kind() == io::ErrorKind::Other));
248
249 let cluster = crate::render::render_cluster(
250 "bf",
251 crate::args::ClusterPositionArg::Medial,
252 crate::args::WordKindArg::Brivla,
253 BrokenWriter(io::ErrorKind::BrokenPipe),
254 )
255 .unwrap_err();
256 assert!(
257 matches!(cluster, RenderError::Io(error) if error.kind() == io::ErrorKind::BrokenPipe)
258 );
259
260 let prosody = crate::render::render_prosody(
261 crate::render::ProsodyRequest {
262 input: "klama",
263 kind: crate::args::WordKindArg::Brivla,
264 syllabic_positions: &[],
265 buffer_after: &[],
266 mode: crate::args::SyllablesArg::Canonical,
267 primary: None,
268 unstressed: false,
269 following: crate::args::FollowingArg::Unspecified,
270 },
271 BrokenWriter(io::ErrorKind::Other),
272 )
273 .unwrap_err();
274 assert!(matches!(prosody, RenderError::Io(error) if error.kind() == io::ErrorKind::Other));
275
276 let profiles =
277 crate::render::render_profiles(BrokenWriter(io::ErrorKind::Other)).unwrap_err();
278 assert_eq!(profiles.kind(), io::ErrorKind::Other);
279
280 let comparison = crate::render::render_comparison_with_profile(
281 "p",
282 "b",
283 bauske_phonology::PronunciationProfile::RepresentativesV1,
284 BrokenWriter(io::ErrorKind::Other),
285 )
286 .unwrap_err();
287 assert!(
288 matches!(comparison, RenderError::Io(error) if error.kind() == io::ErrorKind::Other)
289 );
290
291 let analyze_code = run(
292 ["relojban", "analyze", "ai", "--kind", "cmavo"],
293 BrokenWriter(io::ErrorKind::BrokenPipe),
294 Vec::new(),
295 );
296 assert_eq!(analyze_code, ExitCode::SUCCESS);
297 let cluster_code = run(
298 [
299 "relojban",
300 "cluster",
301 "bf",
302 "--position",
303 "medial",
304 "--kind",
305 "brivla",
306 ],
307 BrokenWriter(io::ErrorKind::BrokenPipe),
308 Vec::new(),
309 );
310 assert_eq!(cluster_code, ExitCode::SUCCESS);
311
312 let prosody_code = run(
313 ["relojban", "prosody", "klama", "--kind", "brivla"],
314 BrokenWriter(io::ErrorKind::BrokenPipe),
315 Vec::new(),
316 );
317 assert_eq!(prosody_code, ExitCode::SUCCESS);
318 let profiles_code = run(
319 ["relojban", "profiles"],
320 BrokenWriter(io::ErrorKind::BrokenPipe),
321 Vec::new(),
322 );
323 assert_eq!(profiles_code, ExitCode::SUCCESS);
324 let compare_code = run(
325 ["relojban", "compare", "p", "b"],
326 BrokenWriter(io::ErrorKind::BrokenPipe),
327 Vec::new(),
328 );
329 assert_eq!(compare_code, ExitCode::SUCCESS);
330 }
331
332 #[test]
333 fn prosody_cli_reports_hint_resolution_and_realization_policy() {
334 let mut output = Vec::new();
335 let code = run(
336 [
337 "relojban",
338 "prosody",
339 "gas,tro",
340 "--kind",
341 "brivla",
342 "--syllables",
343 "hints",
344 ],
345 &mut output,
346 Vec::new(),
347 );
348 assert_eq!(code, ExitCode::SUCCESS);
349 let output = String::from_utf8(output).unwrap();
350 assert!(output.contains("syllabification_resolved\ttrue"));
351 assert!(output.contains("realization_policy\tbuffer-plan-v1"));
352
353 for (word, expected) in [
354 ("a,'i", "syllabification_resolved\ttrue"),
355 (
356 "a',i",
357 "syllable_limitation\tsyllables.hint-conflicts-with-apostrophe",
358 ),
359 ] {
360 let mut output = Vec::new();
361 let code = run(
362 [
363 "relojban",
364 "prosody",
365 word,
366 "--kind",
367 "cmavo",
368 "--syllables",
369 "hints",
370 ],
371 &mut output,
372 Vec::new(),
373 );
374 assert_eq!(code, ExitCode::SUCCESS, "{word}");
375 assert!(
376 String::from_utf8(output).unwrap().contains(expected),
377 "{word}"
378 );
379 }
380 }
381}