diff --git a/crates/tss-gui/src/app/mod.rs b/crates/tss-gui/src/app/mod.rs index 92950f70..eab34247 100644 --- a/crates/tss-gui/src/app/mod.rs +++ b/crates/tss-gui/src/app/mod.rs @@ -381,7 +381,7 @@ impl App { if let Some(study) = &mut self.state.study && let Some(domain_state) = study.domain_mut(&domain) { - domain_state.validation_cache = Some(report); + domain_state.set_validation_cache(report); } Task::none() } diff --git a/crates/tss-gui/src/handler/domain_editor.rs b/crates/tss-gui/src/handler/domain_editor.rs index 01449e7f..f360029f 100644 --- a/crates/tss-gui/src/handler/domain_editor.rs +++ b/crates/tss-gui/src/handler/domain_editor.rs @@ -64,9 +64,14 @@ fn trigger_preview_rebuild(state: &AppState, domain_code: &str) -> Task return Task::none(); }; + // Only source domains have mapping/normalization for preview + let Some(src) = domain.as_source() else { + return Task::none(); + }; + let input = PreviewInput { - source_df: domain.source.data.clone(), - mapping: domain.mapping.clone(), + source_df: src.source.data.clone(), + mapping: src.mapping.clone(), ct_registry: state.terminology.clone(), }; @@ -118,11 +123,12 @@ fn handle_mapping_message(state: &mut AppState, msg: MappingMessage) -> Task Task Task Task Task Task Task Ta return Task::none(); }; + // Only source domains support refresh validation + let Some(src) = domain.as_source() else { + return Task::none(); + }; + let df = match &state.view { ViewState::DomainEditor(editor) => { // Use preview cache if available, otherwise clone from Arc editor .preview_cache .clone() - .unwrap_or_else(|| (*domain.source.data).clone()) + .unwrap_or_else(|| (*src.source.data).clone()) } - _ => (*domain.source.data).clone(), + _ => (*src.source.data).clone(), }; - let sdtm_domain = domain.mapping.domain().clone(); + let sdtm_domain = src.mapping.domain().clone(); let not_collected: std::collections::BTreeSet = - domain.mapping.all_not_collected().keys().cloned().collect(); + src.mapping.all_not_collected().keys().cloned().collect(); let input = ValidationInput { domain: sdtm_domain, @@ -410,8 +427,10 @@ fn handle_validation_message(state: &mut AppState, msg: ValidationMessage) -> Ta ValidationMessage::GoToIssueSource { variable } => { if let ViewState::DomainEditor(editor) = &mut state.view { editor.tab = EditorTab::Mapping; - if let Some(domain) = state.study.as_ref().and_then(|s| s.domain(&domain_code)) { - let sdtm_domain = domain.mapping.domain(); + if let Some(domain) = state.study.as_ref().and_then(|s| s.domain(&domain_code)) + && let Some(src) = domain.as_source() + { + let sdtm_domain = src.mapping.domain(); if let Some(idx) = sdtm_domain .variables .iter() @@ -471,7 +490,13 @@ fn handle_preview_message(state: &mut AppState, msg: PreviewMessage) -> Task { - let Some(domain) = state.study.as_ref().and_then(|s| s.domain(&domain_code)) else { + // Preview rebuild only applies to source domains + let Some(source) = state + .study + .as_ref() + .and_then(|s| s.domain(&domain_code)) + .and_then(|d| d.as_source()) + else { return Task::none(); }; @@ -481,8 +506,8 @@ fn handle_preview_message(state: &mut AppState, msg: PreviewMessage) -> Task Task editor.supp_ui.selected_column = Some(col_name.clone()); editor.supp_ui.edit_draft = None; } - // Initialize config if not exists - if let Some(domain) = state + // Initialize config if not exists (only for source domains) + if let Some(source) = state .study .as_mut() .and_then(|s| s.domain_mut(&domain_code)) + .and_then(|d| d.as_source_mut()) { - domain + source .supp_config .entry(col_name.clone()) .or_insert_with(|| SuppColumnConfig::from_column(&col_name)); @@ -596,11 +622,12 @@ fn handle_supp_message(state: &mut AppState, msg: SuppMessage) -> Task }; if let Some(col_name) = col - && let Some(domain) = state + && let Some(source) = state .study .as_mut() .and_then(|s| s.domain_mut(&domain_code)) - && let Some(config) = domain.supp_config.get_mut(&col_name) + .and_then(|d| d.as_source_mut()) + && let Some(config) = source.supp_config.get_mut(&col_name) { if config.qnam.trim().is_empty() || config.qlabel.trim().is_empty() { return Task::none(); @@ -621,11 +648,12 @@ fn handle_supp_message(state: &mut AppState, msg: SuppMessage) -> Task }; if let Some(col_name) = col - && let Some(domain) = state + && let Some(source) = state .study .as_mut() .and_then(|s| s.domain_mut(&domain_code)) - && let Some(config) = domain.supp_config.get_mut(&col_name) + .and_then(|d| d.as_source_mut()) + && let Some(config) = source.supp_config.get_mut(&col_name) { config.action = SuppAction::Skip; state.dirty_tracker.mark_dirty(); @@ -643,11 +671,12 @@ fn handle_supp_message(state: &mut AppState, msg: SuppMessage) -> Task }; if let Some(col_name) = col - && let Some(domain) = state + && let Some(source) = state .study .as_mut() .and_then(|s| s.domain_mut(&domain_code)) - && let Some(config) = domain.supp_config.get_mut(&col_name) + .and_then(|d| d.as_source_mut()) + && let Some(config) = source.supp_config.get_mut(&col_name) { config.action = SuppAction::Pending; state.dirty_tracker.mark_dirty(); @@ -665,8 +694,12 @@ fn handle_supp_message(state: &mut AppState, msg: SuppMessage) -> Task }; if let Some(col_name) = &col - && let Some(domain) = state.study.as_ref().and_then(|s| s.domain(&domain_code)) - && let Some(config) = domain.supp_config.get(col_name) + && let Some(source) = state + .study + .as_ref() + .and_then(|s| s.domain(&domain_code)) + .and_then(|d| d.as_source()) + && let Some(config) = source.supp_config.get(col_name) { let draft = SuppEditDraft::from_config(config); if let ViewState::DomainEditor(editor) = &mut state.view { @@ -690,11 +723,12 @@ fn handle_supp_message(state: &mut AppState, msg: SuppMessage) -> Task return Task::none(); } - if let Some(domain) = state + if let Some(source) = state .study .as_mut() .and_then(|s| s.domain_mut(&domain_code)) - && let Some(config) = domain.supp_config.get_mut(&col_name) + .and_then(|d| d.as_source_mut()) + && let Some(config) = source.supp_config.get_mut(&col_name) { config.qnam = draft.qnam; config.qlabel = draft.qlabel; @@ -746,8 +780,12 @@ where let mut dummy = SuppColumnConfig::from_column(""); update(&mut dummy, Some(draft)); } - } else if let Some(domain) = state.study.as_mut().and_then(|s| s.domain_mut(domain_code)) - && let Some(config) = domain.supp_config.get_mut(&col_name) + } else if let Some(source) = state + .study + .as_mut() + .and_then(|s| s.domain_mut(domain_code)) + .and_then(|d| d.as_source_mut()) + && let Some(config) = source.supp_config.get_mut(&col_name) { update(config, None); } diff --git a/crates/tss-gui/src/handler/export.rs b/crates/tss-gui/src/handler/export.rs index 616bab40..18221d9a 100644 --- a/crates/tss-gui/src/handler/export.rs +++ b/crates/tss-gui/src/handler/export.rs @@ -183,15 +183,13 @@ fn start_export(state: &mut AppState) -> Task { for code in &selected_domains { if let Some(gui_domain) = study.domain(code) { - // Collect not_collected variables for validation - let not_collected: std::collections::BTreeSet = gui_domain - .mapping - .all_not_collected() - .keys() - .cloned() - .collect(); - if !not_collected.is_empty() { - not_collected_map.insert(code.clone(), not_collected); + // Collect not_collected variables for validation (source domains only) + if let Some(source) = gui_domain.as_source() { + let not_collected: std::collections::BTreeSet = + source.mapping.all_not_collected().keys().cloned().collect(); + if !not_collected.is_empty() { + not_collected_map.insert(code.clone(), not_collected); + } } match crate::service::export::build_domain_export_data( diff --git a/crates/tss-gui/src/handler/project.rs b/crates/tss-gui/src/handler/project.rs index d79bee8a..62d15624 100644 --- a/crates/tss-gui/src/handler/project.rs +++ b/crates/tss-gui/src/handler/project.rs @@ -13,9 +13,10 @@ use iced::Task; use crate::message::Message; use crate::state::AppState; use tss_persistence::{ - DomainSnapshot, MappingEntry, MappingSnapshot, ProjectFile, SourceAssignment, StudyMetadata, - SuppActionSnapshot, SuppColumnSnapshot, SuppOriginSnapshot, WorkflowTypeSnapshot, - compute_file_hash, load_project_async, save_project_async, + DomainSnapshot, MappingEntry, MappingSnapshot, ProjectFile, SourceAssignment, + SourceDomainSnapshot, StudyMetadata, SuppActionSnapshot, SuppColumnSnapshot, + SuppOriginSnapshot, WorkflowTypeSnapshot, compute_file_hash, load_project_async, + save_project_async, }; // ============================================================================= @@ -452,28 +453,33 @@ fn create_project_file_from_state(study: &crate::state::Study, state: &AppState) // Add source assignments and domain snapshots for code in study.domain_codes() { if let Some(domain_state) = study.domain(code) { + // Only source domains have file-based persistence + // Generated domains will be handled separately in the future + let Some(source) = domain_state.as_source() else { + continue; + }; + // Get file size for source assignment - let file_size = std::fs::metadata(&domain_state.source.file_path) + let file_size = std::fs::metadata(&source.source.file_path) .map(|m| m.len()) .unwrap_or(0); // Add source assignment with content hash for change detection - let source_path = domain_state.source.file_path.to_string_lossy().to_string(); - let content_hash = - compute_file_hash(&domain_state.source.file_path).unwrap_or_else(|e| { - tracing::warn!("Failed to compute hash for {}: {}", source_path, e); - String::new() - }); + let source_path = source.source.file_path.to_string_lossy().to_string(); + let content_hash = compute_file_hash(&source.source.file_path).unwrap_or_else(|e| { + tracing::warn!("Failed to compute hash for {}: {}", source_path, e); + String::new() + }); let assignment = SourceAssignment::new(&source_path, code, content_hash, file_size); project.source_assignments.push(assignment); - // Create domain snapshot - let mut snapshot = DomainSnapshot::new(code); - snapshot.label = domain_state.source.label.clone(); + // Create source domain snapshot + let mut source_snapshot = SourceDomainSnapshot::new(code); + source_snapshot.label = source.source.label.clone(); // Create mapping snapshot // Note: We discard confidence scores - they're only meaningful during active mapping - let mapping = &domain_state.mapping; + let mapping = &source.mapping; let mapping_snapshot = MappingSnapshot { study_id: mapping.study_id().to_string(), accepted: mapping @@ -485,10 +491,10 @@ fn create_project_file_from_state(study: &crate::state::Study, state: &AppState) omitted: mapping.all_omitted().clone(), auto_generated: mapping.all_auto_generated().clone(), }; - snapshot.mapping = mapping_snapshot; + source_snapshot.mapping = mapping_snapshot; // Add SUPP config - for (col, config) in &domain_state.supp_config { + for (col, config) in &source.supp_config { let supp_snapshot = SuppColumnSnapshot { column: col.clone(), qnam: config.qnam.clone(), @@ -505,10 +511,15 @@ fn create_project_file_from_state(study: &crate::state::Study, state: &AppState) crate::state::SuppAction::Skip => SuppActionSnapshot::Skip, }, }; - snapshot.supp_config.insert(col.clone(), supp_snapshot); + source_snapshot + .supp_config + .insert(col.clone(), supp_snapshot); } - project.domains.insert(code.to_string(), snapshot); + // Wrap in DomainSnapshot::Source and insert + project + .domains + .insert(code.to_string(), DomainSnapshot::Source(source_snapshot)); } } @@ -691,12 +702,24 @@ pub fn restore_project_mappings(state: &mut AppState, project: &tss_persistence: // Iterate through saved domain snapshots for (domain_code, snapshot) in &project.domains { + // Only restore source domain snapshots + let Some(source_snapshot) = snapshot.as_source() else { + // Skip generated domain snapshots for now (they'll be regenerated) + tracing::debug!("Skipping generated domain {} in restore", domain_code); + continue; + }; + if let Some(domain_state) = study.domain_mut(domain_code) { + // Only restore mapping for source domains + let Some(source) = domain_state.as_source_mut() else { + continue; + }; + // Restore mapping decisions - let mapping = &mut domain_state.mapping; + let mapping = &mut source.mapping; // Apply accepted mappings - for (var, entry) in &snapshot.mapping.accepted { + for (var, entry) in &source_snapshot.mapping.accepted { // Use accept_manual to apply saved mappings if let Err(e) = mapping.accept_manual(var, &entry.source_column) { tracing::warn!( @@ -709,26 +732,26 @@ pub fn restore_project_mappings(state: &mut AppState, project: &tss_persistence: } // Apply not collected - for (var, reason) in &snapshot.mapping.not_collected { + for (var, reason) in &source_snapshot.mapping.not_collected { if let Err(e) = mapping.mark_not_collected(var, reason) { tracing::warn!("Failed to mark {} as not collected: {}", var, e); } } // Apply omitted - for var in &snapshot.mapping.omitted { + for var in &source_snapshot.mapping.omitted { if let Err(e) = mapping.mark_omit(var) { tracing::warn!("Failed to mark {} as omitted: {}", var, e); } } // Apply auto-generated - for var in &snapshot.mapping.auto_generated { + for var in &source_snapshot.mapping.auto_generated { mapping.mark_auto_generated(var); } // Restore SUPP configurations - for (col, supp_snapshot) in &snapshot.supp_config { + for (col, supp_snapshot) in &source_snapshot.supp_config { let config = crate::state::SuppColumnConfig { column: col.clone(), qnam: supp_snapshot.qnam.clone(), @@ -745,17 +768,17 @@ pub fn restore_project_mappings(state: &mut AppState, project: &tss_persistence: SuppActionSnapshot::Skip => crate::state::SuppAction::Skip, }, }; - domain_state.supp_config.insert(col.clone(), config); + source.supp_config.insert(col.clone(), config); } tracing::debug!( "Restored mappings for domain {}: {} accepted, {} not_collected, {} omitted, {} auto_generated, {} supp", domain_code, - snapshot.mapping.accepted.len(), - snapshot.mapping.not_collected.len(), - snapshot.mapping.omitted.len(), - snapshot.mapping.auto_generated.len(), - snapshot.supp_config.len() + source_snapshot.mapping.accepted.len(), + source_snapshot.mapping.not_collected.len(), + source_snapshot.mapping.omitted.len(), + source_snapshot.mapping.auto_generated.len(), + source_snapshot.supp_config.len() ); } else { tracing::warn!("Domain {} in project file not found in study", domain_code); diff --git a/crates/tss-gui/src/service/export.rs b/crates/tss-gui/src/service/export.rs index 3a2bf46a..a89c3039 100644 --- a/crates/tss-gui/src/service/export.rs +++ b/crates/tss-gui/src/service/export.rs @@ -417,28 +417,28 @@ fn build_supp_domain_definition( // DOMAIN EXPORT DATA BUILDER // ============================================================================= -/// Build export data from a GUI domain. +/// Build export data from a domain. /// -/// Performs data transformation using the normalization pipeline and -/// builds SUPP DataFrame if the domain has included SUPP columns. +/// Performs data transformation using the normalization pipeline +/// and builds SUPP DataFrame if the domain has included SUPP columns. pub fn build_domain_export_data( code: &str, - gui_domain: &DomainState, + domain: &DomainState, study_id: &str, terminology: Option<&TerminologyRegistry>, ) -> Result { // Get CDISC domain definition from mapping state - let cdisc_domain = gui_domain.mapping.domain().clone(); + let cdisc_domain = domain.mapping.domain().clone(); // Build NormalizationContext from mapping state - let mappings: BTreeMap = gui_domain + let mappings: BTreeMap = domain .mapping .all_accepted() .iter() - .map(|(target, (source, _))| (target.clone(), source.clone())) + .map(|(target, (src, _)): (&String, &(String, f32))| (target.clone(), src.clone())) .collect(); - let omitted = gui_domain.mapping.all_omitted().clone(); + let omitted = domain.mapping.all_omitted().clone(); let mut context = NormalizationContext::new(study_id, code) .with_mappings(mappings) @@ -450,11 +450,11 @@ pub fn build_domain_export_data( // Execute normalization pipeline to transform source data let transformed_data = - execute_normalization(&gui_domain.source.data, &gui_domain.normalization, &context) + execute_normalization(&domain.source.data, &domain.normalization, &context) .map_err(|e| ExportError::for_domain(code, format!("Normalization failed: {}", e)))?; // Build SUPP DataFrame if there are included SUPP columns - let supp_data = build_supp_dataframe(code, gui_domain, study_id, &transformed_data)?; + let supp_data = build_supp_dataframe(code, domain, study_id, &transformed_data)?; Ok(DomainExportData { code: code.to_string(), @@ -467,12 +467,12 @@ pub fn build_domain_export_data( /// Build SUPP DataFrame from domain's supp_config. fn build_supp_dataframe( domain_code: &str, - gui_domain: &DomainState, + domain: &DomainState, study_id: &str, transformed_data: &DataFrame, ) -> Result, ExportError> { // Get included SUPP columns - let included: Vec<_> = gui_domain + let included: Vec<(&String, &SuppColumnConfig)> = domain .supp_config .iter() .filter(|(_, config)| config.should_include()) @@ -482,7 +482,7 @@ fn build_supp_dataframe( return Ok(None); } - let source_df = &gui_domain.source.data; + let source_df = &domain.source.data; let row_count = source_df.height(); // SUPP columns: STUDYID, RDOMAIN, USUBJID, IDVAR, IDVARVAL, QNAM, QLABEL, QVAL, QORIG, QEVAL @@ -509,7 +509,7 @@ fn build_supp_dataframe( }; // Build rows for each SUPP column - for (source_col_name, config) in &included { + for (source_col_name, config) in included { let source_col = match source_df.column(source_col_name) { Ok(col) => col, Err(_) => continue, @@ -648,18 +648,29 @@ fn count_validation_errors(report: &ValidationReport) -> usize { // ============================================================================= /// Check if a domain has any included SUPP columns. +/// +/// Generated domains never have SUPP columns. pub fn domain_has_supp(gui_domain: &DomainState) -> bool { - gui_domain - .supp_config - .values() - .any(SuppColumnConfig::should_include) + gui_domain.as_source().is_some_and(|source| { + source + .supp_config + .values() + .any(SuppColumnConfig::should_include) + }) } /// Get count of included SUPP columns for a domain. +/// +/// Returns 0 for generated domains. pub fn domain_supp_count(gui_domain: &DomainState) -> usize { gui_domain - .supp_config - .values() - .filter(|config| config.should_include()) - .count() + .as_source() + .map(|source| { + source + .supp_config + .values() + .filter(|config: &&SuppColumnConfig| config.should_include()) + .count() + }) + .unwrap_or(0) } diff --git a/crates/tss-gui/src/service/study.rs b/crates/tss-gui/src/service/study.rs index 7fa6d2b5..f76057b8 100644 --- a/crates/tss-gui/src/service/study.rs +++ b/crates/tss-gui/src/service/study.rs @@ -2,12 +2,15 @@ //! //! Background tasks for creating studies from user assignments. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use std::path::PathBuf; +use polars::prelude::{AnyValue, Column, DataFrame, NamedFrom, Series}; +use tss_standards::TerminologyRegistry; +use tss_standards::sdtm::get_reciprocal_srel; + use crate::error::GuiError; use crate::state::{DomainSource, DomainState, Study, WorkflowMode}; -use tss_standards::TerminologyRegistry; /// Create a study from manual source-to-domain assignments. /// @@ -88,9 +91,10 @@ pub async fn create_study_from_assignments( .unwrap_or_default() .to_string(); - let (df, _headers) = tss_ingest::read_csv_table(&file_path, header_rows).map_err(|e| { - GuiError::domain_load(&domain_code, format!("Failed to load {}: {}", file_stem, e)) - })?; + let (mut df, _headers) = + tss_ingest::read_csv_table(&file_path, header_rows).map_err(|e| { + GuiError::domain_load(&domain_code, format!("Failed to load {}: {}", file_stem, e)) + })?; // Find domain in IG let ig_domain = ig_domains @@ -102,6 +106,18 @@ pub async fn create_study_from_assignments( continue; }; + // RELSUB: Auto-generate missing reciprocal relationships per SDTM-IG Section 8.7 + if domain_code.eq_ignore_ascii_case("RELSUB") { + let (augmented_df, added_count) = ensure_relsub_bidirectional(df); + df = augmented_df; + if added_count > 0 { + tracing::info!( + "Auto-generated {} reciprocal relationship(s) for RELSUB domain", + added_count + ); + } + } + // Create source let source = DomainSource::new(file_path, df.clone(), ig_domain.label.clone()); @@ -135,3 +151,169 @@ pub async fn create_study_from_assignments( Ok((study, terminology)) } + +// ============================================================================= +// RELSUB BIDIRECTIONAL AUTO-GENERATION +// ============================================================================= + +/// Post-process RELSUB domain after import: auto-generate missing reciprocal relationships. +/// +/// Per SDTM-IG v3.4 Section 8.7 Assumption 7: +/// "Every relationship between 2 study subjects is represented in RELSUB +/// as 2 directional relationships: (1) with the first subject's identifier +/// in USUBJID and the second subject's identifier in RSUBJID, and (2) with +/// the second subject's identifier in USUBJID and the first subject's +/// identifier in RSUBJID." +/// +/// # Example +/// +/// Input CSV with only: +/// ```text +/// STUDYID,USUBJID,RSUBJID,SREL +/// STUDY1,SUBJ-001,SUBJ-002,MOTHER, BIOLOGICAL +/// ``` +/// +/// Output DataFrame will have 2 rows: +/// ```text +/// STUDY1,SUBJ-001,SUBJ-002,MOTHER, BIOLOGICAL +/// STUDY1,SUBJ-002,SUBJ-001,CHILD, BIOLOGICAL +/// ``` +pub fn ensure_relsub_bidirectional(df: DataFrame) -> (DataFrame, usize) { + // Check if this is a RELSUB-like DataFrame with required columns + let has_usubjid = df.column("USUBJID").is_ok(); + let has_rsubjid = df.column("RSUBJID").is_ok(); + let has_srel = df.column("SREL").is_ok(); + + if !has_usubjid || !has_rsubjid || !has_srel { + return (df, 0); + } + + // Get STUDYID and DOMAIN if present for the new rows + let studyid_col = df.column("STUDYID").ok(); + let domain_col = df.column("DOMAIN").ok(); + + let usubjid_col = df.column("USUBJID").expect("USUBJID column exists"); + let rsubjid_col = df.column("RSUBJID").expect("RSUBJID column exists"); + let srel_col = df.column("SREL").expect("SREL column exists"); + + // Build set of existing relationships + let mut existing: HashSet<(String, String)> = HashSet::new(); + + for i in 0..df.height() { + let usubjid = get_string_value(usubjid_col, i); + let rsubjid = get_string_value(rsubjid_col, i); + existing.insert((usubjid, rsubjid)); + } + + // Find missing reciprocals + let mut new_rows: Vec<(String, String, String, String, String)> = Vec::new(); // (studyid, domain, usubjid, rsubjid, srel) + + for i in 0..df.height() { + let usubjid = get_string_value(usubjid_col, i); + let rsubjid = get_string_value(rsubjid_col, i); + let srel = get_string_value(srel_col, i); + + let reverse_key = (rsubjid.clone(), usubjid.clone()); + + // Only add reciprocal if: + // 1. The reverse relationship doesn't exist + // 2. We can find a reciprocal SREL term + if !existing.contains(&reverse_key) + && let Some(reciprocal_srel) = get_reciprocal_srel(&srel) + { + let studyid = studyid_col + .map(|c| get_string_value(c, i)) + .unwrap_or_default(); + let domain = domain_col + .map(|c| get_string_value(c, i)) + .unwrap_or_else(|| "RELSUB".to_string()); + + new_rows.push(( + studyid, + domain, + rsubjid.clone(), + usubjid.clone(), + reciprocal_srel.to_string(), + )); + existing.insert(reverse_key); + } + } + + let added_count = new_rows.len(); + + if new_rows.is_empty() { + return (df, 0); + } + + // Build new DataFrame with original + new rows + let original_height = df.height(); + let new_height = original_height + new_rows.len(); + + // Extract original columns as vectors and append new values + let mut studyid_vec: Vec = (0..original_height) + .map(|i| { + studyid_col + .map(|c| get_string_value(c, i)) + .unwrap_or_default() + }) + .collect(); + let mut domain_vec: Vec = (0..original_height) + .map(|i| { + domain_col + .map(|c| get_string_value(c, i)) + .unwrap_or_else(|| "RELSUB".to_string()) + }) + .collect(); + let mut usubjid_vec: Vec = (0..original_height) + .map(|i| get_string_value(usubjid_col, i)) + .collect(); + let mut rsubjid_vec: Vec = (0..original_height) + .map(|i| get_string_value(rsubjid_col, i)) + .collect(); + let mut srel_vec: Vec = (0..original_height) + .map(|i| get_string_value(srel_col, i)) + .collect(); + + // Append new rows + for (studyid, domain, usubjid, rsubjid, srel) in new_rows { + studyid_vec.push(studyid); + domain_vec.push(domain); + usubjid_vec.push(usubjid); + rsubjid_vec.push(rsubjid); + srel_vec.push(srel); + } + + // Build new DataFrame + let new_df = DataFrame::new(vec![ + Series::new("STUDYID".into(), studyid_vec).into(), + Series::new("DOMAIN".into(), domain_vec).into(), + Series::new("USUBJID".into(), usubjid_vec).into(), + Series::new("RSUBJID".into(), rsubjid_vec).into(), + Series::new("SREL".into(), srel_vec).into(), + ]); + + match new_df { + Ok(df) => { + tracing::info!( + "Added {} reciprocal relationship(s) to RELSUB (total: {} rows)", + added_count, + new_height + ); + (df, added_count) + } + Err(e) => { + tracing::warn!("Failed to create augmented RELSUB DataFrame: {}", e); + (df, 0) + } + } +} + +/// Helper to extract string value from a column at a given row index. +fn get_string_value(col: &Column, idx: usize) -> String { + match col.get(idx) { + Ok(AnyValue::String(s)) => s.to_string(), + Ok(AnyValue::StringOwned(s)) => s.to_string(), + Ok(v) => v.to_string(), + Err(_) => String::new(), + } +} diff --git a/crates/tss-gui/src/state/domain_state.rs b/crates/tss-gui/src/state/domain_state.rs index d9737536..31727270 100644 --- a/crates/tss-gui/src/state/domain_state.rs +++ b/crates/tss-gui/src/state/domain_state.rs @@ -1,15 +1,17 @@ -//! Domain state - source data and mapping. +//! Domain state - source-mapped domains. //! -//! A domain represents a single SDTM dataset (e.g., DM, AE, LB). -//! This module contains: -//! - [`DomainSource`] - Immutable source data (CSV file + DataFrame) -//! - [`DomainState`] - Domain with source data and mapping state -//! - [`SuppColumnConfig`] - SUPP qualifier configuration for unmapped columns +//! A domain represents a single SDTM dataset (e.g., DM, AE, LB, CO, RELREC). +//! All domains are mapped from CSV source data. +//! +//! Per CDISC guidelines, CO, RELREC, RELSPEC, and RELSUB domains contain +//! **collected data** and should be imported from source CSV files, not +//! manually generated. (SDTM-IG v3.4 Sections 5.1, 8.2, 8.7, 8.8) use polars::prelude::{DataFrame, PlSmallStr}; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; +use tss_standards::SdtmDomain; use tss_submit::MappingState; use tss_submit::{NormalizationPipeline, Severity, ValidationReport, infer_normalization_rules}; @@ -178,39 +180,26 @@ impl DomainSource { } // ============================================================================= -// DOMAIN (Source + Mapping) +// DOMAIN STATE // ============================================================================= -/// A single SDTM domain with source data and mapping state. -/// -/// # Design Notes -/// -/// - **Normalization pipeline is computed once** - The pipeline is derived from -/// the SDTM domain metadata when the domain is created. It defines what -/// transformations will be applied to each variable during export. -/// -/// - **Validation cache persists across navigation** - Stored here so it survives -/// view changes. Use `validation_summary()` for quick stats. -/// -/// - **Mapping state is from `tss_map`** - The core mapping logic lives in the -/// `tss_map` crate. This struct just holds the state. -/// -/// # Example -/// -/// ```ignore -/// let domain = DomainState::new(source, mapping); -/// -/// // Check mapping status -/// let summary = domain.mapping.summary(); -/// println!("Mapped: {}/{}", summary.mapped, summary.total_variables); +/// Type alias for backwards compatibility. +/// All domains are now source-mapped domains. +pub type DomainState = SourceDomainState; + +// ============================================================================= +// SOURCE DOMAIN STATE +// ============================================================================= + +/// State for domains mapped from source CSV files. /// -/// // Check validation (if run) -/// if let Some((warnings, errors)) = domain.validation_summary() { -/// println!("Issues: {} warnings, {} errors", warnings, errors); -/// } -/// ``` +/// This contains all the data needed for the mapping/normalization workflow: +/// - Source data from CSV +/// - Mapping state (which source columns map to which CDISC variables) +/// - Normalization pipeline (transformations to apply during export) +/// - SUPP configuration for unmapped columns #[derive(Clone)] -pub struct DomainState { +pub struct SourceDomainState { /// Immutable source data (CSV). pub source: DomainSource, @@ -231,8 +220,8 @@ pub struct DomainState { pub validation_cache: Option, } -impl DomainState { - /// Create a new domain. +impl SourceDomainState { + /// Create a new source domain. /// /// Automatically infers the normalization pipeline from the SDTM domain /// metadata. This pipeline defines what transformations will be applied @@ -250,6 +239,38 @@ impl DomainState { } } + /// Get display name: "Demographics" or fallback to code "DM". + pub fn display_name(&self, code: &str) -> String { + match &self.source.label { + Some(label) => label.to_string(), + None => code.to_string(), + } + } + + /// Get row count from source data. + #[inline] + pub fn row_count(&self) -> usize { + self.source.row_count() + } + + /// Get unmapped source columns (for SUPP configuration). + /// + /// Returns columns that are not mapped to any SDTM variable. + pub fn unmapped_columns(&self) -> Vec { + let mapped_columns: std::collections::BTreeSet<_> = self + .mapping + .all_accepted() + .values() + .map(|(col, _)| col.as_str()) + .collect(); + + self.source + .column_names() + .into_iter() + .filter(|col| !mapped_columns.contains(col.as_str())) + .collect() + } + /// Get validation summary as (warnings, errors) count. /// /// Returns `None` if validation hasn't been run yet. @@ -270,51 +291,59 @@ impl DomainState { }) } - /// Clear validation cache (call when mapping/normalization changes). - pub fn invalidate_validation(&mut self) { - self.validation_cache = None; + /// Get the cached validation report. + pub fn validation_cache(&self) -> Option<&ValidationReport> { + self.validation_cache.as_ref() } - /// Get display name: "Demographics" or fallback to code "DM". - pub fn display_name(&self, code: &str) -> String { - match &self.source.label { - Some(label) => label.to_string(), - None => code.to_string(), - } + /// Set the validation cache. + pub fn set_validation_cache(&mut self, report: ValidationReport) { + self.validation_cache = Some(report); } - /// Get row count from source data. - #[inline] - pub fn row_count(&self) -> usize { - self.source.row_count() + /// Clear validation cache (call when data changes). + pub fn invalidate_validation(&mut self) { + self.validation_cache = None; } /// Get mapping summary. - #[inline] pub fn summary(&self) -> tss_submit::MappingSummary { self.mapping.summary() } - /// Get unmapped source columns (for SUPP configuration). - /// - /// Returns columns that are not mapped to any SDTM variable. - pub fn unmapped_columns(&self) -> Vec { - let mapped_columns: std::collections::BTreeSet<_> = self - .mapping - .all_accepted() - .values() - .map(|(col, _)| col.as_str()) - .collect(); + /// Get the domain label (if set). + pub fn label(&self) -> Option<&str> { + self.source.label.as_deref() + } - self.source - .column_names() - .into_iter() - .filter(|col| !mapped_columns.contains(col.as_str())) - .collect() + /// Get the DataFrame for this domain. + pub fn data(&self) -> &Arc { + &self.source.data + } + + /// Get the CDISC domain definition. + pub fn definition(&self) -> &SdtmDomain { + self.mapping.domain() + } + + /// Check if this is a source-mapped domain (always true now). + #[inline] + pub fn is_source(&self) -> bool { + true + } + + /// Get as source domain (returns self for backwards compatibility). + pub fn as_source(&self) -> Option<&SourceDomainState> { + Some(self) + } + + /// Get as mutable source domain (returns self for backwards compatibility). + pub fn as_source_mut(&mut self) -> Option<&mut SourceDomainState> { + Some(self) } } -impl std::fmt::Debug for DomainState { +impl std::fmt::Debug for SourceDomainState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("DomainState") .field("source", &self.source.file_path) diff --git a/crates/tss-gui/src/state/mod.rs b/crates/tss-gui/src/state/mod.rs index bb6f8855..8b3efc3e 100644 --- a/crates/tss-gui/src/state/mod.rs +++ b/crates/tss-gui/src/state/mod.rs @@ -34,7 +34,9 @@ mod view_state; pub use dialog::{DialogRegistry, DialogState, ExportProgressState, PendingAction}; // Re-export DialogType from dialog module (remove local definition) pub use dialog::DialogType; -pub use domain_state::{DomainSource, DomainState, SuppAction, SuppColumnConfig, SuppOrigin}; +pub use domain_state::{ + DomainSource, DomainState, SourceDomainState, SuppAction, SuppColumnConfig, SuppOrigin, +}; pub use settings::{ AssignmentMode, ExportFormat, RecentProject, SdtmIgVersion, Settings, WorkflowType, XptVersion, }; diff --git a/crates/tss-gui/src/view/domain_editor/mapping.rs b/crates/tss-gui/src/view/domain_editor/mapping.rs index 0c67b144..f0af74a6 100644 --- a/crates/tss-gui/src/view/domain_editor/mapping.rs +++ b/crates/tss-gui/src/view/domain_editor/mapping.rs @@ -19,7 +19,7 @@ use crate::component::layout::SplitView; use crate::component::panels::{DetailHeader, FilterToggle}; use crate::message::domain_editor::MappingMessage; use crate::message::{DomainEditorMessage, Message}; -use crate::state::{AppState, DomainState, MappingUiState, NotCollectedEdit, ViewState}; +use crate::state::{AppState, MappingUiState, NotCollectedEdit, SourceDomainState, ViewState}; use crate::theme::{ BORDER_RADIUS_SM, ClinicalColors, MASTER_WIDTH, SPACING_LG, SPACING_MD, SPACING_SM, SPACING_XS, button_primary, button_secondary, @@ -54,12 +54,28 @@ pub fn view_mapping_tab<'a>(state: &'a AppState, domain_code: &'a str) -> Elemen } }; + // Mapping only applies to source domains + let source = match domain.as_source() { + Some(s) => s, + None => { + return EmptyState::new( + container(lucide::info().size(48)).style(|theme: &Theme| container::Style { + text_color: Some(theme.clinical().text_muted), + ..Default::default() + }), + "Generated domains do not require mapping", + ) + .centered() + .view(); + } + }; + let mapping_ui = match &state.view { ViewState::DomainEditor(editor) => &editor.mapping_ui, _ => return text("Invalid view state").into(), }; - let sdtm_domain = domain.mapping.domain(); + let sdtm_domain = source.mapping.domain(); // Apply filters let filtered_indices: Vec = sdtm_domain @@ -83,7 +99,7 @@ pub fn view_mapping_tab<'a>(state: &'a AppState, domain_code: &'a str) -> Elemen // Unmapped filter if mapping_ui.filter_unmapped { - let status = domain.mapping.status(&var.name); + let status = source.mapping.status(&var.name); if !matches!(status, VariableStatus::Unmapped | VariableStatus::Suggested) { return false; } @@ -99,11 +115,11 @@ pub fn view_mapping_tab<'a>(state: &'a AppState, domain_code: &'a str) -> Elemen .map(|(idx, _)| idx) .collect(); - let master_header = view_variable_list_header(domain, mapping_ui); - let master_content = view_variable_list_content(domain, &filtered_indices, mapping_ui); + let master_header = view_variable_list_header(source, mapping_ui); + let master_content = view_variable_list_content(source, &filtered_indices, mapping_ui); let detail = if let Some(selected_idx) = mapping_ui.selected_variable { if let Some(var) = sdtm_domain.variables.get(selected_idx) { - view_variable_detail(state, domain, var) + view_variable_detail(state, source, var) } else { view_no_selection() } @@ -122,10 +138,10 @@ pub fn view_mapping_tab<'a>(state: &'a AppState, domain_code: &'a str) -> Elemen // ============================================================================= fn view_variable_list_header<'a>( - domain: &'a DomainState, + source: &'a SourceDomainState, mapping_ui: &'a MappingUiState, ) -> Element<'a, Message> { - let summary = domain.summary(); + let summary = source.mapping.summary(); // Search input let search_input = text_input("Search variables...", &mapping_ui.search_filter) @@ -188,11 +204,11 @@ fn view_variable_list_header<'a>( } fn view_variable_list_content<'a>( - domain: &'a DomainState, + source: &'a SourceDomainState, filtered_indices: &[usize], mapping_ui: &'a MappingUiState, ) -> Element<'a, Message> { - let sdtm_domain = domain.mapping.domain(); + let sdtm_domain = source.mapping.domain(); if filtered_indices.is_empty() { return container( @@ -221,7 +237,7 @@ fn view_variable_list_content<'a>( let mut items = column![].spacing(SPACING_XS); for &idx in filtered_indices { if let Some(var) = sdtm_domain.variables.get(idx) { - let status = domain.mapping.status(&var.name); + let status = source.mapping.status(&var.name); let is_selected = mapping_ui.selected_variable == Some(idx); items = items.push(view_variable_item(idx, var, status, is_selected)); } @@ -315,10 +331,10 @@ fn view_variable_item<'a>( fn view_variable_detail<'a>( state: &'a AppState, - domain: &'a DomainState, + source: &'a SourceDomainState, var: &'a tss_standards::SdtmVariable, ) -> Element<'a, Message> { - let status = domain.mapping.status(&var.name); + let status = source.mapping.status(&var.name); let not_collected_edit = match &state.view { ViewState::DomainEditor(editor) => editor.mapping_ui.not_collected_edit.as_ref(), _ => None, @@ -334,10 +350,10 @@ fn view_variable_detail<'a>( } else { Space::new().height(0.0).into() }; - let mapping_status = view_mapping_status(domain, var, status); + let mapping_status = view_mapping_status(source, var, status); let source_picker: Element<'a, Message> = if matches!(status, VariableStatus::Unmapped | VariableStatus::Suggested) { - view_source_column_picker(domain, var) + view_source_column_picker(source, var) } else { Space::new().height(0.0).into() }; @@ -350,7 +366,7 @@ fn view_variable_detail<'a>( _ if is_required && !matches!(status, VariableStatus::Accepted) => { Space::new().height(0.0).into() } - _ => view_mapping_actions(domain, var, status), + _ => view_mapping_actions(source, var, status), }; scrollable(column![ @@ -401,7 +417,7 @@ fn view_variable_metadata<'a>(var: &'a tss_standards::SdtmVariable) -> Element<' } fn view_mapping_status<'a>( - domain: &'a DomainState, + source: &'a SourceDomainState, var: &'a tss_standards::SdtmVariable, status: VariableStatus, ) -> Element<'a, Message> { @@ -413,7 +429,7 @@ fn view_mapping_status<'a>( let status_content: Element<'a, Message> = match status { VariableStatus::Accepted => { - if let Some((col, conf)) = domain.mapping.accepted(&var.name) { + if let Some((col, conf)) = source.mapping.accepted(&var.name) { let conf_pct = (conf * 100.0) as u32; StatusCard::new(container(lucide::circle_check().size(16)).style( |theme: &Theme| container::Style { @@ -441,7 +457,7 @@ fn view_mapping_status<'a>( .description("This variable is populated automatically by the system") .view(), VariableStatus::Suggested => { - if let Some((col, conf)) = domain.mapping.suggestion(&var.name) { + if let Some((col, conf)) = source.mapping.suggestion(&var.name) { let conf_pct = (conf * 100.0) as u32; let var_name = var.name.clone(); StatusCard::new( @@ -469,7 +485,7 @@ fn view_mapping_status<'a>( } } VariableStatus::NotCollected => { - let reason = domain + let reason = source .mapping .not_collected_reason(&var.name) .unwrap_or("No reason provided"); @@ -753,19 +769,19 @@ impl std::fmt::Display for ColumnOption { } fn view_source_column_picker<'a>( - domain: &'a DomainState, + source: &'a SourceDomainState, var: &'a tss_standards::SdtmVariable, ) -> Element<'a, Message> { - let source_columns = domain.source.column_names(); - let mapped_columns: std::collections::BTreeSet = domain + let source_columns = source.source.column_names(); + let mapped_columns: std::collections::BTreeSet = source .mapping .all_accepted() .values() - .map(|(col, _)| col.clone()) + .map(|(col, _): &(String, f32)| col.clone()) .collect(); - let suggestion = domain.mapping.suggestion(&var.name); - let suggested_col: Option<&str> = suggestion.as_ref().map(|(col, _)| col.as_ref()); + let suggestion = source.mapping.suggestion(&var.name); + let suggested_col: Option<&str> = suggestion.as_ref().map(|(col, _)| *col); let suggested_conf = suggestion.as_ref().map(|(_, conf)| (*conf * 100.0) as u32); let mut column_options: Vec = source_columns @@ -887,7 +903,7 @@ fn view_source_column_picker<'a>( // ============================================================================= fn view_mapping_actions<'a>( - domain: &'a DomainState, + source: &'a SourceDomainState, var: &'a tss_standards::SdtmVariable, status: VariableStatus, ) -> Element<'a, Message> { @@ -927,7 +943,7 @@ fn view_mapping_actions<'a>( // Edit reason + Revert (if NotCollected) if matches!(status, VariableStatus::NotCollected) { - let current_reason = domain + let current_reason = source .mapping .not_collected_reason(&var.name) .unwrap_or("") diff --git a/crates/tss-gui/src/view/domain_editor/normalization.rs b/crates/tss-gui/src/view/domain_editor/normalization.rs index 73b25343..f5f698f3 100644 --- a/crates/tss-gui/src/view/domain_editor/normalization.rs +++ b/crates/tss-gui/src/view/domain_editor/normalization.rs @@ -16,7 +16,7 @@ use crate::component::layout::SplitView; use crate::component::panels::DetailHeader; use crate::message::domain_editor::NormalizationMessage; use crate::message::{DomainEditorMessage, Message}; -use crate::state::{AppState, DomainState, NormalizationUiState, ViewState}; +use crate::state::{AppState, NormalizationUiState, SourceDomainState, ViewState}; use crate::theme::{ BORDER_RADIUS_SM, ClinicalColors, MASTER_WIDTH, SPACING_LG, SPACING_MD, SPACING_SM, SPACING_XS, }; @@ -51,19 +51,35 @@ pub fn view_normalization_tab<'a>( } }; + // Normalization only applies to source domains + let source = match domain.as_source() { + Some(s) => s, + None => { + return EmptyState::new( + container(lucide::info().size(48)).style(|theme: &Theme| container::Style { + text_color: Some(theme.clinical().text_muted), + ..Default::default() + }), + "Generated domains do not require normalization", + ) + .centered() + .view(); + } + }; + let normalization_ui = match &state.view { ViewState::DomainEditor(editor) => &editor.normalization_ui, _ => return text("Invalid view state").into(), }; - let normalization = &domain.normalization; - let sdtm_domain = domain.mapping.domain(); + let normalization = &source.normalization; + let sdtm_domain = source.mapping.domain(); let master_header = view_rules_header(normalization.rules.len(), &normalization.rules); - let master_content = view_rules_list(domain, &normalization.rules, normalization_ui); + let master_content = view_rules_list(source, &normalization.rules, normalization_ui); let detail = if let Some(selected_idx) = normalization_ui.selected_rule { if let Some(rule) = normalization.rules.get(selected_idx) { - view_rule_detail(domain, rule, sdtm_domain, state.terminology.as_ref()) + view_rule_detail(source, rule, sdtm_domain, state.terminology.as_ref()) } else { view_no_selection() } @@ -163,7 +179,7 @@ fn view_rules_header<'a>( } fn view_rules_list<'a>( - domain: &'a DomainState, + domain: &'a SourceDomainState, rules: &'a [tss_submit::NormalizationRule], ui_state: &'a NormalizationUiState, ) -> Element<'a, Message> { @@ -253,7 +269,7 @@ fn get_status_dot_color(var_status: VariableStatus) -> Color { // ============================================================================= fn view_rule_detail<'a>( - domain: &'a DomainState, + domain: &'a SourceDomainState, rule: &'a tss_submit::NormalizationRule, sdtm_domain: &'a tss_standards::SdtmDomain, terminology: Option<&'a TerminologyRegistry>, @@ -496,7 +512,7 @@ fn view_transformation_only<'a>(rule: &'a tss_submit::NormalizationRule) -> Elem // ============================================================================= fn view_before_after_preview<'a>( - domain: &'a DomainState, + domain: &'a SourceDomainState, rule: &'a tss_submit::NormalizationRule, terminology: Option<&'a TerminologyRegistry>, ) -> Element<'a, Message> { @@ -535,7 +551,7 @@ fn view_before_after_preview<'a>( } fn build_preview_content<'a>( - domain: &'a DomainState, + domain: &'a SourceDomainState, rule: &'a tss_submit::NormalizationRule, terminology: Option<&'a TerminologyRegistry>, ) -> Element<'a, Message> { diff --git a/crates/tss-gui/src/view/domain_editor/preview.rs b/crates/tss-gui/src/view/domain_editor/preview.rs index a4be2d6e..b3836459 100644 --- a/crates/tss-gui/src/view/domain_editor/preview.rs +++ b/crates/tss-gui/src/view/domain_editor/preview.rs @@ -19,7 +19,7 @@ use polars::prelude::DataFrame; use crate::component::display::{EmptyState, ErrorState, LoadingState}; use crate::message::domain_editor::PreviewMessage; use crate::message::{DomainEditorMessage, Message}; -use crate::state::{AppState, DomainState, PreviewUiState, ViewState}; +use crate::state::{AppState, PreviewUiState, SourceDomainState, ViewState}; use crate::theme::{ BORDER_RADIUS_SM, ClinicalColors, SPACING_LG, SPACING_MD, SPACING_SM, SPACING_XS, ThemeConfig, button_ghost, button_primary, @@ -96,6 +96,25 @@ pub fn view_preview_tab<'a>(state: &'a AppState, domain_code: &'a str) -> Elemen } }; + // Preview only applies to source domains + let source = match domain.as_source() { + Some(s) => s, + None => { + let theme = config.to_theme(false); + let text_muted = theme.clinical().text_muted; + return container( + text("Generated domains do not have preview") + .size(14) + .color(text_muted), + ) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Shrink) + .center_y(Length::Shrink) + .into(); + } + }; + // Get preview UI state and cached DataFrame let (preview_cache, preview_ui) = match &state.view { ViewState::DomainEditor(editor) => (&editor.preview_cache, &editor.preview_ui), @@ -111,7 +130,7 @@ pub fn view_preview_tab<'a>(state: &'a AppState, domain_code: &'a str) -> Elemen } else if let Some(error) = &preview_ui.error { view_error_state(error.as_str()) } else if let Some(df) = preview_cache { - view_data_table(config, df, preview_ui, domain) + view_data_table(config, df, preview_ui, source) } else { view_empty_state(config) }; @@ -247,7 +266,7 @@ fn view_data_table<'a>( config: &ThemeConfig, df: &DataFrame, preview_ui: &PreviewUiState, - domain: &DomainState, + source: &SourceDomainState, ) -> Element<'a, Message> { let theme = config.to_theme(false); let bg_secondary = theme.clinical().background_secondary; @@ -263,7 +282,7 @@ fn view_data_table<'a>( let page_size = preview_ui.rows_per_page; // Get the set of "Not Collected" variable names - let not_collected_cols: HashSet<&str> = domain + let not_collected_cols: HashSet<&str> = source .mapping .all_not_collected() .keys() diff --git a/crates/tss-gui/src/view/domain_editor/supp.rs b/crates/tss-gui/src/view/domain_editor/supp.rs index b451d60d..8774a9d2 100644 --- a/crates/tss-gui/src/view/domain_editor/supp.rs +++ b/crates/tss-gui/src/view/domain_editor/supp.rs @@ -29,8 +29,8 @@ use crate::component::panels::{DetailHeader, FilterToggle}; use crate::message::domain_editor::SuppMessage; use crate::message::{DomainEditorMessage, Message}; use crate::state::{ - AppState, DomainState, SuppAction, SuppColumnConfig, SuppEditDraft, SuppFilterMode, SuppOrigin, - SuppUiState, ViewState, + AppState, SourceDomainState, SuppAction, SuppColumnConfig, SuppEditDraft, SuppFilterMode, + SuppOrigin, SuppUiState, ViewState, }; use crate::theme::{ BORDER_RADIUS_SM, ClinicalColors, MASTER_WIDTH, MAX_CHARS_SHORT_LABEL, MAX_CHARS_VARIABLE_NAME, @@ -59,6 +59,25 @@ pub fn view_supp_tab<'a>(state: &'a AppState, domain_code: &'a str) -> Element<' } }; + // SUPP configuration only applies to source domains + let source = match domain.as_source() { + Some(s) => s, + None => { + return container( + text("Generated domains do not have SUPP columns") + .size(14) + .style(|theme: &Theme| text::Style { + color: Some(theme.clinical().text_muted), + }), + ) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Shrink) + .center_y(Length::Shrink) + .into(); + } + }; + // Get UI state let supp_ui = match &state.view { ViewState::DomainEditor(editor) => &editor.supp_ui, @@ -66,7 +85,7 @@ pub fn view_supp_tab<'a>(state: &'a AppState, domain_code: &'a str) -> Element<' }; // Get unmapped columns - let unmapped_columns = domain.unmapped_columns(); + let unmapped_columns = source.unmapped_columns(); // If no unmapped columns, show success state if unmapped_columns.is_empty() { @@ -76,7 +95,7 @@ pub fn view_supp_tab<'a>(state: &'a AppState, domain_code: &'a str) -> Element<' // Filter columns based on search and filter mode let filtered: Vec = unmapped_columns .iter() - .filter(|col| { + .filter(|col: &&String| { // Search filter if !supp_ui.search_filter.is_empty() && !col @@ -87,7 +106,7 @@ pub fn view_supp_tab<'a>(state: &'a AppState, domain_code: &'a str) -> Element<' } // Action filter - let supp_config = domain.supp_config.get(*col); + let supp_config = source.supp_config.get(*col); match supp_ui.filter_mode { SuppFilterMode::All => true, SuppFilterMode::Pending => { @@ -108,10 +127,10 @@ pub fn view_supp_tab<'a>(state: &'a AppState, domain_code: &'a str) -> Element<' let master_header = build_master_header_pinned(supp_ui, filtered.len()); // Build master content (scrollable column list) - let master_content = build_master_content(&filtered, domain, supp_ui); + let master_content = build_master_content(&filtered, source, supp_ui); // Build detail panel - let detail = build_detail_panel(domain, supp_ui, domain_code); + let detail = build_detail_panel(source, supp_ui, domain_code); // Use split view layout with pinned header SplitView::new(master_content, detail) @@ -174,7 +193,7 @@ fn build_master_header_pinned<'a>( /// Left panel content: scrollable list of columns. fn build_master_content<'a>( filtered: &[String], - domain: &'a DomainState, + domain: &'a SourceDomainState, ui: &'a SuppUiState, ) -> Element<'a, Message> { if filtered.is_empty() { @@ -318,27 +337,27 @@ fn build_column_item( // ============================================================================= fn build_detail_panel( - domain: &DomainState, + source: &SourceDomainState, ui: &SuppUiState, domain_code: &str, ) -> Element<'static, Message> { match &ui.selected_column { Some(col) => { - let config = domain + let config = source .supp_config .get(col) .cloned() .unwrap_or_else(|| SuppColumnConfig::from_column(col)); match (&config.action, ui.edit_draft.as_ref()) { - (SuppAction::Pending, _) => build_pending_view(domain, col, &config, domain_code), + (SuppAction::Pending, _) => build_pending_view(source, col, &config, domain_code), (SuppAction::Include, Some(draft)) => { - build_edit_view(domain, col, draft, domain_code) + build_edit_view(source, col, draft, domain_code) } (SuppAction::Include, None) => { - build_included_view(domain, col, &config, domain_code) + build_included_view(source, col, &config, domain_code) } - (SuppAction::Skip, _) => build_skipped_view(domain, col, domain_code), + (SuppAction::Skip, _) => build_skipped_view(source, col, domain_code), } } None => build_no_selection_state(), @@ -363,16 +382,16 @@ fn build_no_selection_state() -> Element<'static, Message> { // ============================================================================= fn build_pending_view( - domain: &DomainState, + source: &SourceDomainState, col_name: &str, config: &SuppColumnConfig, domain_code: &str, ) -> Element<'static, Message> { let header = build_detail_header(col_name, domain_code); - let sample_data = build_sample_data(domain, col_name); + let sample_data = build_sample_data(source, col_name); // Check QNAM conflict (only against included columns) - let qnam_error = check_qnam_conflict(domain, col_name, &config.qnam); + let qnam_error = check_qnam_conflict(source, col_name, &config.qnam); // Editable fields let fields = build_editable_fields(config, qnam_error); @@ -486,13 +505,13 @@ fn build_pending_actions(domain_code: &str) -> Element<'static, Message> { // ============================================================================= fn build_included_view( - domain: &DomainState, + source: &SourceDomainState, col_name: &str, config: &SuppColumnConfig, domain_code: &str, ) -> Element<'static, Message> { let header = build_detail_header(col_name, domain_code); - let sample_data = build_sample_data(domain, col_name); + let sample_data = build_sample_data(source, col_name); // Read-only summary let summary = build_readonly_summary(config); @@ -655,13 +674,13 @@ fn build_included_actions() -> Element<'static, Message> { // ============================================================================= fn build_edit_view( - domain: &DomainState, + source: &SourceDomainState, col_name: &str, draft: &SuppEditDraft, domain_code: &str, ) -> Element<'static, Message> { let header = build_detail_header(col_name, domain_code); - let sample_data = build_sample_data(domain, col_name); + let sample_data = build_sample_data(source, col_name); // Create a temporary config from draft for display let temp_config = SuppColumnConfig { @@ -678,7 +697,7 @@ fn build_edit_view( }; // Check QNAM conflict - let qnam_error = check_qnam_conflict(domain, col_name, &draft.qnam); + let qnam_error = check_qnam_conflict(source, col_name, &draft.qnam); // Editable fields let fields = build_editable_fields(&temp_config, qnam_error); @@ -808,12 +827,12 @@ fn build_edit_actions() -> Element<'static, Message> { // ============================================================================= fn build_skipped_view( - domain: &DomainState, + source: &SourceDomainState, col_name: &str, domain_code: &str, ) -> Element<'static, Message> { let header = build_detail_header(col_name, domain_code); - let sample_data = build_sample_data(domain, col_name); + let sample_data = build_sample_data(source, col_name); // Skip message let skip_message = container( @@ -931,8 +950,8 @@ fn build_detail_header(col_name: &str, domain_code: &str) -> Element<'static, Me .view() } -fn build_sample_data(domain: &DomainState, col_name: &str) -> Element<'static, Message> { - let samples = get_sample_values(domain, col_name, 5); +fn build_sample_data(source: &SourceDomainState, col_name: &str) -> Element<'static, Message> { + let samples = get_sample_values(source, col_name, 5); let sample_chips: Vec> = samples .into_iter() @@ -1069,11 +1088,11 @@ fn build_origin_picker(current: SuppOrigin) -> Element<'static, Message> { // HELPER FUNCTIONS // ============================================================================= -fn get_sample_values(domain: &DomainState, col_name: &str, max: usize) -> Vec { +fn get_sample_values(source: &SourceDomainState, col_name: &str, max: usize) -> Vec { let mut samples = Vec::new(); let mut seen = std::collections::HashSet::new(); - if let Ok(col) = domain.source.data.column(col_name) { + if let Ok(col) = source.source.data.column(col_name) { for i in 0..col.len().min(100) { if let Ok(val) = col.get(i) { let s = format_value(&val); @@ -1099,13 +1118,17 @@ fn format_value(value: &AnyValue) -> String { } } -fn check_qnam_conflict(domain: &DomainState, current_col: &str, qnam: &str) -> Option { +fn check_qnam_conflict( + source: &SourceDomainState, + current_col: &str, + qnam: &str, +) -> Option { if qnam.is_empty() { return None; } // Only check against columns already included in SUPP - for (col, config) in &domain.supp_config { + for (col, config) in &source.supp_config { if col != current_col && config.action == SuppAction::Include && config.qnam.eq_ignore_ascii_case(qnam) diff --git a/crates/tss-gui/src/view/domain_editor/validation.rs b/crates/tss-gui/src/view/domain_editor/validation.rs index 29b14eb8..ed5a1088 100644 --- a/crates/tss-gui/src/view/domain_editor/validation.rs +++ b/crates/tss-gui/src/view/domain_editor/validation.rs @@ -47,8 +47,9 @@ pub fn view_validation_tab<'a>(state: &'a AppState, domain_code: &'a str) -> Ele }; // Get validation cache from domain (persists across navigation) - let Some(report) = &domain.validation_cache else { - return view_no_validation_run(); + let report: &ValidationReport = match domain.validation_cache() { + Some(r) => r, + None => return view_no_validation_run(), }; // If validation passed (no issues), show success state diff --git a/crates/tss-gui/src/view/export.rs b/crates/tss-gui/src/view/export.rs index de4bbac6..7aee3351 100644 --- a/crates/tss-gui/src/view/export.rs +++ b/crates/tss-gui/src/view/export.rs @@ -198,37 +198,50 @@ fn view_domain_row<'a>( let is_selected = export_state.is_selected(code); let row_count = domain.row_count(); - // Determine status based on mapping progress - let mapping = &domain.mapping; - let accepted_count = mapping.all_accepted().len(); - let total_count = mapping.domain().variables.len(); - let mapped_ratio = if total_count > 0 { - accepted_count as f32 / total_count as f32 - } else { - 0.0 - }; - - let status_icon: Element<'a, Message> = if mapped_ratio >= 0.9 { - container(lucide::circle_check().size(14)) - .style(|theme: &Theme| container::Style { - text_color: Some(theme.extended_palette().success.base.color), - ..Default::default() - }) - .into() - } else if mapped_ratio >= 0.5 { - container(lucide::circle_alert().size(14)) - .style(|theme: &Theme| container::Style { - text_color: Some(theme.extended_palette().warning.base.color), - ..Default::default() - }) - .into() - } else { - container(lucide::circle().size(14)) - .style(|theme: &Theme| container::Style { - text_color: Some(theme.clinical().text_disabled), - ..Default::default() - }) - .into() + // Determine status based on mapping progress (source domains) or ready status (generated) + let status_icon: Element<'a, Message> = match domain.as_source() { + Some(source) => { + let mapping = &source.mapping; + let accepted_count = mapping.all_accepted().len(); + let total_count = mapping.domain().variables.len(); + let mapped_ratio = if total_count > 0 { + accepted_count as f32 / total_count as f32 + } else { + 0.0 + }; + + if mapped_ratio >= 0.9 { + container(lucide::circle_check().size(14)) + .style(|theme: &Theme| container::Style { + text_color: Some(theme.extended_palette().success.base.color), + ..Default::default() + }) + .into() + } else if mapped_ratio >= 0.5 { + container(lucide::circle_alert().size(14)) + .style(|theme: &Theme| container::Style { + text_color: Some(theme.extended_palette().warning.base.color), + ..Default::default() + }) + .into() + } else { + container(lucide::circle().size(14)) + .style(|theme: &Theme| container::Style { + text_color: Some(theme.clinical().text_disabled), + ..Default::default() + }) + .into() + } + } + None => { + // Generated domains are always ready (marked with success) + container(lucide::circle_check().size(14)) + .style(|theme: &Theme| container::Style { + text_color: Some(theme.extended_palette().success.base.color), + ..Default::default() + }) + .into() + } }; let code_string = code.to_string(); diff --git a/crates/tss-gui/src/view/home/study.rs b/crates/tss-gui/src/view/home/study.rs index d3e171a9..7ad20634 100644 --- a/crates/tss-gui/src/view/home/study.rs +++ b/crates/tss-gui/src/view/home/study.rs @@ -169,8 +169,11 @@ fn view_path_info<'a>(study: &Study) -> Element<'a, Message> { fn view_domains<'a>(state: &'a AppState, study: &'a Study) -> Element<'a, Message> { let domain_codes = study.domain_codes_dm_first(); + // Count domains + let domain_count = domain_codes.len(); + // Section header with domain count - let domain_count_str = format!("{}", domain_codes.len()); + let domain_count_str = format!("{}", domain_count); let header = row![ text("Domains").size(16).style(|theme: &Theme| text::Style { color: Some(theme.clinical().text_secondary), diff --git a/crates/tss-persistence/src/lib.rs b/crates/tss-persistence/src/lib.rs index 0c091133..170145b9 100644 --- a/crates/tss-persistence/src/lib.rs +++ b/crates/tss-persistence/src/lib.rs @@ -69,7 +69,10 @@ pub use io::{ verify_file_hash, }; pub use types::{ - CURRENT_SCHEMA_VERSION, DomainSnapshot, MAGIC_BYTES, MappingEntry, MappingSnapshot, - ProjectFile, ProjectPlaceholders, SourceAssignment, StudyMetadata, SuppActionSnapshot, - SuppColumnSnapshot, SuppOriginSnapshot, WorkflowTypeSnapshot, + CURRENT_SCHEMA_VERSION, CommentEntrySnapshot, DomainSnapshot, GeneratedDomainEntrySnapshot, + GeneratedDomainSnapshot, GeneratedDomainTypeSnapshot, MAGIC_BYTES, MappingEntry, + MappingSnapshot, ProjectFile, ProjectPlaceholders, RelrecEntrySnapshot, RelrecRelTypeSnapshot, + RelspecEntrySnapshot, RelsubEntrySnapshot, SourceAssignment, SourceDomainSnapshot, + StudyMetadata, SuppActionSnapshot, SuppColumnSnapshot, SuppOriginSnapshot, + WorkflowTypeSnapshot, }; diff --git a/crates/tss-persistence/src/types/domain.rs b/crates/tss-persistence/src/types/domain.rs index de0e4c33..b07f054c 100644 --- a/crates/tss-persistence/src/types/domain.rs +++ b/crates/tss-persistence/src/types/domain.rs @@ -4,10 +4,89 @@ use std::collections::{BTreeMap, BTreeSet}; use rkyv::{Archive, Deserialize, Serialize}; -use super::SuppColumnSnapshot; +use super::{GeneratedDomainEntrySnapshot, GeneratedDomainTypeSnapshot, SuppColumnSnapshot}; + +// ============================================================================= +// DOMAIN SNAPSHOT ENUM +// ============================================================================= /// Snapshot of a domain's state for persistence. /// +/// This is an enum to handle both source (CSV-mapped) and generated domains. +#[derive(Debug, Clone, Archive, Serialize, Deserialize)] +#[rkyv(compare(PartialEq))] +pub enum DomainSnapshot { + /// Source domain (mapped from CSV file). + Source(SourceDomainSnapshot), + /// Generated domain (CO, RELREC, RELSPEC, RELSUB). + Generated(GeneratedDomainSnapshot), +} + +impl DomainSnapshot { + /// Create a new source domain snapshot. + pub fn new(domain_code: impl Into) -> Self { + Self::Source(SourceDomainSnapshot::new(domain_code)) + } + + /// Create a source snapshot with a label. + pub fn with_label(domain_code: impl Into, label: impl Into) -> Self { + Self::Source(SourceDomainSnapshot::with_label(domain_code, label)) + } + + /// Create a new generated domain snapshot. + pub fn new_generated(domain_type: GeneratedDomainTypeSnapshot) -> Self { + Self::Generated(GeneratedDomainSnapshot::new(domain_type)) + } + + /// Get the domain code. + pub fn domain_code(&self) -> &str { + match self { + Self::Source(s) => &s.domain_code, + Self::Generated(g) => g.domain_type.code(), + } + } + + /// Check if this is a source domain. + pub fn is_source(&self) -> bool { + matches!(self, Self::Source(_)) + } + + /// Check if this is a generated domain. + pub fn is_generated(&self) -> bool { + matches!(self, Self::Generated(_)) + } + + /// Get as source snapshot. + pub fn as_source(&self) -> Option<&SourceDomainSnapshot> { + match self { + Self::Source(s) => Some(s), + Self::Generated(_) => None, + } + } + + /// Get as source snapshot mutably. + pub fn as_source_mut(&mut self) -> Option<&mut SourceDomainSnapshot> { + match self { + Self::Source(s) => Some(s), + Self::Generated(_) => None, + } + } + + /// Get as generated snapshot. + pub fn as_generated(&self) -> Option<&GeneratedDomainSnapshot> { + match self { + Self::Source(_) => None, + Self::Generated(g) => Some(g), + } + } +} + +// ============================================================================= +// SOURCE DOMAIN SNAPSHOT +// ============================================================================= + +/// Snapshot of a source domain's state (mapped from CSV). +/// /// # Design Note /// /// This snapshot intentionally does NOT include: @@ -17,7 +96,7 @@ use super::SuppColumnSnapshot; /// Both are regenerated on load from the persisted mapping state. #[derive(Debug, Clone, Archive, Serialize, Deserialize)] #[rkyv(compare(PartialEq))] -pub struct DomainSnapshot { +pub struct SourceDomainSnapshot { /// Domain code (e.g., "DM", "AE"). pub domain_code: String, @@ -31,8 +110,8 @@ pub struct DomainSnapshot { pub supp_config: BTreeMap, } -impl DomainSnapshot { - /// Create a new domain snapshot. +impl SourceDomainSnapshot { + /// Create a new source domain snapshot. pub fn new(domain_code: impl Into) -> Self { Self { domain_code: domain_code.into(), @@ -53,6 +132,57 @@ impl DomainSnapshot { } } +// ============================================================================= +// GENERATED DOMAIN SNAPSHOT +// ============================================================================= + +/// Snapshot of a generated domain's state (CO, RELREC, RELSPEC, RELSUB). +/// +/// Generated domains store their entries directly. The DataFrame is regenerated +/// from entries on load using the generation service. +#[derive(Debug, Clone, Archive, Serialize, Deserialize)] +#[rkyv(compare(PartialEq))] +pub struct GeneratedDomainSnapshot { + /// Type of generated domain. + pub domain_type: GeneratedDomainTypeSnapshot, + + /// Entries used to generate the domain data. + pub entries: Vec, +} + +impl GeneratedDomainSnapshot { + /// Create a new generated domain snapshot. + pub fn new(domain_type: GeneratedDomainTypeSnapshot) -> Self { + Self { + domain_type, + entries: Vec::new(), + } + } + + /// Create with entries. + pub fn with_entries( + domain_type: GeneratedDomainTypeSnapshot, + entries: Vec, + ) -> Self { + Self { + domain_type, + entries, + } + } +} + +impl GeneratedDomainTypeSnapshot { + /// Get the CDISC domain code. + pub fn code(&self) -> &'static str { + match self { + Self::Comments => "CO", + Self::RelatedRecords => "RELREC", + Self::RelatedSpecimens => "RELSPEC", + Self::RelatedSubjects => "RELSUB", + } + } +} + /// Snapshot of mapping state. /// /// Note: We don't persist suggestions - they're regenerated from source data. diff --git a/crates/tss-persistence/src/types/generated_domains.rs b/crates/tss-persistence/src/types/generated_domains.rs new file mode 100644 index 00000000..8025cef2 --- /dev/null +++ b/crates/tss-persistence/src/types/generated_domains.rs @@ -0,0 +1,154 @@ +//! Generated domain entry snapshots for persistence. +//! +//! These types mirror the GUI state entry types but with rkyv serialization. +//! +//! Domain categories per SDTM-IG v3.4: +//! - **Special-Purpose**: CO (Comments) +//! - **Relationship**: RELREC, RELSPEC, RELSUB + +use rkyv::{Archive, Deserialize, Serialize}; + +// ============================================================================= +// CO (COMMENTS) SNAPSHOT +// ============================================================================= + +/// Snapshot of a comment entry for CO domain. +#[derive(Debug, Clone, Archive, Serialize, Deserialize)] +#[rkyv(compare(PartialEq))] +pub struct CommentEntrySnapshot { + /// Subject identifier (USUBJID). + pub usubjid: String, + + /// Comment text (COVAL). + pub comment: String, + + /// Related domain (RDOMAIN). + pub rdomain: Option, + + /// Identifying variable name (IDVAR). + pub idvar: Option, + + /// Identifying variable value (IDVARVAL). + pub idvarval: Option, + + /// Comment reference (COREF). + pub coref: Option, + + /// Date/time of comment (CODTC). + pub codtc: Option, + + /// Evaluator (COEVAL). + pub coeval: Option, +} + +// ============================================================================= +// RELREC (RELATED RECORDS) SNAPSHOT +// ============================================================================= + +/// Snapshot of a related record entry for RELREC domain. +#[derive(Debug, Clone, Archive, Serialize, Deserialize)] +#[rkyv(compare(PartialEq))] +pub struct RelrecEntrySnapshot { + /// Relationship identifier (RELID). + pub relid: String, + + /// Subject identifier (USUBJID). + pub usubjid: Option, + + /// Related domain (RDOMAIN). + pub rdomain: String, + + /// Identifying variable name (IDVAR). + pub idvar: String, + + /// Identifying variable value (IDVARVAL). + pub idvarval: Option, + + /// Relationship type (RELTYPE). + pub reltype: Option, +} + +/// RELTYPE values for RELREC domain. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Archive, Serialize, Deserialize)] +#[rkyv(compare(PartialEq))] +pub enum RelrecRelTypeSnapshot { + /// Single record in the relationship. + One, + /// Multiple records in the relationship. + Many, +} + +// ============================================================================= +// RELSPEC (RELATED SPECIMENS) SNAPSHOT +// ============================================================================= + +/// Snapshot of a related specimen entry for RELSPEC domain. +#[derive(Debug, Clone, Archive, Serialize, Deserialize)] +#[rkyv(compare(PartialEq))] +pub struct RelspecEntrySnapshot { + /// Subject identifier (USUBJID). + pub usubjid: String, + + /// Specimen identifier (REFID). + pub refid: String, + + /// Specimen type (SPEC). + pub spec: Option, + + /// Parent specimen identifier (PARENT). + pub parent: Option, +} + +// ============================================================================= +// RELSUB (RELATED SUBJECTS) SNAPSHOT +// ============================================================================= + +/// Snapshot of a related subject entry for RELSUB domain. +#[derive(Debug, Clone, Archive, Serialize, Deserialize)] +#[rkyv(compare(PartialEq))] +pub struct RelsubEntrySnapshot { + /// Subject identifier (USUBJID). + pub usubjid: String, + + /// Related subject identifier (RSUBJID). + pub rsubjid: String, + + /// Subject relationship (SREL). + pub srel: String, +} + +// ============================================================================= +// UNIFIED ENTRY ENUM +// ============================================================================= + +/// Snapshot of a generated domain entry. +#[derive(Debug, Clone, Archive, Serialize, Deserialize)] +#[rkyv(compare(PartialEq))] +pub enum GeneratedDomainEntrySnapshot { + /// CO (Comments) entry. + Comment(CommentEntrySnapshot), + /// RELREC (Related Records) entry. + RelatedRecord(RelrecEntrySnapshot), + /// RELSPEC (Related Specimens) entry. + RelatedSpecimen(RelspecEntrySnapshot), + /// RELSUB (Related Subjects) entry. + RelatedSubject(RelsubEntrySnapshot), +} + +// ============================================================================= +// GENERATED DOMAIN TYPE +// ============================================================================= + +/// Type of generated domain for persistence. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Archive, Serialize, Deserialize)] +#[rkyv(compare(PartialEq))] +pub enum GeneratedDomainTypeSnapshot { + /// CO (Comments) - Special-Purpose domain. + Comments, + /// RELREC (Related Records) - Relationship domain. + RelatedRecords, + /// RELSPEC (Related Specimens) - Relationship domain. + RelatedSpecimens, + /// RELSUB (Related Subjects) - Relationship domain. + RelatedSubjects, +} diff --git a/crates/tss-persistence/src/types/mod.rs b/crates/tss-persistence/src/types/mod.rs index 952d167b..cedaa719 100644 --- a/crates/tss-persistence/src/types/mod.rs +++ b/crates/tss-persistence/src/types/mod.rs @@ -4,12 +4,19 @@ //! They mirror the GUI state types but are optimized for storage. mod domain; +mod generated_domains; mod placeholders; mod project; mod source; mod supp; -pub use domain::{DomainSnapshot, MappingEntry, MappingSnapshot}; +pub use domain::{ + DomainSnapshot, GeneratedDomainSnapshot, MappingEntry, MappingSnapshot, SourceDomainSnapshot, +}; +pub use generated_domains::{ + CommentEntrySnapshot, GeneratedDomainEntrySnapshot, GeneratedDomainTypeSnapshot, + RelrecEntrySnapshot, RelrecRelTypeSnapshot, RelspecEntrySnapshot, RelsubEntrySnapshot, +}; pub use placeholders::ProjectPlaceholders; pub use project::{ProjectFile, StudyMetadata, WorkflowTypeSnapshot}; pub use source::SourceAssignment; @@ -19,9 +26,11 @@ pub use supp::{SuppActionSnapshot, SuppColumnSnapshot, SuppOriginSnapshot}; /// /// Increment this when making breaking changes to the persistence format. /// The loader will reject files with version > CURRENT_SCHEMA_VERSION. -pub const CURRENT_SCHEMA_VERSION: u32 = 1; +/// +/// v2: Added generated domain support (DomainSnapshot now enum with Source/Generated variants) +pub const CURRENT_SCHEMA_VERSION: u32 = 2; /// Magic bytes at the start of .tss files. /// -/// Format: "TSS" + version byte (0x01 for v1) -pub const MAGIC_BYTES: [u8; 4] = [b'T', b'S', b'S', 0x01]; +/// Format: "TSS" + version byte (0x02 for v2) +pub const MAGIC_BYTES: [u8; 4] = [b'T', b'S', b'S', 0x02]; diff --git a/crates/tss-standards/src/sdtm/mod.rs b/crates/tss-standards/src/sdtm/mod.rs index a4c3e568..ce35bca8 100644 --- a/crates/tss-standards/src/sdtm/mod.rs +++ b/crates/tss-standards/src/sdtm/mod.rs @@ -5,6 +5,8 @@ pub mod domain; pub mod enums; +pub mod reciprocal; pub use domain::{SdtmDomain, SdtmVariable}; pub use enums::{SdtmDatasetClass, VariableRole}; +pub use reciprocal::{get_parent_srel_for_child, get_reciprocal_srel, is_symmetric_srel}; diff --git a/crates/tss-standards/src/sdtm/reciprocal.rs b/crates/tss-standards/src/sdtm/reciprocal.rs new file mode 100644 index 00000000..4a6a8a69 --- /dev/null +++ b/crates/tss-standards/src/sdtm/reciprocal.rs @@ -0,0 +1,214 @@ +//! RELSUB reciprocal relationship lookups. +//! +//! Per SDTM-IG v3.4 Section 8.7, RELSUB relationships MUST be bidirectional. +//! This module provides lookup functions for reciprocal SREL terms. + +use std::collections::HashMap; +use std::sync::LazyLock; + +/// Lookup table for reciprocal SREL terms. +/// +/// Per SDTM-IG v3.4: +/// - If A is MOTHER to B, then B is CHILD to A +/// - If A is FATHER to B, then B is CHILD to A +/// - Twin relationships are symmetric (TWIN to TWIN) +/// - Sibling relationships are symmetric (SIBLING to SIBLING) +static RECIPROCAL_SREL: LazyLock> = LazyLock::new(|| { + let mut map = HashMap::new(); + + // Parent-child relationships (biological) + map.insert("MOTHER, BIOLOGICAL", "CHILD, BIOLOGICAL"); + map.insert("FATHER, BIOLOGICAL", "CHILD, BIOLOGICAL"); + // Note: CHILD, BIOLOGICAL reciprocal depends on parent's sex - handled specially + + // Parent-child relationships (adoptive) + map.insert("MOTHER, ADOPTIVE", "CHILD, ADOPTIVE"); + map.insert("FATHER, ADOPTIVE", "CHILD, ADOPTIVE"); + + // Parent-child relationships (foster) + map.insert("MOTHER, FOSTER", "CHILD, FOSTER"); + map.insert("FATHER, FOSTER", "CHILD, FOSTER"); + + // Parent-child relationships (step) + map.insert("MOTHER, STEP", "CHILD, STEP"); + map.insert("FATHER, STEP", "CHILD, STEP"); + + // Symmetric relationships (twin types) + map.insert("TWIN, DIZYGOTIC", "TWIN, DIZYGOTIC"); + map.insert("TWIN, MONOZYGOTIC", "TWIN, MONOZYGOTIC"); + map.insert("TWIN, UNKNOWN ZYGOSITY", "TWIN, UNKNOWN ZYGOSITY"); + + // Symmetric relationships (sibling) + map.insert("SIBLING", "SIBLING"); + map.insert("SIBLING, BIOLOGICAL", "SIBLING, BIOLOGICAL"); + map.insert("SIBLING, HALF", "SIBLING, HALF"); + map.insert("SIBLING, STEP", "SIBLING, STEP"); + map.insert("SIBLING, ADOPTIVE", "SIBLING, ADOPTIVE"); + + // Grandparent relationships + map.insert("GRANDMOTHER, BIOLOGICAL", "GRANDCHILD, BIOLOGICAL"); + map.insert("GRANDFATHER, BIOLOGICAL", "GRANDCHILD, BIOLOGICAL"); + map.insert("GRANDMOTHER, ADOPTIVE", "GRANDCHILD, ADOPTIVE"); + map.insert("GRANDFATHER, ADOPTIVE", "GRANDCHILD, ADOPTIVE"); + + // Spouse relationships (symmetric) + map.insert("SPOUSE", "SPOUSE"); + map.insert("HUSBAND", "WIFE"); + map.insert("WIFE", "HUSBAND"); + + // Other family relationships + map.insert("AUNT, BIOLOGICAL", "NEPHEW/NIECE, BIOLOGICAL"); + map.insert("UNCLE, BIOLOGICAL", "NEPHEW/NIECE, BIOLOGICAL"); + map.insert("COUSIN, BIOLOGICAL", "COUSIN, BIOLOGICAL"); + + map +}); + +/// Get the reciprocal SREL term for a given relationship. +/// +/// Returns `Some(reciprocal)` if a known reciprocal exists, or `None` if: +/// - The relationship is not in the lookup table +/// - The relationship requires context (e.g., CHILD needs parent's sex) +/// +/// # Examples +/// +/// ``` +/// use tss_standards::sdtm::get_reciprocal_srel; +/// +/// assert_eq!(get_reciprocal_srel("MOTHER, BIOLOGICAL"), Some("CHILD, BIOLOGICAL")); +/// assert_eq!(get_reciprocal_srel("TWIN, MONOZYGOTIC"), Some("TWIN, MONOZYGOTIC")); +/// assert_eq!(get_reciprocal_srel("SIBLING"), Some("SIBLING")); +/// ``` +pub fn get_reciprocal_srel(srel: &str) -> Option<&'static str> { + RECIPROCAL_SREL.get(srel.trim()).copied() +} + +/// Check if a relationship is symmetric (same term for both directions). +/// +/// # Examples +/// +/// ``` +/// use tss_standards::sdtm::is_symmetric_srel; +/// +/// assert!(is_symmetric_srel("TWIN, DIZYGOTIC")); +/// assert!(is_symmetric_srel("SIBLING")); +/// assert!(!is_symmetric_srel("MOTHER, BIOLOGICAL")); +/// ``` +pub fn is_symmetric_srel(srel: &str) -> bool { + RECIPROCAL_SREL + .get(srel.trim()) + .is_some_and(|&reciprocal| reciprocal == srel.trim()) +} + +/// Get the reciprocal for a CHILD relationship based on parent's biological sex. +/// +/// CHILD relationships are special because the reciprocal depends on +/// the parent's sex (MOTHER vs FATHER). +/// +/// # Arguments +/// +/// * `child_type` - The child relationship type (e.g., "BIOLOGICAL", "ADOPTIVE") +/// * `parent_sex` - The parent's SEX value ("M" or "F") +/// +/// # Returns +/// +/// The appropriate parent term (e.g., "MOTHER, BIOLOGICAL" or "FATHER, BIOLOGICAL") +pub fn get_parent_srel_for_child(child_type: &str, parent_sex: &str) -> Option<&'static str> { + let parent_prefix = match parent_sex.trim().to_uppercase().as_str() { + "F" => "MOTHER", + "M" => "FATHER", + _ => return None, + }; + + // Match the child type to return the full parent term + match child_type.trim().to_uppercase().as_str() { + "CHILD, BIOLOGICAL" | "BIOLOGICAL" => Some(if parent_prefix == "MOTHER" { + "MOTHER, BIOLOGICAL" + } else { + "FATHER, BIOLOGICAL" + }), + "CHILD, ADOPTIVE" | "ADOPTIVE" => Some(if parent_prefix == "MOTHER" { + "MOTHER, ADOPTIVE" + } else { + "FATHER, ADOPTIVE" + }), + "CHILD, FOSTER" | "FOSTER" => Some(if parent_prefix == "MOTHER" { + "MOTHER, FOSTER" + } else { + "FATHER, FOSTER" + }), + "CHILD, STEP" | "STEP" => Some(if parent_prefix == "MOTHER" { + "MOTHER, STEP" + } else { + "FATHER, STEP" + }), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_reciprocal_parent_child() { + assert_eq!( + get_reciprocal_srel("MOTHER, BIOLOGICAL"), + Some("CHILD, BIOLOGICAL") + ); + assert_eq!( + get_reciprocal_srel("FATHER, BIOLOGICAL"), + Some("CHILD, BIOLOGICAL") + ); + } + + #[test] + fn test_reciprocal_symmetric() { + assert_eq!( + get_reciprocal_srel("TWIN, DIZYGOTIC"), + Some("TWIN, DIZYGOTIC") + ); + assert_eq!( + get_reciprocal_srel("TWIN, MONOZYGOTIC"), + Some("TWIN, MONOZYGOTIC") + ); + assert_eq!(get_reciprocal_srel("SIBLING"), Some("SIBLING")); + } + + #[test] + fn test_is_symmetric() { + assert!(is_symmetric_srel("TWIN, DIZYGOTIC")); + assert!(is_symmetric_srel("SIBLING")); + assert!(is_symmetric_srel("SPOUSE")); + assert!(!is_symmetric_srel("MOTHER, BIOLOGICAL")); + assert!(!is_symmetric_srel("HUSBAND")); + } + + #[test] + fn test_spouse_reciprocals() { + assert_eq!(get_reciprocal_srel("HUSBAND"), Some("WIFE")); + assert_eq!(get_reciprocal_srel("WIFE"), Some("HUSBAND")); + assert_eq!(get_reciprocal_srel("SPOUSE"), Some("SPOUSE")); + } + + #[test] + fn test_parent_for_child() { + assert_eq!( + get_parent_srel_for_child("CHILD, BIOLOGICAL", "F"), + Some("MOTHER, BIOLOGICAL") + ); + assert_eq!( + get_parent_srel_for_child("CHILD, BIOLOGICAL", "M"), + Some("FATHER, BIOLOGICAL") + ); + assert_eq!( + get_parent_srel_for_child("BIOLOGICAL", "F"), + Some("MOTHER, BIOLOGICAL") + ); + } + + #[test] + fn test_unknown_relationship() { + assert_eq!(get_reciprocal_srel("UNKNOWN_REL"), None); + } +} diff --git a/crates/tss-submit/src/map/state.rs b/crates/tss-submit/src/map/state.rs index ffedde23..c21f6ac8 100644 --- a/crates/tss-submit/src/map/state.rs +++ b/crates/tss-submit/src/map/state.rs @@ -35,16 +35,18 @@ pub struct MappingSummary { pub total_variables: usize, /// Number of variables with accepted mappings. pub mapped: usize, - /// Number of variables with suggestions (not yet accepted). - pub suggested: usize, + /// Number of variables still unmapped but required. + pub unmapped_required: usize, + /// Number of variables still unmapped but expected. + pub unmapped_expected: usize, + /// Number of variables still unmapped but permissible. + pub unmapped_permissible: usize, + /// Number of variables that are auto-generated. + pub auto_generated: usize, /// Number of variables marked as not collected. pub not_collected: usize, /// Number of variables marked to omit. pub omitted: usize, - /// Number of required variables. - pub required_total: usize, - /// Number of required variables that are mapped. - pub required_mapped: usize, } /// A single column-to-variable mapping. @@ -418,32 +420,28 @@ impl MappingState { /// Get summary statistics. pub fn summary(&self) -> MappingSummary { - let required_vars: Vec<_> = self - .domain - .variables - .iter() - .filter(|v| v.core == Some(CoreDesignation::Required)) - .collect(); - - // Count required variables that are mapped OR auto-generated - let required_mapped = required_vars - .iter() - .filter(|v| { - self.accepted.contains_key(&v.name) || self.auto_generated.contains(&v.name) - }) - .count(); - - // Count suggestions that haven't been accepted or otherwise assigned - let pending_suggestions = self - .suggestions - .keys() - .filter(|var| { - !self.accepted.contains_key(*var) - && !self.auto_generated.contains(*var) - && !self.not_collected.contains_key(*var) - && !self.omitted.contains(*var) - }) - .count(); + // Helper to check if a variable is handled (mapped, auto-gen, not-collected, or omitted) + let is_handled = |name: &str| { + self.accepted.contains_key(name) + || self.auto_generated.contains(name) + || self.not_collected.contains_key(name) + || self.omitted.contains(name) + }; + + // Count unmapped variables by core designation + let mut unmapped_required = 0; + let mut unmapped_expected = 0; + let mut unmapped_permissible = 0; + + for var in &self.domain.variables { + if !is_handled(&var.name) { + match var.core { + Some(CoreDesignation::Required) => unmapped_required += 1, + Some(CoreDesignation::Expected) => unmapped_expected += 1, + Some(CoreDesignation::Permissible) | None => unmapped_permissible += 1, + } + } + } // Total mapped includes both user-accepted and auto-generated let total_mapped = self.accepted.len() + self.auto_generated.len(); @@ -451,11 +449,12 @@ impl MappingState { MappingSummary { total_variables: self.domain.variables.len(), mapped: total_mapped, - suggested: pending_suggestions, + unmapped_required, + unmapped_expected, + unmapped_permissible, + auto_generated: self.auto_generated.len(), not_collected: self.not_collected.len(), omitted: self.omitted.len(), - required_total: required_vars.len(), - required_mapped, } } @@ -676,8 +675,8 @@ mod tests { let summary = state.summary(); assert_eq!(summary.total_variables, 3); assert_eq!(summary.mapped, 1); - assert_eq!(summary.required_total, 2); - assert_eq!(summary.required_mapped, 1); + // 2 required variables (USUBJID, AETERM), 1 mapped → 1 unmapped required + assert_eq!(summary.unmapped_required, 1); } #[test] @@ -847,7 +846,10 @@ mod tests { assert_eq!(summary.mapped, 1); assert_eq!(summary.not_collected, 1); assert_eq!(summary.omitted, 1); - assert_eq!(summary.suggested, 0); // No pending suggestions + // All variables are handled (mapped, not_collected, or omitted) + assert_eq!(summary.unmapped_required, 0); + assert_eq!(summary.unmapped_expected, 0); + assert_eq!(summary.unmapped_permissible, 0); } #[test]