From 347175a7fb1db6374670506c7d763a8c0a0af693 Mon Sep 17 00:00:00 2001 From: Matthew Pope Date: Mon, 1 Jul 2024 11:34:53 -0700 Subject: [PATCH] Improve capabilities of ion schema subcommands --- Cargo.lock | 34 +++- Cargo.toml | 6 +- src/bin/ion/commands/schema/check.rs | 38 +++++ src/bin/ion/commands/schema/load.rs | 72 -------- src/bin/ion/commands/schema/mod.rs | 190 ++++++++++++++++++++- src/bin/ion/commands/schema/validate.rs | 211 ++++++++++++++---------- src/bin/ion/input_grouping.rs | 65 ++++++++ src/bin/ion/main.rs | 1 + 8 files changed, 445 insertions(+), 172 deletions(-) create mode 100644 src/bin/ion/commands/schema/check.rs delete mode 100644 src/bin/ion/commands/schema/load.rs create mode 100644 src/bin/ion/input_grouping.rs diff --git a/Cargo.lock b/Cargo.lock index 6fe4ab01..eaf472e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -312,6 +312,7 @@ dependencies = [ "anstyle", "clap_lex 0.7.2", "strsim 0.11.1", + "terminal_size", ] [[package]] @@ -1051,6 +1052,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + [[package]] name = "log" version = "0.4.22" @@ -1484,10 +1491,23 @@ dependencies = [ "errno 0.3.9", "io-lifetimes", "libc", - "linux-raw-sys", + "linux-raw-sys 0.3.8", "windows-sys 0.48.0", ] +[[package]] +name = "rustix" +version = "0.38.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" +dependencies = [ + "bitflags 2.6.0", + "errno 0.3.9", + "libc", + "linux-raw-sys 0.4.14", + "windows-sys 0.52.0", +] + [[package]] name = "ryu" version = "1.0.18" @@ -1699,7 +1719,7 @@ dependencies = [ "cfg-if", "fastrand", "redox_syscall", - "rustix", + "rustix 0.37.27", "windows-sys 0.45.0", ] @@ -1734,6 +1754,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "terminal_size" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f599bd7ca042cfdf8f4512b277c02ba102247820f9d9d4a9f521f496751a6ef" +dependencies = [ + "rustix 0.38.37", + "windows-sys 0.59.0", +] + [[package]] name = "termtree" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index e3197bb2..4960b3ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,14 +13,16 @@ keywords = ["format", "parse", "encode"] [dependencies] anyhow = "1.0" -clap = { version = "4.5.8", features = ["cargo", "env"] } +clap = { version = "4.5.8", features = ["cargo", "env","wrap_help"] } colored = "2.0.0" digest = "0.9" sha2 = "0.9" sha3 = "0.9" flate2 = "1.0" infer = "0.15.0" -ion-rs = { version = "1.0.0-rc.8", features = ["experimental", "experimental-ion-hash"] } +# ion-rs version must be pinned because we are using experimental features +# See https://github.com/amazon-ion/ion-cli/issues/155 +ion-rs = { version = "=1.0.0-rc.8", features = ["experimental", "experimental-ion-hash"] } tempfile = "3.2.0" ion-schema = "0.14.0" lowcharts = "0.5.8" diff --git a/src/bin/ion/commands/schema/check.rs b/src/bin/ion/commands/schema/check.rs new file mode 100644 index 00000000..f5025c1b --- /dev/null +++ b/src/bin/ion/commands/schema/check.rs @@ -0,0 +1,38 @@ +use crate::commands::schema::IonSchemaCommandInput; +use crate::commands::IonCliCommand; +use anyhow::Result; +use clap::{Arg, ArgAction, ArgMatches, Command}; + +pub struct CheckCommand; + +impl IonCliCommand for CheckCommand { + fn name(&self) -> &'static str { + "check" + } + + fn about(&self) -> &'static str { + "Loads a schema and checks it for problems." + } + + fn is_stable(&self) -> bool { + false + } + + fn configure_args(&self, command: Command) -> Command { + command.args(IonSchemaCommandInput::schema_args()).arg( + Arg::new("show-debug") + .short('D') + .long("show-debug") + .action(ArgAction::SetTrue), + ) + } + + fn run(&self, _command_path: &mut Vec, args: &ArgMatches) -> Result<()> { + let ion_schema_input = IonSchemaCommandInput::read_from_args(args)?; + let schema = ion_schema_input.get_schema(); + if args.get_flag("show-debug") { + println!("Schema: {:#?}", schema); + } + Ok(()) + } +} diff --git a/src/bin/ion/commands/schema/load.rs b/src/bin/ion/commands/schema/load.rs deleted file mode 100644 index a5e8df67..00000000 --- a/src/bin/ion/commands/schema/load.rs +++ /dev/null @@ -1,72 +0,0 @@ -use crate::commands::IonCliCommand; -use anyhow::Result; -use clap::{Arg, ArgAction, ArgMatches, Command}; -use ion_schema::authority::{DocumentAuthority, FileSystemDocumentAuthority}; -use ion_schema::system::SchemaSystem; -use std::path::Path; - -pub struct LoadCommand; - -impl IonCliCommand for LoadCommand { - fn name(&self) -> &'static str { - "load" - } - - fn about(&self) -> &'static str { - r"Loads an Ion Schema file indicated by a user-provided schema ID and outputs a result message.\ - Shows an error message if any invalid schema syntax was found during the load process." - } - - fn is_stable(&self) -> bool { - false - } - - fn configure_args(&self, command: Command) -> Command { - command - .arg( - // Input file can be specified by the "-s" or "--schema" flags. - Arg::new("schema") - .long("schema") - .short('s') - .required(true) - .value_name("SCHEMA") - .help("The Ion Schema file to load"), - ) - .arg( - // Directory(s) that will be used as authority(s) for schema system - Arg::new("directories") - .long("directory") - .short('d') - // If this appears more than once, collect all values - .action(ArgAction::Append) - .value_name("DIRECTORY") - .required(true) - .help("One or more directories that will be searched for the requested schema"), - ) - } - - fn run(&self, _command_path: &mut Vec, args: &ArgMatches) -> Result<()> { - // Extract the user provided document authorities/ directories - let authorities: Vec<&String> = args.get_many("directories").unwrap().collect(); - - // Extract schema file provided by user - let schema_id = args.get_one::("schema").unwrap(); - - // Set up document authorities vector - let mut document_authorities: Vec> = vec![]; - - for authority in authorities { - document_authorities.push(Box::new(FileSystemDocumentAuthority::new(Path::new( - authority, - )))) - } - - // Create a new schema system from given document authorities - let mut schema_system = SchemaSystem::new(document_authorities); - - // load given schema - println!("Schema: {:#?}", schema_system.load_schema(schema_id)?); - - Ok(()) - } -} diff --git a/src/bin/ion/commands/schema/mod.rs b/src/bin/ion/commands/schema/mod.rs index 4bdd2501..e51e2532 100644 --- a/src/bin/ion/commands/schema/mod.rs +++ b/src/bin/ion/commands/schema/mod.rs @@ -1,11 +1,20 @@ -pub mod load; +pub mod check; pub mod validate; use crate::commands::command_namespace::IonCliNamespace; -use crate::commands::IonCliCommand; - -use crate::commands::schema::load::LoadCommand; +use crate::commands::schema::check::CheckCommand; use crate::commands::schema::validate::ValidateCommand; +use crate::commands::IonCliCommand; +use anyhow::Context; +use clap::{Arg, ArgAction, ArgMatches, ValueHint}; +use ion_rs::Element; +use ion_schema::authority::{DocumentAuthority, FileSystemDocumentAuthority}; +use ion_schema::schema::Schema; +use ion_schema::system::SchemaSystem; +use ion_schema::types::TypeDefinition; +use std::fs; +use std::path::Path; +use std::sync::Arc; pub struct SchemaNamespace; @@ -19,6 +28,177 @@ impl IonCliNamespace for SchemaNamespace { } fn subcommands(&self) -> Vec> { - vec![Box::new(LoadCommand), Box::new(ValidateCommand)] + vec![ + Box::new(CheckCommand), + Box::new(ValidateCommand), + // TODO: Filter values command? + // TODO: Compare types command? + // TODO: Canonical representation of types command? + ] + } +} + +/// A type that encapsulates the arguments for loading schemas and types. +/// +/// This allows users to specify file authorities, schema files, schema ids, inline schemas, and types. +/// +/// See [CheckCommand] and [ValidateCommand] for example usages. +struct IonSchemaCommandInput { + schema_system: SchemaSystem, + schema: Arc, + type_definition: Option, +} + +impl IonSchemaCommandInput { + fn read_from_args(args: &ArgMatches) -> anyhow::Result { + // Extract the user provided document authorities/ directories + let mut authorities: Vec> = vec![]; + args.get_many::("authority") + .unwrap_or_default() + .map(Path::new) + .map(FileSystemDocumentAuthority::new) + .for_each(|a| authorities.push(Box::new(a))); + + // Create a new schema system from given document authorities + let mut schema_system = SchemaSystem::new(authorities); + + // Load the appropriate schema + let mut empty_schema_version = None; + let mut schema = if args.contains_id("schema-id") { + let schema_id = args.get_one::("schema").unwrap(); + schema_system.load_schema(schema_id)? + } else if args.contains_id("schema-file") { + let file_name = args.get_one::("schema-file").unwrap(); + let content = fs::read(file_name)?; + schema_system.new_schema(&content, "user-provided-schema")? + } else if args.contains_id("schema-text") { + let content = args.get_one::<&str>("schema-text").unwrap(); + schema_system.new_schema(content.as_bytes(), "user-provided-schema")? + } else { + let version = match args.get_one::("empty-schema") { + Some(version) if version == "1.0" => "$ion_schema_1_0", + _ => "$ion_schema_2_0", + }; + empty_schema_version = Some(version); + schema_system + .new_schema(version.as_bytes(), "empty-schema") + .expect("Creating an empty schema should be effectively infallible.") + }; + + // Get the type definition, if the command uses the type-ref arg and a value is provided. + // Clap ensures that if `type` is required, the user must have provided it, so we don't + // have to check the case where the command uses the arg but no value is provided. + let mut type_definition = None; + if let Ok(Some(type_name_or_inline_type)) = args.try_get_one::("type-ref") { + // We allow an inline type when there's an empty schema. + // The easiest way to determine whether this is an inline type or a type name + // is to just try to get it from the schema. If nothing is found, then we'll attempt + // to treat it as an inline type. + type_definition = schema.get_type(type_name_or_inline_type); + + if type_definition.is_none() && empty_schema_version.is_some() { + let version = empty_schema_version.unwrap(); + // There is no convenient way to add a type to an existing schema, so we'll + // construct a new one. + + // Create a symbol element so that ion-rs handle escaping any special characters. + let type_name = Element::symbol(type_name_or_inline_type); + + let new_schema = format!( + r#" + {version} + type::{{ + name: {type_name}, + type: {type_name_or_inline_type} + }} + "# + ); + // And finally update the schema and type. + schema = schema_system.new_schema(new_schema.as_bytes(), "new-schema")?; + type_definition = schema.get_type(type_name_or_inline_type); + } + + // Validate that the user didn't pass in an invalid type + type_definition + .as_ref() + .with_context(|| format!("Type not found {}", type_name_or_inline_type))?; + } + + Ok(IonSchemaCommandInput { + schema_system, + schema, + type_definition, + }) + } + + fn get_schema_system(&self) -> &SchemaSystem { + &self.schema_system + } + + fn get_schema(&self) -> Arc { + self.schema.clone() + } + + /// Guaranteed to be Some if the command uses the `type-ref` argument and that argument is required. + fn get_type(&self) -> Option<&TypeDefinition> { + self.type_definition.as_ref() + } + + fn type_arg() -> Arg { + Arg::new("type-ref") + .required(true) + .value_name("type") + .help("An ISL type name or, if no schema is specified, an inline type definition.") + } + + fn schema_args() -> Vec { + let schema_options_header = "Selecting a schema"; + let schema_options_group_name = "schema-group"; + vec![ + Arg::new("empty-schema") + .help_heading(schema_options_header) + .group(schema_options_group_name) + .long("empty") + // This is the default if no schema is specified, so we don't need a short flag. + .action(ArgAction::Set) + .value_name("version") + .value_parser(["1.0", "2.0"]) + .default_value("2.0") + .help("An empty schema document for the specified Ion Schema version."), + Arg::new("schema-file") + .help_heading(schema_options_header) + .group(schema_options_group_name) + .long("schema-file") + .short('f') + .action(ArgAction::Set) + .value_hint(ValueHint::FilePath) + .help("A schema file"), + Arg::new("schema-text") + .help_heading(schema_options_header) + .group(schema_options_group_name) + .long("schema-text") + .action(ArgAction::Set) + .help("The Ion text contents of a schema document."), + Arg::new("schema-id") + .help_heading(schema_options_header) + .group(schema_options_group_name) + .long("id") + .requires("authority") + .action(ArgAction::Set) + .help("The ID of a schema to load from one of the configured authorities."), + Arg::new("authority") + .help_heading(schema_options_header) + .long("authority") + .short('A') + .required(false) + .action(ArgAction::Append) + .value_name("directory") + .value_hint(ValueHint::DirPath) + .help( + "The root(s) of the file system authority(s). Authorities are only required if your \ + schema needs to import a type from another schema or if you are loading a schema using \ + the --id option.", + ), + ] } } diff --git a/src/bin/ion/commands/schema/validate.rs b/src/bin/ion/commands/schema/validate.rs index 146a3cd8..62f7f053 100644 --- a/src/bin/ion/commands/schema/validate.rs +++ b/src/bin/ion/commands/schema/validate.rs @@ -1,12 +1,15 @@ -use crate::commands::IonCliCommand; -use anyhow::{Context, Result}; +use crate::commands::schema::validate::InputGrouping::{FileHandles, Lines, TopLevelValues}; +use crate::commands::schema::IonSchemaCommandInput; +use crate::commands::{CommandIo, IonCliCommand, WithIonCliArgument}; +use crate::input_grouping::InputGrouping; +use anyhow::Result; +use clap::builder::ArgPredicate; use clap::{Arg, ArgAction, ArgMatches, Command}; -use ion_rs::{v1_0, Element, Sequence, SequenceWriter, StructWriter, TextFormat, Writer}; -use ion_schema::authority::{DocumentAuthority, FileSystemDocumentAuthority}; -use ion_schema::system::SchemaSystem; -use std::fs; -use std::path::Path; -use std::str::from_utf8; +use ion_rs::{ion_sexp, AnyEncoding, ElementReader, Reader, SequenceWriter, TextFormat, Writer}; +use ion_rs::{v1_0, Element, IonResult, ValueWriter}; +use ion_schema::result::ValidationResult; +use ion_schema::AsDocumentHint; +use std::io::BufRead; pub struct ValidateCommand; @@ -24,109 +27,135 @@ impl IonCliCommand for ValidateCommand { } fn is_porcelain(&self) -> bool { - true // TODO: Should this command be made into plumbing? + true // TODO: This command should be made into plumbing, or we should add a plumbing equivalent. } fn configure_args(&self, command: Command) -> Command { command - .arg( - // Input ion file can be specified by the "-i" or "--input" flags. - Arg::new("input") - .long("input") - .short('i') - .required(true) - .value_name("INPUT_FILE") - .help("Input file containing the Ion values to be validated"), - ) - .arg( - // Schema file can be specified by the "-s" or "--schema" flags. - Arg::new("schema") - .long("schema") - .short('s') - .required(true) - .value_name("SCHEMA") - .help("The Ion Schema file to load"), + .after_help( + "All Ion Schema types are defined in the context of a schema document, so it is necessary to always \ + have a schema document, even if that schema document is an implicit, empty schema. If a schema is \ + not specified, the default is an implicit, empty Ion Schema 2.0 document." ) + // Positional args -- It is a breaking change to change the relative order of these args. + .arg(IonSchemaCommandInput::type_arg().required(true)) + .with_input() + // Non-positional args + .args(IonSchemaCommandInput::schema_args()) + .args(InputGrouping::args()) + .with_output() .arg( - // Directory(s) that will be used as authority(s) for schema system - Arg::new("directories") - .long("directory") - .short('d') - .action(ArgAction::Append) - .value_name("DIRECTORY") - .required(true) - .help("One or more directories that will be searched for the requested schema"), + Arg::new("error-on-invalid") + .long("error-on-invalid") + .short('E') + .default_value("false") + // "quiet" implies "error-on-invalid" so that the command always has some sort of useful output + .default_value_if("quiet", ArgPredicate::IsPresent, "true") + .action(ArgAction::SetTrue) + .help("Return a non-zero exit code when a value is invalid for the given type.") ) .arg( - // Schema Type can be specified by the "-t" or "--type" flags. - Arg::new("type") - .long("type") - .short('t') - .required(true) - .value_name("TYPE") - .help("Name of schema type from given schema that needs to be used for validation"), + Arg::new("quiet") + .short('q') + .long("quiet") + .help("Suppresses the violations output.") + .default_value("false") + .action(ArgAction::SetTrue) ) } fn run(&self, _command_path: &mut Vec, args: &ArgMatches) -> Result<()> { - // Extract the user provided document authorities/ directories - let authorities: Vec<&String> = args.get_many("directories").unwrap().collect(); + let ion_schema_input = IonSchemaCommandInput::read_from_args(args)?; + let type_ref = ion_schema_input.get_type().unwrap(); - // Extract schema file provided by user - let schema_id = args.get_one::("schema").unwrap(); + let grouping = InputGrouping::read_from_args(args); - // Extract the schema type provided by user - let schema_type = args.get_one::("type").unwrap(); + let quiet = args.get_flag("quiet"); - // Extract Ion value provided by user - let input_file = args.get_one::("input").unwrap(); - let value = - fs::read(input_file).with_context(|| format!("Could not open '{}'", schema_id))?; - let elements: Sequence = Element::read_all(value) - .with_context(|| format!("Could not parse Ion file: '{}'", schema_id))?; + let mut all_valid = true; - // Set up document authorities vector - let mut document_authorities: Vec> = vec![]; + CommandIo::new(args).for_each_input(|output, input| { + // Output always uses 'lines' format so that we can have one output line per grouped input. + // If the user wants something different, use 'ion cat' to change it. + let mut writer = Writer::new(v1_0::Text.with_format(TextFormat::Lines), output)?; - for authority in authorities { - document_authorities.push(Box::new(FileSystemDocumentAuthority::new(Path::new( - authority, - )))) - } - - // Create a new schema system from given document authorities - let mut schema_system = SchemaSystem::new(document_authorities); - - // load schema - let schema = schema_system.load_schema(schema_id); - - // get the type provided by user from the schema file - let type_ref = schema? - .get_type(schema_type) - .with_context(|| format!("Schema {} does not have type {}", schema_id, schema_type))?; - - // create a text writer to make the output - let mut writer = Writer::new(v1_0::Text.with_format(TextFormat::Pretty), vec![])?; - - // validate owned_elements according to type_ref - for owned_element in elements { - // create a validation report with validation result, value, schema and/or violation - let mut struct_writer = writer.struct_writer()?; - let validation_result = type_ref.validate(&owned_element); - match validation_result { - Ok(_) => { - struct_writer.write("result", "Valid")?; - struct_writer.write("value", format!("{}", &owned_element))?; - struct_writer.write("schema", schema_id)?; + match grouping { + FileHandles => { + let reader = Reader::new(AnyEncoding, input.into_source())?; + let document: Vec<_> = reader.into_elements().collect::>()?; + let result = type_ref.validate(document.as_document()); + all_valid &= result.is_ok(); + if !quiet { + write_validation_result(result, writer.value_writer())?; + } + } + Lines => { + for line in input.into_source().lines() { + let document = Element::read_all(line?)?; + let result = type_ref.validate(document.as_document()); + all_valid &= result.is_ok(); + if !quiet { + write_validation_result(result, writer.value_writer())?; + } + } } - Err(error) => { - struct_writer.write("result", "Invalid")?; - struct_writer.write("violation", format!("{:#?}", error))?; + TopLevelValues => { + let reader = Reader::new(AnyEncoding, input.into_source())?; + for value in reader.into_elements() { + let result = type_ref.validate(&value?); + all_valid &= result.is_ok(); + if !quiet { + write_validation_result(result, writer.value_writer())?; + } + } } } + writer.close()?; + Ok(()) + })?; + + let exit_with_error_when_invalid = + *args.get_one::("error-on-invalid").unwrap_or(&false); + if !all_valid && exit_with_error_when_invalid { + std::process::exit(1) + } else { + Ok(()) } - println!("Validation report:"); - println!("{}", from_utf8(writer.output()).unwrap()); - Ok(()) } } + +/// Writes the validation result +/// +/// Current format is an s-expression that starts with the symbol 'valid' or 'invalid'. +/// If invalid, then it also contains an s-expression describing each violation. +/// This output is not (yet?) intended to be stable. +fn write_validation_result( + validation_result: ValidationResult, + writer: W, +) -> IonResult<()> { + match validation_result { + Ok(_) => writer.write_sexp(vec![&Element::symbol("valid")]), + Err(violation) => { + let mut violations: Vec<_> = vec![Element::symbol("invalid")]; + violation + .flattened_violations() + .iter() + .map(|v| { + ion_sexp!( + Element::symbol(v.code().to_string()) + Element::string(v.message().as_str()) + Element::string(v.ion_path().to_string().as_str()) + ) + }) + .map(ion_rs::Element::from) + .for_each(|s| violations.push(s)); + + writer.write_sexp(vec_of_refs(&violations)) + } + } +} + +/// Transposes a borrowed vec of owned elements into an owned vec of borrowed elements. +fn vec_of_refs(the_vec: &Vec) -> Vec<&Element> { + the_vec.iter().collect() +} diff --git a/src/bin/ion/input_grouping.rs b/src/bin/ion/input_grouping.rs new file mode 100644 index 00000000..e8e3197f --- /dev/null +++ b/src/bin/ion/input_grouping.rs @@ -0,0 +1,65 @@ +use clap::{Arg, ArgAction, ArgMatches}; + +/// Flags for determining how a command should group/split its input values. +/// +/// The choices are +/// * `FileHandles` (default) +/// * `Lines` (`-L`) +/// * `TopLevelValues` (`-T`) +/// +/// Default is `FileHandles` because that is the default behavior for commands that do not support +/// these options. +/// +/// To add this to a command: +/// ``` +/// # use clap::Command; +/// fn configure_args(&self, command: Command) -> Command { +/// command.args(InputGrouping::args()) +/// } +/// ``` +/// To read the value in the command: +/// ``` +/// # use clap::ArgMatches; +/// fn run(&self, _command_path: &mut Vec, args: &ArgMatches) -> Result<()> { +/// +/// let grouping = InputGrouping::read_from_args(args); +/// +/// // ... +/// +/// Ok(()) +/// } +/// ``` +#[derive(Copy, Clone)] +pub(crate) enum InputGrouping { + FileHandles, + Lines, + TopLevelValues, +} + +impl InputGrouping { + pub(crate) fn args() -> impl Iterator { + vec![ + Arg::new("group-by-lines") + .group("input-grouping-mode") + .short('L') + .help("Interpret each line as a separate input.") + .action(ArgAction::SetTrue), + Arg::new("group-by-values") + .group("input-grouping-mode") + .short('T') + .help("Interpret each top level value as a separate input.") + .action(ArgAction::SetTrue), + ] + .into_iter() + } + + pub(crate) fn read_from_args(args: &ArgMatches) -> InputGrouping { + if args.get_flag("group-by-lines") { + InputGrouping::Lines + } else if args.get_flag("group-by-values") { + InputGrouping::TopLevelValues + } else { + InputGrouping::FileHandles + } + } +} diff --git a/src/bin/ion/main.rs b/src/bin/ion/main.rs index 13a1fac3..879ba133 100644 --- a/src/bin/ion/main.rs +++ b/src/bin/ion/main.rs @@ -2,6 +2,7 @@ mod auto_decompress; mod commands; mod file_writer; mod input; +mod input_grouping; mod output; mod transcribe;