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            ["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}