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