From 83abda2771c82c7ecbe5a48c0fea111d29b6eb8e Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Tue, 19 May 2026 22:31:29 -0700 Subject: [PATCH 01/21] Adds recursive functionality to find nested groups on hdf5 imports --- .../src/cmd/import/hdf5/detect_hdf5_schema.rs | 151 ++++++++++++++---- .../sift_cli/src/cmd/import/hdf5/tests.rs | 87 +++++++++- 2 files changed, 204 insertions(+), 34 deletions(-) diff --git a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs index 1be19ee73..bc3b913e2 100644 --- a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs +++ b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs @@ -1,20 +1,52 @@ +use std::collections::HashMap; use std::path::Path; use anyhow::{Context as AnyhowContext, Result, anyhow}; use hdf5::types::{FloatSize, IntSize, TypeDescriptor, VarLenAscii, VarLenUnicode}; -use hdf5::{Dataset, File}; +use hdf5::{Dataset, File, Group}; use sift_rs::{ - common::r#type::v1::{ChannelConfig, ChannelDataType}, + common::r#type::v1::{ChannelConfig, ChannelDataType, ChannelEnumType}, data_imports::v2::Hdf5DataConfig, }; use crate::cli::hdf5::Hdf5Schema; const TIME_NAMES: &[&str] = &["time", "timestamp", "timestamps", "ts"]; +const VALUE_NAMES: &[&str] = &["value", "values"]; + +pub(super) fn basename(path: &str) -> &str { + path.rsplit('/').next().unwrap_or(path) +} + +pub(super) fn parent_path(path: &str) -> String { + match path.rfind('/') { + Some(0) => "/".to_string(), + Some(idx) => path[..idx].to_string(), + None => "/".to_string(), + } +} pub(super) fn is_time_dataset_name(name: &str) -> bool { - let trimmed = name.trim_start_matches('/').to_ascii_lowercase(); - TIME_NAMES.iter().any(|n| *n == trimmed) + let leaf = basename(name).to_ascii_lowercase(); + TIME_NAMES.iter().any(|n| *n == leaf) +} + +fn is_value_leaf(name: &str) -> bool { + let leaf = basename(name).to_ascii_lowercase(); + VALUE_NAMES.iter().any(|n| *n == leaf) +} + +fn collect_datasets_recursive(group: &Group) -> Result> { + let mut datasets = group + .datasets() + .map_err(|e| anyhow!("failed to enumerate datasets in {}: {e}", group.name()))?; + let subgroups = group + .groups() + .map_err(|e| anyhow!("failed to enumerate groups in {}: {e}", group.name()))?; + for sub in &subgroups { + datasets.extend(collect_datasets_recursive(sub)?); + } + Ok(datasets) } fn get_string_attr(ds: &Dataset, name: &str) -> Option { @@ -31,7 +63,7 @@ fn get_string_attr(ds: &Dataset, name: &str) -> Option { /// Supported HDF5 channel types. Anything outside this set is rejected with a /// client-side error so users get clear feedback before upload. pub(super) const SUPPORTED_TYPES_BLURB: &str = - "bool, int8/16/32/64, uint8/16/32/64, float32, float64"; + "bool, int8/16/32/64, uint8/16/32/64, float32, float64, string, enum"; pub(super) fn hdf5_to_sift_data_type(ty: &TypeDescriptor) -> Option { match ty { @@ -46,10 +78,29 @@ pub(super) fn hdf5_to_sift_data_type(ty: &TypeDescriptor) -> Option Some(ChannelDataType::Uint64), TypeDescriptor::Float(FloatSize::U4) => Some(ChannelDataType::Float), TypeDescriptor::Float(FloatSize::U8) => Some(ChannelDataType::Double), + TypeDescriptor::VarLenUnicode + | TypeDescriptor::VarLenAscii + | TypeDescriptor::FixedAscii(_) + | TypeDescriptor::FixedUnicode(_) => Some(ChannelDataType::String), + TypeDescriptor::Enum(_) => Some(ChannelDataType::Enum), _ => None, } } +pub(super) fn enum_types_for(ty: &TypeDescriptor) -> Vec { + let TypeDescriptor::Enum(e) = ty else { + return Vec::new(); + }; + e.members + .iter() + .map(|m| ChannelEnumType { + name: m.name.clone(), + key: m.value as u32, + is_signed: e.signed, + }) + .collect() +} + pub(super) fn detect_config( path: &Path, schema: Hdf5Schema, @@ -57,9 +108,7 @@ pub(super) fn detect_config( time_field: Option<&str>, ) -> Result<(Vec, Vec)> { let file = File::open(path).map_err(|e| anyhow!("failed to open hdf5 file: {e}"))?; - let datasets = file - .datasets() - .map_err(|e| anyhow!("failed to enumerate datasets: {e}"))?; + let datasets = collect_datasets_recursive(&file)?; let result = match schema { Hdf5Schema::OneD => detect_one_d(&datasets), @@ -125,35 +174,50 @@ fn no_match_error( } fn detect_one_d(datasets: &[Dataset]) -> Result<(Vec, Vec)> { - let time_dataset = datasets - .iter() - .find(|d| is_time_dataset_name(&d.name())) - .map(|d| d.name()) - .ok_or_else(|| { - anyhow!("no time dataset found — expected one of {TIME_NAMES:?} (case-insensitive)") - })?; + let mut group_time: HashMap = HashMap::new(); + for ds in datasets { + if !is_time_dataset_name(&ds.name()) || ds.ndim() != 1 { + continue; + } + let name = ds.name(); + group_time.entry(parent_path(&name)).or_insert(name); + } + + if group_time.is_empty() { + return Err(anyhow!( + "no time dataset found — expected one of {TIME_NAMES:?} (case-insensitive) \ + at the root or within any group" + )); + } let mut data_configs = Vec::new(); let mut channel_configs = Vec::new(); for ds in datasets { let name = ds.name(); - if name == time_dataset { + if is_time_dataset_name(&name) || ds.ndim() != 1 { continue; } - if ds.ndim() != 1 { + let Some(time_dataset) = nearest_time_dataset(&group_time, &name) else { continue; - } - let dtype = ds - .dtype() - .map_err(|e| anyhow!("failed to read dtype for {name}: {e}"))? - .to_descriptor() - .map_err(|e| anyhow!("failed to describe dtype for {name}: {e}"))?; + }; + + let dtype = match ds.dtype().and_then(|t| t.to_descriptor()) { + Ok(d) => d, + Err(e) => { + eprintln!( + "skipping {name}: cannot describe HDF5 dtype ({e}). \ + Supported types: {SUPPORTED_TYPES_BLURB}." + ); + continue; + } + }; let Some(channel_type) = hdf5_to_sift_data_type(&dtype) else { - return Err(anyhow!( - "unsupported HDF5 type for dataset {name}: {dtype:?}. \ + eprintln!( + "skipping {name}: unsupported HDF5 type {dtype:?}. \ Supported types: {SUPPORTED_TYPES_BLURB}." - )); + ); + continue; }; let units = get_string_attr(ds, "units").unwrap_or_default(); @@ -161,16 +225,19 @@ fn detect_one_d(datasets: &[Dataset]) -> Result<(Vec, Vec Result<(Vec, Vec, + value_path: &str, +) -> Option { + let mut current = parent_path(value_path); + loop { + if let Some(t) = group_time.get(¤t) { + return Some(t.clone()); + } + if current == "/" { + return None; + } + current = parent_path(¤t); + } +} + +fn one_d_channel_name(value_path: &str) -> String { + if is_value_leaf(value_path) { + let parent = parent_path(value_path); + if parent != "/" { + return parent.trim_start_matches('/').to_string(); + } + } + value_path.trim_start_matches('/').to_string() +} + fn detect_two_d( datasets: &[Dataset], time_index: u64, @@ -224,6 +317,7 @@ fn detect_two_d( let channel_config = ChannelConfig { name: channel_name, data_type: channel_type as i32, + enum_types: enum_types_for(&dtype), ..Default::default() }; @@ -299,6 +393,7 @@ fn detect_compound( let channel_config = ChannelConfig { name: channel_name, data_type: channel_type as i32, + enum_types: enum_types_for(&field.ty), ..Default::default() }; diff --git a/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs b/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs index 9e36b6332..e47ecc519 100644 --- a/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs +++ b/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs @@ -8,7 +8,9 @@ use sift_rs::data_imports::v2::TimeFormat as ProtoTimeFormat; use crate::cli::hdf5::Hdf5Schema; use crate::cli::time::TimeFormat; use crate::cli::{CommonImportArgs, ImportHdf5Args}; -use crate::cmd::import::hdf5::detect_hdf5_schema::{hdf5_to_sift_data_type, is_time_dataset_name}; +use crate::cmd::import::hdf5::detect_hdf5_schema::{ + basename, hdf5_to_sift_data_type, is_time_dataset_name, parent_path, +}; use crate::cmd::import::hdf5::import::build_hdf5_config; fn make_args() -> ImportHdf5Args { @@ -137,6 +139,31 @@ fn is_time_dataset_name_rejects_unrelated_names() { assert!(!is_time_dataset_name("")); } +#[test] +fn is_time_dataset_name_recognizes_nested_paths() { + assert!(is_time_dataset_name("/group1/time")); + assert!(is_time_dataset_name("/a/b/c/Timestamp")); + assert!(is_time_dataset_name("nested/ts")); + assert!(!is_time_dataset_name("/group1/time_series")); + assert!(!is_time_dataset_name("/time/sensor")); +} + +#[test] +fn basename_returns_leaf() { + assert_eq!(basename("/group/sub/leaf"), "leaf"); + assert_eq!(basename("/leaf"), "leaf"); + assert_eq!(basename("leaf"), "leaf"); + assert_eq!(basename("/"), ""); +} + +#[test] +fn parent_path_walks_up() { + assert_eq!(parent_path("/a/b/c"), "/a/b"); + assert_eq!(parent_path("/a"), "/"); + assert_eq!(parent_path("/"), "/"); + assert_eq!(parent_path("leaf"), "/"); +} + #[test] fn hdf5_to_sift_data_type_maps_boolean() { assert_eq!( @@ -218,15 +245,63 @@ fn hdf5_to_sift_data_type_maps_float_u8() { } #[test] -fn hdf5_to_sift_data_type_rejects_strings() { - assert_eq!(hdf5_to_sift_data_type(&TypeDescriptor::VarLenUnicode), None); - assert_eq!(hdf5_to_sift_data_type(&TypeDescriptor::VarLenAscii), None); +fn hdf5_to_sift_data_type_maps_strings() { + assert_eq!( + hdf5_to_sift_data_type(&TypeDescriptor::VarLenUnicode), + Some(ChannelDataType::String) + ); + assert_eq!( + hdf5_to_sift_data_type(&TypeDescriptor::VarLenAscii), + Some(ChannelDataType::String) + ); assert_eq!( hdf5_to_sift_data_type(&TypeDescriptor::FixedAscii(16)), - None + Some(ChannelDataType::String) ); assert_eq!( hdf5_to_sift_data_type(&TypeDescriptor::FixedUnicode(16)), - None + Some(ChannelDataType::String) ); } + +#[test] +fn hdf5_to_sift_data_type_maps_enum() { + use hdf5::types::{EnumMember, EnumType}; + let ty = TypeDescriptor::Enum(EnumType { + size: IntSize::U4, + signed: false, + members: vec![EnumMember { + name: "RED".into(), + value: 0, + }], + }); + assert_eq!(hdf5_to_sift_data_type(&ty), Some(ChannelDataType::Enum)); +} + +#[test] +fn enum_types_for_extracts_members() { + use hdf5::types::{EnumMember, EnumType}; + use crate::cmd::import::hdf5::detect_hdf5_schema::enum_types_for; + let ty = TypeDescriptor::Enum(EnumType { + size: IntSize::U4, + signed: true, + members: vec![ + EnumMember { name: "OFF".into(), value: 0 }, + EnumMember { name: "ON".into(), value: 1 }, + ], + }); + let mapped = enum_types_for(&ty); + assert_eq!(mapped.len(), 2); + assert_eq!(mapped[0].name, "OFF"); + assert_eq!(mapped[0].key, 0); + assert!(mapped[0].is_signed); + assert_eq!(mapped[1].name, "ON"); + assert_eq!(mapped[1].key, 1); +} + +#[test] +fn enum_types_for_returns_empty_for_non_enum() { + use crate::cmd::import::hdf5::detect_hdf5_schema::enum_types_for; + assert!(enum_types_for(&TypeDescriptor::Boolean).is_empty()); + assert!(enum_types_for(&TypeDescriptor::Integer(IntSize::U4)).is_empty()); +} From 5db9e95317b79b12bba919851d9128a6c7cd7a7e Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Tue, 19 May 2026 22:32:30 -0700 Subject: [PATCH 02/21] cargo fmt --- .../src/cmd/import/hdf5/detect_hdf5_schema.rs | 5 +---- rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs | 12 +++++++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs index bc3b913e2..8625bc4f5 100644 --- a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs +++ b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs @@ -251,10 +251,7 @@ fn detect_one_d(datasets: &[Dataset]) -> Result<(Vec, Vec, - value_path: &str, -) -> Option { +fn nearest_time_dataset(group_time: &HashMap, value_path: &str) -> Option { let mut current = parent_path(value_path); loop { if let Some(t) = group_time.get(¤t) { diff --git a/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs b/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs index e47ecc519..fda1fc43a 100644 --- a/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs +++ b/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs @@ -280,14 +280,20 @@ fn hdf5_to_sift_data_type_maps_enum() { #[test] fn enum_types_for_extracts_members() { - use hdf5::types::{EnumMember, EnumType}; use crate::cmd::import::hdf5::detect_hdf5_schema::enum_types_for; + use hdf5::types::{EnumMember, EnumType}; let ty = TypeDescriptor::Enum(EnumType { size: IntSize::U4, signed: true, members: vec![ - EnumMember { name: "OFF".into(), value: 0 }, - EnumMember { name: "ON".into(), value: 1 }, + EnumMember { + name: "OFF".into(), + value: 0, + }, + EnumMember { + name: "ON".into(), + value: 1, + }, ], }); let mapped = enum_types_for(&ty); From 74c06506a1be45c623738eb1fcc274878f112ba1 Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Tue, 19 May 2026 22:34:41 -0700 Subject: [PATCH 03/21] polishing --- .../sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs | 6 ++---- rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs | 8 ++------ 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs index 8625bc4f5..bcad35149 100644 --- a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs +++ b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs @@ -60,8 +60,6 @@ fn get_string_attr(ds: &Dataset, name: &str) -> Option { None } -/// Supported HDF5 channel types. Anything outside this set is rejected with a -/// client-side error so users get clear feedback before upload. pub(super) const SUPPORTED_TYPES_BLURB: &str = "bool, int8/16/32/64, uint8/16/32/64, float32, float64, string, enum"; @@ -176,10 +174,10 @@ fn no_match_error( fn detect_one_d(datasets: &[Dataset]) -> Result<(Vec, Vec)> { let mut group_time: HashMap = HashMap::new(); for ds in datasets { - if !is_time_dataset_name(&ds.name()) || ds.ndim() != 1 { + let name = ds.name(); + if !is_time_dataset_name(&name) || ds.ndim() != 1 { continue; } - let name = ds.name(); group_time.entry(parent_path(&name)).or_insert(name); } diff --git a/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs b/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs index fda1fc43a..a1b4c3c7d 100644 --- a/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs +++ b/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use chrono::DateTime; -use hdf5::types::{FloatSize, IntSize, TypeDescriptor}; +use hdf5::types::{EnumMember, EnumType, FloatSize, IntSize, TypeDescriptor}; use sift_rs::common::r#type::v1::ChannelDataType; use sift_rs::data_imports::v2::TimeFormat as ProtoTimeFormat; @@ -9,7 +9,7 @@ use crate::cli::hdf5::Hdf5Schema; use crate::cli::time::TimeFormat; use crate::cli::{CommonImportArgs, ImportHdf5Args}; use crate::cmd::import::hdf5::detect_hdf5_schema::{ - basename, hdf5_to_sift_data_type, is_time_dataset_name, parent_path, + basename, enum_types_for, hdf5_to_sift_data_type, is_time_dataset_name, parent_path, }; use crate::cmd::import::hdf5::import::build_hdf5_config; @@ -266,7 +266,6 @@ fn hdf5_to_sift_data_type_maps_strings() { #[test] fn hdf5_to_sift_data_type_maps_enum() { - use hdf5::types::{EnumMember, EnumType}; let ty = TypeDescriptor::Enum(EnumType { size: IntSize::U4, signed: false, @@ -280,8 +279,6 @@ fn hdf5_to_sift_data_type_maps_enum() { #[test] fn enum_types_for_extracts_members() { - use crate::cmd::import::hdf5::detect_hdf5_schema::enum_types_for; - use hdf5::types::{EnumMember, EnumType}; let ty = TypeDescriptor::Enum(EnumType { size: IntSize::U4, signed: true, @@ -307,7 +304,6 @@ fn enum_types_for_extracts_members() { #[test] fn enum_types_for_returns_empty_for_non_enum() { - use crate::cmd::import::hdf5::detect_hdf5_schema::enum_types_for; assert!(enum_types_for(&TypeDescriptor::Boolean).is_empty()); assert!(enum_types_for(&TypeDescriptor::Integer(IntSize::U4)).is_empty()); } From f216f33746569621fb118c7869d62b80d7aea9dc Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Wed, 20 May 2026 11:31:11 -0700 Subject: [PATCH 04/21] removing granularity of pub(super) --- .../src/cmd/import/hdf5/detect_hdf5_schema.rs | 14 +++++++------- rust/crates/sift_cli/src/cmd/import/hdf5/mod.rs | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs index bcad35149..f2f835e95 100644 --- a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs +++ b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs @@ -14,11 +14,11 @@ use crate::cli::hdf5::Hdf5Schema; const TIME_NAMES: &[&str] = &["time", "timestamp", "timestamps", "ts"]; const VALUE_NAMES: &[&str] = &["value", "values"]; -pub(super) fn basename(path: &str) -> &str { +pub fn basename(path: &str) -> &str { path.rsplit('/').next().unwrap_or(path) } -pub(super) fn parent_path(path: &str) -> String { +pub fn parent_path(path: &str) -> String { match path.rfind('/') { Some(0) => "/".to_string(), Some(idx) => path[..idx].to_string(), @@ -26,7 +26,7 @@ pub(super) fn parent_path(path: &str) -> String { } } -pub(super) fn is_time_dataset_name(name: &str) -> bool { +pub fn is_time_dataset_name(name: &str) -> bool { let leaf = basename(name).to_ascii_lowercase(); TIME_NAMES.iter().any(|n| *n == leaf) } @@ -60,10 +60,10 @@ fn get_string_attr(ds: &Dataset, name: &str) -> Option { None } -pub(super) const SUPPORTED_TYPES_BLURB: &str = +pub const SUPPORTED_TYPES_BLURB: &str = "bool, int8/16/32/64, uint8/16/32/64, float32, float64, string, enum"; -pub(super) fn hdf5_to_sift_data_type(ty: &TypeDescriptor) -> Option { +pub fn hdf5_to_sift_data_type(ty: &TypeDescriptor) -> Option { match ty { TypeDescriptor::Boolean => Some(ChannelDataType::Bool), TypeDescriptor::Integer(IntSize::U1) @@ -85,7 +85,7 @@ pub(super) fn hdf5_to_sift_data_type(ty: &TypeDescriptor) -> Option Vec { +pub fn enum_types_for(ty: &TypeDescriptor) -> Vec { let TypeDescriptor::Enum(e) = ty else { return Vec::new(); }; @@ -99,7 +99,7 @@ pub(super) fn enum_types_for(ty: &TypeDescriptor) -> Vec { .collect() } -pub(super) fn detect_config( +pub fn detect_config( path: &Path, schema: Hdf5Schema, time_index: u64, diff --git a/rust/crates/sift_cli/src/cmd/import/hdf5/mod.rs b/rust/crates/sift_cli/src/cmd/import/hdf5/mod.rs index e38bea69f..881173e75 100644 --- a/rust/crates/sift_cli/src/cmd/import/hdf5/mod.rs +++ b/rust/crates/sift_cli/src/cmd/import/hdf5/mod.rs @@ -1,4 +1,4 @@ -pub mod detect_hdf5_schema; +mod detect_hdf5_schema; pub mod import; #[cfg(test)] From 9beff4adffeddd28f29c9be68d206b8532faeaf6 Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Wed, 20 May 2026 13:00:35 -0700 Subject: [PATCH 05/21] prefer to use .nth() for basepath() --- rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs index f2f835e95..4de0c40a2 100644 --- a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs +++ b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs @@ -15,7 +15,7 @@ const TIME_NAMES: &[&str] = &["time", "timestamp", "timestamps", "ts"]; const VALUE_NAMES: &[&str] = &["value", "values"]; pub fn basename(path: &str) -> &str { - path.rsplit('/').next().unwrap_or(path) + path.rsplit('/').nth(0).unwrap_or(path) } pub fn parent_path(path: &str) -> String { From 30ce34476bab5beb2da28832bb3cf0d4e58bae37 Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Wed, 20 May 2026 13:03:17 -0700 Subject: [PATCH 06/21] clippy the overlord has spoken, .next() is better than .nth(0) --- rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs index 4de0c40a2..f2f835e95 100644 --- a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs +++ b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs @@ -15,7 +15,7 @@ const TIME_NAMES: &[&str] = &["time", "timestamp", "timestamps", "ts"]; const VALUE_NAMES: &[&str] = &["value", "values"]; pub fn basename(path: &str) -> &str { - path.rsplit('/').nth(0).unwrap_or(path) + path.rsplit('/').next().unwrap_or(path) } pub fn parent_path(path: &str) -> String { From 87f2c6da2118296ec1408115505fb4048955b199 Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Wed, 20 May 2026 13:20:55 -0700 Subject: [PATCH 07/21] update parent_path to return &str so that caller can decide ownership --- .../src/cmd/import/hdf5/detect_hdf5_schema.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs index f2f835e95..5ec8da63b 100644 --- a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs +++ b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs @@ -18,11 +18,11 @@ pub fn basename(path: &str) -> &str { path.rsplit('/').next().unwrap_or(path) } -pub fn parent_path(path: &str) -> String { +pub fn parent_path(path: &str) -> &str { match path.rfind('/') { - Some(0) => "/".to_string(), - Some(idx) => path[..idx].to_string(), - None => "/".to_string(), + Some(0) => "/", + Some(idx) => &path[..idx], + None => "/", } } @@ -178,7 +178,7 @@ fn detect_one_d(datasets: &[Dataset]) -> Result<(Vec, Vec Result<(Vec, Vec, value_path: &str) -> Option { let mut current = parent_path(value_path); loop { - if let Some(t) = group_time.get(¤t) { + if let Some(t) = group_time.get(current) { return Some(t.clone()); } if current == "/" { return None; } - current = parent_path(¤t); + current = parent_path(current); } } From a54d5dec43a6569edcc0823ce81498990109cf8c Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Wed, 20 May 2026 13:36:27 -0700 Subject: [PATCH 08/21] prefer to use .with_context for robust error statements --- .../crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs index 5ec8da63b..b24ceae0a 100644 --- a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs +++ b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs @@ -39,10 +39,10 @@ fn is_value_leaf(name: &str) -> bool { fn collect_datasets_recursive(group: &Group) -> Result> { let mut datasets = group .datasets() - .map_err(|e| anyhow!("failed to enumerate datasets in {}: {e}", group.name()))?; + .with_context(|| anyhow!("failed to enumerate datasets in {}", group.name()))?; let subgroups = group .groups() - .map_err(|e| anyhow!("failed to enumerate groups in {}: {e}", group.name()))?; + .with_context(|| anyhow!("failed to enumerate groups in {}", group.name()))?; for sub in &subgroups { datasets.extend(collect_datasets_recursive(sub)?); } From b4e709d7e9683a80b246117ba9128b43386518c1 Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Wed, 20 May 2026 14:13:06 -0700 Subject: [PATCH 09/21] swap to forma --- .../crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs index b24ceae0a..6ad2046e0 100644 --- a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs +++ b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs @@ -39,10 +39,10 @@ fn is_value_leaf(name: &str) -> bool { fn collect_datasets_recursive(group: &Group) -> Result> { let mut datasets = group .datasets() - .with_context(|| anyhow!("failed to enumerate datasets in {}", group.name()))?; + .with_context(|| format!("failed to enumerate datasets in {}", group.name()))?; let subgroups = group .groups() - .with_context(|| anyhow!("failed to enumerate groups in {}", group.name()))?; + .with_context(|| format!("failed to enumerate groups in {}", group.name()))?; for sub in &subgroups { datasets.extend(collect_datasets_recursive(sub)?); } From 40f4d521e183cce49b01347c2035cc13891f2209 Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Wed, 20 May 2026 14:13:56 -0700 Subject: [PATCH 10/21] replace numeric casting to instead use try_from to properly catch errors --- .../src/cmd/import/hdf5/detect_hdf5_schema.rs | 22 +++++++++++-------- .../sift_cli/src/cmd/import/hdf5/tests.rs | 6 ++--- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs index 6ad2046e0..5aadb9921 100644 --- a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs +++ b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs @@ -85,16 +85,20 @@ pub fn hdf5_to_sift_data_type(ty: &TypeDescriptor) -> Option { } } -pub fn enum_types_for(ty: &TypeDescriptor) -> Vec { +pub fn enum_types_for(ty: &TypeDescriptor) -> Result> { let TypeDescriptor::Enum(e) = ty else { - return Vec::new(); + return Ok(Vec::new()); }; e.members .iter() - .map(|m| ChannelEnumType { - name: m.name.clone(), - key: m.value as u32, - is_signed: e.signed, + .map(|m| { + Ok(ChannelEnumType { + name: m.name.clone(), + key: u32::try_from(m.value).with_context(|| { + format!("enum member '{}' value {} doesn't fit in u32", m.name, m.value) + })?, + is_signed: e.signed, + }) }) .collect() } @@ -230,7 +234,7 @@ fn detect_one_d(datasets: &[Dataset]) -> Result<(Vec, Vec Date: Wed, 20 May 2026 14:16:14 -0700 Subject: [PATCH 11/21] change 1 letter vars to be more descriptive --- .../src/cmd/import/hdf5/detect_hdf5_schema.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs index 5aadb9921..a4b642dcd 100644 --- a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs +++ b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs @@ -86,18 +86,22 @@ pub fn hdf5_to_sift_data_type(ty: &TypeDescriptor) -> Option { } pub fn enum_types_for(ty: &TypeDescriptor) -> Result> { - let TypeDescriptor::Enum(e) = ty else { + let TypeDescriptor::Enum(enum_type) = ty else { return Ok(Vec::new()); }; - e.members + enum_type + .members .iter() - .map(|m| { + .map(|member| { Ok(ChannelEnumType { - name: m.name.clone(), - key: u32::try_from(m.value).with_context(|| { - format!("enum member '{}' value {} doesn't fit in u32", m.name, m.value) + name: member.name.clone(), + key: u32::try_from(member.value).with_context(|| { + format!( + "enum member '{}' value {} doesn't fit in u32", + member.name, member.value + ) })?, - is_signed: e.signed, + is_signed: enum_type.signed, }) }) .collect() From 9deddea4f62a99682ce858dac019bfa4f51f891d Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Wed, 20 May 2026 14:23:37 -0700 Subject: [PATCH 12/21] Update eprints to use tty::Output instead --- .../src/cmd/import/hdf5/detect_hdf5_schema.rs | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs index a4b642dcd..d89f29daa 100644 --- a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs +++ b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs @@ -10,6 +10,7 @@ use sift_rs::{ }; use crate::cli::hdf5::Hdf5Schema; +use crate::util::tty::Output; const TIME_NAMES: &[&str] = &["time", "timestamp", "timestamps", "ts"]; const VALUE_NAMES: &[&str] = &["value", "values"]; @@ -211,18 +212,22 @@ fn detect_one_d(datasets: &[Dataset]) -> Result<(Vec, Vec d, Err(e) => { - eprintln!( - "skipping {name}: cannot describe HDF5 dtype ({e}). \ - Supported types: {SUPPORTED_TYPES_BLURB}." - ); + Output::new() + .line(format!( + "skipping {name}: cannot describe HDF5 dtype ({e}). \ + Supported types: {SUPPORTED_TYPES_BLURB}." + )) + .eprint(); continue; } }; let Some(channel_type) = hdf5_to_sift_data_type(&dtype) else { - eprintln!( - "skipping {name}: unsupported HDF5 type {dtype:?}. \ - Supported types: {SUPPORTED_TYPES_BLURB}." - ); + Output::new() + .line(format!( + "skipping {name}: unsupported HDF5 type {dtype:?}. \ + Supported types: {SUPPORTED_TYPES_BLURB}." + )) + .eprint(); continue; }; From 781a21040962f2b0f20f761c042decb5d61e8775 Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Wed, 20 May 2026 14:27:42 -0700 Subject: [PATCH 13/21] update nearest_time_dataset to use while --- .../sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs index d89f29daa..328da2168 100644 --- a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs +++ b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs @@ -264,15 +264,13 @@ fn detect_one_d(datasets: &[Dataset]) -> Result<(Vec, Vec, value_path: &str) -> Option { let mut current = parent_path(value_path); - loop { + while current != "/" { if let Some(t) = group_time.get(current) { return Some(t.clone()); } - if current == "/" { - return None; - } current = parent_path(current); } + group_time.get("/").cloned() } fn one_d_channel_name(value_path: &str) -> String { From 9a94c048cea117ec6bd7f74c86e2e561deb446b7 Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Wed, 20 May 2026 14:29:56 -0700 Subject: [PATCH 14/21] encode ROOT_PATH as constant --- .../src/cmd/import/hdf5/detect_hdf5_schema.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs index 328da2168..e7d459505 100644 --- a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs +++ b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs @@ -12,6 +12,7 @@ use sift_rs::{ use crate::cli::hdf5::Hdf5Schema; use crate::util::tty::Output; +const ROOT_PATH: &str = "/"; const TIME_NAMES: &[&str] = &["time", "timestamp", "timestamps", "ts"]; const VALUE_NAMES: &[&str] = &["value", "values"]; @@ -21,9 +22,9 @@ pub fn basename(path: &str) -> &str { pub fn parent_path(path: &str) -> &str { match path.rfind('/') { - Some(0) => "/", + Some(0) => ROOT_PATH, Some(idx) => &path[..idx], - None => "/", + None => ROOT_PATH, } } @@ -264,19 +265,19 @@ fn detect_one_d(datasets: &[Dataset]) -> Result<(Vec, Vec, value_path: &str) -> Option { let mut current = parent_path(value_path); - while current != "/" { + while current != ROOT_PATH { if let Some(t) = group_time.get(current) { return Some(t.clone()); } current = parent_path(current); } - group_time.get("/").cloned() + group_time.get(ROOT_PATH).cloned() } fn one_d_channel_name(value_path: &str) -> String { if is_value_leaf(value_path) { let parent = parent_path(value_path); - if parent != "/" { + if parent != ROOT_PATH { return parent.trim_start_matches('/').to_string(); } } From 382d07bc26b5d9d36bc49716df64c5d9f4a0a31a Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Wed, 20 May 2026 14:59:21 -0700 Subject: [PATCH 15/21] cargo fmt --- .../sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs | 4 +++- rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs index e7d459505..a1c362fe2 100644 --- a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs +++ b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs @@ -188,7 +188,9 @@ fn detect_one_d(datasets: &[Dataset]) -> Result<(Vec, Vec Date: Wed, 20 May 2026 16:56:40 -0700 Subject: [PATCH 16/21] Trim slashes on channel name, so on import it properly groups channels under sift groups --- .../src/cmd/import/hdf5/detect_hdf5_schema.rs | 9 +++---- .../sift_cli/src/cmd/import/hdf5/tests.rs | 24 +++++++++++++++++++ rust/crates/sift_cli/src/cmd/import/utils.rs | 7 ++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs index a1c362fe2..7015f4967 100644 --- a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs +++ b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs @@ -10,6 +10,7 @@ use sift_rs::{ }; use crate::cli::hdf5::Hdf5Schema; +use crate::cmd::import::utils::group_path_to_channel_name; use crate::util::tty::Output; const ROOT_PATH: &str = "/"; @@ -280,10 +281,10 @@ fn one_d_channel_name(value_path: &str) -> String { if is_value_leaf(value_path) { let parent = parent_path(value_path); if parent != ROOT_PATH { - return parent.trim_start_matches('/').to_string(); + return group_path_to_channel_name(parent); } } - value_path.trim_start_matches('/').to_string() + group_path_to_channel_name(value_path) } fn detect_two_d( @@ -322,7 +323,7 @@ fn detect_two_d( if col == time_index { continue; } - let channel_name = format!("{}.{col}", name.trim_start_matches('/')); + let channel_name = format!("{}.{col}", group_path_to_channel_name(&name)); let channel_config = ChannelConfig { name: channel_name, data_type: channel_type as i32, @@ -398,7 +399,7 @@ fn detect_compound( field.ty )); }; - let channel_name = format!("{}.{}", name.trim_start_matches('/'), field.name); + let channel_name = format!("{}.{}", group_path_to_channel_name(&name), field.name); let channel_config = ChannelConfig { name: channel_name, data_type: channel_type as i32, diff --git a/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs b/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs index 653e6ca90..06e1b1e6c 100644 --- a/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs +++ b/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs @@ -12,6 +12,7 @@ use crate::cmd::import::hdf5::detect_hdf5_schema::{ basename, enum_types_for, hdf5_to_sift_data_type, is_time_dataset_name, parent_path, }; use crate::cmd::import::hdf5::import::build_hdf5_config; +use crate::cmd::import::utils::group_path_to_channel_name; fn make_args() -> ImportHdf5Args { ImportHdf5Args { @@ -311,3 +312,26 @@ fn enum_types_for_returns_empty_for_non_enum() { .is_empty() ); } + +#[test] +fn group_path_to_channel_name_root_dataset() { + assert_eq!(group_path_to_channel_name("/cpu_usage"), "cpu_usage"); +} + +#[test] +fn group_path_to_channel_name_single_nested_group() { + assert_eq!(group_path_to_channel_name("/group1/current"), "group1.current"); +} + +#[test] +fn group_path_to_channel_name_deeply_nested() { + assert_eq!( + group_path_to_channel_name("/group2/group3/group4/cell_voltage"), + "group2.group3.group4.cell_voltage" + ); +} + +#[test] +fn group_path_to_channel_name_no_leading_slash() { + assert_eq!(group_path_to_channel_name("group1/current"), "group1.current"); +} diff --git a/rust/crates/sift_cli/src/cmd/import/utils.rs b/rust/crates/sift_cli/src/cmd/import/utils.rs index 8fbc73830..23398f1b3 100644 --- a/rust/crates/sift_cli/src/cmd/import/utils.rs +++ b/rust/crates/sift_cli/src/cmd/import/utils.rs @@ -75,6 +75,13 @@ pub fn validate_time_format( } } +/// Convert a slash-delimited group path (HDF5 dataset path, TDMS group/channel, +/// etc.) into a Sift channel name. Strips a leading `/` and rewrites remaining +/// `/` separators as `.`. +pub fn group_path_to_channel_name(path: &str) -> String { + path.trim_start_matches('/').replace('/', ".") +} + pub fn try_parse_enum_config(val: &str) -> Result> { let values = val.split("|").collect::>(); From 770b6ba0b9db28745c87cceab450f8b9c2bf2ca7 Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Wed, 20 May 2026 16:57:17 -0700 Subject: [PATCH 17/21] cargo fmt --- rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs b/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs index 06e1b1e6c..623b55e73 100644 --- a/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs +++ b/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs @@ -320,7 +320,10 @@ fn group_path_to_channel_name_root_dataset() { #[test] fn group_path_to_channel_name_single_nested_group() { - assert_eq!(group_path_to_channel_name("/group1/current"), "group1.current"); + assert_eq!( + group_path_to_channel_name("/group1/current"), + "group1.current" + ); } #[test] @@ -333,5 +336,8 @@ fn group_path_to_channel_name_deeply_nested() { #[test] fn group_path_to_channel_name_no_leading_slash() { - assert_eq!(group_path_to_channel_name("group1/current"), "group1.current"); + assert_eq!( + group_path_to_channel_name("group1/current"), + "group1.current" + ); } From 0e0f1da38654aca25a7a2827fd87e169c2ac8cc9 Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Fri, 22 May 2026 11:44:19 -0700 Subject: [PATCH 18/21] Add optional flag to override time column name if needed --- rust/crates/sift_cli/src/cli/mod.rs | 15 ++++++-- .../src/cmd/import/hdf5/detect_hdf5_schema.rs | 38 +++++++++++++------ .../sift_cli/src/cmd/import/hdf5/import.rs | 2 + .../sift_cli/src/cmd/import/hdf5/tests.rs | 1 + 4 files changed, 41 insertions(+), 15 deletions(-) diff --git a/rust/crates/sift_cli/src/cli/mod.rs b/rust/crates/sift_cli/src/cli/mod.rs index ef00512e4..4647c4ddb 100644 --- a/rust/crates/sift_cli/src/cli/mod.rs +++ b/rust/crates/sift_cli/src/cli/mod.rs @@ -450,11 +450,18 @@ pub struct ImportHdf5Args { pub relative_start_time: Option, /// (two-d / compound) Index of the time column or field. Defaults to 0. - /// Mutually exclusive with --time-field. - #[arg(long, conflicts_with = "time_field")] + /// Mutually exclusive with --time-field and --time-name. + #[arg(long, conflicts_with_all = ["time_field", "time_name"])] pub time_index: Option, - /// (compound) Name of the time field. Mutually exclusive with --time-index. - #[arg(long)] + /// (compound) Name of the time field. Mutually exclusive with --time-index + /// and --time-name. + #[arg(long, conflicts_with = "time_name")] pub time_field: Option, + + /// (one-d) Leaf name of the time dataset when it doesn't match the default + /// auto-detected names (time, timestamp, timestamps, ts). Mutually exclusive + /// with --time-index and --time-field. + #[arg(long)] + pub time_name: Option, } diff --git a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs index 7015f4967..3b94351c1 100644 --- a/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs +++ b/rust/crates/sift_cli/src/cmd/import/hdf5/detect_hdf5_schema.rs @@ -115,20 +115,21 @@ pub fn detect_config( schema: Hdf5Schema, time_index: u64, time_field: Option<&str>, + time_name: Option<&str>, ) -> Result<(Vec, Vec)> { let file = File::open(path).map_err(|e| anyhow!("failed to open hdf5 file: {e}"))?; let datasets = collect_datasets_recursive(&file)?; let result = match schema { - Hdf5Schema::OneD => detect_one_d(&datasets), + Hdf5Schema::OneD => detect_one_d(&datasets, time_name), Hdf5Schema::TwoD => detect_two_d(&datasets, time_index), Hdf5Schema::Compound => detect_compound(&datasets, time_index, time_field), }; match result { - Ok((data, _)) if data.is_empty() => { - Err(no_match_error(&datasets, schema, time_index, time_field)) - } + Ok((data, _)) if data.is_empty() => Err(no_match_error( + &datasets, schema, time_index, time_field, time_name, + )), Ok(other) => Ok(other), Err(e) => Err(e), } @@ -139,6 +140,7 @@ fn no_match_error( selected: Hdf5Schema, time_index: u64, time_field: Option<&str>, + time_name: Option<&str>, ) -> anyhow::Error { let alternatives: &[(Hdf5Schema, &str)] = &[ (Hdf5Schema::OneD, "one-d"), @@ -151,7 +153,7 @@ fn no_match_error( .filter(|(s, _)| *s != selected) .filter_map(|(s, name)| { let probe = match s { - Hdf5Schema::OneD => detect_one_d(datasets), + Hdf5Schema::OneD => detect_one_d(datasets, time_name), Hdf5Schema::TwoD => detect_two_d(datasets, time_index), Hdf5Schema::Compound => detect_compound(datasets, time_index, time_field), }; @@ -182,11 +184,18 @@ fn no_match_error( } } -fn detect_one_d(datasets: &[Dataset]) -> Result<(Vec, Vec)> { +fn detect_one_d( + datasets: &[Dataset], + time_name: Option<&str>, +) -> Result<(Vec, Vec)> { let mut group_time: HashMap = HashMap::new(); for ds in datasets { let name = ds.name(); - if !is_time_dataset_name(&name) || ds.ndim() != 1 { + let matches = match time_name { + Some(want) => basename(&name) == want, + None => is_time_dataset_name(&name), + }; + if !matches || ds.ndim() != 1 { continue; } group_time @@ -195,10 +204,17 @@ fn detect_one_d(datasets: &[Dataset]) -> Result<(Vec, Vec anyhow!( + "no time dataset found with name '{want}'. \ + Verify --time-name matches a leaf dataset name in the file." + ), + None => anyhow!( + "no time dataset found — expected one of {TIME_NAMES:?} (case-insensitive) \ + at the root or within any group. \ + If your file uses a custom name, pass it via --time-name." + ), + }); } let mut data_configs = Vec::new(); diff --git a/rust/crates/sift_cli/src/cmd/import/hdf5/import.rs b/rust/crates/sift_cli/src/cmd/import/hdf5/import.rs index c6e3674ab..73983f006 100644 --- a/rust/crates/sift_cli/src/cmd/import/hdf5/import.rs +++ b/rust/crates/sift_cli/src/cmd/import/hdf5/import.rs @@ -44,6 +44,7 @@ pub async fn run(ctx: Context, args: ImportHdf5Args) -> Result { args.schema, args.time_index.unwrap_or(0), args.time_field.as_deref(), + args.time_name.as_deref(), ) { Ok((_, channel_configs)) => { let refs: Vec<&ChannelConfig> = channel_configs.iter().collect(); @@ -65,6 +66,7 @@ pub async fn run(ctx: Context, args: ImportHdf5Args) -> Result { args.schema, args.time_index.unwrap_or(0), args.time_field.as_deref(), + args.time_name.as_deref(), ) .context("failed to parse hdf5 file")?; hdf5_config.data = data_configs; diff --git a/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs b/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs index 623b55e73..449fc0355 100644 --- a/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs +++ b/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs @@ -29,6 +29,7 @@ fn make_args() -> ImportHdf5Args { relative_start_time: None, time_index: None, time_field: None, + time_name: None, } } From 8f9227a511064b1ecce640a39c1df1d77da61e0e Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Fri, 22 May 2026 11:45:03 -0700 Subject: [PATCH 19/21] Organize help page into mode specific sections --- rust/crates/sift_cli/src/cli/mod.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/rust/crates/sift_cli/src/cli/mod.rs b/rust/crates/sift_cli/src/cli/mod.rs index 4647c4ddb..5a4c257ab 100644 --- a/rust/crates/sift_cli/src/cli/mod.rs +++ b/rust/crates/sift_cli/src/cli/mod.rs @@ -451,17 +451,21 @@ pub struct ImportHdf5Args { /// (two-d / compound) Index of the time column or field. Defaults to 0. /// Mutually exclusive with --time-field and --time-name. - #[arg(long, conflicts_with_all = ["time_field", "time_name"])] + #[arg( + long, + conflicts_with_all = ["time_field", "time_name"], + help_heading = "Two-d schema options", + )] pub time_index: Option, /// (compound) Name of the time field. Mutually exclusive with --time-index /// and --time-name. - #[arg(long, conflicts_with = "time_name")] + #[arg(long, conflicts_with = "time_name", help_heading = "Compound schema options")] pub time_field: Option, /// (one-d) Leaf name of the time dataset when it doesn't match the default /// auto-detected names (time, timestamp, timestamps, ts). Mutually exclusive /// with --time-index and --time-field. - #[arg(long)] + #[arg(long, help_heading = "One-d schema options")] pub time_name: Option, } From 34b6d270c220c91d673cc85f3cb6c8521a1cb779 Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Fri, 22 May 2026 11:47:39 -0700 Subject: [PATCH 20/21] Add unsigned enum test --- .../sift_cli/src/cmd/import/hdf5/tests.rs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs b/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs index 449fc0355..cc62da325 100644 --- a/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs +++ b/rust/crates/sift_cli/src/cmd/import/hdf5/tests.rs @@ -304,6 +304,37 @@ fn enum_types_for_extracts_members() { assert_eq!(mapped[1].key, 1); } +#[test] +fn enum_types_for_unsigned_enum() { + let ty = TypeDescriptor::Enum(EnumType { + size: IntSize::U4, + signed: false, + members: vec![ + EnumMember { + name: "IDLE".into(), + value: 0, + }, + EnumMember { + name: "RUNNING".into(), + value: 1, + }, + EnumMember { + name: "ERROR".into(), + value: 99, + }, + ], + }); + let mapped = enum_types_for(&ty).unwrap(); + assert_eq!(mapped.len(), 3); + assert!(!mapped[0].is_signed); + assert!(!mapped[1].is_signed); + assert!(!mapped[2].is_signed); + assert_eq!(mapped[0].name, "IDLE"); + assert_eq!(mapped[0].key, 0); + assert_eq!(mapped[2].name, "ERROR"); + assert_eq!(mapped[2].key, 99); +} + #[test] fn enum_types_for_returns_empty_for_non_enum() { assert!(enum_types_for(&TypeDescriptor::Boolean).unwrap().is_empty()); From 3171977fb3d70588e4218638515453eceaca63ac Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Fri, 22 May 2026 11:54:22 -0700 Subject: [PATCH 21/21] cargo fmt --- rust/crates/sift_cli/src/cli/mod.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rust/crates/sift_cli/src/cli/mod.rs b/rust/crates/sift_cli/src/cli/mod.rs index 5a4c257ab..5653f07c9 100644 --- a/rust/crates/sift_cli/src/cli/mod.rs +++ b/rust/crates/sift_cli/src/cli/mod.rs @@ -460,7 +460,11 @@ pub struct ImportHdf5Args { /// (compound) Name of the time field. Mutually exclusive with --time-index /// and --time-name. - #[arg(long, conflicts_with = "time_name", help_heading = "Compound schema options")] + #[arg( + long, + conflicts_with = "time_name", + help_heading = "Compound schema options" + )] pub time_field: Option, /// (one-d) Leaf name of the time dataset when it doesn't match the default