// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 //! Generic output formatting helpers for CLI commands. //! //! This module provides helper functions to eliminate duplication across CLI commands //! that support `--output` flags (json, yaml, table). //! //! # Early-return Pattern //! //! All helper functions return `Result`: //! - `Ok(true)` — Format was handled (json/yaml), caller should return immediately //! - `Ok(false)` — Format is "table", caller should continue to table rendering //! - `Err(e)` — Unsupported format or serialization error //! //! # Example //! //! ```ignore //! use crate::output::print_output_collection; //! //! pub fn sandbox_list(output: &str) -> Result<()> { //! let sandboxes = fetch_sandboxes()?; //! //! if print_output_collection(output, &sandboxes, sandbox_to_json)? { //! return Ok(()); //! } //! //! // Fall through to table rendering //! render_sandbox_table(&sandboxes); //! Ok(()) //! } //! ``` use miette::{IntoDiagnostic, Result}; use std::io::Write; /// Report a continuation token for table output. pub fn print_next_page_token(next_page_token: &str) { if !next_page_token.is_empty() { eprintln!("Next page token: {next_page_token}"); } } fn paginated_collection_value( collection_name: &str, items: &[T], next_page_token: &str, to_json: F, ) -> serde_json::Value where F: Fn(&T) -> serde_json::Value, { let mut envelope = serde_json::Map::new(); envelope.insert( collection_name.to_string(), serde_json::Value::Array(items.iter().map(to_json).collect()), ); envelope.insert( "next_page_token".to_string(), serde_json::Value::String(next_page_token.to_string()), ); serde_json::Value::Object(envelope) } /// Print a single page as a structured response envelope. /// /// JSON and YAML output include both the named collection and /// `next_page_token`, so callers can continue without scraping stderr. Table /// output is not handled; callers should render the table and may report the /// token with [`print_next_page_token`]. pub fn print_paginated_output_collection( format: impl AsRef, collection_name: &str, items: &[T], next_page_token: &str, to_json: F, ) -> Result where F: Fn(&T) -> serde_json::Value, { match format.as_ref() { "json" => { let envelope = paginated_collection_value(collection_name, items, next_page_token, to_json); println!( "{}", serde_json::to_string_pretty(&envelope).into_diagnostic()? ); Ok(true) } "yaml" => { let envelope = paginated_collection_value(collection_name, items, next_page_token, to_json); print!( "{}", openshell_core::yaml::to_string(&envelope).into_diagnostic()? ); Ok(true) } "table" => Ok(false), _ => Err(miette::miette!( "unsupported output format: {}", format.as_ref() )), } } /// Print collection output in specified format (json/yaml/table). /// /// # Returns /// - `Ok(true)` if format was handled (json/yaml), caller should return /// - `Ok(false)` if format is "table", caller should continue to table rendering /// - `Err` for unsupported formats or serialization errors /// /// # Behavior /// - JSON: uses `println!()` (includes trailing newline) /// - YAML: uses `print!()` (no trailing newline, `serde_yml` includes one) /// /// # Example /// ```ignore /// if print_output_collection(output, &sandboxes, sandbox_to_json)? { /// return Ok(()); /// } /// ``` pub fn print_output_collection( format: impl AsRef, items: &[T], to_json: F, ) -> Result where F: Fn(&T) -> serde_json::Value, { match format.as_ref() { "json" => { let values: Vec<_> = items.iter().map(to_json).collect(); println!( "{}", serde_json::to_string_pretty(&values).into_diagnostic()? ); Ok(true) } "yaml" => { let values: Vec<_> = items.iter().map(to_json).collect(); print!( "{}", openshell_core::yaml::to_string(&values).into_diagnostic()? ); Ok(true) } "table" => Ok(false), _ => Err(miette::miette!( "unsupported output format: {}", format.as_ref() )), } } /// Print single item output in specified format (json/yaml/table). /// /// # Returns /// - `Ok(true)` if format was handled (json/yaml), caller should return /// - `Ok(false)` if format is "table", caller should continue to table rendering /// - `Err` for unsupported formats or serialization errors /// /// # Example /// ```ignore /// if print_output_single(output, &sandbox, sandbox_to_json)? { /// return Ok(()); /// } /// ``` pub fn print_output_single(format: impl AsRef, item: &T, to_json: F) -> Result where F: Fn(&T) -> serde_json::Value, { match format.as_ref() { "json" => { let value = to_json(item); println!( "{}", serde_json::to_string_pretty(&value).into_diagnostic()? ); Ok(true) } "yaml" => { let value = to_json(item); print!( "{}", openshell_core::yaml::to_string(&value).into_diagnostic()? ); Ok(true) } "table" => Ok(false), _ => Err(miette::miette!( "unsupported output format: {}", format.as_ref() )), } } /// Print pre-formatted output in specified format (json/yaml/table). /// /// Use this when converter functions return `Result` instead of `serde_json::Value`. /// /// # Returns /// - `Ok(true)` if format was handled (json/yaml), caller should return /// - `Ok(false)` if format is "table", caller should continue to table rendering /// - `Err` for unsupported formats or conversion errors /// /// # Example /// ```ignore /// if print_output_direct( /// output, /// || profiles_to_json(&profiles).into_diagnostic(), /// || profiles_to_yaml(&profiles).into_diagnostic(), /// )? { /// return Ok(()); /// } /// ``` pub fn print_output_direct(format: impl AsRef, json_fn: J, yaml_fn: Y) -> Result where J: FnOnce() -> Result, Y: FnOnce() -> Result, { match format.as_ref() { "json" => { let json = json_fn()?; println!("{json}"); Ok(true) } "yaml" => { let yaml = yaml_fn()?; print!("{yaml}"); Ok(true) } "table" => Ok(false), _ => Err(miette::miette!( "unsupported output format: {}", format.as_ref() )), } } /// Print collection output to a custom writer in specified format (json/yaml/table). /// /// Writer variant for commands that need custom output destinations. /// /// # Returns /// - `Ok(true)` if format was handled (json/yaml), caller should return /// - `Ok(false)` if format is "table", caller should continue to table rendering /// - `Err` for unsupported formats, serialization errors, or write errors pub fn print_output_collection_to_writer( format: impl AsRef, writer: &mut W, items: &[T], to_json: F, ) -> Result where W: Write, F: Fn(&T) -> serde_json::Value, { match format.as_ref() { "json" => { let values: Vec<_> = items.iter().map(to_json).collect(); writeln!( writer, "{}", serde_json::to_string_pretty(&values).into_diagnostic()? ) .into_diagnostic()?; Ok(true) } "yaml" => { let values: Vec<_> = items.iter().map(to_json).collect(); write!( writer, "{}", openshell_core::yaml::to_string(&values).into_diagnostic()? ) .into_diagnostic()?; Ok(true) } "table" => Ok(false), _ => Err(miette::miette!( "unsupported output format: {}", format.as_ref() )), } } /// Writer variant of [`print_paginated_output_collection`]. pub fn print_paginated_output_collection_to_writer( format: impl AsRef, writer: &mut W, collection_name: &str, items: &[T], next_page_token: &str, to_json: F, ) -> Result where W: Write, F: Fn(&T) -> serde_json::Value, { match format.as_ref() { "json" => { let envelope = paginated_collection_value(collection_name, items, next_page_token, to_json); writeln!( writer, "{}", serde_json::to_string_pretty(&envelope).into_diagnostic()? ) .into_diagnostic()?; Ok(true) } "yaml" => { let envelope = paginated_collection_value(collection_name, items, next_page_token, to_json); write!( writer, "{}", openshell_core::yaml::to_string(&envelope).into_diagnostic()? ) .into_diagnostic()?; Ok(true) } "table" => Ok(false), _ => Err(miette::miette!( "unsupported output format: {}", format.as_ref() )), } } /// Print single item output to a custom writer in specified format (json/yaml/table). /// /// Writer variant for commands that need custom output destinations. /// /// # Returns /// - `Ok(true)` if format was handled (json/yaml), caller should return /// - `Ok(false)` if format is "table", caller should continue to table rendering /// - `Err` for unsupported formats, serialization errors, or write errors pub fn print_output_single_to_writer( format: impl AsRef, writer: &mut W, item: &T, to_json: F, ) -> Result where W: Write, F: Fn(&T) -> serde_json::Value, { match format.as_ref() { "json" => { let value = to_json(item); writeln!( writer, "{}", serde_json::to_string_pretty(&value).into_diagnostic()? ) .into_diagnostic()?; Ok(true) } "yaml" => { let value = to_json(item); write!( writer, "{}", openshell_core::yaml::to_string(&value).into_diagnostic()? ) .into_diagnostic()?; Ok(true) } "table" => Ok(false), _ => Err(miette::miette!( "unsupported output format: {}", format.as_ref() )), } } /// Print pre-formatted output to a custom writer in specified format (json/yaml/table). /// /// Writer variant for commands that need custom output destinations and use /// pre-formatted converters returning `Result`. /// /// # Returns /// - `Ok(true)` if format was handled (json/yaml), caller should return /// - `Ok(false)` if format is "table", caller should continue to table rendering /// - `Err` for unsupported formats, conversion errors, or write errors pub fn print_output_direct_to_writer( format: impl AsRef, writer: &mut W, json_fn: J, yaml_fn: Y, ) -> Result where W: Write, J: FnOnce() -> Result, Y: FnOnce() -> Result, { match format.as_ref() { "json" => { let json = json_fn()?; writeln!(writer, "{json}").into_diagnostic()?; Ok(true) } "yaml" => { let yaml = yaml_fn()?; write!(writer, "{yaml}").into_diagnostic()?; Ok(true) } "table" => Ok(false), _ => Err(miette::miette!( "unsupported output format: {}", format.as_ref() )), } } #[cfg(test)] mod tests { use super::*; #[derive(Debug, Clone)] struct TestItem { id: u32, name: String, } fn test_item_to_json(item: &TestItem) -> serde_json::Value { serde_json::json!({ "id": item.id, "name": item.name, }) } #[test] fn test_print_output_collection_json() { let items = vec![ TestItem { id: 1, name: "first".to_string(), }, TestItem { id: 2, name: "second".to_string(), }, ]; // Note: This test doesn't capture stdout, but verifies the function returns Ok(true) let result = print_output_collection("json", &items, test_item_to_json); assert!(result.is_ok()); assert!(result.unwrap()); } #[test] fn test_print_output_collection_yaml() { let items = vec![TestItem { id: 1, name: "test".to_string(), }]; let result = print_output_collection("yaml", &items, test_item_to_json); assert!(result.is_ok()); assert!(result.unwrap()); } #[test] fn test_print_output_collection_table() { let items = vec![TestItem { id: 1, name: "test".to_string(), }]; let result = print_output_collection("table", &items, test_item_to_json); assert!(result.is_ok()); assert!(!result.unwrap()); // Should return false for table } #[test] fn test_print_output_collection_unsupported() { let items = vec![TestItem { id: 1, name: "test".to_string(), }]; let result = print_output_collection("csv", &items, test_item_to_json); assert!(result.is_err()); assert!( result .unwrap_err() .to_string() .contains("unsupported output format") ); } #[test] fn test_print_output_collection_empty() { let items: Vec = vec![]; let result = print_output_collection("json", &items, test_item_to_json); assert!(result.is_ok()); assert!(result.unwrap()); } #[test] fn test_print_output_single_json() { let item = TestItem { id: 42, name: "single".to_string(), }; let result = print_output_single("json", &item, test_item_to_json); assert!(result.is_ok()); assert!(result.unwrap()); } #[test] fn test_print_output_single_yaml() { let item = TestItem { id: 42, name: "single".to_string(), }; let result = print_output_single("yaml", &item, test_item_to_json); assert!(result.is_ok()); assert!(result.unwrap()); } #[test] fn test_print_output_direct_json() { let json_fn = || Ok(r#"{"test": true}"#.to_string()); let yaml_fn = || Ok("test: true".to_string()); let result = print_output_direct("json", json_fn, yaml_fn); assert!(result.is_ok()); assert!(result.unwrap()); } #[test] fn test_print_output_direct_yaml() { let json_fn = || Ok(r#"{"test": true}"#.to_string()); let yaml_fn = || Ok("test: true".to_string()); let result = print_output_direct("yaml", json_fn, yaml_fn); assert!(result.is_ok()); assert!(result.unwrap()); } #[test] fn test_print_output_direct_error_propagation() { let json_fn = || Err(miette::miette!("json conversion failed")); let yaml_fn = || Ok("test: true".to_string()); let result = print_output_direct("json", json_fn, yaml_fn); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("json conversion")); } #[test] fn test_print_output_collection_to_writer_json() { let items = vec![TestItem { id: 1, name: "test".to_string(), }]; let mut buffer = Vec::new(); let result = print_output_collection_to_writer("json", &mut buffer, &items, test_item_to_json); assert!(result.is_ok()); assert!(result.unwrap()); let output = String::from_utf8(buffer).unwrap(); assert!(output.contains("\"id\": 1")); assert!(output.contains("\"name\": \"test\"")); } #[test] fn test_print_output_collection_to_writer_yaml() { let items = vec![TestItem { id: 1, name: "test".to_string(), }]; let mut buffer = Vec::new(); let result = print_output_collection_to_writer("yaml", &mut buffer, &items, test_item_to_json); assert!(result.is_ok()); assert!(result.unwrap()); let output = String::from_utf8(buffer).unwrap(); assert!(output.contains("id: 1")); assert!(output.contains("name: test")); } #[test] fn test_print_paginated_output_collection_to_writer_json() { let items = vec![TestItem { id: 1, name: "test".to_string(), }]; let mut buffer = Vec::new(); let handled = print_paginated_output_collection_to_writer( "json", &mut buffer, "items", &items, "next-token", test_item_to_json, ) .unwrap(); assert!(handled); let output: serde_json::Value = serde_json::from_slice(&buffer).unwrap(); assert_eq!(output["items"][0]["id"], 1); assert_eq!(output["next_page_token"], "next-token"); } #[test] fn test_print_paginated_output_collection_to_writer_yaml() { let items = vec![TestItem { id: 1, name: "test".to_string(), }]; let mut buffer = Vec::new(); let handled = print_paginated_output_collection_to_writer( "yaml", &mut buffer, "items", &items, "", test_item_to_json, ) .unwrap(); assert!(handled); let output: serde_json::Value = serde_yml::from_slice(&buffer).unwrap(); assert_eq!(output["items"][0]["name"], "test"); assert_eq!(output["next_page_token"], ""); } #[test] fn test_print_paginated_output_collection_table_is_not_handled() { let items = Vec::::new(); let mut buffer = Vec::new(); let handled = print_paginated_output_collection_to_writer( "table", &mut buffer, "items", &items, "next-token", test_item_to_json, ) .unwrap(); assert!(!handled); assert!(buffer.is_empty()); } #[test] fn test_print_output_single_to_writer_json() { let item = TestItem { id: 42, name: "single".to_string(), }; let mut buffer = Vec::new(); let result = print_output_single_to_writer("json", &mut buffer, &item, test_item_to_json); assert!(result.is_ok()); assert!(result.unwrap()); let output = String::from_utf8(buffer).unwrap(); assert!(output.contains("\"id\": 42")); assert!(output.contains("\"name\": \"single\"")); } #[test] fn test_print_output_direct_to_writer_json() { let mut buffer = Vec::new(); let json_fn = || Ok(r#"{"test": true}"#.to_string()); let yaml_fn = || Ok("test: true".to_string()); let result = print_output_direct_to_writer("json", &mut buffer, json_fn, yaml_fn); assert!(result.is_ok()); assert!(result.unwrap()); let output = String::from_utf8(buffer).unwrap(); assert!(output.contains(r#"{"test": true}"#)); } }