Skip to content

Conversation

wfchandler
Copy link
Collaborator

Currently all commands will display their results as pretty-printed JSON. Tabular output is convenient for easy comparison of a single field when multiple objects are returned, and more amenable to traditional shell processing.

Add a new global --format flag to control output format, defaulting to the existing JSON output. As larger output items, such as instances, can easily overflow a typical terminal width, we allow users to specify which fields to print with --format=table:field1,field2,....

Non-scalar fields within a returned object will be printed in compact JSON format, e.g. {"cpus":0,"memory":0,"storage":0} for the allocated field on oxide silo utilization list.

To determine the field names to be shown in the table header, we parse the schema for the return type as part of OxideOverride. This logic makes some assumptions about the shape of the data returned, and we need to ensure that it remains valid. Add a new return_types xtask job, which writes out all return types from the Oxide API to a file, against which we test the parsing logic.

@wfchandler wfchandler marked this pull request as draft July 22, 2025 21:47
@wfchandler wfchandler force-pushed the wc/tablular-output branch from 9ef7b5d to d005c78 Compare July 23, 2025 15:17
Currently all commands will display their results as pretty-printed
JSON. Tabular output is convenient for easy comparison of a single field
when multiple objects are returned, and more amenable to traditional
shell processing.

Add a new global `--format` flag to control output format, defaulting to
the existing JSON output. As larger output items, such as instances, can
easily overflow a typical terminal width, we allow users to specify
which fields to print with `--format=table:field1,field2,...`.

Non-scalar fields within a returned object will be printed in compact
JSON format, e.g. `{"cpus":0,"memory":0,"storage":0}` for the
`allocated` field on `oxide silo utilization list`.

To determine the field names to be shown in the table header, we parse
the schema for the return type as part of `OxideOverride`. This logic
makes some assumptions about the shape of the data returned, and we need
to ensure that it remains valid. Add a new return_types `xtask` job,
which writes out all return types from the Oxide API to a file, against
which we test the parsing logic.
@wfchandler wfchandler force-pushed the wc/tablular-output branch from d005c78 to bc5cd9b Compare July 23, 2025 15:34
@wfchandler wfchandler marked this pull request as ready for review July 23, 2025 16:06
@wfchandler wfchandler requested a review from ahl July 23, 2025 16:06
@wfchandler
Copy link
Collaborator Author

I manually verified this works against all list and view subcommands, other than alert receiver, which don't have any data in our environments. Also spot checked a couple create commands, though we won't show a table for these as they have a string return type.

Copy link
Collaborator

@ahl ahl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be helpful to have an overview of how this works.

Obviously I had designed the overrides to enable this work, but this isn't how I expected it to be implemented.

}

if return_types {
print!("generating return types ... ");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this may require additional explanation

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No longer printed now that return types are generated in sdk.

std::io::stdout().flush().unwrap();
let mut ret_types = BTreeSet::new();

for path in spec.paths.paths.values().cloned() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this approach seems brittle. Did you consider using syn to parse the generated sdk:

for types in mod builder {
  look for an `fn send`
  take its response type
}

You could make this part of the "generate SDK" potentially if you're worried about input and output getting out of sync.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a little more verbose to use syn, but I agree that it's less likely to blow up in the future. I've also moved this into the sdk step as suggested.

let printable_fields = set_header_fields(&fields, available_fields, &mut table);

let serde_json::Value::Object(obj) =
serde_json::to_value(std::ops::Deref::deref(value))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need a JSON object to access the fields expected from the schema.

Does that answer your question?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry: try value.as_ref()

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah ok, makes sense.

}

// Store our list of fields to print.
std::mem::swap(&mut printable_fields, &mut fields);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't follow why you're doing this

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've refactored the table logic into a new TableFormatter struct. I think this easier to follow.

@wfchandler
Copy link
Collaborator Author

I think it would be helpful to have an overview of how this works.

Added a comment on this in 0dd9137

Obviously I had designed the overrides to enable this work, but this isn't how I expected it to be implemented.

What was your plan?

@wfchandler wfchandler requested a review from ahl August 13, 2025 18:39
Copy link
Collaborator

@ahl ahl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$ cargo run -- --format table:t project list
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s
     Running `target/debug/oxide --format 'table:t' project list`
WARNING: 644 permissions on "/Users/ahl/.config/oxide/credentials.toml" may allow other users to access your login credentials.
WARNING: 't' is not a valid field
table formatting is not supported for this command

One of the things I'm struggling with here is how it this option will be used outside of a demo context. For example, --format table doesn't seem very usable on its own, so I need to choose specific columns. But how do I know what those are? What's a typical workflow? Would we imagine people embedding these lists .. in scripts? What happens when a new and important field is added to the output.


let schemas = return_types.into_iter().map(|ty| {
quote! {
(stringify!(oxide::#ty), schemars::schema_for!(oxide::#ty))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why stringify! rather than making it a string here? I guess they're equivalent, but curious about the decision.

Comment on lines +212 to +224
let schemas = return_types.into_iter().map(|ty| {
quote! {
(stringify!(oxide::#ty), schemars::schema_for!(oxide::#ty))
}
});

quote! {
pub fn schemas() -> Vec<(&'static str, schemars::schema::RootSchema)> {
vec![
#(#schemas),*
]
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I think you could inline this if you wanted:

quote! {
    pub fn schemas() -> ... {
        vec![
            #( stringify!(oxide:: #return_types), schemars::schema_for!(oxide:: #return_types)),*
        ]
    }
}

_ => None,
}) {
if let ReturnType::Type(_, ty) = &method.sig.output {
if let Some(return_type) = extract_response_value_inner_type(ty) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if this fails we ignore it? Is that intended?

Comment on lines +227 to +228
/// Extract the Oxide success type returned by `send`.
/// For example, `types::Vpc` in `Result<ResponseValue<types::Vpc>, Error>`.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think more words here would be helpful

Comment on lines +139 to +142
if available_fields.is_empty() {
println_nopipe!("{TABLE_NOT_SUPPORTED}");
return;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in what situation will this occur?

}
};

// Check if we're receiving an array.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems to do the opposite i.e. it checks if we're not receiving an array.

}

/// Find the fields present on a the object returned by `success_item`.
fn success_item_fields(root_schema: &RootSchema) -> (Vec<String>, ReturnType) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm concerned that this function seems fairly brittle. Is the idea that it will be mitigated by the tests?

include!("../tests/data/api_return_types.rs");
}
for (type_name, schema) in schemas::schemas() {
let fields = if type_name.ends_with("Page") {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we articulate this heuristic?

use super::*;

#[test]
fn test_table_schema_parsing() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My fear is that this test is going to break frequently as a result of unexpected schema data. Is that a rational fear or do you expect that this will be fairly robust?

@@ -0,0 +1,600 @@
// This Source Code Form is subject to the terms of the Mozilla Public
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be easier to review this if we first move override to its own file.

@wfchandler
Copy link
Collaborator Author

Closing in favor of #1221

@wfchandler wfchandler closed this Sep 29, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants