diff --git a/Cargo.lock b/Cargo.lock index 9b43783..451512c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2950,6 +2950,7 @@ dependencies = [ "anyhow", "clap", "clap-markdown", + "csv", "dirs", "envtestkit", "futures", diff --git a/Cargo.toml b/Cargo.toml index af3e0f5..7882704 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.65" clap = { version = "4.0.17", features = ["derive", "cargo"] } +csv = "1" clap-markdown = "0.1.4" dirs = "6.0.0" futures = "0.3.28" diff --git a/README.md b/README.md index fc60be8..983b48e 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,38 @@ $ API_SERVER_NAME=local cargo build --release Explore available commands [here](https://developer.screenly.io/cli/#commands). +## Output Formats + +All list and get commands support three output formats via the global `--output` (`-o`) flag: + +| Format | Flag | Description | +|--------|------|-------------| +| Table | `--output table` | Human-readable table (default) | +| JSON | `--output json` | JSON output | +| CSV | `--output csv` | CSV output, suitable for piping to files or other tools | + +```bash +# Human-readable table (default) +$ screenly screen list + +# JSON output +$ screenly --output json asset list + +# CSV output saved to a file +$ screenly --output csv screen list > screens.csv + +# JSON output saved to a file +$ screenly --output json screen list > screens.json +``` + +> [!NOTE] +> In debug builds, the CLI outputs log messages to stdout. Use `RUST_LOG=off` to suppress them +> when redirecting output to a file: +> ```bash +> $ RUST_LOG=off screenly --output csv screen list > screens.csv +> $ RUST_LOG=off screenly --output json screen list > screens.json +> ``` + ## MCP Server (AI Assistant Integration) The Screenly CLI includes a built-in [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server, enabling AI assistants like Claude, Cursor, and others to interact with your Screenly digital signage network. diff --git a/docs/CommandLineHelp.md b/docs/CommandLineHelp.md index 797429b..13e9d01 100644 --- a/docs/CommandLineHelp.md +++ b/docs/CommandLineHelp.md @@ -66,7 +66,18 @@ Command line interface is intended for quick interaction with Screenly through t ###### **Options:** -* `-j`, `--json` — Enables JSON output +* `-o`, `--output ` — Output format: table (default), json, or csv + + Default value: `table` + + Possible values: + - `table`: + Human-readable table (default) + - `json`: + JSON output + - `csv`: + CSV output + @@ -105,11 +116,7 @@ Screen related commands Lists your screens -**Usage:** `screenly screen list [OPTIONS]` - -###### **Options:** - -* `-j`, `--json` — Enables JSON output +**Usage:** `screenly screen list` @@ -117,33 +124,25 @@ Lists your screens Gets a single screen by id -**Usage:** `screenly screen get [OPTIONS] ` +**Usage:** `screenly screen get ` ###### **Arguments:** * `` — UUID of the screen -###### **Options:** - -* `-j`, `--json` — Enables JSON output - ## `screenly screen add` Adds a new screen -**Usage:** `screenly screen add [OPTIONS] [NAME]` +**Usage:** `screenly screen add [NAME]` ###### **Arguments:** * `` — Pin code created with registrations endpoint * `` — Optional name of the new screen -###### **Options:** - -* `-j`, `--json` — Enables JSON output - ## `screenly screen delete` @@ -182,11 +181,7 @@ Asset related commands Lists your assets -**Usage:** `screenly asset list [OPTIONS]` - -###### **Options:** - -* `-j`, `--json` — Enables JSON output +**Usage:** `screenly asset list` @@ -194,33 +189,25 @@ Lists your assets Gets a single asset by id -**Usage:** `screenly asset get [OPTIONS] ` +**Usage:** `screenly asset get ` ###### **Arguments:** * `` — UUID of the asset -###### **Options:** - -* `-j`, `--json` — Enables JSON output - ## `screenly asset add` Adds a new asset -**Usage:** `screenly asset add [OPTIONS] ` +**Usage:** `screenly asset add <PATH> <TITLE>` ###### **Arguments:** * `<PATH>` — Path to local file or URL for remote file * `<TITLE>` — Asset title -###### **Options:** - -* `-j`, `--json` — Enables JSON output - ## `screenly asset delete` @@ -332,7 +319,7 @@ Time reference (ms): 32400000=9AM, 43200000=12PM, 61200000=5PM Examples: TRUE - Always show $WEEKDAY IN {1, 2, 3, 4, 5} - Weekdays only $TIME BETWEEN {32400000, 61200000} - 9 AM to 5 PM NOT $WEEKDAY IN {0, 6} - Exclude weekends -**Usage:** `screenly playlist create [OPTIONS] <TITLE> [PREDICATE]` +**Usage:** `screenly playlist create <TITLE> [PREDICATE]` ###### **Arguments:** @@ -357,21 +344,13 @@ Examples: TRUE - Always show $WEEKDAY IN {1, Default: TRUE -###### **Options:** - -* `-j`, `--json` — Enables JSON output - ## `screenly playlist list` Lists your playlists -**Usage:** `screenly playlist list [OPTIONS]` - -###### **Options:** - -* `-j`, `--json` — Enables JSON output +**Usage:** `screenly playlist list` @@ -403,7 +382,7 @@ Deletes a playlist. This cannot be undone Adds an asset to the end of the playlist -**Usage:** `screenly playlist append [OPTIONS] <UUID> <ASSET_UUID> [DURATION]` +**Usage:** `screenly playlist append <UUID> <ASSET_UUID> [DURATION]` ###### **Arguments:** @@ -411,17 +390,13 @@ Adds an asset to the end of the playlist * `<ASSET_UUID>` — UUID of the asset * `<DURATION>` — Duration of the playlist item in seconds. Defaults to 15 seconds -###### **Options:** - -* `-j`, `--json` — Enables JSON output - ## `screenly playlist prepend` Adds an asset to the beginning of the playlist -**Usage:** `screenly playlist prepend [OPTIONS] <UUID> <ASSET_UUID> [DURATION]` +**Usage:** `screenly playlist prepend <UUID> <ASSET_UUID> [DURATION]` ###### **Arguments:** @@ -429,10 +404,6 @@ Adds an asset to the beginning of the playlist * `<ASSET_UUID>` — UUID of the asset * `<DURATION>` — Duration of the playlist item in seconds. Defaults to 15 seconds -###### **Options:** - -* `-j`, `--json` — Enables JSON output - ## `screenly playlist update` @@ -482,11 +453,7 @@ Creates an Edge App in the store Lists your Edge Apps -**Usage:** `screenly edge-app list [OPTIONS]` - -###### **Options:** - -* `-j`, `--json` — Enables JSON output +**Usage:** `screenly edge-app list` @@ -539,7 +506,6 @@ Lists Edge App settings ###### **Options:** * `-p`, `--path <PATH>` — Path to the directory with the manifest. Defaults to the current working directory -* `-j`, `--json` — Enables JSON output @@ -583,7 +549,6 @@ Lists Edge App instances ###### **Options:** * `-p`, `--path <PATH>` — Path to the directory with the manifest. Defaults to the current working directory -* `-j`, `--json` — Enables JSON output diff --git a/src/cli.rs b/src/cli.rs index d3ba88e..1509aa2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -56,6 +56,17 @@ fn parse_key_val(s: &str) -> Result<(String, String), ParseError> { Ok((s[..pos].to_string(), s[pos + 1..].to_string())) } +#[derive(clap::ValueEnum, Clone, Copy, Debug, Default, PartialEq)] +pub enum OutputFormat { + /// Human-readable table (default). + #[default] + Table, + /// JSON output. + Json, + /// CSV output. + Csv, +} + #[derive(Parser)] #[command( version, @@ -64,9 +75,13 @@ fn parse_key_val(s: &str) -> Result<(String, String), ParseError> { )] #[command(propagate_version = true)] pub struct Cli { - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, + /// Output format: table (default), json, or csv. + #[arg(short, long, value_enum, default_value_t = OutputFormat::Table, global = true)] + pub(crate) output: OutputFormat, + + /// Deprecated: use --output json instead. + #[arg(long, hide = true, global = true, conflicts_with = "output")] + pub(crate) json: bool, #[command(subcommand)] pub(crate) command: Commands, @@ -100,24 +115,14 @@ pub enum Commands { #[derive(Subcommand, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum ScreenCommands { /// Lists your screens. - List { - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, - }, + List, /// Gets a single screen by id. Get { - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, /// UUID of the screen. uuid: String, }, /// Adds a new screen. Add { - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, /// Pin code created with registrations endpoint. pin: String, /// Optional name of the new screen. @@ -152,9 +157,6 @@ pub enum PlaylistCommands { /// $TIME BETWEEN {32400000, 61200000} - 9 AM to 5 PM /// NOT $WEEKDAY IN {0, 6} - Exclude weekends Create { - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, /// Title of the new playlist. title: String, /// Predicate expression controlling when the playlist is shown. @@ -178,11 +180,7 @@ pub enum PlaylistCommands { predicate: Option<String>, }, /// Lists your playlists. - List { - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, - }, + List, /// Gets a single playlist by id. Get { /// UUID of the playlist. @@ -195,9 +193,6 @@ pub enum PlaylistCommands { }, /// Adds an asset to the end of the playlist. Append { - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, /// UUID of the playlist. uuid: String, /// UUID of the asset. @@ -207,9 +202,6 @@ pub enum PlaylistCommands { }, /// Adds an asset to the beginning of the playlist. Prepend { - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, /// UUID of the playlist. uuid: String, /// UUID of the asset. @@ -218,7 +210,7 @@ pub enum PlaylistCommands { duration: Option<u32>, }, /// Updates a playlist from JSON input on stdin. - Update {}, + Update, } #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] @@ -265,24 +257,14 @@ fn parse_key_values<T: KeyValuePairs>(s: &str) -> Result<T, ParseError> { #[derive(Subcommand, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum AssetCommands { /// Lists your assets. - List { - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, - }, + List, /// Gets a single asset by id. Get { - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, /// UUID of the asset. uuid: String, }, /// Adds a new asset. Add { - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, /// Path to local file or URL for remote file. path: String, /// Asset title. @@ -365,11 +347,7 @@ pub enum EdgeAppCommands { }, /// Lists your Edge Apps. - List { - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, - }, + List, /// Renames an Edge App. Rename { /// Path to the directory with the manifest. Defaults to the current working directory. @@ -435,10 +413,6 @@ pub enum EdgeAppSettingsCommands { /// Path to the directory with the manifest. Defaults to the current working directory. #[arg(short, long)] path: Option<String>, - - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, }, /// Sets an Edge App setting. Set { @@ -459,10 +433,6 @@ pub enum EdgeAppInstanceCommands { /// Path to the directory with the manifest. Defaults to the current working directory. #[arg(short, long)] path: Option<String>, - - /// Enables JSON output. - #[arg(short, long, action = clap::ArgAction::SetTrue)] - json: Option<bool>, }, /// Creates an Edge App instance. Create { @@ -490,16 +460,25 @@ pub enum EdgeAppInstanceCommands { pub fn handle_command_execution_result<T: Formatter>( result: anyhow::Result<T, CommandError>, - json: &Option<bool>, + output: OutputFormat, ) { + if output == OutputFormat::Csv && !T::supports_csv() { + error!("CSV output is not supported for this command. Use --output table or --output json instead."); + std::process::exit(1); + } match result { Ok(screen) => { - let output_type = if json.unwrap_or(false) { - OutputType::Json - } else { - OutputType::HumanReadable + let output_type = match output { + OutputFormat::Json => OutputType::Json, + OutputFormat::Csv => OutputType::Csv, + OutputFormat::Table => OutputType::HumanReadable, }; - println!("{}", screen.format(output_type)); + let formatted = screen.format(output_type); + if output == OutputFormat::Csv { + print!("{formatted}"); + } else { + println!("{formatted}"); + } } Err(e) => { match e { @@ -562,6 +541,13 @@ pub fn get_asset_title( } pub fn handle_cli(cli: &Cli) { + let output = if cli.json { + eprintln!("Warning: --json is deprecated, use --output json instead."); + OutputFormat::Json + } else { + cli.output + }; + match &cli.command { Commands::Login {} => { print!("Enter your API Token: "); @@ -585,10 +571,10 @@ pub fn handle_cli(cli: &Cli) { }, } } - Commands::Screen(command) => handle_cli_screen_command(command), - Commands::Asset(command) => handle_cli_asset_command(command), - Commands::EdgeApp(command) => handle_cli_edge_app_command(command), - Commands::Playlist(command) => handle_cli_playlist_command(command), + Commands::Screen(command) => handle_cli_screen_command(command, output), + Commands::Asset(command) => handle_cli_asset_command(command, output), + Commands::EdgeApp(command) => handle_cli_edge_app_command(command, output), + Commands::Playlist(command) => handle_cli_playlist_command(command, output), Commands::Logout {} => { Authentication::remove_token().expect("Failed to remove token."); info!("Logout successful."); @@ -635,19 +621,19 @@ fn get_user_input() -> String { user_input.trim().to_string() } -pub fn handle_cli_screen_command(command: &ScreenCommands) { +pub fn handle_cli_screen_command(command: &ScreenCommands, output: OutputFormat) { let authentication = get_authentication(); let screen_command = commands::screen::ScreenCommand::new(authentication); match command { - ScreenCommands::List { json } => { - handle_command_execution_result(screen_command.list(), json); + ScreenCommands::List => { + handle_command_execution_result(screen_command.list(), output); } - ScreenCommands::Get { uuid, json } => { - handle_command_execution_result(screen_command.get(uuid), json); + ScreenCommands::Get { uuid } => { + handle_command_execution_result(screen_command.get(uuid), output); } - ScreenCommands::Add { pin, name, json } => { - handle_command_execution_result(screen_command.add(pin, name.clone()), json); + ScreenCommands::Add { pin, name } => { + handle_command_execution_result(screen_command.add(pin, name.clone()), output); } ScreenCommands::Delete { uuid } => { match get_screen_name(uuid, &screen_command) { @@ -679,33 +665,20 @@ pub fn handle_cli_screen_command(command: &ScreenCommands) { } } -pub fn handle_cli_playlist_command(command: &PlaylistCommands) { +pub fn handle_cli_playlist_command(command: &PlaylistCommands, output: OutputFormat) { let playlist_command = PlaylistCommand::new(get_authentication()); match command { - PlaylistCommands::Create { - json, - title, - predicate, - } => { + PlaylistCommands::Create { title, predicate } => { handle_command_execution_result( playlist_command.create(title, &predicate.clone().unwrap_or("TRUE".to_owned())), - json, + output, ); } - PlaylistCommands::List { json } => { - handle_command_execution_result(playlist_command.list(), json); + PlaylistCommands::List => { + handle_command_execution_result(playlist_command.list(), output); } PlaylistCommands::Get { uuid } => { - let playlist_file = playlist_command.get_playlist_file(uuid); - match playlist_file { - Ok(playlist) => { - let pretty_playlist_file = serde_json::to_string_pretty(&playlist).unwrap(); - println!("{pretty_playlist_file}"); - } - Err(e) => { - eprintln!("Error occurred when getting playlist: {e:?}") - } - } + handle_command_execution_result(playlist_command.get_playlist_file(uuid), output); } PlaylistCommands::Delete { uuid } => match playlist_command.delete(uuid) { Ok(()) => { @@ -716,7 +689,6 @@ pub fn handle_cli_playlist_command(command: &PlaylistCommands) { } }, PlaylistCommands::Append { - json, uuid, asset_uuid, duration, @@ -727,11 +699,10 @@ pub fn handle_cli_playlist_command(command: &PlaylistCommands) { asset_uuid, (*duration).unwrap_or(DEFAULT_ASSET_DURATION), ), - json, + output, ); } PlaylistCommands::Prepend { - json, uuid, asset_uuid, duration, @@ -742,10 +713,10 @@ pub fn handle_cli_playlist_command(command: &PlaylistCommands) { asset_uuid, (*duration).unwrap_or(DEFAULT_ASSET_DURATION), ), - json, + output, ); } - PlaylistCommands::Update {} => { + PlaylistCommands::Update => { let mut input = String::new(); io::stdin() .read_to_string(&mut input) @@ -765,19 +736,19 @@ pub fn handle_cli_playlist_command(command: &PlaylistCommands) { } } -pub fn handle_cli_asset_command(command: &AssetCommands) { +pub fn handle_cli_asset_command(command: &AssetCommands, output: OutputFormat) { let authentication = get_authentication(); let asset_command = commands::asset::AssetCommand::new(authentication); match command { - AssetCommands::List { json } => { - handle_command_execution_result(asset_command.list(), json); + AssetCommands::List => { + handle_command_execution_result(asset_command.list(), output); } - AssetCommands::Get { uuid, json } => { - handle_command_execution_result(asset_command.get(uuid), json); + AssetCommands::Get { uuid } => { + handle_command_execution_result(asset_command.get(uuid), output); } - AssetCommands::Add { path, title, json } => { - handle_command_execution_result(asset_command.add(path, title), json); + AssetCommands::Add { path, title } => { + handle_command_execution_result(asset_command.add(path, title), output); } AssetCommands::Delete { uuid } => { match get_asset_title(uuid, &asset_command) { @@ -906,7 +877,7 @@ pub fn handle_cli_asset_command(command: &AssetCommands) { } } -pub fn handle_cli_edge_app_command(command: &EdgeAppCommands) { +pub fn handle_cli_edge_app_command(command: &EdgeAppCommands, output: OutputFormat) { let authentication = get_authentication(); let edge_app_command = commands::edge_app::EdgeAppCommand::new(authentication); @@ -946,8 +917,8 @@ pub fn handle_cli_edge_app_command(command: &EdgeAppCommands) { } } - EdgeAppCommands::List { json } => { - handle_command_execution_result(edge_app_command.list(), json); + EdgeAppCommands::List => { + handle_command_execution_result(edge_app_command.list(), output); } EdgeAppCommands::Deploy { path, @@ -962,8 +933,11 @@ pub fn handle_cli_edge_app_command(command: &EdgeAppCommands) { } }, EdgeAppCommands::Setting(command) => match command { - EdgeAppSettingsCommands::List { path, json } => { - handle_command_execution_result(edge_app_command.list_settings(path.clone()), json); + EdgeAppSettingsCommands::List { path } => { + handle_command_execution_result( + edge_app_command.list_settings(path.clone()), + output, + ); } EdgeAppSettingsCommands::Set { setting_pair, path } => { match edge_app_command.set_setting(path.clone(), &setting_pair.0, &setting_pair.1) { @@ -1155,7 +1129,7 @@ pub fn handle_cli_edge_app_command(command: &EdgeAppCommands) { } } EdgeAppCommands::Instance(command) => match command { - EdgeAppInstanceCommands::List { path, json } => { + EdgeAppInstanceCommands::List { path } => { let actual_app_id = match edge_app_command.get_app_id(path.clone()) { Ok(id) => id, Err(e) => { @@ -1165,7 +1139,7 @@ pub fn handle_cli_edge_app_command(command: &EdgeAppCommands) { }; handle_command_execution_result( edge_app_command.list_instances(&actual_app_id), - json, + output, ); } EdgeAppInstanceCommands::Create { path, name } => { @@ -1338,4 +1312,18 @@ mod tests { assert!(message.contains("Authentication error")); assert!(message.contains("Please run `screenly login` to authenticate")); } + + #[test] + fn test_json_flag_sets_json_field() { + let cli = Cli::try_parse_from(["screenly", "--json", "screen", "list"]).unwrap(); + assert!(cli.json); + assert_eq!(cli.output, OutputFormat::Table); + } + + #[test] + fn test_json_flag_conflicts_with_output_flag() { + let result = + Cli::try_parse_from(["screenly", "--json", "--output", "json", "screen", "list"]); + assert!(result.is_err()); + } } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index db4f605..8d9d0e3 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -22,10 +22,17 @@ pub(crate) mod serde_utils; pub enum OutputType { HumanReadable, Json, + Csv, } pub trait Formatter { fn format(&self, output_type: OutputType) -> String; + fn supports_csv() -> bool + where + Self: Sized, + { + false + } } pub trait FormatterValue { @@ -67,6 +74,37 @@ where table.to_string() } OutputType::Json => serde_json::to_string_pretty(&value.value()).unwrap(), + OutputType::Csv => { + let mut wtr = csv::WriterBuilder::new().from_writer(vec![]); + wtr.write_record(&column_names).unwrap(); + if let Some(values) = value.value().as_array() { + for v in values { + let row: Vec<String> = field_names + .iter() + .map(|field| { + let fv = &v[field]; + if let Some(s) = fv.as_str() { + s.to_string() + } else if let Some(b) = fv.as_bool() { + b.to_string() + } else if let Some(n) = fv.as_u64() { + n.to_string() + } else if let Some(n) = fv.as_i64() { + n.to_string() + } else if let Some(n) = fv.as_f64() { + n.to_string() + } else if fv.is_null() { + String::new() + } else { + fv.to_string() + } + }) + .collect(); + wtr.write_record(&row).unwrap(); + } + } + String::from_utf8(wtr.into_inner().unwrap()).unwrap() + } } } @@ -275,6 +313,41 @@ impl PlaylistFile { } } +impl Formatter for PlaylistFile { + fn supports_csv() -> bool { + true + } + + fn format(&self, output_type: OutputType) -> String { + match output_type { + OutputType::Json => serde_json::to_string_pretty(self).unwrap(), + OutputType::HumanReadable => { + let mut table = prettytable::Table::new(); + table.add_row(Row::from(vec!["Asset Id", "Duration"])); + for item in &self.items { + table.add_row(Row::new(vec![ + Cell::new(&item.asset_id), + Cell::new( + &indicatif::HumanDuration(Duration::from_secs(item.duration as u64)) + .to_string(), + ), + ])); + } + table.to_string() + } + OutputType::Csv => { + let mut wtr = csv::WriterBuilder::new().from_writer(vec![]); + wtr.write_record(["asset_id", "duration"]).unwrap(); + for item in &self.items { + wtr.write_record([item.asset_id.as_str(), &item.duration.to_string()]) + .unwrap(); + } + String::from_utf8(wtr.into_inner().unwrap()).unwrap() + } + } + } +} + impl EdgeApps { pub fn new(value: serde_json::Value) -> Self { Self { value } @@ -296,6 +369,10 @@ impl Formatter for EdgeApps { None::<fn(&str, &serde_json::Value) -> Cell>, ) } + + fn supports_csv() -> bool { + true + } } #[derive(Debug)] @@ -380,6 +457,10 @@ impl Formatter for EdgeAppInstances { ), ) } + + fn supports_csv() -> bool { + true + } } #[derive(Debug)] @@ -409,6 +490,10 @@ impl Formatter for Assets { None::<fn(&str, &serde_json::Value) -> Cell>, ) } + + fn supports_csv() -> bool { + true + } } #[derive(Debug)] @@ -429,6 +514,10 @@ impl FormatterValue for Screens { } impl Formatter for Screens { + fn supports_csv() -> bool { + true + } + fn format(&self, output_type: OutputType) -> String { fn format_boolean_field(value: &serde_json::Value) -> Cell { if value.as_bool().unwrap_or(false) { @@ -497,6 +586,10 @@ impl FormatterValue for Playlists { } impl Formatter for Playlists { + fn supports_csv() -> bool { + true + } + fn format(&self, output_type: OutputType) -> String { fn format_boolean_field(value: &serde_json::Value) -> Cell { if value.as_bool().unwrap_or(false) { @@ -540,6 +633,10 @@ impl FormatterValue for PlaylistItems { } impl Formatter for PlaylistItems { + fn supports_csv() -> bool { + true + } + fn format(&self, output_type: OutputType) -> String { format_value( output_type, @@ -564,6 +661,36 @@ impl Formatter for PlaylistItems { mod tests { use super::*; + #[test] + fn test_assets_csv_round_trip() { + let data = r#"[ + {"id": "abc-123", "title": "Plain Title", "type": "video/mp4", "status": "active"}, + {"id": "def-456", "title": "Comma, Title", "type": "image/png", "status": "active"}, + {"id": "ghi-789", "title": "Quote \"Title\"", "type": "image/jpeg", "status": "active"} + ]"#; + let assets = Assets::new(serde_json::from_str(data).unwrap()); + let output = assets.format(OutputType::Csv); + let mut lines = output.lines(); + assert_eq!(lines.next().unwrap(), "Id,Title,Type,Status"); + assert_eq!( + lines.next().unwrap(), + "abc-123,Plain Title,video/mp4,active" + ); + assert_eq!( + lines.next().unwrap(), + r#"def-456,"Comma, Title",image/png,active"# + ); + assert_eq!( + lines.next().unwrap(), + r#"ghi-789,"Quote ""Title""",image/jpeg,active"# + ); + } + + #[test] + fn test_edge_app_settings_does_not_support_csv() { + assert!(!EdgeAppSettings::supports_csv()); + } + #[test] fn test_edge_app_instance_formatter_format_output_properly() { let data = r#"[{