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 ["bauske", "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!["bauske", "prosody", "klama", "--kind", "brivla"],
179 vec!["bauske", "profiles"],
180 vec!["bauske", "compare", "e", "o", "--profile", "close-mid-e-v1"],
181 ] {
182 let code = run(
183 args.iter().copied(),
184 BrokenWriter(io::ErrorKind::Other),
185 Vec::new(),
186 );
187 assert_eq!(code, ExitCode::from(1), "args={args:?}");
188 }
189 }
190
191 #[test]
192 fn broken_pipe_is_a_successful_exit() {
193 let code = run(
194 ["bauske", "inventory"],
195 BrokenWriter(io::ErrorKind::BrokenPipe),
196 Vec::new(),
197 );
198 assert_eq!(code, ExitCode::SUCCESS);
199 }
200
201 #[test]
202 fn help_writer_failure_is_handled() {
203 let code = run(
204 ["bauske", "--help"],
205 BrokenWriter(io::ErrorKind::BrokenPipe),
206 Vec::new(),
207 );
208 assert_eq!(code, ExitCode::SUCCESS);
209 }
210
211 #[test]
212 fn renderers_surface_io_failures_at_the_writer_boundary() {
213 let ordinary = crate::render::render_inventory(
214 crate::args::InventoryKind::Alphabet,
215 BrokenWriter(io::ErrorKind::Other),
216 )
217 .unwrap_err();
218 assert_eq!(ordinary.kind(), io::ErrorKind::Other);
219
220 let broken = crate::render::render_comparison_with_profile(
221 "p",
222 "b",
223 bauske_phonology::PronunciationProfile::RepresentativesV1,
224 BrokenWriter(io::ErrorKind::BrokenPipe),
225 )
226 .unwrap_err();
227 assert!(
228 matches!(broken, RenderError::Io(error) if error.kind() == io::ErrorKind::BrokenPipe)
229 );
230 }
231 #[test]
232 fn new_renderers_surface_io_failures_and_commands_handle_broken_pipe() {
233 let analysis = crate::render::render_analysis(
234 "ai",
235 crate::args::WordKindArg::Cmavo,
236 &[],
237 BrokenWriter(io::ErrorKind::Other),
238 )
239 .unwrap_err();
240 assert!(matches!(analysis, RenderError::Io(error) if error.kind() == io::ErrorKind::Other));
241
242 let cluster = crate::render::render_cluster(
243 "bf",
244 crate::args::ClusterPositionArg::Medial,
245 crate::args::WordKindArg::Brivla,
246 BrokenWriter(io::ErrorKind::BrokenPipe),
247 )
248 .unwrap_err();
249 assert!(
250 matches!(cluster, RenderError::Io(error) if error.kind() == io::ErrorKind::BrokenPipe)
251 );
252
253 let prosody = crate::render::render_prosody(
254 crate::render::ProsodyRequest {
255 input: "klama",
256 kind: crate::args::WordKindArg::Brivla,
257 syllabic_positions: &[],
258 buffer_after: &[],
259 mode: crate::args::SyllablesArg::Canonical,
260 primary: None,
261 unstressed: false,
262 following: crate::args::FollowingArg::Unspecified,
263 },
264 BrokenWriter(io::ErrorKind::Other),
265 )
266 .unwrap_err();
267 assert!(matches!(prosody, RenderError::Io(error) if error.kind() == io::ErrorKind::Other));
268
269 let profiles =
270 crate::render::render_profiles(BrokenWriter(io::ErrorKind::Other)).unwrap_err();
271 assert_eq!(profiles.kind(), io::ErrorKind::Other);
272
273 let comparison = crate::render::render_comparison_with_profile(
274 "p",
275 "b",
276 bauske_phonology::PronunciationProfile::RepresentativesV1,
277 BrokenWriter(io::ErrorKind::Other),
278 )
279 .unwrap_err();
280 assert!(
281 matches!(comparison, RenderError::Io(error) if error.kind() == io::ErrorKind::Other)
282 );
283
284 let analyze_code = run(
285 ["bauske", "analyze", "ai", "--kind", "cmavo"],
286 BrokenWriter(io::ErrorKind::BrokenPipe),
287 Vec::new(),
288 );
289 assert_eq!(analyze_code, ExitCode::SUCCESS);
290 let cluster_code = run(
291 [
292 "bauske",
293 "cluster",
294 "bf",
295 "--position",
296 "medial",
297 "--kind",
298 "brivla",
299 ],
300 BrokenWriter(io::ErrorKind::BrokenPipe),
301 Vec::new(),
302 );
303 assert_eq!(cluster_code, ExitCode::SUCCESS);
304
305 let prosody_code = run(
306 ["bauske", "prosody", "klama", "--kind", "brivla"],
307 BrokenWriter(io::ErrorKind::BrokenPipe),
308 Vec::new(),
309 );
310 assert_eq!(prosody_code, ExitCode::SUCCESS);
311 let profiles_code = run(
312 ["bauske", "profiles"],
313 BrokenWriter(io::ErrorKind::BrokenPipe),
314 Vec::new(),
315 );
316 assert_eq!(profiles_code, ExitCode::SUCCESS);
317 let compare_code = run(
318 ["bauske", "compare", "p", "b"],
319 BrokenWriter(io::ErrorKind::BrokenPipe),
320 Vec::new(),
321 );
322 assert_eq!(compare_code, ExitCode::SUCCESS);
323 }
324
325 #[test]
326 fn prosody_cli_reports_hint_resolution_and_realization_policy() {
327 let mut output = Vec::new();
328 let code = run(
329 [
330 "bauske",
331 "prosody",
332 "gas,tro",
333 "--kind",
334 "brivla",
335 "--syllables",
336 "hints",
337 ],
338 &mut output,
339 Vec::new(),
340 );
341 assert_eq!(code, ExitCode::SUCCESS);
342 let output = String::from_utf8(output).unwrap();
343 assert!(output.contains("syllabification_resolved\ttrue"));
344 assert!(output.contains("realization_policy\tbuffer-plan-v1"));
345
346 for (word, expected) in [
347 ("a,'i", "syllabification_resolved\ttrue"),
348 (
349 "a',i",
350 "syllable_limitation\tsyllables.hint-conflicts-with-apostrophe",
351 ),
352 ] {
353 let mut output = Vec::new();
354 let code = run(
355 [
356 "bauske",
357 "prosody",
358 word,
359 "--kind",
360 "cmavo",
361 "--syllables",
362 "hints",
363 ],
364 &mut output,
365 Vec::new(),
366 );
367 assert_eq!(code, ExitCode::SUCCESS, "{word}");
368 assert!(
369 String::from_utf8(output).unwrap().contains(expected),
370 "{word}"
371 );
372 }
373 }
374}