diff --git a/pcap2fix/src/main.rs b/pcap2fix/src/main.rs
index 599c2e8..f2c36ba 100644
--- a/pcap2fix/src/main.rs
+++ b/pcap2fix/src/main.rs
@@ -772,4 +772,108 @@ mod tests {
assert!(out.is_empty());
assert_eq!(buf, b"8=FI");
}
+
+ #[test]
+ fn parse_delimiter_rejects_invalid_values() {
+ assert!(parse_delimiter("long").is_err());
+ assert!(parse_delimiter("\\xGG").is_err());
+ }
+
+ #[test]
+ fn find_message_end_rejects_invalid_body_length_and_checksum_fields() {
+ assert!(matches!(
+ find_message_end(b"8=FIX.4.4|35=0|10=000|", b'|'),
+ MessageEnd::Invalid
+ ));
+ assert!(matches!(
+ find_message_end(b"8=FIX.4.4|9=abc|35=0|10=000|", b'|'),
+ MessageEnd::Invalid
+ ));
+ assert!(matches!(
+ find_message_end(b"8=FIX.4.4|9=4|35=0|10=xyz|", b'|'),
+ MessageEnd::Invalid
+ ));
+ assert!(matches!(
+ find_message_end(b"8=FIX.4.4|9=4|35=0|10=000!", b'|'),
+ MessageEnd::Invalid
+ ));
+ }
+
+ #[test]
+ fn retain_partial_begin_string_clears_non_matching_tail() {
+ let mut buf = b"trailing-noise".to_vec();
+ retain_partial_begin_string(&mut buf);
+ assert!(buf.is_empty());
+ }
+
+ #[test]
+ fn append_segment_trims_overlap_and_store_future_segment_prefers_longest() {
+ let mut flow = FlowState {
+ next_seq: Some(14),
+ buffer: b"8=FIX.4.4|9=".to_vec(),
+ pending: BTreeMap::new(),
+ last_seen: Instant::now(),
+ };
+
+ append_segment(&mut flow, 12, b"9=5|35=0|");
+ assert_eq!(flow.buffer, b"8=FIX.4.4|9=5|35=0|");
+
+ store_future_segment(&mut flow, 30, b"short");
+ store_future_segment(&mut flow, 30, b"longer-segment");
+ assert_eq!(
+ flow.pending.get(&30).map(Vec::as_slice),
+ Some(&b"longer-segment"[..])
+ );
+ }
+
+ #[test]
+ fn reassembly_overflow_clears_flow_state() {
+ let mut flow = FlowState::default();
+ let mut out = Vec::new();
+ let err = reassemble_and_emit(&mut flow, 1, b"0123456789", b'|', 4, &mut out).unwrap_err();
+
+ assert!(err.to_string().contains("flow exceeded max buffer"));
+ assert!(flow.buffer.is_empty());
+ assert!(flow.pending.is_empty());
+ assert!(flow.next_seq.is_none());
+ }
+
+ #[test]
+ fn evict_idle_drops_stale_flows() {
+ let mut flows = HashMap::new();
+ flows.insert(
+ FlowKey {
+ src: "10.0.0.1".parse().unwrap(),
+ dst: "10.0.0.2".parse().unwrap(),
+ sport: 5000,
+ dport: 5001,
+ },
+ FlowState {
+ last_seen: Instant::now() - Duration::from_secs(120),
+ ..FlowState::default()
+ },
+ );
+ flows.insert(
+ FlowKey {
+ src: "10.0.0.3".parse().unwrap(),
+ dst: "10.0.0.4".parse().unwrap(),
+ sport: 5002,
+ dport: 5003,
+ },
+ FlowState::default(),
+ );
+
+ evict_idle(&mut flows, Duration::from_secs(60));
+
+ assert_eq!(flows.len(), 1);
+ assert!(flows.keys().any(|flow| flow.sport == 5002));
+ }
+
+ #[test]
+ fn open_reader_errors_for_missing_file() {
+ let err = open_reader("/definitely/missing/file.pcap")
+ .err()
+ .expect("missing file should error");
+ assert!(err.to_string().contains("open pcap"));
+ }
}
diff --git a/src/bin/generate_sensitive_tags.rs b/src/bin/generate_sensitive_tags.rs
index be82d8e..ab2fbfb 100644
--- a/src/bin/generate_sensitive_tags.rs
+++ b/src/bin/generate_sensitive_tags.rs
@@ -170,3 +170,157 @@ struct FixField {
#[serde(rename = "@name")]
name: String,
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::{Mutex, OnceLock};
+ use tempfile::tempdir;
+
+ fn xml_with_fields(fields: &[(u32, &str)]) -> String {
+ let body = fields
+ .iter()
+ .map(|(number, name)| format!(r#""#))
+ .collect::>()
+ .join("");
+ format!(r#"{body}"#)
+ }
+
+ fn cwd_lock() -> &'static Mutex<()> {
+ static LOCK: OnceLock> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(()))
+ }
+
+ fn with_current_dir(dir: &Path, f: impl FnOnce() -> T) -> T {
+ let _guard = cwd_lock().lock().expect("cwd lock");
+ let original = env::current_dir().expect("current dir");
+ env::set_current_dir(dir).expect("set current dir");
+ let result = f();
+ env::set_current_dir(original).expect("restore current dir");
+ result
+ }
+
+ #[test]
+ fn find_repo_root_walks_up_from_nested_directories() {
+ let temp = tempdir().expect("tempdir");
+ let repo_root = temp.path();
+ fs::write(
+ repo_root.join("Cargo.toml"),
+ "[package]\nname='demo'\nversion='0.1.0'\n",
+ )
+ .expect("cargo file");
+ fs::create_dir(repo_root.join("resources")).expect("resources dir");
+ let nested = repo_root.join("a/b/c");
+ fs::create_dir_all(&nested).expect("nested dirs");
+
+ let found = with_current_dir(&nested, || find_repo_root().expect("repo root"));
+
+ assert_eq!(
+ found.canonicalize().expect("canonical found path"),
+ repo_root.canonicalize().expect("canonical repo root")
+ );
+ }
+
+ #[test]
+ fn collect_xml_paths_returns_sorted_xml_files_only() {
+ let temp = tempdir().expect("tempdir");
+ let resources = temp.path();
+ fs::write(resources.join("b.xml"), "").expect("write b.xml");
+ fs::write(resources.join("a.xml"), "").expect("write a.xml");
+ fs::write(resources.join("notes.txt"), "ignore").expect("write txt");
+
+ let paths = collect_xml_paths(resources).expect("collect xml paths");
+ let names: Vec<_> = paths
+ .iter()
+ .map(|path| path.file_name().and_then(|name| name.to_str()).unwrap())
+ .collect();
+
+ assert_eq!(names, vec!["a.xml", "b.xml"]);
+ }
+
+ #[test]
+ fn load_fields_parses_and_deduplicates_by_tag_number() {
+ let temp = tempdir().expect("tempdir");
+ let first = temp.path().join("first.xml");
+ let second = temp.path().join("second.xml");
+ fs::write(
+ &first,
+ xml_with_fields(&[(1, "Account"), (2, "Password"), (50, "SenderSubID")]),
+ )
+ .expect("write first xml");
+ fs::write(
+ &second,
+ xml_with_fields(&[(2, "ShouldNotReplace"), (554, "Password")]),
+ )
+ .expect("write second xml");
+
+ let fields = load_fields(&[first, second]).expect("load fields");
+
+ assert_eq!(fields.get(&1).map(String::as_str), Some("Account"));
+ assert_eq!(fields.get(&2).map(String::as_str), Some("Password"));
+ assert_eq!(fields.get(&554).map(String::as_str), Some("Password"));
+ }
+
+ #[test]
+ fn filter_sensitive_selects_expected_field_names() {
+ let fields = BTreeMap::from([
+ (1, "Account".to_string()),
+ (49, "SenderCompID".to_string()),
+ (50, "SenderSubID".to_string()),
+ (553, "Username".to_string()),
+ (9999, "Unrelated".to_string()),
+ ]);
+
+ let filtered = filter_sensitive(&fields);
+
+ assert_eq!(filtered.len(), 4);
+ assert!(filtered.contains_key(&1));
+ assert!(filtered.contains_key(&49));
+ assert!(filtered.contains_key(&50));
+ assert!(filtered.contains_key(&553));
+ assert!(!filtered.contains_key(&9999));
+ }
+
+ #[test]
+ fn write_output_creates_parent_directory_and_serialises_tags() {
+ let temp = tempdir().expect("tempdir");
+ let out = temp.path().join("src/fix/sensitive.rs");
+ let tags = BTreeMap::from([(1, "Account".to_string()), (554, "Password".to_string())]);
+
+ write_output(&out, &tags).expect("write output");
+
+ let rendered = fs::read_to_string(out).expect("read output");
+ assert!(rendered.contains("SENSITIVE_TAG_NAMES"));
+ assert!(rendered.contains("map.insert(1, \"Account\");"));
+ assert!(rendered.contains("map.insert(554, \"Password\");"));
+ }
+
+ #[test]
+ fn run_generates_sensitive_file_from_repo_resources() {
+ let temp = tempdir().expect("tempdir");
+ let repo_root = temp.path();
+ fs::write(
+ repo_root.join("Cargo.toml"),
+ "[package]\nname='demo'\nversion='0.1.0'\n",
+ )
+ .expect("cargo file");
+ fs::create_dir_all(repo_root.join("resources")).expect("resources dir");
+ fs::create_dir_all(repo_root.join("src/fix")).expect("src/fix dir");
+ fs::write(
+ repo_root.join("resources/fix44.xml"),
+ xml_with_fields(&[(1, "Account"), (35, "MsgType"), (554, "Password")]),
+ )
+ .expect("write fix xml");
+ let nested = repo_root.join("tests/nested");
+ fs::create_dir_all(&nested).expect("nested dir");
+
+ let result = with_current_dir(&nested, run);
+
+ result.expect("run should succeed");
+ let generated = fs::read_to_string(repo_root.join("src/fix/sensitive.rs"))
+ .expect("generated sensitive.rs");
+ assert!(generated.contains("map.insert(1, \"Account\");"));
+ assert!(generated.contains("map.insert(554, \"Password\");"));
+ assert!(!generated.contains("MsgType"));
+ }
+}
diff --git a/src/decoder/validator.rs b/src/decoder/validator.rs
index 30585ae..822bfb5 100644
--- a/src/decoder/validator.rs
+++ b/src/decoder/validator.rs
@@ -479,7 +479,7 @@ mod tests {
use super::*;
use crate::decoder::schema::{
ComponentContainer, ComponentDef, Field, FieldContainer, FieldRef, FixDictionary, GroupDef,
- Message, MessageContainer, ValuesWrapper,
+ Message, MessageContainer, Value, ValuesWrapper,
};
const SOH: &str = "\u{0001}";
@@ -494,6 +494,22 @@ mod tests {
}
}
+ fn enum_field(name: &str, number: u32, field_type: &str, values: &[(&str, &str)]) -> Field {
+ Field {
+ name: name.to_string(),
+ number,
+ field_type: field_type.to_string(),
+ values: values
+ .iter()
+ .map(|(enumeration, description)| Value {
+ enumeration: (*enumeration).to_string(),
+ description: (*description).to_string(),
+ })
+ .collect(),
+ values_wrapper: ValuesWrapper::default(),
+ }
+ }
+
fn test_lookup() -> FixTagLookup {
let dict = FixDictionary {
typ: "FIX".to_string(),
@@ -506,8 +522,12 @@ mod tests {
field("BodyLength", 9, "LENGTH"),
field("MsgType", 35, "STRING"),
field("CheckSum", 10, "STRING"),
+ enum_field("Side", 54, "CHAR", &[("1", "Buy"), ("2", "Sell")]),
+ field("TransactTime", 60, "UTCTIMESTAMP"),
field("NoItems", 100, "NUMINGROUP"),
field("ItemValue", 101, "STRING"),
+ field("ItemCode", 102, "STRING"),
+ field("ItemExtra", 103, "STRING"),
],
},
messages: MessageContainer {
@@ -522,10 +542,20 @@ mod tests {
groups: vec![GroupDef {
name: "NoItems".to_string(),
required: Some("Y".to_string()),
- fields: vec![FieldRef {
- name: "ItemValue".to_string(),
- required: Some("N".to_string()),
- }],
+ fields: vec![
+ FieldRef {
+ name: "ItemValue".to_string(),
+ required: Some("N".to_string()),
+ },
+ FieldRef {
+ name: "ItemCode".to_string(),
+ required: Some("N".to_string()),
+ },
+ FieldRef {
+ name: "ItemExtra".to_string(),
+ required: Some("N".to_string()),
+ },
+ ],
groups: Vec::new(),
components: Vec::new(),
}],
@@ -652,4 +682,129 @@ mod tests {
"tag error map should include tag 35 when missing"
);
}
+
+ #[test]
+ fn detects_unknown_msg_type() {
+ let dict = test_lookup();
+ let msg = build_message(&[(35, "Q"), (100, "0")], None);
+ let report = validate_fix_message(&msg, &dict);
+ assert!(
+ report
+ .errors
+ .iter()
+ .any(|e| e.contains("Unknown MsgType: Q")),
+ "expected unknown MsgType error, got {:?}",
+ report.errors
+ );
+ assert!(
+ report
+ .tag_errors
+ .get(&35)
+ .is_some_and(|errs| errs.iter().any(|e| e.contains("Unknown MsgType: Q")))
+ );
+ }
+
+ #[test]
+ fn detects_invalid_enum_and_type() {
+ let dict = test_lookup();
+ let msg = build_message(&[(35, "Z"), (54, "LONG"), (100, "0")], None);
+ let report = validate_fix_message(&msg, &dict);
+ assert!(
+ report
+ .errors
+ .iter()
+ .any(|e| e.contains("Invalid enum value 'LONG'")),
+ "expected enum validation error, got {:?}",
+ report.errors
+ );
+ assert!(
+ report
+ .errors
+ .iter()
+ .any(|e| e.contains("Invalid type: expected CHAR")),
+ "expected type validation error, got {:?}",
+ report.errors
+ );
+ }
+
+ #[test]
+ fn detects_invalid_numingroup_and_tag_outside_group() {
+ let dict = test_lookup();
+ let msg = build_message(&[(35, "Z"), (100, "oops"), (101, "ALPHA")], None);
+ let report = validate_fix_message(&msg, &dict);
+ assert!(
+ report
+ .errors
+ .iter()
+ .any(|e| e.contains("Invalid NumInGroup value 'oops'")),
+ "expected invalid NumInGroup error, got {:?}",
+ report.errors
+ );
+ assert!(
+ report
+ .errors
+ .iter()
+ .any(|e| e.contains("appears outside of repeating group 100")),
+ "expected out-of-group error, got {:?}",
+ report.errors
+ );
+ }
+
+ #[test]
+ fn detects_out_of_order_tags_within_group() {
+ let dict = test_lookup();
+ let msg = build_message(
+ &[
+ (35, "Z"),
+ (100, "1"),
+ (101, "ALPHA"),
+ (103, "EXTRA"),
+ (102, "CODE"),
+ ],
+ None,
+ );
+ let report = validate_fix_message(&msg, &dict);
+ assert!(
+ report
+ .errors
+ .iter()
+ .any(|e| e.contains("Tag 102 (ItemCode) out of order within repeating group 100")),
+ "expected group ordering error, got {:?}",
+ report.errors
+ );
+ }
+
+ #[test]
+ fn missing_checksum_is_reported() {
+ let dict = test_lookup();
+ let msg = format!("8=FIX.4.4{SOH}9=010{SOH}35=Z{SOH}100=0{SOH}");
+ let report = validate_fix_message(&msg, &dict);
+ assert!(
+ report
+ .errors
+ .iter()
+ .any(|e| e.contains("Missing required checksum tag 10")),
+ "expected missing checksum error, got {:?}",
+ report.errors
+ );
+ }
+
+ #[test]
+ fn helper_type_validators_cover_temporal_formats() {
+ assert!(is_valid_type("20240101-00:00:00", "UTCTIMESTAMP"));
+ assert!(!is_valid_type("2024-01-01 00:00:00", "UTCTIMESTAMP"));
+ assert!(is_valid_type("20240101", "UTCDATEONLY"));
+ assert!(is_valid_type("12:34:56.789", "UTCTIMEONLY"));
+ assert!(!is_valid_type("25:00:00", "UTCTIMEONLY"));
+ assert!(is_valid_type("202401", "MONTHYEAR"));
+ assert!(is_valid_type("202401w3", "MONTHYEAR"));
+ assert!(!is_valid_type("2024-13", "MONTHYEAR"));
+ }
+
+ #[test]
+ fn helper_checksum_and_body_length_fall_back_when_fields_are_missing() {
+ let msg = format!("8=FIX.4.4{SOH}9=005{SOH}35=Z{SOH}");
+ assert_eq!(calculate_checksum(&msg), -1);
+ assert_eq!(compute_actual_body_length(&msg), None);
+ }
}
diff --git a/src/fix/mod.rs b/src/fix/mod.rs
index 8b8a8f0..787836f 100644
--- a/src/fix/mod.rs
+++ b/src/fix/mod.rs
@@ -21,3 +21,26 @@ pub fn supported_fix_versions() -> &'static str {
pub fn create_obfuscator(enabled: bool) -> Obfuscator {
Obfuscator::from_sensitive_tags(&SENSITIVE_TAG_NAMES, enabled)
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn choose_embedded_xml_defaults_to_fix44() {
+ let xml = choose_embedded_xml("unknown");
+ assert!(xml.contains(" Result<()> {
mod tests {
use super::*;
use std::collections::HashMap;
+ use std::io::Write;
+ use tempfile::NamedTempFile;
fn dummy_opts(version: &str) -> CliOptions {
CliOptions {
@@ -1991,4 +1993,100 @@ mod tests {
assert_eq!(dictionary_marker(Some("fix44"), "FIX50"), " ");
assert_eq!(dictionary_marker(None, "FIX44"), " ");
}
+
+ #[test]
+ fn parse_paging_rejects_empty_and_unknown_values() {
+ assert!(parse_paging(Some(&"".to_string())).is_err());
+ assert!(parse_paging(Some(&"sometimes".to_string())).is_err());
+ }
+
+ #[test]
+ fn parse_output_style_value_rejects_invalid_component() {
+ let err = parse_output_style_value("header,wat").unwrap_err();
+ assert!(err.to_string().contains("invalid --style component"));
+ }
+
+ #[test]
+ fn find_message_supports_name_and_msg_type_queries() {
+ let schema = load_schema_for_key("FIX44", &HashMap::new()).expect("load FIX44 schema");
+
+ let by_name = find_message(&schema, "Heartbeat").expect("lookup by name");
+ let by_type = find_message(&schema, "0").expect("lookup by msg type");
+
+ assert_eq!(by_name.msg_type, "0");
+ assert_eq!(by_type.name, "Heartbeat");
+ assert!(find_message(&schema, "NoSuchMessage").is_none());
+ }
+
+ #[test]
+ fn session_component_helpers_cover_fix50_family() {
+ assert!(!requires_session_components("FIX44"));
+ assert!(requires_session_components("FIX50"));
+ assert!(requires_session_components("FIX50SP1"));
+ assert!(requires_session_components("FIX50SP2"));
+ assert_eq!(key_to_xml_id("fix44"), Some("44"));
+ assert_eq!(key_to_xml_id("FIXT11"), Some("T11"));
+ assert_eq!(key_to_xml_id("FIX99"), None);
+ }
+
+ #[test]
+ fn ensure_session_components_backfills_missing_fix50_header_and_trailer() {
+ let mut dict =
+ FixDictionary::from_xml(fix::choose_embedded_xml("50")).expect("parse FIX50");
+ dict.header = Default::default();
+ dict.trailer = Default::default();
+
+ ensure_session_components("FIX50", &mut dict);
+
+ assert!(component_def_has_entries(&dict.header));
+ assert!(component_def_has_entries(&dict.trailer));
+ }
+
+ #[test]
+ fn component_def_has_entries_detects_fields_groups_and_components() {
+ let mut block = decoder::schema::ComponentDef::default();
+ assert!(!component_def_has_entries(&block));
+
+ block.fields.push(decoder::schema::FieldRef {
+ name: "BeginString".into(),
+ required: Some("Y".into()),
+ });
+ assert!(component_def_has_entries(&block));
+
+ let mut block = decoder::schema::ComponentDef::default();
+ block.groups.push(decoder::schema::GroupDef {
+ name: "NoPartyIDs".into(),
+ required: Some("N".into()),
+ fields: Vec::new(),
+ groups: Vec::new(),
+ components: Vec::new(),
+ });
+ assert!(component_def_has_entries(&block));
+
+ let mut block = decoder::schema::ComponentDef::default();
+ block.components.push(decoder::schema::ComponentRef {
+ name: "Header".into(),
+ _required: Some("Y".into()),
+ });
+ assert!(component_def_has_entries(&block));
+ }
+
+ #[test]
+ fn load_custom_dictionaries_keeps_last_duplicate_entry() {
+ let mut first = NamedTempFile::new().expect("temp xml");
+ let mut second = NamedTempFile::new().expect("temp xml");
+ write!(first, "{}", fix::choose_embedded_xml("44")).expect("write first xml");
+ write!(second, "{}", fix::choose_embedded_xml("44")).expect("write second xml");
+
+ let paths = vec![
+ first.path().display().to_string(),
+ second.path().display().to_string(),
+ ];
+ let dicts = load_custom_dictionaries(&paths).expect("load custom dictionaries");
+
+ let fix44 = dicts.get("FIX44").expect("FIX44 custom dictionary");
+ assert_eq!(fix44.path, second.path().display().to_string());
+ assert_eq!(fix44.dict.major, "4");
+ assert_eq!(fix44.dict.minor, "4");
+ }
}
diff --git a/tests/cli.rs b/tests/cli.rs
index ffb1c0b..2e59b9f 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -200,3 +200,140 @@ fn explicit_header_style_renders_source_banner_for_files() {
.success()
.stdout(contains(expected));
}
+
+#[test]
+fn info_flag_lists_available_dictionaries_and_highlights_selection() {
+ cargo_bin_cmd!("fixdecoder")
+ .args(["--fix=44", "--info"])
+ .assert()
+ .success()
+ .stdout(
+ contains("Available FIX Dictionaries")
+ .and(contains("Loaded dictionaries"))
+ .and(contains("FIX44")),
+ );
+}
+
+#[test]
+fn message_listing_works_in_plain_and_column_modes() {
+ cargo_bin_cmd!("fixdecoder")
+ .args(["--fix=44", "--message"])
+ .assert()
+ .success()
+ .stdout(contains("Heartbeat").and(contains("ExecutionReport")));
+
+ cargo_bin_cmd!("fixdecoder")
+ .args(["--fix=44", "--message", "--column"])
+ .assert()
+ .success()
+ .stdout(contains("Heartbeat").and(contains("Logon")));
+}
+
+#[test]
+fn message_detail_accepts_msg_type_lookup() {
+ cargo_bin_cmd!("fixdecoder")
+ .args([
+ "--fix=44",
+ "--message",
+ "0",
+ "--verbose",
+ "--header",
+ "--trailer",
+ "--column",
+ ])
+ .assert()
+ .success()
+ .stdout(
+ contains("Message: ")
+ .and(contains("Heartbeat"))
+ .and(contains("Header"))
+ .and(contains("Trailer"))
+ .and(contains("HEARTBEAT")),
+ );
+}
+
+#[test]
+fn missing_message_is_reported() {
+ cargo_bin_cmd!("fixdecoder")
+ .args(["--fix=44", "--message", "NoSuchMessage"])
+ .assert()
+ .success()
+ .stdout(contains("Message not found: NoSuchMessage"));
+}
+
+#[test]
+fn tag_listing_works_in_plain_and_column_modes() {
+ cargo_bin_cmd!("fixdecoder")
+ .args(["--fix=44", "--tag"])
+ .assert()
+ .success()
+ .stdout(contains("BeginString").and(contains("CheckSum")));
+
+ cargo_bin_cmd!("fixdecoder")
+ .args(["--fix=44", "--tag", "--column"])
+ .assert()
+ .success()
+ .stdout(contains("BeginString").and(contains("ClOrdID")));
+}
+
+#[test]
+fn tag_detail_verbose_columns_show_enum_values() {
+ cargo_bin_cmd!("fixdecoder")
+ .args(["--fix=44", "--tag", "54", "--verbose", "--column"])
+ .assert()
+ .success()
+ .stdout(contains("Side").and(contains("BUY")).and(contains("SELL")));
+}
+
+#[test]
+fn invalid_and_missing_tags_are_reported() {
+ cargo_bin_cmd!("fixdecoder")
+ .args(["--fix=44", "--tag", "nope"])
+ .assert()
+ .failure()
+ .stderr(contains("Invalid tag: nope"));
+
+ cargo_bin_cmd!("fixdecoder")
+ .args(["--fix=44", "--tag", "999999"])
+ .assert()
+ .success()
+ .stdout(contains("Tag not found: 999999"));
+}
+
+#[test]
+fn component_listing_works_in_plain_and_column_modes() {
+ cargo_bin_cmd!("fixdecoder")
+ .args(["--fix=44", "--component"])
+ .assert()
+ .success()
+ .stdout(contains("Header").and(contains("CommissionData")));
+
+ cargo_bin_cmd!("fixdecoder")
+ .args(["--fix=44", "--component", "--column"])
+ .assert()
+ .success()
+ .stdout(contains("Header").and(contains("Parties")));
+}
+
+#[test]
+fn component_detail_verbose_columns_show_fields() {
+ cargo_bin_cmd!("fixdecoder")
+ .args(["--fix=44", "--component", "Header", "--verbose", "--column"])
+ .assert()
+ .success()
+ .stdout(
+ contains("Component: ")
+ .and(contains("Header"))
+ .and(contains("BeginString"))
+ .and(contains("NEW_ORDER_SINGLE")),
+ );
+}
+
+#[test]
+fn missing_component_is_reported() {
+ cargo_bin_cmd!("fixdecoder")
+ .args(["--fix=44", "--component", "NoSuchComponent"])
+ .assert()
+ .success()
+ .stdout(contains("Component not found: NoSuchComponent"));
+}