diff --git a/packages/app-api/src/lib.rs b/packages/app-api/src/lib.rs index eaf4fa8ac..a71046c41 100644 --- a/packages/app-api/src/lib.rs +++ b/packages/app-api/src/lib.rs @@ -3,16 +3,16 @@ pub mod datasets; pub mod pipelines; pub mod progress; -pub use treetime::commands::ancestral::args::TreetimeAncestralArgs; +pub use treetime::commands::ancestral::args::{TreetimeAncestralArgs, TreetimeAncestralArgsRaw}; pub use treetime::commands::ancestral::result::AncestralResult; -pub use treetime::commands::clock::args::TreetimeClockArgs; +pub use treetime::commands::clock::args::{TreetimeClockArgs, TreetimeClockArgsRaw}; pub use treetime::commands::clock::run::ClockResult; -pub use treetime::commands::mugration::args::TreetimeMugrationArgs; -pub use treetime::commands::optimize::args::TreetimeOptimizeArgs; +pub use treetime::commands::mugration::args::{TreetimeMugrationArgs, TreetimeMugrationArgsRaw}; +pub use treetime::commands::optimize::args::{TreetimeOptimizeArgs, TreetimeOptimizeArgsRaw}; pub use treetime::commands::optimize::result::OptimizeResult; -pub use treetime::commands::prune::args::TreetimePruneArgs; +pub use treetime::commands::prune::args::{TreetimePruneArgs, TreetimePruneArgsRaw}; pub use treetime::commands::prune::result::PruneResult; -pub use treetime::commands::timetree::args::TreetimeTimetreeArgs; +pub use treetime::commands::timetree::args::{TreetimeTimetreeArgs, TreetimeTimetreeArgsRaw}; pub use treetime::commands::timetree::result::TimetreeResult; pub use treetime::mugration::result::MugrationResult; diff --git a/packages/app-cli/src/bin/treetime.rs b/packages/app-cli/src/bin/treetime.rs index 39facd7e4..a9d6faefd 100644 --- a/packages/app-cli/src/bin/treetime.rs +++ b/packages/app-cli/src/bin/treetime.rs @@ -23,12 +23,19 @@ use app_cli::cli::verbosity::Verbosity; use ctor::ctor; use eyre::Report; use log::info; +use treetime::commands::ancestral::args::TreetimeAncestralArgs; use treetime::commands::ancestral::run::run_ancestral_reconstruction; +use treetime::commands::clock::args::TreetimeClockArgs; use treetime::commands::clock::run::run_clock; +use treetime::commands::homoplasy::args::TreetimeHomoplasyArgs; use treetime::commands::homoplasy::run::run_homoplasy; +use treetime::commands::mugration::args::TreetimeMugrationArgs; use treetime::commands::mugration::run::run_mugration; +use treetime::commands::optimize::args::TreetimeOptimizeArgs; use treetime::commands::optimize::run::run_optimize; +use treetime::commands::prune::args::TreetimePruneArgs; use treetime::commands::prune::run::run_prune; +use treetime::commands::timetree::args::TreetimeTimetreeArgs; use treetime::commands::timetree::run::run_timetree_estimation; use treetime::progress::{NoopProgress, ProgressSink}; use treetime_utils::init::global::global_init; @@ -75,18 +82,23 @@ fn main() -> Result<(), Report> { match args.command { TreetimeCommands::Timetree(timetree_args) => { + let timetree_args = TreetimeTimetreeArgs::try_from(*timetree_args)?; run_timetree_estimation(&timetree_args, &*progress)?; }, TreetimeCommands::Optimize(optimize_args) => { + let optimize_args = TreetimeOptimizeArgs::try_from(optimize_args)?; run_optimize(&optimize_args, &*progress)?; }, TreetimeCommands::Prune(prune_args) => { + let prune_args = TreetimePruneArgs::try_from(prune_args)?; run_prune(&prune_args, &*progress)?; }, TreetimeCommands::Ancestral(ancestral_args) => { + let ancestral_args = TreetimeAncestralArgs::try_from(ancestral_args)?; run_ancestral_reconstruction(&ancestral_args, &*progress)?; }, TreetimeCommands::Clock(clock_args) => { + let clock_args = TreetimeClockArgs::try_from(clock_args)?; let result = run_clock(&clock_args, &*progress)?; if let Some(outdir) = &clock_args.output.output_all { write_clock_regression_chart_svg( @@ -105,9 +117,10 @@ fn main() -> Result<(), Report> { } }, TreetimeCommands::Homoplasy(homoplasy_args) => { - run_homoplasy(homoplasy_args)?; + run_homoplasy(TreetimeHomoplasyArgs::try_from(homoplasy_args)?)?; }, TreetimeCommands::Mugration(mugration_args) => { + let mugration_args = TreetimeMugrationArgs::try_from(mugration_args)?; run_mugration(&mugration_args, &*progress)?; }, TreetimeCommands::Pipeline(pipeline_args) => { diff --git a/packages/app-cli/src/cli/config.rs b/packages/app-cli/src/cli/config.rs index db9c7088e..9b960edae 100644 --- a/packages/app-cli/src/cli/config.rs +++ b/packages/app-cli/src/cli/config.rs @@ -21,6 +21,16 @@ use treetime_utils::io::fs::read_file_to_string; /// the shared diagnostics layer, so a bad value renders a caret-annotated error pointing into the /// file (a leaf supplied only on the command line has no file span, so it falls back to a plain /// message). The typed value is materialized only after validation passes. +/// +/// Two invariants of `T` keep the merge lossless: +/// +/// - Symmetric serde round-trip: `from_value(to_value(x))` reproduces `x`. The merge serializes the +/// defaults and the CLI args to JSON, layers them, and deserializes the result back, so an +/// asymmetric field serializer would corrupt every load. The `test_config_*_round_trip` tests pin +/// this per command. +/// - Per-struct `#[serde(default)]`: the config file is partial, so every field the file omits must +/// deserialize from the layered defaults rather than error as missing. Each command args struct and +/// each nested arg group therefore carries `#[serde(default)]`. pub fn overlay_config(args: &mut T, matches: &ArgMatches) -> Result<(), Report> where T: Serialize + DeserializeOwned + Default + JsonSchema, @@ -135,7 +145,6 @@ mod tests { mod end_to_end { use crate::cli::config::overlay_config; use crate::cli::treetime_cli::TreetimeArgs; - use crate::cli::validate::Validate; use clap::{CommandFactory, FromArgMatches}; use eyre::Report; use indoc::indoc; @@ -143,21 +152,21 @@ mod tests { use std::fs; use std::path::{Path, PathBuf}; use tempfile::tempdir; - use treetime::commands::ancestral::args::TreetimeAncestralArgs; - use treetime::commands::clock::args::TreetimeClockArgs; - use treetime::commands::homoplasy::args::TreetimeHomoplasyArgs; - use treetime::commands::mugration::args::TreetimeMugrationArgs; - use treetime::commands::optimize::args::TreetimeOptimizeArgs; - use treetime::commands::prune::args::TreetimePruneArgs; - use treetime::commands::timetree::args::TreetimeTimetreeArgs; + use treetime::commands::ancestral::args::{TreetimeAncestralArgs, TreetimeAncestralArgsRaw}; + use treetime::commands::clock::args::TreetimeClockArgsRaw; + use treetime::commands::homoplasy::args::TreetimeHomoplasyArgsRaw; + use treetime::commands::mugration::args::TreetimeMugrationArgsRaw; + use treetime::commands::optimize::args::TreetimeOptimizeArgsRaw; + use treetime::commands::prune::args::TreetimePruneArgsRaw; + use treetime::commands::timetree::args::TreetimeTimetreeArgsRaw; use treetime_utils::{assert_error, pretty_assert_ulps_eq}; // Drive the real parse path: full clap parse (which records value sources), then the `--config` // overlay, exactly as `treetime_parse_cli_args` does for a subcommand. - fn parse_timetree(argv: &[&str]) -> TreetimeTimetreeArgs { + fn parse_timetree(argv: &[&str]) -> TreetimeTimetreeArgsRaw { let matches = TreetimeArgs::command().get_matches_from(argv); let sub = matches.subcommand_matches("timetree").unwrap(); - let mut args = TreetimeTimetreeArgs::from_arg_matches(sub).unwrap(); + let mut args = TreetimeTimetreeArgsRaw::from_arg_matches(sub).unwrap(); overlay_config(&mut args, sub).unwrap(); args } @@ -212,14 +221,14 @@ mod tests { pretty_assert_ulps_eq!(2.0, args.coalescent_confidence); } - // Full resolve path for a command with a required argument: parse, overlay `--config`, validate. + // Full resolve path for a command with a required argument: parse the raw args, overlay + // `--config`, then convert to the validated form (which enforces required-argument presence). fn resolve_ancestral(argv: &[&str]) -> Result { let matches = TreetimeArgs::command().get_matches_from(argv); let sub = matches.subcommand_matches("ancestral").unwrap(); - let mut args = TreetimeAncestralArgs::from_arg_matches(sub).unwrap(); + let mut args = TreetimeAncestralArgsRaw::from_arg_matches(sub).unwrap(); overlay_config(&mut args, sub)?; - args.validate()?; - Ok(args) + TreetimeAncestralArgs::try_from(args) } // The headline of Part 1: a required argument may be supplied entirely by the config file, with no @@ -280,29 +289,29 @@ mod tests { reject_unknown_top_level_key!( test_config_ancestral_rejects_unknown_key, "ancestral", - TreetimeAncestralArgs + TreetimeAncestralArgsRaw ); - reject_unknown_top_level_key!(test_config_clock_rejects_unknown_key, "clock", TreetimeClockArgs); + reject_unknown_top_level_key!(test_config_clock_rejects_unknown_key, "clock", TreetimeClockArgsRaw); reject_unknown_top_level_key!( test_config_timetree_rejects_unknown_key, "timetree", - TreetimeTimetreeArgs + TreetimeTimetreeArgsRaw ); reject_unknown_top_level_key!( test_config_optimize_rejects_unknown_key, "optimize", - TreetimeOptimizeArgs + TreetimeOptimizeArgsRaw ); - reject_unknown_top_level_key!(test_config_prune_rejects_unknown_key, "prune", TreetimePruneArgs); + reject_unknown_top_level_key!(test_config_prune_rejects_unknown_key, "prune", TreetimePruneArgsRaw); reject_unknown_top_level_key!( test_config_mugration_rejects_unknown_key, "mugration", - TreetimeMugrationArgs + TreetimeMugrationArgsRaw ); reject_unknown_top_level_key!( test_config_homoplasy_rejects_unknown_key, "homoplasy", - TreetimeHomoplasyArgs + TreetimeHomoplasyArgsRaw ); // C1: strictness reaches into nested arg groups too, so a typo inside a flattened struct (here @@ -319,7 +328,7 @@ mod tests { "}, ) .unwrap(); - let result = overlay_result::("ancestral", &path); + let result = overlay_result::("ancestral", &path); assert_error!(result, "invalid configuration (1 problem reported above)"); } @@ -344,4 +353,143 @@ mod tests { pretty_assert_ulps_eq!(5.7e-05, args.clock_rate.unwrap()); } } + + // C2/C3: the raw -> validated conversion enforces required-argument presence and reports every + // missing flag with the clap-style message, built from the command's clap metadata so a flag + // rename cannot desync it. + mod required_args { + use pretty_assertions::assert_eq; + use treetime::commands::ancestral::args::{TreetimeAncestralArgs, TreetimeAncestralArgsRaw}; + use treetime::commands::clock::args::{TreetimeClockArgs, TreetimeClockArgsRaw}; + use treetime::commands::homoplasy::args::{TreetimeHomoplasyArgs, TreetimeHomoplasyArgsRaw}; + use treetime::commands::mugration::args::{TreetimeMugrationArgs, TreetimeMugrationArgsRaw}; + use treetime::commands::optimize::args::{TreetimeOptimizeArgs, TreetimeOptimizeArgsRaw}; + use treetime::commands::prune::args::{TreetimePruneArgs, TreetimePruneArgsRaw}; + use treetime::commands::timetree::args::{TreetimeTimetreeArgs, TreetimeTimetreeArgsRaw}; + use treetime_utils::assert_error; + + #[test] + fn test_config_required_ancestral_missing_tree_errors() { + assert_error!( + TreetimeAncestralArgs::try_from(TreetimeAncestralArgsRaw::default()), + "the following required arguments were not provided:\n --tree " + ); + } + + #[test] + fn test_config_required_ancestral_with_tree_ok() { + let raw = TreetimeAncestralArgsRaw { + tree: Some("tree.nwk".into()), + ..Default::default() + }; + let args = TreetimeAncestralArgs::try_from(raw).unwrap(); + assert_eq!(std::path::Path::new("tree.nwk"), args.tree()); + } + + #[test] + fn test_config_required_optimize_missing_tree_errors() { + assert_error!( + TreetimeOptimizeArgs::try_from(TreetimeOptimizeArgsRaw::default()), + "the following required arguments were not provided:\n --tree " + ); + } + + #[test] + fn test_config_required_prune_missing_tree_errors() { + assert_error!( + TreetimePruneArgs::try_from(TreetimePruneArgsRaw::default()), + "the following required arguments were not provided:\n --tree " + ); + } + + #[test] + fn test_config_required_clock_missing_metadata_errors() { + assert_error!( + TreetimeClockArgs::try_from(TreetimeClockArgsRaw::default()), + "the following required arguments were not provided:\n --metadata " + ); + } + + #[test] + fn test_config_required_mugration_missing_both_lists_both() { + assert_error!( + TreetimeMugrationArgs::try_from(TreetimeMugrationArgsRaw::default()), + "the following required arguments were not provided:\n --metadata \n --attribute " + ); + } + + #[test] + fn test_config_required_mugration_only_attribute_missing_lists_attribute() { + let raw = TreetimeMugrationArgsRaw { + metadata: Some("metadata.tsv".into()), + ..Default::default() + }; + assert_error!( + TreetimeMugrationArgs::try_from(raw), + "the following required arguments were not provided:\n --attribute " + ); + } + + #[test] + fn test_config_required_mugration_only_metadata_missing_lists_metadata() { + let raw = TreetimeMugrationArgsRaw { + attribute: Some("country".to_owned()), + ..Default::default() + }; + assert_error!( + TreetimeMugrationArgs::try_from(raw), + "the following required arguments were not provided:\n --metadata " + ); + } + + #[test] + fn test_config_required_homoplasy_missing_tree_errors() { + // Homoplasy has no required field of its own; its required `--tree` lives in the embedded + // ancestral args and is enforced through the nested conversion. + assert_error!( + TreetimeHomoplasyArgs::try_from(TreetimeHomoplasyArgsRaw::default()), + "the following required arguments were not provided:\n --tree " + ); + } + + #[test] + fn test_config_required_timetree_defaults_ok() { + // Timetree has no required-only argument, so the conversion is infallible. + TreetimeTimetreeArgs::try_from(TreetimeTimetreeArgsRaw::default()).unwrap(); + } + } + + // C5: the `--config` overlay serializes the args to JSON, layers the file, then deserializes back, + // so every raw args type must round-trip losslessly. A field with an asymmetric serializer, or a + // nested struct missing `#[serde(default)]`, fails here rather than corrupting a user's config load. + mod round_trip { + use pretty_assertions::assert_eq; + use serde_json::{Value, from_value, to_value}; + use treetime::commands::ancestral::args::TreetimeAncestralArgsRaw; + use treetime::commands::clock::args::TreetimeClockArgsRaw; + use treetime::commands::homoplasy::args::TreetimeHomoplasyArgsRaw; + use treetime::commands::mugration::args::TreetimeMugrationArgsRaw; + use treetime::commands::optimize::args::TreetimeOptimizeArgsRaw; + use treetime::commands::prune::args::TreetimePruneArgsRaw; + use treetime::commands::timetree::args::TreetimeTimetreeArgsRaw; + + macro_rules! round_trip { + ($test:ident, $ty:ty) => { + #[test] + fn $test() { + let value: Value = to_value(<$ty>::default()).unwrap(); + let back: $ty = from_value(value.clone()).unwrap(); + assert_eq!(value, to_value(back).unwrap()); + } + }; + } + + round_trip!(test_config_round_trip_ancestral, TreetimeAncestralArgsRaw); + round_trip!(test_config_round_trip_clock, TreetimeClockArgsRaw); + round_trip!(test_config_round_trip_homoplasy, TreetimeHomoplasyArgsRaw); + round_trip!(test_config_round_trip_mugration, TreetimeMugrationArgsRaw); + round_trip!(test_config_round_trip_optimize, TreetimeOptimizeArgsRaw); + round_trip!(test_config_round_trip_prune, TreetimePruneArgsRaw); + round_trip!(test_config_round_trip_timetree, TreetimeTimetreeArgsRaw); + } } diff --git a/packages/app-cli/src/cli/mod.rs b/packages/app-cli/src/cli/mod.rs index 2cd40dba7..58d6f4dcf 100644 --- a/packages/app-cli/src/cli/mod.rs +++ b/packages/app-cli/src/cli/mod.rs @@ -8,5 +8,4 @@ pub mod rtt_chart; mod rtt_chart_render; pub mod schema; pub mod treetime_cli; -pub mod validate; pub mod verbosity; diff --git a/packages/app-cli/src/cli/pipeline/resolve.rs b/packages/app-cli/src/cli/pipeline/resolve.rs index d9771791b..265ed8021 100644 --- a/packages/app-cli/src/cli/pipeline/resolve.rs +++ b/packages/app-cli/src/cli/pipeline/resolve.rs @@ -358,7 +358,12 @@ mod tests { fn tree_of(step: &ResolvedStep) -> String { match &step.command { - PipelineStepCommand::Ancestral(args) => args.tree().to_string_lossy().into_owned(), + PipelineStepCommand::Ancestral(args) => args + .tree + .as_deref() + .expect("ancestral step has a tree") + .to_string_lossy() + .into_owned(), _ => panic!("expected ancestral step"), } } diff --git a/packages/app-cli/src/cli/pipeline/runner.rs b/packages/app-cli/src/cli/pipeline/runner.rs index 8137a6a8f..1f5ce7cf5 100644 --- a/packages/app-cli/src/cli/pipeline/runner.rs +++ b/packages/app-cli/src/cli/pipeline/runner.rs @@ -9,11 +9,17 @@ use itertools::Itertools; use serde_json::{Map, Value}; use std::collections::BTreeSet; use std::path::Path; +use treetime::commands::ancestral::args::TreetimeAncestralArgs; use treetime::commands::ancestral::run::run_ancestral_reconstruction; +use treetime::commands::clock::args::TreetimeClockArgs; use treetime::commands::clock::run::run_clock; +use treetime::commands::mugration::args::TreetimeMugrationArgs; use treetime::commands::mugration::run::run_mugration; +use treetime::commands::optimize::args::TreetimeOptimizeArgs; use treetime::commands::optimize::run::run_optimize; +use treetime::commands::prune::args::TreetimePruneArgs; use treetime::commands::prune::run::run_prune; +use treetime::commands::timetree::args::TreetimeTimetreeArgs; use treetime::commands::timetree::run::run_timetree_estimation; use treetime::progress::ProgressSink; use treetime_utils::io::fs::read_file_to_string; @@ -107,13 +113,29 @@ pub fn run_pipeline( /// clock step. The interactive terminal chart is intentionally skipped: a pipeline is non-interactive. fn run_step(step: &ResolvedStep, progress: &dyn ProgressSink) -> Result<(), Report> { match &step.command { - PipelineStepCommand::Timetree(args) => run_timetree_estimation(args, progress).map(|_| ()), - PipelineStepCommand::Optimize(args) => run_optimize(args, progress).map(|_| ()), - PipelineStepCommand::Prune(args) => run_prune(args, progress).map(|_| ()), - PipelineStepCommand::Ancestral(args) => run_ancestral_reconstruction(args, progress).map(|_| ()), - PipelineStepCommand::Mugration(args) => run_mugration(args, progress).map(|_| ()), + PipelineStepCommand::Timetree(args) => { + let args = TreetimeTimetreeArgs::try_from((**args).clone())?; + run_timetree_estimation(&args, progress).map(|_| ()) + }, + PipelineStepCommand::Optimize(args) => { + let args = TreetimeOptimizeArgs::try_from(args.clone())?; + run_optimize(&args, progress).map(|_| ()) + }, + PipelineStepCommand::Prune(args) => { + let args = TreetimePruneArgs::try_from(args.clone())?; + run_prune(&args, progress).map(|_| ()) + }, + PipelineStepCommand::Ancestral(args) => { + let args = TreetimeAncestralArgs::try_from(args.clone())?; + run_ancestral_reconstruction(&args, progress).map(|_| ()) + }, + PipelineStepCommand::Mugration(args) => { + let args = TreetimeMugrationArgs::try_from(args.clone())?; + run_mugration(&args, progress).map(|_| ()) + }, PipelineStepCommand::Clock(args) => { - let result = run_clock(args, progress)?; + let args = TreetimeClockArgs::try_from(args.clone())?; + let result = run_clock(&args, progress)?; if let Some(outdir) = &args.output.output_all { write_clock_regression_chart_svg( &result.regression_results, diff --git a/packages/app-cli/src/cli/pipeline/types.rs b/packages/app-cli/src/cli/pipeline/types.rs index ace6c82eb..df58ed47f 100644 --- a/packages/app-cli/src/cli/pipeline/types.rs +++ b/packages/app-cli/src/cli/pipeline/types.rs @@ -5,14 +5,14 @@ use schemars::JsonSchema; use serde::Serialize; use serde_json::{Map, Value}; use std::path::Path; -use treetime::commands::ancestral::args::TreetimeAncestralArgs; -use treetime::commands::clock::args::TreetimeClockArgs; -use treetime::commands::mugration::args::TreetimeMugrationArgs; -use treetime::commands::optimize::args::TreetimeOptimizeArgs; -use treetime::commands::prune::args::TreetimePruneArgs; +use treetime::commands::ancestral::args::TreetimeAncestralArgsRaw; +use treetime::commands::clock::args::TreetimeClockArgsRaw; +use treetime::commands::mugration::args::TreetimeMugrationArgsRaw; +use treetime::commands::optimize::args::TreetimeOptimizeArgsRaw; +use treetime::commands::prune::args::TreetimePruneArgsRaw; use treetime::commands::shared::output::{CommandKind, ResolvedOutputs}; use treetime::commands::shared::resolve_outputs::ResolveOutputs; -use treetime::commands::timetree::args::TreetimeTimetreeArgs; +use treetime::commands::timetree::args::TreetimeTimetreeArgsRaw; use treetime_utils::make_error; /// Reserved top-level key that associates a JSON config with its schema in an editor. @@ -31,17 +31,19 @@ pub const COMMAND_TAGS: [&str; 6] = ["timetree", "optimize", "prune", "ancestral /// A single analysis command invocation within a pipeline. /// /// Externally tagged and kebab-cased so a step's payload is exactly the command's serialized args -/// object (the same shape the per-command `--config` accepts). `timetree` is boxed to match the -/// CLI enum and keep the variant sizes balanced. +/// object (the same shape the per-command `--config` accepts, i.e. the raw args). `timetree` is boxed +/// to match the CLI enum and keep the variant sizes balanced. The runner converts each raw command to +/// its validated form before executing it, so a missing required input is a proper error rather than a +/// panic. #[derive(Debug, Serialize, JsonSchema)] #[serde(rename_all = "kebab-case")] pub enum PipelineStepCommand { - Timetree(Box), - Optimize(TreetimeOptimizeArgs), - Prune(TreetimePruneArgs), - Ancestral(TreetimeAncestralArgs), - Clock(TreetimeClockArgs), - Mugration(TreetimeMugrationArgs), + Timetree(Box), + Optimize(TreetimeOptimizeArgsRaw), + Prune(TreetimePruneArgsRaw), + Ancestral(TreetimeAncestralArgsRaw), + Clock(TreetimeClockArgsRaw), + Mugration(TreetimeMugrationArgsRaw), } impl PipelineStepCommand { diff --git a/packages/app-cli/src/cli/schema.rs b/packages/app-cli/src/cli/schema.rs index 77ccbaad5..be633a6d0 100644 --- a/packages/app-cli/src/cli/schema.rs +++ b/packages/app-cli/src/cli/schema.rs @@ -8,12 +8,12 @@ use schemars::{JsonSchema, Schema, SchemaGenerator}; use serde_json::{Value, json}; use std::io::Write; use std::path::{Path, PathBuf}; -use treetime::commands::ancestral::args::TreetimeAncestralArgs; -use treetime::commands::clock::args::TreetimeClockArgs; -use treetime::commands::mugration::args::TreetimeMugrationArgs; -use treetime::commands::optimize::args::TreetimeOptimizeArgs; -use treetime::commands::prune::args::TreetimePruneArgs; -use treetime::commands::timetree::args::TreetimeTimetreeArgs; +use treetime::commands::ancestral::args::TreetimeAncestralArgsRaw; +use treetime::commands::clock::args::TreetimeClockArgsRaw; +use treetime::commands::mugration::args::TreetimeMugrationArgsRaw; +use treetime::commands::optimize::args::TreetimeOptimizeArgsRaw; +use treetime::commands::prune::args::TreetimePruneArgsRaw; +use treetime::commands::timetree::args::TreetimeTimetreeArgsRaw; use treetime_schema::{TreetimeSchemaFormat, generate_schema as generate_data_schema}; use treetime_utils::io::json::{JsonPretty, json_write_str}; @@ -113,12 +113,12 @@ fn generate_one(target: SchemaTarget, output: &Path) -> Result<(), Report> { let schema = match target { SchemaTarget::Pipeline => pipeline_schema(), - SchemaTarget::Timetree => command_schema::(), - SchemaTarget::Optimize => command_schema::(), - SchemaTarget::Prune => command_schema::(), - SchemaTarget::Ancestral => command_schema::(), - SchemaTarget::Clock => command_schema::(), - SchemaTarget::Mugration => command_schema::(), + SchemaTarget::Timetree => command_schema::(), + SchemaTarget::Optimize => command_schema::(), + SchemaTarget::Prune => command_schema::(), + SchemaTarget::Ancestral => command_schema::(), + SchemaTarget::Clock => command_schema::(), + SchemaTarget::Mugration => command_schema::(), SchemaTarget::All | SchemaTarget::VersionInfo | SchemaTarget::ProgressEvent | SchemaTarget::ErrorResponse => { unreachable!("aggregate and data-type targets are handled earlier") }, @@ -151,12 +151,12 @@ pub fn command_schema() -> Schema { /// precise leaf error and its location. pub fn command_schema_for(tag: &str) -> Option { Some(match tag { - "timetree" => command_schema::(), - "optimize" => command_schema::(), - "prune" => command_schema::(), - "ancestral" => command_schema::(), - "clock" => command_schema::(), - "mugration" => command_schema::(), + "timetree" => command_schema::(), + "optimize" => command_schema::(), + "prune" => command_schema::(), + "ancestral" => command_schema::(), + "clock" => command_schema::(), + "mugration" => command_schema::(), _ => return None, }) } @@ -238,7 +238,7 @@ mod tests { // loosened and no template pattern appears anywhere in the document. #[test] fn test_schema_command_is_strict_without_templates() { - let schema = serde_json::to_value(command_schema::()).unwrap(); + let schema = serde_json::to_value(command_schema::()).unwrap(); assert!( !helpers::contains_template_pattern(&schema), "a per-command schema must not loosen any leaf to a template string" diff --git a/packages/app-cli/src/cli/treetime_cli.rs b/packages/app-cli/src/cli/treetime_cli.rs index c91668d58..cee6bd82a 100644 --- a/packages/app-cli/src/cli/treetime_cli.rs +++ b/packages/app-cli/src/cli/treetime_cli.rs @@ -1,7 +1,6 @@ use crate::cli::config::overlay_config; use crate::cli::jobs::Jobs; use crate::cli::schema::SchemaTarget; -use crate::cli::validate::Validate; use crate::cli::verbosity::Verbosity; use clap::{ArgMatches, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum, ValueHint}; use clap_complete::{Shell, generate}; @@ -14,13 +13,13 @@ use std::fmt::Debug; use std::io; use std::path::PathBuf; use std::sync::LazyLock; -use treetime::commands::ancestral::args::TreetimeAncestralArgs; -use treetime::commands::clock::args::TreetimeClockArgs; -use treetime::commands::homoplasy::args::TreetimeHomoplasyArgs; -use treetime::commands::mugration::args::TreetimeMugrationArgs; -use treetime::commands::optimize::args::TreetimeOptimizeArgs; -use treetime::commands::prune::args::TreetimePruneArgs; -use treetime::commands::timetree::args::TreetimeTimetreeArgs; +use treetime::commands::ancestral::args::TreetimeAncestralArgsRaw; +use treetime::commands::clock::args::TreetimeClockArgsRaw; +use treetime::commands::homoplasy::args::TreetimeHomoplasyArgsRaw; +use treetime::commands::mugration::args::TreetimeMugrationArgsRaw; +use treetime::commands::optimize::args::TreetimeOptimizeArgsRaw; +use treetime::commands::prune::args::TreetimePruneArgsRaw; +use treetime::commands::timetree::args::TreetimeTimetreeArgsRaw; use treetime_utils::init::clap_styles::styles; use treetime_utils::init::global::setup_logger; use treetime_utils::make_report; @@ -67,25 +66,25 @@ pub enum TreetimeCommands { }, /// Estimates time trees from an initial tree topology, a set of date constraints (e.g. tip dates), and an alignment (optional). - Timetree(Box), + Timetree(Box), /// Optimizes the branch lengths and likelihood of a phylogenetic tree given aligned sequences. - Optimize(TreetimeOptimizeArgs), + Optimize(TreetimeOptimizeArgsRaw), /// Prunes short branches and/or branches without mutations from a phylogenetic tree. - Prune(TreetimePruneArgs), + Prune(TreetimePruneArgsRaw), /// Reconstructs ancestral sequences and maps mutations to the tree. The output consists of a file 'ancestral.fasta' with ancestral sequences and a tree 'ancestral.nexus' with mutations added as comments like A45G,G136T,..., number in SNPs used 1-based index by default. The inferred GTR model is written to stdout. - Ancestral(TreetimeAncestralArgs), + Ancestral(TreetimeAncestralArgsRaw), /// Calculates the root-to-tip regression and quantifies the 'clock-i-ness' of the tree. It will reroot the tree to maximize the clock-like signal and recalculate branch length unless run with --keep_root. - Clock(TreetimeClockArgs), + Clock(TreetimeClockArgsRaw), /// Reconstructs ancestral sequences and maps mutations to the tree. The tree is then scanned for homoplasies. An excess number of homoplasies might suggest contamination, recombination, culture adaptation or similar. - Homoplasy(TreetimeHomoplasyArgs), + Homoplasy(TreetimeHomoplasyArgsRaw), /// Reconstructs discrete ancestral states, for example geographic location, host, or similar. In addition to ancestral states, a GTR model of state transitions is inferred. - Mugration(TreetimeMugrationArgs), + Mugration(TreetimeMugrationArgsRaw), /// Runs an ordered list of analysis commands from one config file, on one dataset, in one process. /// @@ -172,12 +171,12 @@ pub fn treetime_parse_cli_args() -> Result { Ok(args) } -/// Merge a `--config` file into the selected command's args, then validate the merged result. +/// Merge a `--config` file into the selected command's raw args. /// -/// Merge (`overlay_config`) and validation (`Validate`) are applied together so a value supplied only -/// by the config file both wins with the right precedence and satisfies required-argument checks. -/// Commands without a configuration object (completions, schema, help) have no `--config` flag and -/// are left untouched. +/// The overlay layers the config file over the CLI-parsed raw args with the right precedence. +/// Required-argument presence is enforced later, when the raw args are converted to their validated +/// form at dispatch. Commands without a configuration object (completions, schema, help) have no +/// `--config` flag and are left untouched. fn resolve_command_config(command: &mut TreetimeCommands, matches: &ArgMatches) -> Result<(), Report> { match command { TreetimeCommands::Timetree(args) => resolve(args.as_mut(), matches), @@ -191,13 +190,12 @@ fn resolve_command_config(command: &mut TreetimeCommands, matches: &ArgMatches) } } -/// Overlay the `--config` file onto `args`, then validate the merged result. +/// Overlay the `--config` file onto the raw command args. fn resolve(args: &mut T, matches: &ArgMatches) -> Result<(), Report> where - T: Serialize + DeserializeOwned + Default + Validate + JsonSchema, + T: Serialize + DeserializeOwned + Default + JsonSchema, { - overlay_config(args, matches)?; - args.validate() + overlay_config(args, matches) } #[cfg(test)] @@ -206,18 +204,18 @@ mod tests { use clap::error::ErrorKind; use pretty_assertions::assert_eq; use rstest::rstest; - use treetime::commands::timetree::args::TreetimeTimetreeArgs; + use treetime::commands::timetree::args::TreetimeTimetreeArgsRaw; use treetime_utils::pretty_assert_ulps_eq; // Timetree declares no clap-required arguments, so a bare invocation exercises defaults. - fn parse_timetree(extra: &[&str]) -> Result { + fn parse_timetree(extra: &[&str]) -> Result { let argv = std::iter::once("timetree").chain(extra.iter().copied()); - TreetimeTimetreeArgs::try_parse_from(argv) + TreetimeTimetreeArgsRaw::try_parse_from(argv) } #[test] fn test_treetime_cli_coalescent_defaults() -> Result<(), clap::Error> { - // Defaults live in `TreetimeTimetreeArgs` (SmartDefault) and clap reads them via `default_value_t`. + // Defaults live in `TreetimeTimetreeArgsRaw` (SmartDefault) and clap reads them via `default_value_t`. let args = parse_timetree(&[])?; pretty_assert_ulps_eq!(2.0, args.coalescent_confidence); assert_eq!(20, args.skyline_n_points); diff --git a/packages/app-cli/src/cli/validate.rs b/packages/app-cli/src/cli/validate.rs deleted file mode 100644 index 8e3b08532..000000000 --- a/packages/app-cli/src/cli/validate.rs +++ /dev/null @@ -1,237 +0,0 @@ -use eyre::Report; -use treetime::commands::ancestral::args::TreetimeAncestralArgs; -use treetime::commands::clock::args::TreetimeClockArgs; -use treetime::commands::homoplasy::args::TreetimeHomoplasyArgs; -use treetime::commands::mugration::args::TreetimeMugrationArgs; -use treetime::commands::optimize::args::TreetimeOptimizeArgs; -use treetime::commands::prune::args::TreetimePruneArgs; -use treetime::commands::timetree::args::TreetimeTimetreeArgs; -use treetime_utils::make_error; - -/// Validate a command's arguments after CLI parsing and the `--config` merge. -/// -/// This is the single place where required-argument presence is checked. The check lives here rather -/// than in clap because `--config` can legitimately supply a value that clap parsing never saw: -/// required fields are `Option` (so clap does not reject them at parse time) and are enforced here, -/// once the merged precedence (explicit CLI > config file > default) has produced the final value. -/// -/// Cross-argument interaction rules belong here too, so validation does not scatter across command -/// run code. -pub trait Validate { - fn validate(&self) -> Result<(), Report>; -} - -/// Emit a clap-style "required arguments were not provided" error listing every missing flag. -fn require(missing: &[&str]) -> Result<(), Report> { - if missing.is_empty() { - Ok(()) - } else { - let list = missing.join("\n "); - make_error!("the following required arguments were not provided:\n {list}") - } -} - -impl Validate for TreetimeAncestralArgs { - fn validate(&self) -> Result<(), Report> { - let mut missing = Vec::new(); - if self.tree.is_none() { - missing.push("--tree "); - } - require(&missing) - } -} - -impl Validate for TreetimePruneArgs { - fn validate(&self) -> Result<(), Report> { - let mut missing = Vec::new(); - if self.tree.is_none() { - missing.push("--tree "); - } - require(&missing) - } -} - -impl Validate for TreetimeOptimizeArgs { - fn validate(&self) -> Result<(), Report> { - let mut missing = Vec::new(); - if self.tree.is_none() { - missing.push("--tree "); - } - require(&missing) - } -} - -impl Validate for TreetimeClockArgs { - fn validate(&self) -> Result<(), Report> { - let mut missing = Vec::new(); - if self.metadata.is_none() { - missing.push("--metadata "); - } - require(&missing) - } -} - -impl Validate for TreetimeMugrationArgs { - fn validate(&self) -> Result<(), Report> { - let mut missing = Vec::new(); - if self.metadata.is_none() { - missing.push("--metadata "); - } - if self.attribute.is_none() { - missing.push("--attribute "); - } - require(&missing) - } -} - -impl Validate for TreetimeHomoplasyArgs { - fn validate(&self) -> Result<(), Report> { - // Homoplasy flattens the ancestral args, so its required `--tree` lives there. - self.ancestral_args.validate() - } -} - -impl Validate for TreetimeTimetreeArgs { - fn validate(&self) -> Result<(), Report> { - // Timetree has no required-only argument: tree, dates, and alignment are each optional at parse - // time and their combinations are resolved downstream. - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use treetime_utils::assert_error; - - #[test] - fn test_validate_ancestral_missing_tree_errors() { - let args = TreetimeAncestralArgs::default(); - assert_error!( - args.validate(), - "the following required arguments were not provided:\n --tree " - ); - } - - #[test] - fn test_validate_ancestral_with_tree_ok() { - let args = TreetimeAncestralArgs { - tree: Some("tree.nwk".into()), - ..Default::default() - }; - args.validate().unwrap(); - } - - #[test] - fn test_validate_mugration_missing_both_lists_both() { - let args = TreetimeMugrationArgs::default(); - assert_error!( - args.validate(), - "the following required arguments were not provided:\n --metadata \n --attribute " - ); - } - - #[test] - fn test_validate_mugration_only_attribute_missing_lists_attribute() { - let args = TreetimeMugrationArgs { - metadata: Some("metadata.tsv".into()), - ..Default::default() - }; - assert_error!( - args.validate(), - "the following required arguments were not provided:\n --attribute " - ); - } - - #[test] - fn test_validate_mugration_only_metadata_missing_lists_metadata() { - let args = TreetimeMugrationArgs { - attribute: Some("country".to_owned()), - ..Default::default() - }; - assert_error!( - args.validate(), - "the following required arguments were not provided:\n --metadata " - ); - } - - #[test] - fn test_validate_clock_missing_metadata_errors() { - let args = TreetimeClockArgs::default(); - assert_error!( - args.validate(), - "the following required arguments were not provided:\n --metadata " - ); - } - - #[test] - fn test_validate_clock_with_metadata_ok() { - let args = TreetimeClockArgs { - metadata: Some("metadata.tsv".into()), - ..Default::default() - }; - args.validate().unwrap(); - } - - #[test] - fn test_validate_optimize_missing_tree_errors() { - let args = TreetimeOptimizeArgs::default(); - assert_error!( - args.validate(), - "the following required arguments were not provided:\n --tree " - ); - } - - #[test] - fn test_validate_optimize_with_tree_ok() { - let args = TreetimeOptimizeArgs { - tree: Some("tree.nwk".into()), - ..Default::default() - }; - args.validate().unwrap(); - } - - #[test] - fn test_validate_prune_missing_tree_errors() { - let args = TreetimePruneArgs::default(); - assert_error!( - args.validate(), - "the following required arguments were not provided:\n --tree " - ); - } - - #[test] - fn test_validate_prune_with_tree_ok() { - let args = TreetimePruneArgs { - tree: Some("tree.nwk".into()), - ..Default::default() - }; - args.validate().unwrap(); - } - - #[test] - fn test_validate_homoplasy_missing_tree_errors() { - let args = TreetimeHomoplasyArgs::default(); - assert_error!( - args.validate(), - "the following required arguments were not provided:\n --tree " - ); - } - - #[test] - fn test_validate_homoplasy_with_tree_ok() { - let args = TreetimeHomoplasyArgs { - ancestral_args: TreetimeAncestralArgs { - tree: Some("tree.nwk".into()), - ..Default::default() - }, - ..Default::default() - }; - args.validate().unwrap(); - } - - #[test] - fn test_validate_timetree_defaults_ok() { - TreetimeTimetreeArgs::default().validate().unwrap(); - } -} diff --git a/packages/app-napi/src/commands.rs b/packages/app-napi/src/commands.rs index 583451199..05355f1cb 100644 --- a/packages/app-napi/src/commands.rs +++ b/packages/app-napi/src/commands.rs @@ -2,8 +2,9 @@ use crate::progress::{self, NapiProgressSink}; use app_api::datasets::discover_datasets; use app_api::progress::{CancelledError, NoopProgress}; use app_api::{ - TreetimeAncestralArgs, TreetimeClockArgs, TreetimeMugrationArgs, TreetimeOptimizeArgs, TreetimePruneArgs, - TreetimeTimetreeArgs, + TreetimeAncestralArgs, TreetimeAncestralArgsRaw, TreetimeClockArgs, TreetimeClockArgsRaw, TreetimeMugrationArgs, + TreetimeMugrationArgsRaw, TreetimeOptimizeArgs, TreetimeOptimizeArgsRaw, TreetimePruneArgs, TreetimePruneArgsRaw, + TreetimeTimetreeArgs, TreetimeTimetreeArgsRaw, }; use napi::Task; use napi::threadsafe_function::ThreadsafeFunction; @@ -27,7 +28,8 @@ pub fn datasets() -> String { #[napi] #[allow(clippy::needless_pass_by_value)] pub fn ancestral_sync(args_json: String) -> napi::Result { - let args: TreetimeAncestralArgs = serde_json::from_str(&args_json).map_err(|e| json_to_napi(&e))?; + let raw: TreetimeAncestralArgsRaw = serde_json::from_str(&args_json).map_err(|e| json_to_napi(&e))?; + let args = TreetimeAncestralArgs::try_from(raw).map_err(|e| eyre_to_napi(&e))?; let result = app_api::commands::ancestral(&args, &NoopProgress).map_err(|e| eyre_to_napi(&e))?; serde_json::to_string(&result).map_err(|e| json_to_napi(&e)) } @@ -50,7 +52,7 @@ fn json_to_napi(err: &serde_json::Error) -> napi::Error { } macro_rules! define_task { - ($task_name:ident, $args_type:ty, $api_fn:path, $napi_fn:ident) => { + ($task_name:ident, $raw_type:ty, $args_type:ty, $api_fn:path, $napi_fn:ident) => { pub struct $task_name { args: $args_type, on_event: Arc>, @@ -81,7 +83,8 @@ macro_rules! define_task { args_json: String, on_event: Arc>, ) -> napi::Result> { - let args: $args_type = serde_json::from_str(&args_json).map_err(|e| json_to_napi(&e))?; + let raw: $raw_type = serde_json::from_str(&args_json).map_err(|e| json_to_napi(&e))?; + let args: $args_type = raw.try_into().map_err(|e| eyre_to_napi(&e))?; Ok(napi::bindgen_prelude::AsyncTask::new($task_name { args, on_event })) } }; @@ -115,27 +118,43 @@ pub fn ancestral( args_json: String, _on_event: Arc>, ) -> napi::Result> { - let args: TreetimeAncestralArgs = serde_json::from_str(&args_json).map_err(|e| json_to_napi(&e))?; + let raw: TreetimeAncestralArgsRaw = serde_json::from_str(&args_json).map_err(|e| json_to_napi(&e))?; + let args = TreetimeAncestralArgs::try_from(raw).map_err(|e| eyre_to_napi(&e))?; Ok(napi::bindgen_prelude::AsyncTask::new(AncestralTaskNoop { args })) } -define_task!(ClockTask, TreetimeClockArgs, app_api::commands::clock, clock); +define_task!( + ClockTask, + TreetimeClockArgsRaw, + TreetimeClockArgs, + app_api::commands::clock, + clock +); define_task!( TimetreeTask, + TreetimeTimetreeArgsRaw, TreetimeTimetreeArgs, app_api::commands::timetree, timetree ); define_task!( MugrationTask, + TreetimeMugrationArgsRaw, TreetimeMugrationArgs, app_api::commands::mugration, mugration ); define_task!( OptimizeTask, + TreetimeOptimizeArgsRaw, TreetimeOptimizeArgs, app_api::commands::optimize, optimize ); -define_task!(PruneTask, TreetimePruneArgs, app_api::commands::prune, prune); +define_task!( + PruneTask, + TreetimePruneArgsRaw, + TreetimePruneArgs, + app_api::commands::prune, + prune +); diff --git a/packages/app-server/src/args.rs b/packages/app-server/src/args.rs index 40bd5d381..5e86ee28a 100644 --- a/packages/app-server/src/args.rs +++ b/packages/app-server/src/args.rs @@ -55,12 +55,11 @@ impl From for TreetimeAncestralArgs { use treetime::commands::shared::model::ModelArgs; use treetime::commands::shared::output::{OutputCoreArgs, TopologyOrderArgs}; Self { - config_args: treetime::commands::shared::config::ConfigArgs::default(), alignment: AlignmentArgs { alignment: s.input_fastas.into_iter().map(PathBuf::from).collect(), }, vcf_reference: s.vcf_reference.map(PathBuf::from), - tree: Some(PathBuf::from(s.tree)), + tree: PathBuf::from(s.tree), alphabet_args: AlphabetArgs { alphabet: s.alphabet }, model_args: ModelArgs { model: s.model_name, @@ -136,17 +135,18 @@ pub struct ServerClockArgs { impl From for TreetimeClockArgs { fn from(s: ServerClockArgs) -> Self { + use treetime::commands::clock::args::{BranchSplitArgs, ClockRegressionArgs}; use treetime::commands::shared::alignment::AlignmentArgs; use treetime::commands::shared::metadata::DateColumnArgs; use treetime::commands::shared::model::ModelArgs; - use treetime::commands::shared::output::OutputCoreArgs; + use treetime::commands::shared::output::{OutputCoreArgs, TopologyOrderArgs}; Self { alignment: AlignmentArgs { alignment: s.aln.into_iter().map(PathBuf::from).collect(), }, tree: s.tree.map(PathBuf::from), vcf_reference: s.vcf_reference.map(PathBuf::from), - metadata: Some(PathBuf::from(s.dates)), + metadata: PathBuf::from(s.dates), metadata_id: { let mut id = treetime::commands::shared::metadata::MetadataIdArgs::default(); if let Some(name_col) = s.name_column { @@ -179,8 +179,16 @@ impl From for TreetimeClockArgs { output_all: Some(PathBuf::from(s.outdir)), ..Default::default() }, + output_clock_model: None, + output_clock_csv: None, + output_selection: vec![], + topology_order: TopologyOrderArgs::default(), seed: s.seed, - ..TreetimeClockArgs::default() + clock_filter_method: None, + plot_rtt: None, + prune_outliers: false, + branch_split: BranchSplitArgs::default(), + clock_regression: ClockRegressionArgs::default(), } } } @@ -261,7 +269,6 @@ impl From for TreetimeTimetreeArgs { use treetime::commands::shared::model::ModelArgs; use treetime::commands::shared::output::{DivergenceUnits, OutputCoreArgs, TopologyOrderArgs}; Self { - config_args: treetime::commands::shared::config::ConfigArgs::default(), alignment: AlignmentArgs { alignment: s.input_fastas.into_iter().map(PathBuf::from).collect(), }, @@ -381,10 +388,9 @@ impl From for TreetimeMugrationArgs { fn from(s: ServerMugrationArgs) -> Self { use treetime::commands::shared::output::{OutputCoreArgs, TopologyOrderArgs}; Self { - config_args: treetime::commands::shared::config::ConfigArgs::default(), tree: s.tree.map(PathBuf::from), - attribute: Some(s.attribute), - metadata: Some(PathBuf::from(s.states)), + attribute: s.attribute, + metadata: PathBuf::from(s.states), weights: s.weights.map(PathBuf::from), metadata_id: { let mut id = treetime::commands::shared::metadata::MetadataIdArgs::default(); @@ -452,11 +458,10 @@ impl From for TreetimeOptimizeArgs { use treetime::commands::shared::model::ModelArgs; use treetime::commands::shared::output::{DivergenceUnits, OutputCoreArgs, TopologyOrderArgs}; Self { - config_args: treetime::commands::shared::config::ConfigArgs::default(), alignment: AlignmentArgs { alignment: s.input_fastas.into_iter().map(PathBuf::from).collect(), }, - tree: Some(PathBuf::from(s.tree)), + tree: PathBuf::from(s.tree), alphabet_args: AlphabetArgs { alphabet: s.alphabet }, model_args: ModelArgs { model: s.model_name, @@ -513,11 +518,10 @@ impl From for TreetimePruneArgs { use treetime::commands::shared::alphabet::AlphabetArgs; use treetime::commands::shared::output::{OutputCoreArgs, TopologyOrderArgs}; Self { - config_args: treetime::commands::shared::config::ConfigArgs::default(), alignment: AlignmentArgs { alignment: s.input_fastas.into_iter().map(PathBuf::from).collect(), }, - tree: Some(PathBuf::from(s.tree)), + tree: PathBuf::from(s.tree), alphabet_args: AlphabetArgs { alphabet: s.alphabet }, output: OutputCoreArgs { output_all: Some(PathBuf::from(s.outdir)), diff --git a/packages/treetime/src/commands/ancestral/__tests__/test_augur_node_data.rs b/packages/treetime/src/commands/ancestral/__tests__/test_augur_node_data.rs index 880c9d4b1..cab941a8f 100644 --- a/packages/treetime/src/commands/ancestral/__tests__/test_augur_node_data.rs +++ b/packages/treetime/src/commands/ancestral/__tests__/test_augur_node_data.rs @@ -184,7 +184,7 @@ mod tests { use crate::alphabet::alphabet::Alphabet; use crate::ancestral::params::MethodAncestral; use crate::commands::ancestral::aa_node_data::{AaCdsNodeData, AaNodeData}; - use crate::commands::ancestral::args::TreetimeAncestralArgs; + use crate::commands::ancestral::args::{TreetimeAncestralArgs, TreetimeAncestralArgsRaw}; use crate::commands::ancestral::augur_node_data::build_augur_node_data_json; use crate::commands::ancestral::run::run_ancestral_reconstruction; use crate::commands::shared::alignment::AlignmentArgs; @@ -292,7 +292,7 @@ mod tests { std::fs::write(&tree_path, "(A:0.1,B:0.1)root;").unwrap(); std::fs::write(&fasta_path, ">A\nACGT\n>B\nACGT\n").unwrap(); - let args = TreetimeAncestralArgs { + let args = TreetimeAncestralArgs::try_from(TreetimeAncestralArgsRaw { alignment: AlignmentArgs { alignment: vec![fasta_path], }, @@ -308,8 +308,9 @@ mod tests { ..Default::default() }, output_augur_node_data: Some(node_data_path.clone()), - ..TreetimeAncestralArgs::default() - }; + ..TreetimeAncestralArgsRaw::default() + }) + .unwrap(); run_ancestral_reconstruction(&args, &NoopProgress).unwrap(); std::fs::read_to_string(node_data_path).unwrap() @@ -332,7 +333,7 @@ mod tests { std::fs::write(translations_dir.join("M.fasta"), ">A\nWY\n>B\nWY\n").unwrap(); let template = format!("{}/{{cds}}.fasta", translations_dir.display()); - let args = TreetimeAncestralArgs { + let args = TreetimeAncestralArgs::try_from(TreetimeAncestralArgsRaw { alignment: AlignmentArgs { alignment: vec![fasta_path], }, @@ -350,8 +351,9 @@ mod tests { ..Default::default() }, output_augur_node_data: Some(node_data_path.clone()), - ..TreetimeAncestralArgs::default() - }; + ..TreetimeAncestralArgsRaw::default() + }) + .unwrap(); run_ancestral_reconstruction(&args, &NoopProgress).unwrap(); json_read_str(std::fs::read_to_string(node_data_path).unwrap()).unwrap() diff --git a/packages/treetime/src/commands/ancestral/args.rs b/packages/treetime/src/commands/ancestral/args.rs index 329bf0b3e..ebe8378fc 100644 --- a/packages/treetime/src/commands/ancestral/args.rs +++ b/packages/treetime/src/commands/ancestral/args.rs @@ -7,18 +7,20 @@ use crate::commands::shared::config::ConfigArgs; use crate::commands::shared::gap_fill::GapFillArgs; use crate::commands::shared::model::ModelArgs; use crate::commands::shared::output::{AncestralOutputSelection, OutputCoreArgs, TopologyOrderArgs}; +use crate::commands::shared::required::missing_required_args; #[cfg(feature = "clap")] use clap::ValueHint; +use eyre::Report; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use smart_default::SmartDefault; use std::fmt::Debug; use std::path::{Path, PathBuf}; -#[derive(Debug, SmartDefault, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, SmartDefault, Serialize, Deserialize, JsonSchema)] #[serde(default, deny_unknown_fields)] #[cfg_attr(feature = "clap", derive(clap::Parser))] -pub struct TreetimeAncestralArgs { +pub struct TreetimeAncestralArgsRaw { #[cfg_attr(feature = "clap", clap(flatten))] #[serde(skip)] pub config_args: ConfigArgs, @@ -220,12 +222,95 @@ pub struct TreetimeAncestralArgs { pub sample_from_profile: SampleMode, } +/// Ancestral reconstruction arguments with required inputs proven present. +/// +/// Produced from [`TreetimeAncestralArgsRaw`] by [`TryFrom`] once the `--config` overlay has run, so +/// the run code reads `tree` without an `Option`. +#[derive(Debug, Clone)] +pub struct TreetimeAncestralArgs { + pub alignment: AlignmentArgs, + pub vcf_reference: Option, + pub tree: PathBuf, + pub alphabet_args: AlphabetArgs, + pub model_args: ModelArgs, + pub method_anc: MethodAncestral, + pub dense: Option, + pub gap_fill_args: GapFillArgs, + pub zero_based: bool, + pub include_leaves: bool, + pub impute_missing_data: bool, + pub reconstruct_tip_states: bool, + pub report_ambiguous: bool, + pub ignore_missing_alns: bool, + pub output_augur_node_data: Option, + pub output_gtr: Option, + pub output_reconstructed_nuc_fasta: Option, + pub translations: Option, + pub cdses: Vec, + pub annotation: Option, + pub aa_root_sequence: Option, + pub aa_model: AaModelName, + pub output_reconstructed_aa_fasta: Option, + pub output: OutputCoreArgs, + pub output_selection: Vec, + pub topology_order: TopologyOrderArgs, + pub gtr_iterations: usize, + pub site_specific_gtr: bool, + pub seed: Option, + pub aa: bool, + pub marginal: bool, + pub custom_gtr: Option, + pub sample_from_profile: SampleMode, +} + impl TreetimeAncestralArgs { - /// Input tree path. Present by construction: required-field validation runs during CLI parsing. + /// Input tree path. pub fn tree(&self) -> &Path { - self + &self.tree + } +} + +impl TryFrom for TreetimeAncestralArgs { + type Error = Report; + + fn try_from(raw: TreetimeAncestralArgsRaw) -> Result { + let tree = raw .tree - .as_deref() - .expect("`--tree` is required and is validated during CLI parsing") + .ok_or_else(|| missing_required_args::(&["tree"]))?; + Ok(Self { + alignment: raw.alignment, + vcf_reference: raw.vcf_reference, + tree, + alphabet_args: raw.alphabet_args, + model_args: raw.model_args, + method_anc: raw.method_anc, + dense: raw.dense, + gap_fill_args: raw.gap_fill_args, + zero_based: raw.zero_based, + include_leaves: raw.include_leaves, + impute_missing_data: raw.impute_missing_data, + reconstruct_tip_states: raw.reconstruct_tip_states, + report_ambiguous: raw.report_ambiguous, + ignore_missing_alns: raw.ignore_missing_alns, + output_augur_node_data: raw.output_augur_node_data, + output_gtr: raw.output_gtr, + output_reconstructed_nuc_fasta: raw.output_reconstructed_nuc_fasta, + translations: raw.translations, + cdses: raw.cdses, + annotation: raw.annotation, + aa_root_sequence: raw.aa_root_sequence, + aa_model: raw.aa_model, + output_reconstructed_aa_fasta: raw.output_reconstructed_aa_fasta, + output: raw.output, + output_selection: raw.output_selection, + topology_order: raw.topology_order, + gtr_iterations: raw.gtr_iterations, + site_specific_gtr: raw.site_specific_gtr, + seed: raw.seed, + aa: raw.aa, + marginal: raw.marginal, + custom_gtr: raw.custom_gtr, + sample_from_profile: raw.sample_from_profile, + }) } } diff --git a/packages/treetime/src/commands/clock/args.rs b/packages/treetime/src/commands/clock/args.rs index 3e7ba3f9c..771c49920 100644 --- a/packages/treetime/src/commands/clock/args.rs +++ b/packages/treetime/src/commands/clock/args.rs @@ -6,20 +6,22 @@ use crate::commands::shared::config::ConfigArgs; use crate::commands::shared::metadata::{DateColumnArgs, MetadataIdArgs}; use crate::commands::shared::model::ModelArgs; use crate::commands::shared::output::{ClockOutputSelection, OutputCoreArgs, TopologyOrderArgs}; +use crate::commands::shared::required::missing_required_args; use crate::commands::shared::reroot::RerootArgs; use crate::optimize::params::BranchLengthMode; #[cfg(feature = "clap")] use clap::ValueHint; +use eyre::Report; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use smart_default::SmartDefault; use std::fmt::Debug; use std::path::{Path, PathBuf}; -#[derive(Debug, SmartDefault, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, SmartDefault, Serialize, Deserialize, JsonSchema)] #[serde(default, deny_unknown_fields)] #[cfg_attr(feature = "clap", derive(clap::Parser))] -pub struct TreetimeClockArgs { +pub struct TreetimeClockArgsRaw { #[cfg_attr(feature = "clap", clap(flatten))] #[serde(skip)] pub config_args: ConfigArgs, @@ -149,13 +151,86 @@ pub struct TreetimeClockArgs { pub clock_regression: ClockRegressionArgs, } +/// Clock arguments with required inputs proven present. +/// +/// Produced from [`TreetimeClockArgsRaw`] by [`TryFrom`] once the `--config` overlay has run, so the +/// run code reads `metadata` without an `Option`. +#[derive(Debug, Clone)] +pub struct TreetimeClockArgs { + pub alignment: AlignmentArgs, + pub tree: Option, + pub vcf_reference: Option, + pub metadata: PathBuf, + pub metadata_id: MetadataIdArgs, + pub date_column: DateColumnArgs, + pub sequence_length: Option, + pub model_args: ModelArgs, + pub branch_length_mode: BranchLengthMode, + pub method_anc: MethodAncestral, + pub clock_filter: f64, + pub reroot: RerootArgs, + pub keep_root: bool, + pub prune_short: bool, + pub tip_slack: Option, + pub covariation: bool, + pub allow_negative_rate: bool, + pub output: OutputCoreArgs, + pub output_clock_model: Option, + pub output_clock_csv: Option, + pub output_selection: Vec, + pub topology_order: TopologyOrderArgs, + pub seed: Option, + pub clock_filter_method: Option, + pub plot_rtt: Option, + pub prune_outliers: bool, + pub branch_split: BranchSplitArgs, + pub clock_regression: ClockRegressionArgs, +} + impl TreetimeClockArgs { - /// Metadata (dates) path. Present by construction: required-field validation runs during CLI parsing. + /// Metadata (dates) path. pub fn metadata(&self) -> &Path { - self + &self.metadata + } +} + +impl TryFrom for TreetimeClockArgs { + type Error = Report; + + fn try_from(raw: TreetimeClockArgsRaw) -> Result { + let metadata = raw .metadata - .as_deref() - .expect("`--metadata` is required and is validated during CLI parsing") + .ok_or_else(|| missing_required_args::(&["metadata"]))?; + Ok(Self { + alignment: raw.alignment, + tree: raw.tree, + vcf_reference: raw.vcf_reference, + metadata, + metadata_id: raw.metadata_id, + date_column: raw.date_column, + sequence_length: raw.sequence_length, + model_args: raw.model_args, + branch_length_mode: raw.branch_length_mode, + method_anc: raw.method_anc, + clock_filter: raw.clock_filter, + reroot: raw.reroot, + keep_root: raw.keep_root, + prune_short: raw.prune_short, + tip_slack: raw.tip_slack, + covariation: raw.covariation, + allow_negative_rate: raw.allow_negative_rate, + output: raw.output, + output_clock_model: raw.output_clock_model, + output_clock_csv: raw.output_clock_csv, + output_selection: raw.output_selection, + topology_order: raw.topology_order, + seed: raw.seed, + clock_filter_method: raw.clock_filter_method, + plot_rtt: raw.plot_rtt, + prune_outliers: raw.prune_outliers, + branch_split: raw.branch_split, + clock_regression: raw.clock_regression, + }) } } diff --git a/packages/treetime/src/commands/homoplasy/args.rs b/packages/treetime/src/commands/homoplasy/args.rs index c32fa731b..47f574c6f 100644 --- a/packages/treetime/src/commands/homoplasy/args.rs +++ b/packages/treetime/src/commands/homoplasy/args.rs @@ -1,18 +1,19 @@ -use crate::commands::ancestral::args::TreetimeAncestralArgs; +use crate::commands::ancestral::args::{TreetimeAncestralArgs, TreetimeAncestralArgsRaw}; #[cfg(feature = "clap")] use clap::ValueHint; +use eyre::Report; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use smart_default::SmartDefault; use std::fmt::Debug; use std::path::PathBuf; -#[derive(Debug, SmartDefault, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, SmartDefault, Serialize, Deserialize, JsonSchema)] #[serde(default, deny_unknown_fields)] #[cfg_attr(feature = "clap", derive(clap::Parser))] -pub struct TreetimeHomoplasyArgs { +pub struct TreetimeHomoplasyArgsRaw { #[cfg_attr(feature = "clap", clap(flatten))] - pub ancestral_args: TreetimeAncestralArgs, + pub ancestral_args: TreetimeAncestralArgsRaw, /// Number of constant sites not included in alignment #[cfg_attr(feature = "clap", clap(long = "const"))] @@ -36,3 +37,32 @@ pub struct TreetimeHomoplasyArgs { #[default = 10] pub num_mut: usize, } + +/// Homoplasy arguments with required inputs proven present. +/// +/// Produced from [`TreetimeHomoplasyArgsRaw`] by [`TryFrom`], which converts the embedded ancestral +/// args and so enforces ancestral's required `tree`. +#[derive(Debug, Clone)] +pub struct TreetimeHomoplasyArgs { + pub ancestral_args: TreetimeAncestralArgs, + pub constant_sites: Option, + pub rescale: bool, + pub detailed: Option, + pub drms: Option, + pub num_mut: usize, +} + +impl TryFrom for TreetimeHomoplasyArgs { + type Error = Report; + + fn try_from(raw: TreetimeHomoplasyArgsRaw) -> Result { + Ok(Self { + ancestral_args: TreetimeAncestralArgs::try_from(raw.ancestral_args)?, + constant_sites: raw.constant_sites, + rescale: raw.rescale, + detailed: raw.detailed, + drms: raw.drms, + num_mut: raw.num_mut, + }) + } +} diff --git a/packages/treetime/src/commands/mugration/args.rs b/packages/treetime/src/commands/mugration/args.rs index ad243d7a7..7fd03a3cd 100644 --- a/packages/treetime/src/commands/mugration/args.rs +++ b/packages/treetime/src/commands/mugration/args.rs @@ -1,18 +1,20 @@ use crate::commands::shared::config::ConfigArgs; use crate::commands::shared::metadata::MetadataIdArgs; use crate::commands::shared::output::{MugrationOutputSelection, OutputCoreArgs, TopologyOrderArgs}; +use crate::commands::shared::required::missing_required_args; #[cfg(feature = "clap")] use clap::ValueHint; +use eyre::Report; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use smart_default::SmartDefault; use std::fmt::Debug; use std::path::{Path, PathBuf}; -#[derive(Debug, SmartDefault, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, SmartDefault, Serialize, Deserialize, JsonSchema)] #[serde(default, deny_unknown_fields)] #[cfg_attr(feature = "clap", derive(clap::Parser))] -pub struct TreetimeMugrationArgs { +pub struct TreetimeMugrationArgsRaw { #[cfg_attr(feature = "clap", clap(flatten))] #[serde(skip)] pub config_args: ConfigArgs, @@ -133,20 +135,83 @@ pub struct TreetimeMugrationArgs { pub topology_order: TopologyOrderArgs, } +/// Mugration arguments with required inputs proven present. +/// +/// Produced from [`TreetimeMugrationArgsRaw`] by [`TryFrom`] once the `--config` overlay has run, so +/// the run code reads `metadata` and `attribute` without an `Option`. +#[derive(Debug, Clone)] +pub struct TreetimeMugrationArgs { + pub tree: Option, + pub attribute: String, + pub metadata: PathBuf, + pub weights: Option, + pub metadata_id: MetadataIdArgs, + pub output_confidence_csv: Option, + pub pc: Option, + pub missing_data: String, + pub missing_weights_threshold: f64, + pub iterations: usize, + pub sampling_bias_correction: Option, + pub smooth_initial_pi: bool, + pub filter_uninformative_root: bool, + pub output_augur_node_data: Option, + pub output_gtr: Option, + pub output_traits_csv: Option, + pub seed: Option, + pub output: OutputCoreArgs, + pub output_selection: Vec, + pub topology_order: TopologyOrderArgs, +} + impl TreetimeMugrationArgs { - /// Metadata (states) path. Present by construction: required-field validation runs during CLI parsing. + /// Metadata (states) path. pub fn metadata(&self) -> &Path { - self - .metadata - .as_deref() - .expect("`--metadata` is required and is validated during CLI parsing") + &self.metadata } - /// Attribute to reconstruct. Present by construction: required-field validation runs during CLI parsing. + /// Attribute to reconstruct. pub fn attribute(&self) -> &str { - self - .attribute - .as_deref() - .expect("`--attribute` is required and is validated during CLI parsing") + &self.attribute + } +} + +impl TryFrom for TreetimeMugrationArgs { + type Error = Report; + + fn try_from(raw: TreetimeMugrationArgsRaw) -> Result { + match (raw.metadata, raw.attribute) { + (Some(metadata), Some(attribute)) => Ok(Self { + tree: raw.tree, + attribute, + metadata, + weights: raw.weights, + metadata_id: raw.metadata_id, + output_confidence_csv: raw.output_confidence_csv, + pc: raw.pc, + missing_data: raw.missing_data, + missing_weights_threshold: raw.missing_weights_threshold, + iterations: raw.iterations, + sampling_bias_correction: raw.sampling_bias_correction, + smooth_initial_pi: raw.smooth_initial_pi, + filter_uninformative_root: raw.filter_uninformative_root, + output_augur_node_data: raw.output_augur_node_data, + output_gtr: raw.output_gtr, + output_traits_csv: raw.output_traits_csv, + seed: raw.seed, + output: raw.output, + output_selection: raw.output_selection, + topology_order: raw.topology_order, + }), + (metadata, attribute) => { + let mut missing = Vec::new(); + if metadata.is_none() { + missing.push("metadata"); + } + if attribute.is_none() { + missing.push("attribute"); + } + Err(missing_required_args::(&missing)) + }, + } } } diff --git a/packages/treetime/src/commands/mugration/run.rs b/packages/treetime/src/commands/mugration/run.rs index 400f3d9a0..c0cc9fb90 100644 --- a/packages/treetime/src/commands/mugration/run.rs +++ b/packages/treetime/src/commands/mugration/run.rs @@ -32,12 +32,15 @@ pub fn run_mugration( let resolved = mugration_args.resolve_outputs()?; + // The attribute names the metadata column to read discrete states from. + let attribute_column = Some(mugration_args.attribute().to_owned()); + let (attr_values, _attr_name) = read_discrete_attrs::( mugration_args.metadata(), &mugration_args.metadata_id.metadata_delimiters, &mugration_args.metadata_id.metadata_id_columns, &None, - &mugration_args.attribute, + &attribute_column, |s| Ok(s.to_owned()), )?; let traits: BTreeMap = attr_values.into_iter().collect(); @@ -47,7 +50,7 @@ pub fn run_mugration( weights_filepath, &mugration_args.metadata_id.metadata_delimiters, &[], - &mugration_args.attribute, + &attribute_column, &Some("weight".to_owned()), |s| Ok(s.parse::()?), )?; diff --git a/packages/treetime/src/commands/optimize/__tests__/test_args_reroot.rs b/packages/treetime/src/commands/optimize/__tests__/test_args_reroot.rs index 835146f91..149c31217 100644 --- a/packages/treetime/src/commands/optimize/__tests__/test_args_reroot.rs +++ b/packages/treetime/src/commands/optimize/__tests__/test_args_reroot.rs @@ -1,7 +1,7 @@ #[cfg(test)] mod tests { use crate::clock::find_best_root::params::{RerootMethod, RerootSpec}; - use crate::commands::optimize::args::{OptimizeRerootMethod, TreetimeOptimizeArgs}; + use crate::commands::optimize::args::{OptimizeRerootMethod, TreetimeOptimizeArgs, TreetimeOptimizeArgsRaw}; use crate::o; use pretty_assertions::assert_eq; @@ -10,17 +10,20 @@ mod tests { reroot_tips: Vec, keep_root: bool, ) -> TreetimeOptimizeArgs { - TreetimeOptimizeArgs { + // A tree is required to build the validated args; `reroot_spec` does not depend on it. + TreetimeOptimizeArgs::try_from(TreetimeOptimizeArgsRaw { + tree: Some("tree.nwk".into()), reroot, reroot_tips, keep_root, - ..TreetimeOptimizeArgs::default() - } + ..Default::default() + }) + .unwrap() } #[test] fn test_optimize_args_reroot_spec_default_keeps_root() { - let args = TreetimeOptimizeArgs::default(); + let args = args_with(None, vec![], false); assert_eq!(None, args.reroot_spec()); } diff --git a/packages/treetime/src/commands/optimize/args.rs b/packages/treetime/src/commands/optimize/args.rs index d4f9dbe70..d48d4573b 100644 --- a/packages/treetime/src/commands/optimize/args.rs +++ b/packages/treetime/src/commands/optimize/args.rs @@ -5,9 +5,11 @@ use crate::commands::shared::config::ConfigArgs; use crate::commands::shared::gap_fill::GapFillArgs; use crate::commands::shared::model::ModelArgs; use crate::commands::shared::output::{DivergenceUnits, OptimizeOutputSelection, OutputCoreArgs, TopologyOrderArgs}; +use crate::commands::shared::required::missing_required_args; use crate::optimize::params::{BranchOptMethod, InitialGuessMode}; #[cfg(feature = "clap")] use clap::ValueHint; +use eyre::Report; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use smart_default::SmartDefault; @@ -34,10 +36,10 @@ impl From for RerootMethod { } } -#[derive(Debug, SmartDefault, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, SmartDefault, Serialize, Deserialize, JsonSchema)] #[serde(default, deny_unknown_fields)] #[cfg_attr(feature = "clap", derive(clap::Parser))] -pub struct TreetimeOptimizeArgs { +pub struct TreetimeOptimizeArgsRaw { #[cfg_attr(feature = "clap", clap(flatten))] #[serde(skip)] pub config_args: ConfigArgs, @@ -193,13 +195,39 @@ pub struct TreetimeOptimizeArgs { pub gap_fill_args: GapFillArgs, } +/// Optimize arguments with required inputs proven present. +/// +/// Produced from [`TreetimeOptimizeArgsRaw`] by [`TryFrom`] once the `--config` overlay has run, so +/// the run code reads `tree` without an `Option`. +#[derive(Debug, Clone)] +pub struct TreetimeOptimizeArgs { + pub alignment: AlignmentArgs, + pub tree: PathBuf, + pub alphabet_args: AlphabetArgs, + pub model_args: ModelArgs, + pub dense: Option, + pub output: OutputCoreArgs, + pub divergence_units: DivergenceUnits, + pub output_augur_node_data: Option, + pub output_gtr: Option, + pub output_selection: Vec, + pub topology_order: TopologyOrderArgs, + pub max_iter: usize, + pub dp: f64, + pub damping: f64, + pub branch_length_initial_guess: InitialGuessMode, + pub opt_method: BranchOptMethod, + pub no_indels: bool, + pub reroot: Option, + pub reroot_tips: Vec, + pub keep_root: bool, + pub gap_fill_args: GapFillArgs, +} + impl TreetimeOptimizeArgs { - /// Input tree path. Present by construction: required-field validation runs during CLI parsing. + /// Input tree path. pub fn tree(&self) -> &Path { - self - .tree - .as_deref() - .expect("`--tree` is required and is validated during CLI parsing") + &self.tree } /// Resolve the requested reroot policy. @@ -222,3 +250,36 @@ impl TreetimeOptimizeArgs { None } } + +impl TryFrom for TreetimeOptimizeArgs { + type Error = Report; + + fn try_from(raw: TreetimeOptimizeArgsRaw) -> Result { + let tree = raw + .tree + .ok_or_else(|| missing_required_args::(&["tree"]))?; + Ok(Self { + alignment: raw.alignment, + tree, + alphabet_args: raw.alphabet_args, + model_args: raw.model_args, + dense: raw.dense, + output: raw.output, + divergence_units: raw.divergence_units, + output_augur_node_data: raw.output_augur_node_data, + output_gtr: raw.output_gtr, + output_selection: raw.output_selection, + topology_order: raw.topology_order, + max_iter: raw.max_iter, + dp: raw.dp, + damping: raw.damping, + branch_length_initial_guess: raw.branch_length_initial_guess, + opt_method: raw.opt_method, + no_indels: raw.no_indels, + reroot: raw.reroot, + reroot_tips: raw.reroot_tips, + keep_root: raw.keep_root, + gap_fill_args: raw.gap_fill_args, + }) + } +} diff --git a/packages/treetime/src/commands/prune/args.rs b/packages/treetime/src/commands/prune/args.rs index 25a5ea807..4888afc76 100644 --- a/packages/treetime/src/commands/prune/args.rs +++ b/packages/treetime/src/commands/prune/args.rs @@ -2,18 +2,20 @@ use crate::commands::shared::alignment::AlignmentArgs; use crate::commands::shared::alphabet::AlphabetArgs; use crate::commands::shared::config::ConfigArgs; use crate::commands::shared::output::{OutputCoreArgs, PruneOutputSelection, TopologyOrderArgs}; +use crate::commands::shared::required::missing_required_args; #[cfg(feature = "clap")] use clap::ValueHint; +use eyre::Report; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use smart_default::SmartDefault; use std::fmt::Debug; use std::path::{Path, PathBuf}; -#[derive(Debug, SmartDefault, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, SmartDefault, Serialize, Deserialize, JsonSchema)] #[serde(default, deny_unknown_fields)] #[cfg_attr(feature = "clap", derive(clap::Parser))] -pub struct TreetimePruneArgs { +pub struct TreetimePruneArgsRaw { #[cfg_attr(feature = "clap", clap(flatten))] #[serde(skip)] pub config_args: ConfigArgs, @@ -110,12 +112,57 @@ pub struct TreetimePruneArgs { pub prune_nodes_list_file_delimiter: char, } +/// Prune arguments with required inputs proven present. +/// +/// Produced from [`TreetimePruneArgsRaw`] by [`TryFrom`] once the `--config` overlay has run, so the +/// run code reads `tree` without an `Option`. +#[derive(Debug, Clone)] +pub struct TreetimePruneArgs { + pub alignment: AlignmentArgs, + pub tree: PathBuf, + pub alphabet_args: AlphabetArgs, + pub output: OutputCoreArgs, + pub output_gtr: Option, + pub output_selection: Vec, + pub topology_order: TopologyOrderArgs, + pub prune_short: Option, + pub prune_empty: bool, + pub merge_shared_mutations: bool, + pub prune_nodes_list: Option, + pub prune_nodes_list_delimiter: char, + pub prune_nodes_list_file: Option, + pub prune_nodes_list_file_delimiter: char, +} + impl TreetimePruneArgs { - /// Input tree path. Present by construction: required-field validation runs during CLI parsing. + /// Input tree path. pub fn tree(&self) -> &Path { - self + &self.tree + } +} + +impl TryFrom for TreetimePruneArgs { + type Error = Report; + + fn try_from(raw: TreetimePruneArgsRaw) -> Result { + let tree = raw .tree - .as_deref() - .expect("`--tree` is required and is validated during CLI parsing") + .ok_or_else(|| missing_required_args::(&["tree"]))?; + Ok(Self { + alignment: raw.alignment, + tree, + alphabet_args: raw.alphabet_args, + output: raw.output, + output_gtr: raw.output_gtr, + output_selection: raw.output_selection, + topology_order: raw.topology_order, + prune_short: raw.prune_short, + prune_empty: raw.prune_empty, + merge_shared_mutations: raw.merge_shared_mutations, + prune_nodes_list: raw.prune_nodes_list, + prune_nodes_list_delimiter: raw.prune_nodes_list_delimiter, + prune_nodes_list_file: raw.prune_nodes_list_file, + prune_nodes_list_file_delimiter: raw.prune_nodes_list_file_delimiter, + }) } } diff --git a/packages/treetime/src/commands/shared/__tests__/test_metadata_args.rs b/packages/treetime/src/commands/shared/__tests__/test_metadata_args.rs index dd8d56333..785ee5cdc 100644 --- a/packages/treetime/src/commands/shared/__tests__/test_metadata_args.rs +++ b/packages/treetime/src/commands/shared/__tests__/test_metadata_args.rs @@ -1,19 +1,19 @@ #[cfg(test)] mod tests { - use crate::commands::clock::args::TreetimeClockArgs; + use crate::commands::clock::args::TreetimeClockArgsRaw; use clap::Parser; use pretty_assertions::assert_eq; #[test] fn test_metadata_args_default_delimiters() { - let args = TreetimeClockArgs::try_parse_from(["treetime", "--metadata=/dev/null"]).unwrap(); + let args = TreetimeClockArgsRaw::try_parse_from(["treetime", "--metadata=/dev/null"]).unwrap(); assert_eq!(vec![',', '\t', ';'], args.metadata_id.metadata_delimiters); } #[test] fn test_metadata_args_explicit_delimiters() { let args = - TreetimeClockArgs::try_parse_from(["treetime", "--metadata=/dev/null", "--metadata-delimiters", "|", ":"]) + TreetimeClockArgsRaw::try_parse_from(["treetime", "--metadata=/dev/null", "--metadata-delimiters", "|", ":"]) .unwrap(); assert_eq!(vec!['|', ':'], args.metadata_id.metadata_delimiters); } diff --git a/packages/treetime/src/commands/shared/__tests__/test_output_args.rs b/packages/treetime/src/commands/shared/__tests__/test_output_args.rs index 2714989f0..dc5191d86 100644 --- a/packages/treetime/src/commands/shared/__tests__/test_output_args.rs +++ b/packages/treetime/src/commands/shared/__tests__/test_output_args.rs @@ -1,14 +1,14 @@ #[cfg(test)] mod tests { - use crate::commands::ancestral::args::TreetimeAncestralArgs; - use crate::commands::clock::args::TreetimeClockArgs; - use crate::commands::mugration::args::TreetimeMugrationArgs; - use crate::commands::optimize::args::TreetimeOptimizeArgs; - use crate::commands::prune::args::TreetimePruneArgs; + use crate::commands::ancestral::args::TreetimeAncestralArgsRaw; + use crate::commands::clock::args::TreetimeClockArgsRaw; + use crate::commands::mugration::args::TreetimeMugrationArgsRaw; + use crate::commands::optimize::args::TreetimeOptimizeArgsRaw; + use crate::commands::prune::args::TreetimePruneArgsRaw; use crate::commands::shared::output::{ AncestralOutputSelection, ClockOutputSelection, MugrationOutputSelection, NwkStyleArg, TimetreeOutputSelection, }; - use crate::commands::timetree::args::TreetimeTimetreeArgs; + use crate::commands::timetree::args::TreetimeTimetreeArgsRaw; use clap::Parser; use pretty_assertions::assert_eq; use std::path::Path; @@ -16,13 +16,13 @@ mod tests { #[test] fn test_output_args_output_all_parses() { let args = - TreetimeAncestralArgs::try_parse_from(["treetime", "--tree=/dev/null", "--output-all=/tmp/out"]).unwrap(); + TreetimeAncestralArgsRaw::try_parse_from(["treetime", "--tree=/dev/null", "--output-all=/tmp/out"]).unwrap(); assert_eq!(args.output.output_all.as_deref(), Some(Path::new("/tmp/out"))); } #[test] fn test_output_args_output_selection_parses_csv() { - let args = TreetimeAncestralArgs::try_parse_from([ + let args = TreetimeAncestralArgsRaw::try_parse_from([ "treetime", "--tree=/dev/null", "--output-all=/tmp/out", @@ -41,21 +41,22 @@ mod tests { #[test] fn test_output_args_output_selection_requires_output_all() { - let result = TreetimeAncestralArgs::try_parse_from(["treetime", "--tree=/dev/null", "--output-selection=nwk"]); + let result = TreetimeAncestralArgsRaw::try_parse_from(["treetime", "--tree=/dev/null", "--output-selection=nwk"]); assert!(result.is_err(), "--output-selection without --output-all should fail"); } #[test] fn test_output_args_per_file_tree_nwk_parses() { let args = - TreetimeAncestralArgs::try_parse_from(["treetime", "--tree=/dev/null", "--output-tree-nwk=/tmp/my.nwk"]).unwrap(); + TreetimeAncestralArgsRaw::try_parse_from(["treetime", "--tree=/dev/null", "--output-tree-nwk=/tmp/my.nwk"]) + .unwrap(); assert_eq!(args.output.output_tree_nwk.as_deref(), Some(Path::new("/tmp/my.nwk"))); } #[test] fn test_output_args_per_file_tree_nexus_parses() { let args = - TreetimeOptimizeArgs::try_parse_from(["treetime", "--tree=/dev/null", "--output-tree-nexus=/tmp/tree.nexus"]) + TreetimeOptimizeArgsRaw::try_parse_from(["treetime", "--tree=/dev/null", "--output-tree-nexus=/tmp/tree.nexus"]) .unwrap(); assert_eq!( args.output.output_tree_nexus.as_deref(), @@ -65,7 +66,7 @@ mod tests { #[test] fn test_output_args_nwk_style_parses_csv() { - let args = TreetimeAncestralArgs::try_parse_from([ + let args = TreetimeAncestralArgsRaw::try_parse_from([ "treetime", "--tree=/dev/null", "--output-tree-nwk=/tmp/my.nwk", @@ -80,13 +81,13 @@ mod tests { #[test] fn test_output_args_short_flag_o_for_output_all() { - let args = TreetimeAncestralArgs::try_parse_from(["treetime", "--tree=/dev/null", "-O", "/tmp/out"]).unwrap(); + let args = TreetimeAncestralArgsRaw::try_parse_from(["treetime", "--tree=/dev/null", "-O", "/tmp/out"]).unwrap(); assert_eq!(args.output.output_all.as_deref(), Some(Path::new("/tmp/out"))); } #[test] fn test_output_args_output_selection_all_variant_parses() { - let args = TreetimeAncestralArgs::try_parse_from([ + let args = TreetimeAncestralArgsRaw::try_parse_from([ "treetime", "--tree=/dev/null", "--output-all=/tmp/out", @@ -98,7 +99,7 @@ mod tests { #[test] fn test_output_args_rejects_unknown_selection_value() { - let result = TreetimeAncestralArgs::try_parse_from([ + let result = TreetimeAncestralArgsRaw::try_parse_from([ "treetime", "--tree=/dev/null", "--output-all=/tmp/out", @@ -110,7 +111,7 @@ mod tests { #[test] fn test_output_args_rejects_out_of_command_selection_variant() { // `tracelog` is timetree-only; the ancestral selection enum has no such variant. - let result = TreetimeAncestralArgs::try_parse_from([ + let result = TreetimeAncestralArgsRaw::try_parse_from([ "treetime", "--tree=/dev/null", "--output-all=/tmp/out", @@ -124,7 +125,7 @@ mod tests { #[test] fn test_ancestral_output_augur_node_data_parses() { - let args = TreetimeAncestralArgs::try_parse_from([ + let args = TreetimeAncestralArgsRaw::try_parse_from([ "treetime", "--tree=/dev/null", "--output-augur-node-data=/tmp/node.json", @@ -139,13 +140,13 @@ mod tests { #[test] fn test_ancestral_output_gtr_parses() { let args = - TreetimeAncestralArgs::try_parse_from(["treetime", "--tree=/dev/null", "--output-gtr=/tmp/gtr.json"]).unwrap(); + TreetimeAncestralArgsRaw::try_parse_from(["treetime", "--tree=/dev/null", "--output-gtr=/tmp/gtr.json"]).unwrap(); assert_eq!(args.output_gtr.as_deref(), Some(Path::new("/tmp/gtr.json"))); } #[test] fn test_timetree_output_clock_model_parses() { - let args = TreetimeTimetreeArgs::try_parse_from([ + let args = TreetimeTimetreeArgsRaw::try_parse_from([ "treetime", "--metadata=/dev/null", "--output-clock-model=/tmp/clock.json", @@ -156,7 +157,7 @@ mod tests { #[test] fn test_timetree_output_confidence_tsv_parses() { - let args = TreetimeTimetreeArgs::try_parse_from([ + let args = TreetimeTimetreeArgsRaw::try_parse_from([ "treetime", "--metadata=/dev/null", "--output-confidence-tsv=/tmp/conf.tsv", @@ -168,18 +169,19 @@ mod tests { #[test] fn test_timetree_output_tracelog_parses_with_alias() { let canonical = - TreetimeTimetreeArgs::try_parse_from(["treetime", "--metadata=/dev/null", "--output-tracelog=/tmp/trace.csv"]) + TreetimeTimetreeArgsRaw::try_parse_from(["treetime", "--metadata=/dev/null", "--output-tracelog=/tmp/trace.csv"]) .unwrap(); assert_eq!(canonical.output_tracelog.as_deref(), Some(Path::new("/tmp/trace.csv"))); let aliased = - TreetimeTimetreeArgs::try_parse_from(["treetime", "--metadata=/dev/null", "--tracelog=/tmp/trace.csv"]).unwrap(); + TreetimeTimetreeArgsRaw::try_parse_from(["treetime", "--metadata=/dev/null", "--tracelog=/tmp/trace.csv"]) + .unwrap(); assert_eq!(aliased.output_tracelog.as_deref(), Some(Path::new("/tmp/trace.csv"))); } #[test] fn test_timetree_selection_confidence_tsv_parses() { - let args = TreetimeTimetreeArgs::try_parse_from([ + let args = TreetimeTimetreeArgsRaw::try_parse_from([ "treetime", "--metadata=/dev/null", "--output-all=/tmp/out", @@ -198,7 +200,7 @@ mod tests { #[test] fn test_clock_output_clock_csv_parses() { let args = - TreetimeClockArgs::try_parse_from(["treetime", "--metadata=/dev/null", "--output-clock-csv=/tmp/clock.csv"]) + TreetimeClockArgsRaw::try_parse_from(["treetime", "--metadata=/dev/null", "--output-clock-csv=/tmp/clock.csv"]) .unwrap(); assert_eq!(args.output_clock_csv.as_deref(), Some(Path::new("/tmp/clock.csv"))); } @@ -206,7 +208,7 @@ mod tests { #[test] fn test_clock_rejects_augur_node_data_selection() { // Clock does not produce augur node data; its selection enum has no such variant. - let result = TreetimeClockArgs::try_parse_from([ + let result = TreetimeClockArgsRaw::try_parse_from([ "treetime", "--metadata=/dev/null", "--output-all=/tmp/out", @@ -217,7 +219,7 @@ mod tests { #[test] fn test_clock_selection_clock_model_parses() { - let args = TreetimeClockArgs::try_parse_from([ + let args = TreetimeClockArgsRaw::try_parse_from([ "treetime", "--metadata=/dev/null", "--output-all=/tmp/out", @@ -232,7 +234,7 @@ mod tests { #[test] fn test_mugration_output_traits_csv_parses() { - let args = TreetimeMugrationArgs::try_parse_from([ + let args = TreetimeMugrationArgsRaw::try_parse_from([ "treetime", "--metadata=/dev/null", "--attribute=country", @@ -244,7 +246,7 @@ mod tests { #[test] fn test_mugration_output_confidence_csv_parses_with_alias() { - let canonical = TreetimeMugrationArgs::try_parse_from([ + let canonical = TreetimeMugrationArgsRaw::try_parse_from([ "treetime", "--metadata=/dev/null", "--attribute=country", @@ -256,7 +258,7 @@ mod tests { Some(Path::new("/tmp/conf.csv")) ); - let aliased = TreetimeMugrationArgs::try_parse_from([ + let aliased = TreetimeMugrationArgsRaw::try_parse_from([ "treetime", "--metadata=/dev/null", "--attribute=country", @@ -271,7 +273,7 @@ mod tests { #[test] fn test_mugration_selection_confidence_csv_parses() { - let args = TreetimeMugrationArgs::try_parse_from([ + let args = TreetimeMugrationArgsRaw::try_parse_from([ "treetime", "--metadata=/dev/null", "--attribute=country", @@ -285,13 +287,13 @@ mod tests { #[test] fn test_prune_output_gtr_parses() { let args = - TreetimePruneArgs::try_parse_from(["treetime", "--tree=/dev/null", "--output-gtr=/tmp/gtr.json"]).unwrap(); + TreetimePruneArgsRaw::try_parse_from(["treetime", "--tree=/dev/null", "--output-gtr=/tmp/gtr.json"]).unwrap(); assert_eq!(args.output_gtr.as_deref(), Some(Path::new("/tmp/gtr.json"))); } #[test] fn test_combined_output_all_and_per_file_parse() { - let args = TreetimeAncestralArgs::try_parse_from([ + let args = TreetimeAncestralArgsRaw::try_parse_from([ "treetime", "--tree=/dev/null", "--output-all=/tmp/out", diff --git a/packages/treetime/src/commands/shared/mod.rs b/packages/treetime/src/commands/shared/mod.rs index b8d7f03ca..15ca61d68 100644 --- a/packages/treetime/src/commands/shared/mod.rs +++ b/packages/treetime/src/commands/shared/mod.rs @@ -5,6 +5,7 @@ pub mod gap_fill; pub mod metadata; pub mod model; pub mod output; +pub mod required; pub mod reroot; pub mod resolve_outputs; pub mod tree_output; diff --git a/packages/treetime/src/commands/shared/required.rs b/packages/treetime/src/commands/shared/required.rs new file mode 100644 index 000000000..9c33d0a5f --- /dev/null +++ b/packages/treetime/src/commands/shared/required.rs @@ -0,0 +1,48 @@ +use crate::make_report; +use eyre::Report; + +/// Build the clap-style "required arguments were not provided" error for a set of missing arg ids. +/// +/// The flag spelling is read from the command's clap definition, so renaming a flag cannot desync the +/// message from what the parser accepts. The `--config` overlay stores required inputs as `Option` +/// (clap must not reject them at parse time, because the config file may supply them), so presence is +/// enforced here when the raw args are converted to their validated form. +/// +/// Without the `clap` feature (non-CLI builds such as the Node bindings) the spelling is reconstructed +/// from the id with clap's default conventions (`--{kebab} <{UPPER}>`), which matches the derived flag +/// for every required argument. +#[cfg(feature = "clap")] +pub fn missing_required_args(missing_ids: &[&str]) -> Report { + let command = C::command(); + let list = missing_ids + .iter() + .map(|id| required_flag(&command, id)) + .collect::>() + .join("\n "); + make_report!("the following required arguments were not provided:\n {list}") +} + +/// Format one missing argument as `--{long} <{VALUE}>` from its clap definition. +#[cfg(feature = "clap")] +fn required_flag(command: &clap::Command, id: &str) -> String { + let arg = command.get_arguments().find(|arg| arg.get_id() == id); + let long = arg + .and_then(clap::Arg::get_long) + .map_or_else(|| id.replace('_', "-"), str::to_owned); + let value = arg + .and_then(|arg| arg.get_value_names().and_then(|names| names.first())) + .map_or_else(|| id.to_uppercase(), ToString::to_string); + format!("--{long} <{value}>") +} + +/// Non-CLI fallback: reconstruct the flag spelling from the id with clap's default conventions. +#[cfg(not(feature = "clap"))] +#[allow(clippy::extra_unused_type_parameters)] // keep one call signature across the `clap` cfg split +pub fn missing_required_args(missing_ids: &[&str]) -> Report { + let list = missing_ids + .iter() + .map(|id| format!("--{} <{}>", id.replace('_', "-"), id.to_uppercase())) + .collect::>() + .join("\n "); + make_report!("the following required arguments were not provided:\n {list}") +} diff --git a/packages/treetime/src/commands/shared/resolve_outputs.rs b/packages/treetime/src/commands/shared/resolve_outputs.rs index e2e7c0f01..7f34f3f10 100644 --- a/packages/treetime/src/commands/shared/resolve_outputs.rs +++ b/packages/treetime/src/commands/shared/resolve_outputs.rs @@ -1,10 +1,10 @@ -use crate::commands::ancestral::args::TreetimeAncestralArgs; -use crate::commands::clock::args::TreetimeClockArgs; -use crate::commands::mugration::args::TreetimeMugrationArgs; -use crate::commands::optimize::args::TreetimeOptimizeArgs; -use crate::commands::prune::args::TreetimePruneArgs; +use crate::commands::ancestral::args::{TreetimeAncestralArgs, TreetimeAncestralArgsRaw}; +use crate::commands::clock::args::{TreetimeClockArgs, TreetimeClockArgsRaw}; +use crate::commands::mugration::args::{TreetimeMugrationArgs, TreetimeMugrationArgsRaw}; +use crate::commands::optimize::args::{TreetimeOptimizeArgs, TreetimeOptimizeArgsRaw}; +use crate::commands::prune::args::{TreetimePruneArgs, TreetimePruneArgsRaw}; use crate::commands::shared::output::{CommandKind, OutputSelection, ResolvedOutputs}; -use crate::commands::timetree::args::TreetimeTimetreeArgs; +use crate::commands::timetree::args::{TreetimeTimetreeArgs, TreetimeTimetreeArgsRaw}; use eyre::Report; use std::path::Path; @@ -26,121 +26,66 @@ fn selection>(selection: &[S]) -> Vec CommandKind { - CommandKind::Ancestral - } - - fn resolve_outputs(&self) -> Result { - self.output.resolve( - CommandKind::Ancestral, - &selection(&self.output_selection), - &[ - (OutputSelection::AugurNodeData, self.output_augur_node_data.as_deref()), - (OutputSelection::Gtr, self.output_gtr.as_deref()), - ( - OutputSelection::ReconstructedNucFasta, - self.output_reconstructed_nuc_fasta.as_deref(), - ), - ( - OutputSelection::ReconstructedAaFasta, - self.output_reconstructed_aa_fasta.as_deref().map(Path::new), - ), - ], - ) - } -} - -impl ResolveOutputs for TreetimeTimetreeArgs { - fn command_kind(&self) -> CommandKind { - CommandKind::Timetree - } - - fn resolve_outputs(&self) -> Result { - self.output.resolve( - CommandKind::Timetree, - &selection(&self.output_selection), - &[ - (OutputSelection::AugurNodeData, self.output_augur_node_data.as_deref()), - (OutputSelection::Gtr, self.output_gtr.as_deref()), - ( - OutputSelection::ReconstructedNucFasta, - self.output_reconstructed_nuc_fasta.as_deref(), - ), - (OutputSelection::ClockModel, self.output_clock_model.as_deref()), - (OutputSelection::ConfidenceTsv, self.output_confidence_tsv.as_deref()), - (OutputSelection::Tracelog, self.output_tracelog.as_deref()), - (OutputSelection::CoalescentTsv, self.output_coalescent_tsv.as_deref()), - (OutputSelection::CoalescentCsv, self.output_coalescent_csv.as_deref()), - (OutputSelection::CoalescentJson, self.output_coalescent_json.as_deref()), - ], - ) - } -} - -impl ResolveOutputs for TreetimeClockArgs { - fn command_kind(&self) -> CommandKind { - CommandKind::Clock - } +/// Implement [`ResolveOutputs`] for each listed type from one output-field mapping. +/// +/// The mapping reads only the shared output fields, which are identical between a command's raw and +/// validated args, so both get one input-independent implementation. Output resolution never touches +/// required inputs: a pipeline step's outputs must resolve during planning, before its inputs are +/// proven present. +macro_rules! impl_resolve_outputs { + ($kind:ident; $($ty:ty),+ $(,)?; |$s:ident| $files:expr) => { + $( + impl ResolveOutputs for $ty { + fn command_kind(&self) -> CommandKind { + CommandKind::$kind + } - fn resolve_outputs(&self) -> Result { - self.output.resolve( - CommandKind::Clock, - &selection(&self.output_selection), - &[ - (OutputSelection::ClockModel, self.output_clock_model.as_deref()), - (OutputSelection::ClockCsv, self.output_clock_csv.as_deref()), - ], - ) - } + fn resolve_outputs(&self) -> Result { + let $s = self; + $s.output + .resolve(CommandKind::$kind, &selection(&$s.output_selection), &$files) + } + } + )+ + }; } -impl ResolveOutputs for TreetimeMugrationArgs { - fn command_kind(&self) -> CommandKind { - CommandKind::Mugration - } +impl_resolve_outputs!(Ancestral; TreetimeAncestralArgs, TreetimeAncestralArgsRaw; |s| [ + (OutputSelection::AugurNodeData, s.output_augur_node_data.as_deref()), + (OutputSelection::Gtr, s.output_gtr.as_deref()), + (OutputSelection::ReconstructedNucFasta, s.output_reconstructed_nuc_fasta.as_deref()), + (OutputSelection::ReconstructedAaFasta, s.output_reconstructed_aa_fasta.as_deref().map(Path::new)), +]); - fn resolve_outputs(&self) -> Result { - self.output.resolve( - CommandKind::Mugration, - &selection(&self.output_selection), - &[ - (OutputSelection::AugurNodeData, self.output_augur_node_data.as_deref()), - (OutputSelection::Gtr, self.output_gtr.as_deref()), - (OutputSelection::ConfidenceCsv, self.output_confidence_csv.as_deref()), - (OutputSelection::TraitsCsv, self.output_traits_csv.as_deref()), - ], - ) - } -} +impl_resolve_outputs!(Timetree; TreetimeTimetreeArgs, TreetimeTimetreeArgsRaw; |s| [ + (OutputSelection::AugurNodeData, s.output_augur_node_data.as_deref()), + (OutputSelection::Gtr, s.output_gtr.as_deref()), + (OutputSelection::ReconstructedNucFasta, s.output_reconstructed_nuc_fasta.as_deref()), + (OutputSelection::ClockModel, s.output_clock_model.as_deref()), + (OutputSelection::ConfidenceTsv, s.output_confidence_tsv.as_deref()), + (OutputSelection::Tracelog, s.output_tracelog.as_deref()), + (OutputSelection::CoalescentTsv, s.output_coalescent_tsv.as_deref()), + (OutputSelection::CoalescentCsv, s.output_coalescent_csv.as_deref()), + (OutputSelection::CoalescentJson, s.output_coalescent_json.as_deref()), +]); -impl ResolveOutputs for TreetimeOptimizeArgs { - fn command_kind(&self) -> CommandKind { - CommandKind::Optimize - } +impl_resolve_outputs!(Clock; TreetimeClockArgs, TreetimeClockArgsRaw; |s| [ + (OutputSelection::ClockModel, s.output_clock_model.as_deref()), + (OutputSelection::ClockCsv, s.output_clock_csv.as_deref()), +]); - fn resolve_outputs(&self) -> Result { - self.output.resolve( - CommandKind::Optimize, - &selection(&self.output_selection), - &[ - (OutputSelection::AugurNodeData, self.output_augur_node_data.as_deref()), - (OutputSelection::Gtr, self.output_gtr.as_deref()), - ], - ) - } -} +impl_resolve_outputs!(Mugration; TreetimeMugrationArgs, TreetimeMugrationArgsRaw; |s| [ + (OutputSelection::AugurNodeData, s.output_augur_node_data.as_deref()), + (OutputSelection::Gtr, s.output_gtr.as_deref()), + (OutputSelection::ConfidenceCsv, s.output_confidence_csv.as_deref()), + (OutputSelection::TraitsCsv, s.output_traits_csv.as_deref()), +]); -impl ResolveOutputs for TreetimePruneArgs { - fn command_kind(&self) -> CommandKind { - CommandKind::Prune - } +impl_resolve_outputs!(Optimize; TreetimeOptimizeArgs, TreetimeOptimizeArgsRaw; |s| [ + (OutputSelection::AugurNodeData, s.output_augur_node_data.as_deref()), + (OutputSelection::Gtr, s.output_gtr.as_deref()), +]); - fn resolve_outputs(&self) -> Result { - self.output.resolve( - CommandKind::Prune, - &selection(&self.output_selection), - &[(OutputSelection::Gtr, self.output_gtr.as_deref())], - ) - } -} +impl_resolve_outputs!(Prune; TreetimePruneArgs, TreetimePruneArgsRaw; |s| [ + (OutputSelection::Gtr, s.output_gtr.as_deref()), +]); diff --git a/packages/treetime/src/commands/timetree/__tests__/test_pipeline.rs b/packages/treetime/src/commands/timetree/__tests__/test_pipeline.rs index a710507d1..ae064102c 100644 --- a/packages/treetime/src/commands/timetree/__tests__/test_pipeline.rs +++ b/packages/treetime/src/commands/timetree/__tests__/test_pipeline.rs @@ -2,7 +2,7 @@ mod tests { use crate::commands::shared::alignment::AlignmentArgs; use crate::commands::shared::output::{LadderizeArg, OutputCoreArgs, TimetreeOutputSelection, TopologyOrderArgs}; - use crate::commands::timetree::args::TreetimeTimetreeArgs; + use crate::commands::timetree::args::{TreetimeTimetreeArgs, TreetimeTimetreeArgsRaw}; use crate::commands::timetree::output::coalescent::{CoalescentOutput, CoalescentOutputMode}; use crate::commands::timetree::run::run_timetree_estimation; use crate::progress::NoopProgress; @@ -20,7 +20,7 @@ mod tests { std::fs::create_dir_all(&outdir)?; let tracelog_path = outdir.join("tracelog.csv"); - let args = TreetimeTimetreeArgs { + let args = TreetimeTimetreeArgs::try_from(TreetimeTimetreeArgsRaw { alignment: AlignmentArgs { alignment: vec![root.join("data/flu/h3n2/20/aln.fasta.xz")], }, @@ -32,8 +32,9 @@ mod tests { ..Default::default() }, output_tracelog: Some(tracelog_path.clone()), - ..TreetimeTimetreeArgs::default() - }; + ..TreetimeTimetreeArgsRaw::default() + }) + .unwrap(); run_timetree_estimation(&args, &NoopProgress)?; @@ -100,7 +101,7 @@ mod tests { fn test_pipeline_timetree_ladderize_applies_to_auspice() -> Result<(), Report> { let root = project_root(); let output = tempfile::tempdir()?; - let args = TreetimeTimetreeArgs { + let args = TreetimeTimetreeArgs::try_from(TreetimeTimetreeArgsRaw { alignment: AlignmentArgs { alignment: vec![root.join("data/flu/h3n2/20/aln.fasta.xz")], }, @@ -116,8 +117,9 @@ mod tests { ladderize: Some(LadderizeArg::Descending), ..TopologyOrderArgs::default() }, - ..TreetimeTimetreeArgs::default() - }; + ..TreetimeTimetreeArgsRaw::default() + }) + .unwrap(); run_timetree_estimation(&args, &NoopProgress)?; @@ -135,7 +137,7 @@ mod tests { fn test_pipeline_timetree_writes_coalescent_outputs() -> Result<(), Report> { let root = project_root(); let output = tempfile::tempdir()?; - let args = TreetimeTimetreeArgs { + let args = TreetimeTimetreeArgs::try_from(TreetimeTimetreeArgsRaw { alignment: AlignmentArgs { alignment: vec![root.join("data/flu/h3n2/20/aln.fasta.xz")], }, @@ -148,8 +150,9 @@ mod tests { ..Default::default() }, output_coalescent_json: Some(output.path().join("timetree.coalescent.json")), - ..TreetimeTimetreeArgs::default() - }; + ..TreetimeTimetreeArgsRaw::default() + }) + .unwrap(); run_timetree_estimation(&args, &NoopProgress)?; diff --git a/packages/treetime/src/commands/timetree/__tests__/test_reconstructed_fasta.rs b/packages/treetime/src/commands/timetree/__tests__/test_reconstructed_fasta.rs index 9c0485149..d281cdda0 100644 --- a/packages/treetime/src/commands/timetree/__tests__/test_reconstructed_fasta.rs +++ b/packages/treetime/src/commands/timetree/__tests__/test_reconstructed_fasta.rs @@ -104,7 +104,7 @@ mod tests { mod helpers { use crate::alphabet::alphabet::Alphabet; use crate::commands::shared::alignment::AlignmentArgs; - use crate::commands::timetree::args::TreetimeTimetreeArgs; + use crate::commands::timetree::args::{TreetimeTimetreeArgs, TreetimeTimetreeArgsRaw}; use crate::commands::timetree::run::run_timetree_estimation; use crate::progress::NoopProgress; use eyre::Report; @@ -141,7 +141,7 @@ mod tests { std::fs::create_dir_all(&outdir)?; let fasta = outdir.join("ancestral_sequences.fasta"); - let mut args = TreetimeTimetreeArgs { + let mut args = TreetimeTimetreeArgs::try_from(TreetimeTimetreeArgsRaw { alignment: AlignmentArgs { alignment: vec![root.join("data/flu/h3n2/20/aln.fasta.xz")], }, @@ -149,8 +149,9 @@ mod tests { metadata: Some(root.join("data/flu/h3n2/20/metadata.tsv")), max_iter: 2, output_reconstructed_nuc_fasta: Some(fasta.clone()), - ..TreetimeTimetreeArgs::default() - }; + ..TreetimeTimetreeArgsRaw::default() + }) + .unwrap(); configure(&mut args); run_timetree_estimation(&args, &NoopProgress)?; diff --git a/packages/treetime/src/commands/timetree/args.rs b/packages/treetime/src/commands/timetree/args.rs index 085ae82d6..9d87fca35 100644 --- a/packages/treetime/src/commands/timetree/args.rs +++ b/packages/treetime/src/commands/timetree/args.rs @@ -10,6 +10,7 @@ use crate::commands::shared::reroot::RerootArgs; use crate::optimize::params::BranchLengthMode; #[cfg(feature = "clap")] use clap::ValueHint; +use eyre::Report; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use smart_default::SmartDefault; @@ -27,10 +28,10 @@ fn parse_skyline_n_points(s: &str) -> Result { pub use crate::timetree::params::TimeMarginalMode; -#[derive(Debug, SmartDefault, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, SmartDefault, Serialize, Deserialize, JsonSchema)] #[serde(default, deny_unknown_fields)] #[cfg_attr(feature = "clap", derive(clap::Parser))] -pub struct TreetimeTimetreeArgs { +pub struct TreetimeTimetreeArgsRaw { #[cfg_attr(feature = "clap", clap(flatten))] #[serde(skip)] pub config_args: ConfigArgs, @@ -115,7 +116,7 @@ pub struct TreetimeTimetreeArgs { /// maximal number of iterations the inference cycle is run. For polytomy resolution and /// coalescence models max_iter should be at least 2 #[default = 2] - #[cfg_attr(feature = "clap", clap(long, default_value_t = TreetimeTimetreeArgs::default().max_iter))] + #[cfg_attr(feature = "clap", clap(long, default_value_t = TreetimeTimetreeArgsRaw::default().max_iter))] pub max_iter: usize, /// Coalescent time scale in years. @@ -151,7 +152,7 @@ pub struct TreetimeTimetreeArgs { /// Only used when --coalescent-skyline is set. Defines how many piecewise linear segments /// are used to model Tc(t) over time. Must be at least 2. Matches Python v0's default. #[default = 20] - #[cfg_attr(feature = "clap", clap(long, default_value_t = TreetimeTimetreeArgs::default().skyline_n_points))] + #[cfg_attr(feature = "clap", clap(long, default_value_t = TreetimeTimetreeArgsRaw::default().skyline_n_points))] #[cfg_attr(feature = "clap", clap(value_parser = parse_skyline_n_points))] pub skyline_n_points: usize, @@ -163,7 +164,7 @@ pub struct TreetimeTimetreeArgs { /// stiffness is dimensionless and scale-independent. Larger values enforce a /// smoother Tc(t). Only used when --coalescent-skyline is set. #[default = 2.0] - #[cfg_attr(feature = "clap", clap(long, default_value_t = TreetimeTimetreeArgs::default().skyline_stiffness))] + #[cfg_attr(feature = "clap", clap(long, default_value_t = TreetimeTimetreeArgsRaw::default().skyline_stiffness))] pub skyline_stiffness: f64, /// Confidence level for coalescent time scale (Tc) bands, in standard deviations. @@ -173,7 +174,7 @@ pub struct TreetimeTimetreeArgs { /// the standard deviation of `ln Tc` from the coalescent likelihood curvature. A fixed /// --coalescent value is not inferred and therefore has no band. #[default = 2.0] - #[cfg_attr(feature = "clap", clap(long, default_value_t = TreetimeTimetreeArgs::default().coalescent_confidence))] + #[cfg_attr(feature = "clap", clap(long, default_value_t = TreetimeTimetreeArgsRaw::default().coalescent_confidence))] pub coalescent_confidence: f64, /// add posterior LH to coalescent model: use the posterior probability distributions of @@ -398,7 +399,7 @@ pub struct TreetimeTimetreeArgs { #[default = 50.0] #[cfg_attr( feature = "clap", - clap(long, default_value_t = TreetimeTimetreeArgs::default().gen_per_year) + clap(long, default_value_t = TreetimeTimetreeArgsRaw::default().gen_per_year) )] pub gen_per_year: f64, @@ -410,3 +411,151 @@ pub struct TreetimeTimetreeArgs { #[cfg_attr(feature = "clap", clap(long, hide = true))] pub stochastic_resolve: bool, } + +/// Timetree arguments after the `--config` overlay. +/// +/// Produced from [`TreetimeTimetreeArgsRaw`] by [`TryFrom`]. Timetree has no required-only argument, +/// so the conversion is infallible and only drops `config_args`; the split keeps a uniform shape +/// across every command. +#[derive(Debug, Clone)] +pub struct TreetimeTimetreeArgs { + pub alignment: AlignmentArgs, + pub tree: Option, + pub vcf_reference: Option, + pub metadata: Option, + pub metadata_id: MetadataIdArgs, + pub date_column_args: DateColumnArgs, + pub sequence_length: Option, + pub clock_rate: Option, + pub clock_std_dev: Option, + pub branch_length_mode: BranchLengthMode, + pub time_marginal: TimeMarginalMode, + pub confidence: bool, + pub keep_polytomies: bool, + pub resolve_polytomies: bool, + pub relax: Vec, + pub max_iter: usize, + pub coalescent: Option, + pub coalescent_opt: bool, + pub coalescent_skyline: bool, + pub skyline_n_points: usize, + pub skyline_stiffness: f64, + pub coalescent_confidence: f64, + pub n_branches_posterior: Option, + pub plot_tree: Option, + pub plot_rtt: Option, + pub tip_labels: bool, + pub no_tip_labels: bool, + pub clock_filter: f64, + pub n_iqd: Option, + pub reroot: RerootArgs, + pub keep_root: bool, + pub allow_negative_rate: bool, + pub tip_slack: Option, + pub covariation: bool, + pub model_args: ModelArgs, + pub method_anc: MethodAncestral, + pub alphabet_args: AlphabetArgs, + pub dense: Option, + pub gap_fill_args: GapFillArgs, + pub zero_based: bool, + pub include_leaves: bool, + pub impute_missing_data: bool, + pub reconstruct_tip_states: bool, + pub report_ambiguous: bool, + pub no_indels: bool, + pub divergence_units: DivergenceUnits, + pub output_augur_node_data: Option, + pub output_gtr: Option, + pub output_reconstructed_nuc_fasta: Option, + pub output_clock_model: Option, + pub output_confidence_tsv: Option, + pub output_tracelog: Option, + pub output_coalescent_tsv: Option, + pub output_coalescent_csv: Option, + pub output_coalescent_json: Option, + pub output: OutputCoreArgs, + pub output_selection: Vec, + pub topology_order: TopologyOrderArgs, + pub seed: Option, + pub aa: bool, + pub custom_gtr: Option, + pub clock_filter_method: Option, + pub gen_per_year: f64, + pub greedy_resolve: bool, + pub stochastic_resolve: bool, +} + +impl TryFrom for TreetimeTimetreeArgs { + type Error = Report; + + fn try_from(raw: TreetimeTimetreeArgsRaw) -> Result { + Ok(Self { + alignment: raw.alignment, + tree: raw.tree, + vcf_reference: raw.vcf_reference, + metadata: raw.metadata, + metadata_id: raw.metadata_id, + date_column_args: raw.date_column_args, + sequence_length: raw.sequence_length, + clock_rate: raw.clock_rate, + clock_std_dev: raw.clock_std_dev, + branch_length_mode: raw.branch_length_mode, + time_marginal: raw.time_marginal, + confidence: raw.confidence, + keep_polytomies: raw.keep_polytomies, + resolve_polytomies: raw.resolve_polytomies, + relax: raw.relax, + max_iter: raw.max_iter, + coalescent: raw.coalescent, + coalescent_opt: raw.coalescent_opt, + coalescent_skyline: raw.coalescent_skyline, + skyline_n_points: raw.skyline_n_points, + skyline_stiffness: raw.skyline_stiffness, + coalescent_confidence: raw.coalescent_confidence, + n_branches_posterior: raw.n_branches_posterior, + plot_tree: raw.plot_tree, + plot_rtt: raw.plot_rtt, + tip_labels: raw.tip_labels, + no_tip_labels: raw.no_tip_labels, + clock_filter: raw.clock_filter, + n_iqd: raw.n_iqd, + reroot: raw.reroot, + keep_root: raw.keep_root, + allow_negative_rate: raw.allow_negative_rate, + tip_slack: raw.tip_slack, + covariation: raw.covariation, + model_args: raw.model_args, + method_anc: raw.method_anc, + alphabet_args: raw.alphabet_args, + dense: raw.dense, + gap_fill_args: raw.gap_fill_args, + zero_based: raw.zero_based, + include_leaves: raw.include_leaves, + impute_missing_data: raw.impute_missing_data, + reconstruct_tip_states: raw.reconstruct_tip_states, + report_ambiguous: raw.report_ambiguous, + no_indels: raw.no_indels, + divergence_units: raw.divergence_units, + output_augur_node_data: raw.output_augur_node_data, + output_gtr: raw.output_gtr, + output_reconstructed_nuc_fasta: raw.output_reconstructed_nuc_fasta, + output_clock_model: raw.output_clock_model, + output_confidence_tsv: raw.output_confidence_tsv, + output_tracelog: raw.output_tracelog, + output_coalescent_tsv: raw.output_coalescent_tsv, + output_coalescent_csv: raw.output_coalescent_csv, + output_coalescent_json: raw.output_coalescent_json, + output: raw.output, + output_selection: raw.output_selection, + topology_order: raw.topology_order, + seed: raw.seed, + aa: raw.aa, + custom_gtr: raw.custom_gtr, + clock_filter_method: raw.clock_filter_method, + gen_per_year: raw.gen_per_year, + greedy_resolve: raw.greedy_resolve, + stochastic_resolve: raw.stochastic_resolve, + }) + } +} diff --git a/packages/treetime/src/optimize/__tests__/test_args.rs b/packages/treetime/src/optimize/__tests__/test_args.rs index d1d9dedcb..026137183 100644 --- a/packages/treetime/src/optimize/__tests__/test_args.rs +++ b/packages/treetime/src/optimize/__tests__/test_args.rs @@ -1,6 +1,6 @@ #[cfg(test)] mod tests { - use crate::commands::optimize::args::TreetimeOptimizeArgs; + use crate::commands::optimize::args::TreetimeOptimizeArgsRaw; use crate::optimize::params::BranchOptMethod; use clap::Parser; use pretty_assertions::assert_eq; @@ -19,7 +19,7 @@ mod tests { #[case::newton_log("newton-log", BranchOptMethod::NewtonLog)] #[trace] fn test_args_opt_method_kebab_case_parses(#[case] flag: &str, #[case] expected: BranchOptMethod) { - let args = TreetimeOptimizeArgs::try_parse_from([ + let args = TreetimeOptimizeArgsRaw::try_parse_from([ "treetime", "--tree=/dev/null", "--output-all=/dev/null", @@ -35,7 +35,7 @@ mod tests { #[test] fn test_args_opt_method_default_is_brent_sqrt() { let args = - TreetimeOptimizeArgs::try_parse_from(["treetime", "--tree=/dev/null", "--output-all=/dev/null"]).unwrap(); + TreetimeOptimizeArgsRaw::try_parse_from(["treetime", "--tree=/dev/null", "--output-all=/dev/null"]).unwrap(); assert_eq!(BranchOptMethod::BrentSqrt, args.opt_method); } @@ -44,7 +44,7 @@ mod tests { /// fallback variant. #[test] fn test_args_opt_method_rejects_unknown() { - let result = TreetimeOptimizeArgs::try_parse_from([ + let result = TreetimeOptimizeArgsRaw::try_parse_from([ "treetime", "--tree=/dev/null", "--output-all=/dev/null", diff --git a/packages/treetime/src/seq/__tests__/test_gap_fill.rs b/packages/treetime/src/seq/__tests__/test_gap_fill.rs index 85cdde7b8..6f21d03de 100644 --- a/packages/treetime/src/seq/__tests__/test_gap_fill.rs +++ b/packages/treetime/src/seq/__tests__/test_gap_fill.rs @@ -1,6 +1,6 @@ #[cfg(test)] mod tests { - use crate::commands::ancestral::args::TreetimeAncestralArgs; + use crate::commands::ancestral::args::TreetimeAncestralArgsRaw; use crate::seq::gap_fill::{GapFill, apply_gap_fill}; use clap::Parser; use pretty_assertions::assert_eq; @@ -128,7 +128,7 @@ mod tests { #[test] fn test_gap_fill_cli_default_is_only_terminal() { - let args = TreetimeAncestralArgs::try_parse_from(base_args()).unwrap(); + let args = TreetimeAncestralArgsRaw::try_parse_from(base_args()).unwrap(); assert_eq!(GapFill::OnlyTerminal, args.gap_fill_args.effective_gap_fill()); } @@ -136,7 +136,7 @@ mod tests { fn test_gap_fill_cli_explicit_none() { let mut a = base_args(); a.push("--gap-fill=none"); - let args = TreetimeAncestralArgs::try_parse_from(a).unwrap(); + let args = TreetimeAncestralArgsRaw::try_parse_from(a).unwrap(); assert_eq!(GapFill::None, args.gap_fill_args.effective_gap_fill()); } @@ -144,7 +144,7 @@ mod tests { fn test_gap_fill_cli_explicit_all() { let mut a = base_args(); a.push("--gap-fill=all"); - let args = TreetimeAncestralArgs::try_parse_from(a).unwrap(); + let args = TreetimeAncestralArgsRaw::try_parse_from(a).unwrap(); assert_eq!(GapFill::All, args.gap_fill_args.effective_gap_fill()); } @@ -152,7 +152,7 @@ mod tests { fn test_gap_fill_cli_keep_overhangs_resolves_to_none() { let mut a = base_args(); a.push("--keep-overhangs"); - let args = TreetimeAncestralArgs::try_parse_from(a).unwrap(); + let args = TreetimeAncestralArgsRaw::try_parse_from(a).unwrap(); assert_eq!(GapFill::None, args.gap_fill_args.effective_gap_fill()); } @@ -161,7 +161,7 @@ mod tests { let mut a = base_args(); a.push("--keep-overhangs"); a.push("--gap-fill=all"); - let result = TreetimeAncestralArgs::try_parse_from(a); + let result = TreetimeAncestralArgsRaw::try_parse_from(a); result.unwrap_err(); } }