diff options
-rw-r--r-- | doc/nitrocli.1 | 6 | ||||
-rw-r--r-- | doc/nitrocli.1.pdf | bin | 40791 -> 41073 bytes | |||
-rw-r--r-- | src/args.rs | 14 | ||||
-rw-r--r-- | src/commands.rs | 532 | ||||
-rw-r--r-- | src/main.rs | 1 | ||||
-rw-r--r-- | src/output.rs | 160 |
6 files changed, 538 insertions, 175 deletions
diff --git a/doc/nitrocli.1 b/doc/nitrocli.1 index 74cc2d9..1fdeb04 100644 --- a/doc/nitrocli.1 +++ b/doc/nitrocli.1 @@ -217,18 +217,12 @@ You can use the \fBlock\fR command to lock the password safe. \fBnitrocli pws get \fIslot \fR[\fB\-n\fR|\fB\-\-name\fR] \ [\fB\-l\fR|\fB\-\-login\fR] \ [\fB\-p\fR|\fB\-\-password\fR] \ -[\fB\-q\fR|\fB\-\-quiet\fR] Print the content of one PWS slot. \fIslot\fR is the number of the slot. Per default, this command prints the name, the login and the password (in that order). If one or more of the options \fB\-\-name\fR, \fB\-\-login\fR, and \fB\-\-password\fR are set, only the selected fields are printed. -The order of the fields never changes. - -The fields are printed together with a label. -Use the \fB\-\-quiet\fR option to suppress the labels and to only output the -values stored in the PWS slot. .TP \fBnitrocli pws set \fIslot name login password\fR Set the content of a PWS slot. diff --git a/doc/nitrocli.1.pdf b/doc/nitrocli.1.pdf Binary files differindex 0646c84..f0d245b 100644 --- a/doc/nitrocli.1.pdf +++ b/doc/nitrocli.1.pdf diff --git a/src/args.rs b/src/args.rs index 3052afa..f54025e 100644 --- a/src/args.rs +++ b/src/args.rs @@ -37,6 +37,15 @@ impl DeviceModel { } } +impl From<nitrokey::Model> for DeviceModel { + fn from(model: nitrokey::Model) -> DeviceModel { + match model { + nitrokey::Model::Pro => DeviceModel::Pro, + nitrokey::Model::Storage => DeviceModel::Storage, + } + } +} + impl From<DeviceModel> for nitrokey::Model { fn from(model: DeviceModel) -> nitrokey::Model { match model { @@ -357,7 +366,7 @@ Command! {PwsCommand, [ Clear(PwsClearArgs) => |ctx, args: PwsClearArgs| crate::commands::pws_clear(ctx, args.slot), /// Reads a password safe slot Get(PwsGetArgs) => |ctx, args: PwsGetArgs| { - crate::commands::pws_get(ctx, args.slot, args.name, args.login, args.password, args.quiet) + crate::commands::pws_get(ctx, args.slot, args.name, args.login, args.password) }, /// Writes a password safe slot Set(PwsSetArgs) => |ctx, args: PwsSetArgs| { @@ -384,9 +393,6 @@ pub struct PwsGetArgs { /// Shows the password stored on the slot #[structopt(short, long)] pub password: bool, - /// Prints the stored data without description - #[structopt(short, long)] - pub quiet: bool, /// The PWS slot to read pub slot: u8, } diff --git a/src/commands.rs b/src/commands.rs index 67d230e..808da97 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -20,6 +20,8 @@ use nitrokey::GenerateOtp; use nitrokey::GetPasswordSafe; use crate::args; +use crate::output; +use crate::output::Output as _; use crate::pinentry; use crate::Context; @@ -152,19 +154,6 @@ where }) } -/// Return a string representation of the given volume status. -fn get_volume_status(status: &nitrokey::VolumeStatus) -> &'static str { - if status.active { - if status.read_only { - "read-only" - } else { - "active" - } - } else { - "inactive" - } -} - /// Try to execute the given function with a pin queried using pinentry. /// /// This function will query the pin of the given type from the user @@ -261,135 +250,230 @@ where }) } -/// Pretty print the status of a Nitrokey Storage. -fn print_storage_status( - ctx: &mut Context<'_>, - status: &nitrokey::StorageStatus, -) -> anyhow::Result<()> { - println!( - ctx, - r#" Storage: - SD card ID: {id:#x} - firmware: {fw} - storage keys: {sk} - volumes: - unencrypted: {vu} - encrypted: {ve} - hidden: {vh}"#, - id = status.serial_number_sd_card, - fw = if status.firmware_locked { - "locked" - } else { - "unlocked" - }, - sk = if status.stick_initialized { - "created" - } else { - "not created" - }, - vu = get_volume_status(&status.unencrypted_volume), - ve = get_volume_status(&status.encrypted_volume), - vh = get_volume_status(&status.hidden_volume), - )?; - Ok(()) +struct Status { + model: args::DeviceModel, + serial_number: String, + firmware_version: String, + user_retry_count: u8, + admin_retry_count: u8, + storage_status: Option<StorageStatus>, } -/// Query and pretty print the status that is common to all Nitrokey devices. -fn print_status( - ctx: &mut Context<'_>, - model: &'static str, - device: &nitrokey::DeviceWrapper<'_>, -) -> anyhow::Result<()> { - let serial_number = device - .get_serial_number() - .context("Could not query the serial number")?; - - println!( - ctx, - r#"Status: - model: {model} - serial number: {id} - firmware version: {fwv} - user retry count: {urc} - admin retry count: {arc}"#, - model = model, - id = serial_number, - fwv = device - .get_firmware_version() - .context("Failed to retrieve firmware version")?, - urc = device - .get_user_retry_count() - .context("Failed to retrieve user retry count")?, - arc = device - .get_admin_retry_count() - .context("Failed to retrieve admin retry count")?, - )?; - - if let nitrokey::DeviceWrapper::Storage(device) = device { - let status = device - .get_storage_status() - .context("Failed to retrieve storage status")?; - - print_storage_status(ctx, &status) - } else { - Ok(()) +struct StorageStatus { + card_id: u32, + firmware_locked: bool, + stick_initialized: bool, + unencrypted_volume: VolumeStatus, + encrypted_volume: VolumeStatus, + hidden_volume: VolumeStatus, +} + +struct VolumeStatus { + read_only: bool, + active: bool, +} + +impl From<&Status> for output::TextObject { + fn from(status: &Status) -> output::TextObject { + let mut o = output::TextObject::new("Status"); + o.push_line("model", status.model.to_string()); + o.push_line("serial number", &status.serial_number); + o.push_line("firmware version", &status.firmware_version); + o.push_line("user retry count", status.user_retry_count.to_string()); + o.push_line("admin retry count", status.admin_retry_count.to_string()); + + if let Some(storage_status) = &status.storage_status { + o.push_object(storage_status.into()); + } + + o + } +} + +impl fmt::Display for Status { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + output::TextObject::from(self).fmt(f) + } +} + +impl From<&StorageStatus> for output::TextObject { + fn from(status: &StorageStatus) -> output::TextObject { + let mut o = output::TextObject::new("Storage"); + o.push_line("SD card ID", format!("{:#x}", status.card_id)); + o.push_line( + "firmware", + if status.firmware_locked { + "locked" + } else { + "unlocked" + }, + ); + o.push_line( + "storage keys", + if status.stick_initialized { + "created" + } else { + "not created " + }, + ); + o.push_line("volumes", ""); + o.push_line(" unencrypted", status.unencrypted_volume.to_string()); + o.push_line(" encrypted", status.encrypted_volume.to_string()); + o.push_line(" hidden", status.hidden_volume.to_string()); + o + } +} + +impl From<nitrokey::StorageStatus> for StorageStatus { + fn from(status: nitrokey::StorageStatus) -> StorageStatus { + StorageStatus { + card_id: status.serial_number_sd_card, + firmware_locked: status.firmware_locked, + stick_initialized: status.stick_initialized, + unencrypted_volume: status.unencrypted_volume.into(), + encrypted_volume: status.encrypted_volume.into(), + hidden_volume: status.hidden_volume.into(), + } + } +} + +impl From<nitrokey::VolumeStatus> for VolumeStatus { + fn from(status: nitrokey::VolumeStatus) -> VolumeStatus { + VolumeStatus { + read_only: status.read_only, + active: status.active, + } + } +} + +impl fmt::Display for VolumeStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(if self.active { + if self.read_only { + "read-only" + } else { + "active" + } + } else { + "inactive" + }) } } /// Inquire the status of the nitrokey. pub fn status(ctx: &mut Context<'_>) -> anyhow::Result<()> { with_device(ctx, |ctx, device| { - let model = match device { - nitrokey::DeviceWrapper::Pro(_) => "Pro", - nitrokey::DeviceWrapper::Storage(_) => "Storage", + let status = Status { + model: device.get_model().into(), + serial_number: device + .get_serial_number() + .context("Could not query the serial number")? + .to_string(), + firmware_version: device + .get_firmware_version() + .context("Failed to retrieve firmware version")? + .to_string(), + user_retry_count: device + .get_user_retry_count() + .context("Failed to retrieve user retry count")?, + admin_retry_count: device + .get_admin_retry_count() + .context("Failed to retrieve admin retry count")?, + storage_status: if let nitrokey::DeviceWrapper::Storage(device) = device { + Some( + device + .get_storage_status() + .context("Failed to retrieve storage status")? + .into(), + ) + } else { + None + }, }; - print_status(ctx, model, &device) + output::Value::new(status).print(ctx) }) } +struct DeviceInfo { + path: String, + model: Option<args::DeviceModel>, + serial_number: Option<String>, +} + +impl DeviceInfo { + fn new( + device_info: nitrokey::DeviceInfo, + serial_number: Option<nitrokey::SerialNumber>, + ) -> DeviceInfo { + DeviceInfo { + path: device_info.path, + model: device_info.model.map(From::from), + serial_number: serial_number.as_ref().map(ToString::to_string), + } + } +} + +impl output::TableItem for DeviceInfo { + fn headers() -> Vec<&'static str> { + vec!["device path", "model", "serial number"] + } + + fn values(&self) -> Vec<String> { + vec![ + self.path.clone(), + self + .model + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| "unknown".to_owned()), + self + .serial_number + .as_ref() + .cloned() + .unwrap_or_else(|| "N/A".to_owned()), + ] + } +} + /// List the attached Nitrokey devices. pub fn list(ctx: &mut Context<'_>, no_connect: bool) -> anyhow::Result<()> { set_log_level(ctx); + let mut table = output::Table::new("No Nitrokey device connected"); let device_infos = nitrokey::list_devices().context("Failed to list connected Nitrokey devices")?; - if device_infos.is_empty() { - println!(ctx, "No Nitrokey device connected")?; - } else { - println!(ctx, "device path\tmodel\tserial number")?; + + if !device_infos.is_empty() { let mut manager = nitrokey::take().context("Failed to acquire access to Nitrokey device manager")?; for device_info in device_infos { - let model = device_info - .model - .map(|m| m.to_string()) - .unwrap_or_else(|| "unknown".into()); let serial_number = match device_info.serial_number { - Some(serial_number) => serial_number.to_string(), + Some(serial_number) => Some(serial_number), None => { // Storage devices do not have the serial number present in // the device information. We have to connect to them to // retrieve the information. if no_connect { - "N/A".to_string() + None } else { let device = manager .connect_path(device_info.path.clone()) .context("Failed to connect to Nitrokey")?; - device - .get_serial_number() - .context("Failed to retrieve device serial number")? - .to_string() + Some( + device + .get_serial_number() + .context("Failed to retrieve device serial number")?, + ) } } }; - println!(ctx, "{}\t{}\t{}", device_info.path, model, serial_number)?; + table.push(DeviceInfo::new(device_info, serial_number)); } } - Ok(()) + table.print(ctx) } /// Perform a factory reset. @@ -538,23 +622,49 @@ fn format_option<T: fmt::Display>(option: Option<T>) -> String { } } +struct Config { + numlock: Option<u8>, + capslock: Option<u8>, + scrolllock: Option<u8>, + user_password: bool, +} + +impl From<&Config> for output::TextObject { + fn from(config: &Config) -> output::TextObject { + let mut o = output::TextObject::new("Config"); + o.push_line("numlock binding", format_option(config.numlock)); + o.push_line("capslock binding", format_option(config.capslock)); + o.push_line("scrolllock binding", format_option(config.scrolllock)); + o.push_line("require user PIN for OTP", config.user_password.to_string()); + o + } +} + +impl From<nitrokey::Config> for Config { + fn from(config: nitrokey::Config) -> Config { + Config { + numlock: config.numlock, + capslock: config.capslock, + scrolllock: config.scrollock, + user_password: config.user_password, + } + } +} + +impl fmt::Display for Config { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + output::TextObject::from(self).fmt(f) + } +} + /// Read the Nitrokey configuration. pub fn config_get(ctx: &mut Context<'_>) -> anyhow::Result<()> { with_device(ctx, |ctx, device| { - let config = device.get_config().context("Failed to get configuration")?; - println!( - ctx, - r#"Config: - numlock binding: {nl} - capslock binding: {cl} - scrollock binding: {sl} - require user PIN for OTP: {otp}"#, - nl = format_option(config.numlock), - cl = format_option(config.capslock), - sl = format_option(config.scrollock), - otp = config.user_password, - )?; - Ok(()) + let config: Config = device + .get_config() + .context("Failed to get configuration")? + .into(); + output::Value::new(config).print(ctx) }) } @@ -644,8 +754,7 @@ pub fn otp_get( } else { get_otp(slot, algorithm, &mut device) }?; - println!(ctx, "{}", otp)?; - Ok(()) + output::Value::new(otp).print(ctx) }) } @@ -734,12 +843,46 @@ pub fn otp_clear( }) } -fn print_otp_status( - ctx: &mut Context<'_>, +struct OtpSlot { + algorithm: args::OtpAlgorithm, + slot: u8, + name: Option<String>, +} + +impl OtpSlot { + fn new(algorithm: args::OtpAlgorithm, slot: u8, name: Option<String>) -> OtpSlot { + OtpSlot { + algorithm, + slot, + name, + } + } +} + +impl output::TableItem for OtpSlot { + fn headers() -> Vec<&'static str> { + vec!["algorithm", "slot", "name"] + } + + fn values(&self) -> Vec<String> { + vec![ + self.algorithm.to_string(), + self.slot.to_string(), + self + .name + .as_ref() + .cloned() + .unwrap_or_else(|| "[not programmed]".to_owned()), + ] + } +} + +fn get_otp_slots( algorithm: args::OtpAlgorithm, device: &nitrokey::DeviceWrapper<'_>, all: bool, -) -> anyhow::Result<()> { +) -> anyhow::Result<Vec<OtpSlot>> { + let mut vec = Vec::new(); let mut slot: u8 = 0; loop { let result = match algorithm { @@ -750,28 +893,29 @@ fn print_otp_status( .checked_add(1) .ok_or_else(|| anyhow::anyhow!("Encountered integer overflow when iterating OTP slots"))?; let name = match result { - Ok(name) => name, - Err(nitrokey::Error::LibraryError(nitrokey::LibraryError::InvalidSlot)) => return Ok(()), + Ok(name) => Some(name), + Err(nitrokey::Error::LibraryError(nitrokey::LibraryError::InvalidSlot)) => break, Err(nitrokey::Error::CommandError(nitrokey::CommandError::SlotNotProgrammed)) => { if all { - "[not programmed]".to_string() + None } else { continue; } } Err(err) => return Err(err).context("Failed to check OTP slot"), }; - println!(ctx, "{}\t{}\t{}", algorithm, slot - 1, name)?; + vec.push(OtpSlot::new(algorithm, slot - 1, name)); } + Ok(vec) } /// Print the status of the OTP slots. pub fn otp_status(ctx: &mut Context<'_>, all: bool) -> anyhow::Result<()> { with_device(ctx, |ctx, device| { - println!(ctx, "alg\tslot\tname")?; - print_otp_status(ctx, args::OtpAlgorithm::Hotp, &device, all)?; - print_otp_status(ctx, args::OtpAlgorithm::Totp, &device, all)?; - Ok(()) + let mut table = output::Table::new("No OTP slots programmed."); + table.append(&mut get_otp_slots(args::OtpAlgorithm::Hotp, &device, all)?); + table.append(&mut get_otp_slots(args::OtpAlgorithm::Totp, &device, all)?); + table.print(ctx) }) } @@ -859,19 +1003,32 @@ pub fn pin_unblock(ctx: &mut Context<'_>) -> anyhow::Result<()> { }) } -fn print_pws_data( - ctx: &mut Context<'_>, - description: &'static str, - result: Result<String, nitrokey::Error>, - quiet: bool, -) -> anyhow::Result<()> { - let value = result.context("Failed to access PWS slot")?; - if quiet { - println!(ctx, "{}", value)?; - } else { - println!(ctx, "{} {}", description, value)?; +struct PwsSlotData { + name: Option<String>, + login: Option<String>, + password: Option<String>, +} + +impl From<&PwsSlotData> for output::TextObject { + fn from(data: &PwsSlotData) -> output::TextObject { + let mut o = output::TextObject::new("PWS slot"); + if let Some(name) = &data.name { + o.push_line("name", name); + } + if let Some(login) = &data.login { + o.push_line("login", login); + } + if let Some(password) = &data.password { + o.push_line("password", password); + } + o + } +} + +impl fmt::Display for PwsSlotData { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + output::TextObject::from(self).fmt(f) } - Ok(()) } fn check_slot(pws: &nitrokey::PasswordSafe<'_, '_>, slot: u8) -> anyhow::Result<()> { @@ -895,22 +1052,45 @@ pub fn pws_get( show_name: bool, show_login: bool, show_password: bool, - quiet: bool, ) -> anyhow::Result<()> { with_password_safe(ctx, |ctx, pws| { check_slot(&pws, slot).context("Failed to access PWS slot")?; let show_all = !show_name && !show_login && !show_password; - if show_all || show_name { - print_pws_data(ctx, "name: ", pws.get_slot_name(slot), quiet)?; - } - if show_all || show_login { - print_pws_data(ctx, "login: ", pws.get_slot_login(slot), quiet)?; - } - if show_all || show_password { - print_pws_data(ctx, "password:", pws.get_slot_password(slot), quiet)?; - } - Ok(()) + let name = if show_all || show_name { + Some( + pws + .get_slot_name(slot) + .context("Failed to access PWS slot")?, + ) + } else { + None + }; + let login = if show_all || show_login { + Some( + pws + .get_slot_login(slot) + .context("Failed to access PWS slot")?, + ) + } else { + None + }; + let password = if show_all || show_password { + Some( + pws + .get_slot_password(slot) + .context("Failed to access PWS slot")?, + ) + } else { + None + }; + + output::Value::new(PwsSlotData { + name, + login, + password, + }) + .print(ctx) }) } @@ -936,35 +1116,57 @@ pub fn pws_clear(ctx: &mut Context<'_>, slot: u8) -> anyhow::Result<()> { }) } -fn print_pws_slot( - ctx: &mut Context<'_>, +struct PwsSlot { + slot: u8, + name: Option<String>, +} + +impl output::TableItem for PwsSlot { + fn headers() -> Vec<&'static str> { + vec!["slot", "name"] + } + + fn values(&self) -> Vec<String> { + vec![ + self.slot.to_string(), + self + .name + .as_ref() + .cloned() + .unwrap_or_else(|| "[not programmed]".to_owned()), + ] + } +} + +fn get_pws_slot( pws: &nitrokey::PasswordSafe<'_, '_>, slot: usize, programmed: bool, -) -> anyhow::Result<()> { +) -> anyhow::Result<PwsSlot> { let slot = u8::try_from(slot).map_err(|_| anyhow::anyhow!("Invalid PWS slot number"))?; let name = if programmed { - pws - .get_slot_name(slot) - .context("Failed to read PWS slot name")? + Some( + pws + .get_slot_name(slot) + .context("Failed to read PWS slot name")?, + ) } else { - "[not programmed]".to_string() + None }; - println!(ctx, "{}\t{}", slot, name)?; - Ok(()) + Ok(PwsSlot { slot, name }) } /// Print the status of all PWS slots. pub fn pws_status(ctx: &mut Context<'_>, all: bool) -> anyhow::Result<()> { with_password_safe(ctx, |ctx, pws| { + let mut table = output::Table::new("No PWS slots programmed"); let slots = pws .get_slot_status() .context("Failed to read PWS slot status")?; - println!(ctx, "slot\tname")?; for (i, &value) in slots.iter().enumerate().filter(|(_, &value)| all || value) { - print_pws_slot(ctx, &pws, i, value)?; + table.push(get_pws_slot(&pws, i, value)?); } - Ok(()) + table.print(ctx) }) } diff --git a/src/main.rs b/src/main.rs index baad15c..4cb1467 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,6 +56,7 @@ mod arg_util; mod args; mod commands; mod config; +mod output; mod pinentry; #[cfg(test)] mod tests; diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..77fb93b --- /dev/null +++ b/src/output.rs @@ -0,0 +1,160 @@ +// output.rs + +// Copyright (C) 2020 The Nitrocli Developers +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Defines data types that can be formatted in different output formats. + +use std::fmt; + +use crate::Context; + +/// A trait for objects that can be printed as nitrocli’s output. +pub trait Output { + /// Formats this object as a string that can be printed to the standard output. + fn format(&self) -> anyhow::Result<String>; + + /// Prints this object to the output set in the given context. + /// + /// The default implementation for this method prints the return value of `format` to + /// `ctx.stdout`. + fn print(&self, ctx: &mut Context<'_>) -> anyhow::Result<()> { + println!(ctx, "{}", self.format()?.trim_end()).map_err(From::from) + } +} + +/// A single object. +pub struct Value<T: fmt::Display>(T); + +/// A list of objects of the same type that is displayed as a table with a fallback message for an +/// empty list. +pub struct Table<T: TableItem> { + items: Vec<T>, + empty_message: String, +} + +/// A trait for objects that can be displayed in a table. +pub trait TableItem { + /// Returns the column headers for this type of table items. + fn headers() -> Vec<&'static str>; + /// Returns the values of the column for this table item. + fn values(&self) -> Vec<String>; +} + +/// A helper struct for building text reprensetations of objects. +pub struct TextObject { + name: String, + items: Vec<(usize, String, String)>, +} + +impl<T: fmt::Display> Value<T> { + pub fn new(value: T) -> Value<T> { + Value(value) + } +} + +impl<T: fmt::Display> Output for Value<T> { + fn format(&self) -> anyhow::Result<String> { + Ok(self.0.to_string()) + } +} + +impl<T: TableItem> Table<T> { + pub fn new(empty_message: impl Into<String>) -> Table<T> { + Table { + items: Vec::new(), + empty_message: empty_message.into(), + } + } + + pub fn push(&mut self, item: T) { + self.items.push(item); + } + + pub fn append(&mut self, vec: &mut Vec<T>) { + self.items.append(vec); + } +} + +impl<T: TableItem> Output for Table<T> { + fn format(&self) -> anyhow::Result<String> { + if self.items.is_empty() { + Ok(self.empty_message.clone()) + } else { + let headers = T::headers().into_iter().map(ToOwned::to_owned).collect(); + let values = self.items.iter().map(TableItem::values); + Ok(print_table(headers, values)) + } + } +} + +fn print_table<I>(headers: Vec<String>, iter: I) -> String +where + I: Iterator<Item = Vec<String>>, +{ + let mut values = Vec::new(); + values.push(headers); + values.extend(iter); + let n = values.iter().map(Vec::len).min().unwrap_or_default(); + let lens: Vec<_> = (0..n) + .map(|idx| { + values + .iter() + .map(|v| v[idx].len()) + .max() + .unwrap_or_default() + }) + .collect(); + values + .iter() + .map(|v| print_table_line(&lens, &v)) + .collect::<Vec<_>>() + .join("\n") +} + +fn print_table_line(lens: &[usize], values: &[String]) -> String { + lens + .iter() + .zip(values) + .map(|(width, value)| format!("{:width$}", value, width = width)) + .collect::<Vec<_>>() + .join("\t") +} + +impl TextObject { + pub fn new(name: impl Into<String>) -> TextObject { + TextObject { + name: name.into(), + items: Vec::new(), + } + } + + pub fn push_line(&mut self, key: impl Into<String>, value: impl Into<String>) { + self.items.push((1, key.into(), value.into())); + } + + pub fn push_object(&mut self, o: TextObject) { + self.push_line(o.name, ""); + for (indent, key, value) in o.items { + self.items.push((1 + indent, key, value)); + } + } +} + +impl fmt::Display for TextObject { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "{}:", self.name)?; + let max_len = self + .items + .iter() + .map(|(indent, key, _)| indent * 2 + key.len()) + .max() + .unwrap_or(0); + for (indent, key, value) in &self.items { + let prefix = " ".repeat(indent * 2); + let padding = " ".repeat(max_len - key.len() - indent * 2); + writeln!(f, "{}{}:{} {}", prefix, key, padding, value)?; + } + Ok(()) + } +} |