Skip to main content

bauske_cli/
lib.rs

1//! Command parsing and output for the Relojban phonology executable.
2
3mod 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
19/// Runs the CLI from caller-supplied arguments and output streams.
20pub 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}