rh_foundation/cli.rs
1//! CLI utilities for command-line applications.
2//!
3//! This module provides common patterns for CLI applications including:
4//! - Reading input from files, stdin, or command-line arguments
5//! - Writing output to files or stdout
6//! - Formatting and printing results with different output formats
7//! - Error handling and exit codes
8
9use crate::error::{io_error_with_path, FoundationError, Result};
10use serde::de::DeserializeOwned;
11use serde::Serialize;
12use std::fs;
13use std::io::{self, Read, Write};
14use std::path::Path;
15
16/// Reads input from a file, inline string, or stdin.
17///
18/// Priority order:
19/// 1. If `file` is Some, reads from that file path
20/// 2. If `inline` is Some, returns that string
21/// 3. Otherwise, reads from stdin
22///
23/// # Examples
24///
25/// ```no_run
26/// use rh_foundation::cli::read_input;
27/// use std::path::PathBuf;
28///
29/// # fn example() -> anyhow::Result<()> {
30/// // Read from file
31/// let content = read_input(Some("input.txt"), None)?;
32///
33/// // Read from PathBuf
34/// let path = PathBuf::from("input.txt");
35/// let content = read_input(Some(&path), None)?;
36///
37/// // Read from inline argument
38/// let content = read_input::<&str>(None, Some("inline data".to_string()))?;
39///
40/// // Read from stdin
41/// let content = read_input::<&str>(None, None)?;
42/// # Ok(())
43/// # }
44/// ```
45pub fn read_input<P: AsRef<Path>>(file: Option<P>, inline: Option<String>) -> Result<String> {
46 if let Some(filename) = file {
47 let path = filename.as_ref();
48 fs::read_to_string(path).map_err(|e| io_error_with_path(e, path, "read file"))
49 } else if let Some(content) = inline {
50 Ok(content)
51 } else {
52 let mut buffer = String::new();
53 io::stdin()
54 .read_to_string(&mut buffer)
55 .map_err(|e| FoundationError::Io(e).with_context("Failed to read from stdin"))?;
56 Ok(buffer)
57 }
58}
59
60/// Reads and parses JSON from a file.
61///
62/// # Examples
63///
64/// ```no_run
65/// use rh_foundation::cli::read_json;
66/// use serde::Deserialize;
67///
68/// #[derive(Deserialize)]
69/// struct Config {
70/// name: String,
71/// value: i32,
72/// }
73///
74/// # fn example() -> anyhow::Result<()> {
75/// let config: Config = read_json("config.json")?;
76/// # Ok(())
77/// # }
78/// ```
79pub fn read_json<T: DeserializeOwned>(path_str: &str) -> Result<T> {
80 let path = Path::new(path_str);
81 let content = fs::read_to_string(path).map_err(|e| io_error_with_path(e, path, "read"))?;
82
83 serde_json::from_str(&content).map_err(|e| {
84 FoundationError::Serialization(e)
85 .with_context(&format!("Failed to parse JSON from {path_str}"))
86 })
87}
88
89/// Writes content to a file or stdout.
90///
91/// If `path` is None, writes to stdout. Otherwise writes to the specified file.
92///
93/// # Examples
94///
95/// ```no_run
96/// use rh_foundation::cli::write_output;
97///
98/// # fn example() -> anyhow::Result<()> {
99/// use std::path::Path;
100///
101/// // Write to file
102/// write_output(Some(Path::new("output.txt")), "Hello, world!")?;
103///
104/// // Write to stdout
105/// write_output(None, "Hello, world!")?;
106/// # Ok(())
107/// # }
108/// ```
109pub fn write_output(path: Option<&Path>, content: &str) -> Result<()> {
110 match path {
111 Some(file_path) => {
112 fs::write(file_path, content).map_err(|e| io_error_with_path(e, file_path, "write to"))
113 }
114 None => io::stdout()
115 .write_all(content.as_bytes())
116 .map_err(|e| FoundationError::Io(e).with_context("Failed to write to stdout")),
117 }
118}
119
120/// Output format for CLI results.
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub enum OutputFormat {
123 /// Human-readable text output
124 Text,
125 /// JSON output (pretty-printed)
126 Json,
127 /// Compact JSON output (single line)
128 JsonCompact,
129 /// Debug format (Rust Debug trait)
130 Debug,
131 /// Pretty debug format (Rust Debug trait with pretty-printing)
132 DebugPretty,
133}
134
135impl std::str::FromStr for OutputFormat {
136 type Err = String;
137
138 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
139 match s.to_lowercase().as_str() {
140 "text" => Ok(OutputFormat::Text),
141 "json" => Ok(OutputFormat::Json),
142 "json-compact" | "compact" => Ok(OutputFormat::JsonCompact),
143 "debug" => Ok(OutputFormat::Debug),
144 "debug-pretty" | "pretty" => Ok(OutputFormat::DebugPretty),
145 _ => Err(format!(
146 "Invalid output format: {s}. Valid options: text, json, compact, debug, pretty"
147 )),
148 }
149 }
150}
151
152/// Prints a value with the specified output format.
153///
154/// # Examples
155///
156/// ```no_run
157/// use rh_foundation::cli::{print_with_format, OutputFormat};
158/// use serde::Serialize;
159///
160/// #[derive(Debug, Serialize)]
161/// struct Data {
162/// name: String,
163/// value: i32,
164/// }
165///
166/// # fn example() -> anyhow::Result<()> {
167/// let data = Data {
168/// name: "test".to_string(),
169/// value: 42,
170/// };
171///
172/// // Print as JSON
173/// print_with_format(&data, OutputFormat::Json)?;
174///
175/// // Print as debug
176/// print_with_format(&data, OutputFormat::Debug)?;
177/// # Ok(())
178/// # }
179/// ```
180pub fn print_with_format<T>(value: &T, format: OutputFormat) -> Result<()>
181where
182 T: std::fmt::Debug + Serialize,
183{
184 match format {
185 OutputFormat::Json => {
186 let json = serde_json::to_string_pretty(value).map_err(|e| {
187 FoundationError::Serialization(e).with_context("Failed to serialize JSON")
188 })?;
189 println!("{json}");
190 }
191 OutputFormat::JsonCompact => {
192 let json = serde_json::to_string(value).map_err(|e| {
193 FoundationError::Serialization(e).with_context("Failed to serialize JSON")
194 })?;
195 println!("{json}");
196 }
197 OutputFormat::Debug => {
198 println!("{value:?}");
199 }
200 OutputFormat::DebugPretty => {
201 println!("{value:#?}");
202 }
203 OutputFormat::Text => {
204 println!("{value:?}");
205 }
206 }
207 Ok(())
208}
209
210/// Prints a result, handling errors with proper formatting.
211///
212/// If the result is an error, it will be printed to stderr and return false.
213/// If the result is Ok, the value will be printed and return true.
214///
215/// # Examples
216///
217/// ```no_run
218/// use rh_foundation::cli::print_result;
219///
220/// # fn example() -> anyhow::Result<()> {
221/// let result: Result<i32, String> = Ok(42);
222/// let success = print_result(result);
223/// assert!(success);
224/// # Ok(())
225/// # }
226/// ```
227pub fn print_result<T, E>(result: std::result::Result<T, E>) -> bool
228where
229 T: std::fmt::Display,
230 E: std::fmt::Display,
231{
232 match result {
233 Ok(value) => {
234 println!("{value}");
235 true
236 }
237 Err(e) => {
238 eprintln!("Error: {e}");
239 false
240 }
241 }
242}
243
244/// Exits the process with an error code if condition is true.
245///
246/// This is useful for implementing `--strict` flags in CLI applications.
247///
248/// # Examples
249///
250/// ```no_run
251/// use rh_foundation::cli::exit_if;
252///
253/// # fn example() {
254/// let has_errors = true;
255/// let strict = true;
256///
257/// // Exit with code 1 if there are errors and strict mode is enabled
258/// exit_if(has_errors && strict, 1);
259/// # }
260/// ```
261pub fn exit_if(condition: bool, code: i32) {
262 if condition {
263 std::process::exit(code);
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 #[test]
272 fn test_output_format_parsing() {
273 assert_eq!("text".parse::<OutputFormat>().unwrap(), OutputFormat::Text);
274 assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
275 assert_eq!(
276 "compact".parse::<OutputFormat>().unwrap(),
277 OutputFormat::JsonCompact
278 );
279 assert_eq!(
280 "debug".parse::<OutputFormat>().unwrap(),
281 OutputFormat::Debug
282 );
283 assert_eq!(
284 "pretty".parse::<OutputFormat>().unwrap(),
285 OutputFormat::DebugPretty
286 );
287
288 assert!("invalid".parse::<OutputFormat>().is_err());
289 }
290
291 #[test]
292 fn test_read_json() {
293 let temp_dir = std::env::temp_dir();
294 let test_file = temp_dir.join("test_cli_json.json");
295
296 let test_data = r#"{"name": "test", "value": 42}"#;
297 fs::write(&test_file, test_data).unwrap();
298
299 #[derive(serde::Deserialize, PartialEq, Debug)]
300 struct TestData {
301 name: String,
302 value: i32,
303 }
304
305 let result: TestData = read_json(test_file.to_str().unwrap()).unwrap();
306 assert_eq!(result.name, "test");
307 assert_eq!(result.value, 42);
308
309 fs::remove_file(test_file).unwrap();
310 }
311}