nbt_sniffer/
lib.rs

1pub mod cli;
2pub mod counter;
3pub mod nbt_utils;
4pub mod tree;
5pub mod view;
6
7use std::{
8    collections::HashMap,
9    io::{Cursor, Read},
10    path::{Path, PathBuf},
11};
12
13use cli::{CliArgs, ItemFilter};
14use counter::{Counter, CounterMap};
15use flate2::read::GzDecoder;
16use mca::RegionReader;
17use nbt_utils::{convert_simdnbt_to_valence_nbt, get_entity_pos_string};
18use ptree::print_tree;
19use serde::{Deserialize, Serialize};
20use tree::ItemSummaryNode;
21use valence_nbt::Value;
22
23const CHUNK_PER_REGION_SIDE: usize = 32;
24
25#[derive(Debug, Clone, PartialEq, Eq, Hash)]
26pub struct Scope {
27    pub dimension: String,
28    pub data_type: DataType,
29}
30
31#[derive(
32    Debug,
33    Clone,
34    Copy,
35    PartialEq,
36    Eq,
37    Hash,
38    PartialOrd,
39    Ord,
40    Serialize,
41    Deserialize,
42    strum::Display,
43    strum::EnumString,
44    strum::EnumIter,
45)]
46pub enum DataType {
47    #[strum(to_string = "Block Entity")]
48    BlockEntity,
49    #[strum(to_string = "Entity")]
50    Entity,
51    #[strum(to_string = "Player Data")]
52    Player,
53}
54
55pub struct ScanTask {
56    pub path: PathBuf,
57    pub scope: Scope,
58}
59
60pub fn list_mca_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
61    let entries = std::fs::read_dir(dir)
62        .map_err(|e| format!("Error: failed to read directory '{}': {e}", dir.display()))?;
63
64    let mut mca_files = Vec::new();
65    for entry_res in entries {
66        match entry_res {
67            Ok(de) => {
68                let path = de.path();
69                if path.extension().and_then(|e| e.to_str()) == Some("mca") {
70                    mca_files.push(path);
71                }
72            }
73            Err(e) => {
74                eprintln!(
75                    "Warning: failed to read an entry in '{}': {}",
76                    dir.display(),
77                    e
78                );
79            }
80        }
81    }
82    Ok(mca_files)
83}
84
85pub fn process_task(
86    task: ScanTask,
87    queries: &[ItemFilter],
88    args: &CliArgs,
89    user_cache: &HashMap<String, String>,
90) -> CounterMap {
91    let mut counter = Counter::new();
92    match task.scope.data_type {
93        DataType::BlockEntity => process_region_file(&task, queries, args, &mut counter),
94        DataType::Entity => process_entities_file(&task, queries, args, &mut counter),
95        DataType::Player => process_player_file(&task, queries, args, &mut counter, user_cache),
96    }
97    let mut map = CounterMap::new();
98    map.merge_scope(task.scope, &counter);
99    map
100}
101
102/// Generic function to process a region file, iterating through its chunks
103/// and applying a given chunk processing function.
104fn process_any_region_file<F>(
105    task: &ScanTask,
106    item_queries: &[ItemFilter],
107    cli_args: &CliArgs,
108    counter: &mut Counter,
109    process_chunk_fn: F,
110) where
111    F: Fn(&mca::RawChunk, usize, usize, &ScanTask, &[ItemFilter], &CliArgs, &mut Counter),
112{
113    let region_file_path = &task.path;
114    let data = match std::fs::read(region_file_path) {
115        Ok(d) => d,
116        Err(e) => {
117            if cli_args.verbose {
118                eprintln!("Failed to read file {}: {e}", region_file_path.display());
119            }
120            return;
121        }
122    };
123
124    let region_reader = match RegionReader::new(&data) {
125        Ok(r) => r,
126        Err(e) => {
127            if cli_args.verbose {
128                eprintln!(
129                    "Failed to parse region file {}: {e}",
130                    region_file_path.display()
131                );
132            }
133            return;
134        }
135    };
136
137    for cy in 0..CHUNK_PER_REGION_SIDE {
138        for cx in 0..CHUNK_PER_REGION_SIDE {
139            let chunk_data = match region_reader.get_chunk(cx, cy) {
140                Ok(Some(c)) => c,
141                Ok(None) => continue, // No chunk data
142                Err(e) => {
143                    if cli_args.verbose {
144                        eprintln!(
145                            "Failed to get chunk ({cx}, {cy}) from {}: {e}",
146                            region_file_path.display()
147                        );
148                    }
149                    continue;
150                }
151            };
152            process_chunk_fn(&chunk_data, cx, cy, task, item_queries, cli_args, counter);
153        }
154    }
155}
156
157/// Scans one region file for block entities.
158pub fn process_region_file(
159    task: &ScanTask,
160    item_queries: &[ItemFilter],
161    cli_args: &CliArgs,
162    counter: &mut Counter,
163) {
164    process_any_region_file(
165        task,
166        item_queries,
167        cli_args,
168        counter,
169        process_chunk_for_block_entities,
170    );
171}
172
173/// Scans one region file for regular entities.
174/// Also merges all found items into the global `counter`.
175pub fn process_entities_file(
176    task: &ScanTask,
177    item_queries: &[ItemFilter],
178    cli_args: &CliArgs,
179    counter: &mut Counter,
180) {
181    process_any_region_file(
182        task,
183        item_queries,
184        cli_args,
185        counter,
186        process_chunk_for_entities,
187    );
188}
189
190/// Generic function to process NBT data from a chunk for a list of compounds.
191#[allow(clippy::too_many_arguments)] // TODO refactor
192fn process_chunk_nbt_list<F>(
193    chunk_data: &mca::RawChunk,
194    cy: usize,
195    cx: usize,
196    task: &ScanTask,
197    item_queries: &[ItemFilter],
198    cli_args: &CliArgs,
199    counter: &mut Counter,
200    nbt_list_name: &str,
201    process_nbt_compound_fn: F,
202) where
203    F: Fn(simdnbt::borrow::NbtCompound, &ScanTask, &[ItemFilter], &CliArgs, &mut Counter),
204{
205    let region_file_path = &task.path;
206    let decompressed_data = match chunk_data.decompress() {
207        Ok(d) => d,
208        Err(e) => {
209            if cli_args.verbose {
210                eprintln!(
211                    "Failed to decompress chunk ({cx}, {cy}) in {}: {e}",
212                    region_file_path.display()
213                );
214            }
215            return;
216        }
217    };
218    let mut cursor = Cursor::new(decompressed_data.as_slice());
219    let nbt_root = match simdnbt::borrow::read(&mut cursor) {
220        Ok(simdnbt::borrow::Nbt::Some(nbt)) => nbt,
221        Ok(simdnbt::borrow::Nbt::None) => {
222            if cli_args.verbose {
223                eprintln!(
224                    "No NBT data found in chunk ({cx}, {cy}) in {}",
225                    region_file_path.display()
226                );
227            }
228            return;
229        }
230        Err(e) => {
231            if cli_args.verbose {
232                eprintln!(
233                    "Failed to read NBT data for chunk ({cx}, {cy}) in {}: {e}",
234                    region_file_path.display()
235                );
236            }
237            return;
238        }
239    };
240
241    let Some(compounds_list) = nbt_root.list(nbt_list_name).and_then(|l| l.compounds()) else {
242        // If the list is not found or is not a list of compounds, this is normal (e.g., chunk with no relevant entities).
243        return;
244    };
245
246    for nbt_compound in compounds_list {
247        process_nbt_compound_fn(nbt_compound, task, item_queries, cli_args, counter);
248    }
249}
250
251/// Processes a single chunk for block entities.
252fn process_chunk_for_block_entities(
253    chunk_data: &mca::RawChunk,
254    cx: usize,
255    cy: usize,
256    task: &ScanTask,
257    item_queries: &[ItemFilter],
258    cli_args: &CliArgs,
259    counter: &mut Counter,
260) {
261    process_chunk_nbt_list(
262        chunk_data,
263        cx,
264        cy,
265        task,
266        item_queries,
267        cli_args,
268        counter,
269        "block_entities", // NBT key for block entities in a chunk
270        process_block_entity,
271    );
272}
273
274/// Processes a single chunk for regular entities.
275fn process_chunk_for_entities(
276    chunk_data: &mca::RawChunk,
277    cx: usize,
278    cy: usize,
279    task: &ScanTask,
280    item_queries: &[ItemFilter],
281    cli_args: &CliArgs,
282    counter: &mut Counter,
283) {
284    process_chunk_nbt_list(
285        chunk_data,
286        cx,
287        cy,
288        task,
289        item_queries,
290        cli_args,
291        counter,
292        "Entities", // NBT key for entities in a chunk
293        process_single_entity,
294    );
295}
296
297/// Processes a player data file (.dat or level.dat for the player section).
298fn process_player_file(
299    task: &ScanTask,
300    queries: &[ItemFilter],
301    cli_args: &CliArgs,
302    counter: &mut Counter,
303    user_cache: &HashMap<String, String>,
304) {
305    let file_path = &task.path;
306    let file_data = match std::fs::read(file_path) {
307        Ok(d) => d,
308        Err(e) => {
309            if cli_args.verbose {
310                eprintln!("Failed to read player file {}: {e}", file_path.display());
311            }
312            return;
313        }
314    };
315
316    let mut decompressor = GzDecoder::new(file_data.as_slice());
317    let mut decompressed_data = Vec::new();
318    if let Err(e) = decompressor.read_to_end(&mut decompressed_data) {
319        if cli_args.verbose {
320            eprintln!(
321                "Failed to decompress player file {}: {e}",
322                file_path.display(),
323            );
324        }
325        return;
326    }
327
328    let mut cursor = Cursor::new(decompressed_data.as_slice());
329    let nbt_root_container = match simdnbt::borrow::read(&mut cursor) {
330        Ok(nbt) => nbt,
331        Err(e) => {
332            if cli_args.verbose {
333                eprintln!(
334                    "Failed to read NBT for player file {}: {e}",
335                    file_path.display(),
336                );
337            }
338            return;
339        }
340    };
341
342    let nbt_root = match nbt_root_container {
343        simdnbt::borrow::Nbt::Some(nbt) => nbt,
344        simdnbt::borrow::Nbt::None => {
345            if cli_args.verbose {
346                eprintln!("No NBT data found in player file {}", file_path.display());
347            }
348            return;
349        }
350    };
351
352    let (player_nbt_compound_opt, source_id, base_location_str): (
353        Option<simdnbt::borrow::NbtCompound>,
354        String,
355        String,
356    ) = if file_path
357        .file_name()
358        .is_some_and(|name| name == "level.dat")
359    {
360        // Handle level.dat for single-player
361        nbt_root
362            .compound(nbt_utils::NBT_KEY_PLAYER_DATA)
363            .and_then(|data_compound| data_compound.compound(nbt_utils::NBT_KEY_PLAYER))
364            .map_or_else(
365                || {
366                    if cli_args.verbose {
367                        eprintln!(
368                            "Player data not found in level.dat: {}",
369                            file_path.display()
370                        );
371                    }
372                    (None, "".to_string(), "".to_string())
373                },
374                |player_data| {
375                    (
376                        Some(player_data),
377                        "Player (level.dat)".to_string(),
378                        "level.dat".to_string(),
379                    )
380                },
381            )
382    } else {
383        // Handle individual <uuid>.dat files
384        let player_uuid = file_path
385            .file_stem()
386            .and_then(|s| s.to_str())
387            .unwrap_or("UnknownPlayer")
388            .to_string();
389
390        // Attempt to get player name from user_cache
391        // player_uuid from file_stem is usually without hyphens.
392        // user_cache keys are stored as hyphenated lowercase UUIDs.
393        let display_name = uuid::Uuid::parse_str(&player_uuid)
394            .ok()
395            .and_then(|u| user_cache.get(&u.to_string())) // Uuid::to_string() is hyphenated lowercase
396            .map_or_else(
397                || player_uuid.clone(),
398                |name| format!("{name} ({player_uuid})"),
399            );
400
401        (
402            Some(nbt_root.as_compound()),
403            display_name,
404            file_path
405                .file_name()
406                .unwrap_or_default()
407                .to_string_lossy()
408                .into_owned(),
409        )
410    };
411
412    if let Some(player_nbt) = player_nbt_compound_opt {
413        let location_str = get_entity_pos_string(&player_nbt).unwrap_or(base_location_str); // Player NBT also has "Pos"
414
415        process_player_nbt_compound(
416            player_nbt,
417            task,
418            queries,
419            cli_args,
420            counter,
421            &source_id,
422            &location_str,
423        );
424    }
425}
426
427/// Extracts the single-player's UUID string from the level.dat file, if present.
428pub fn extract_single_player_uuid_from_level_dat(
429    level_dat_path: &Path,
430    cli_args: &CliArgs,
431) -> Option<String> {
432    if let Ok(file_data) = std::fs::read(level_dat_path) {
433        let mut decompressor = GzDecoder::new(file_data.as_slice());
434        let mut decompressed_data = Vec::new();
435        if decompressor.read_to_end(&mut decompressed_data).is_ok() {
436            let mut cursor = Cursor::new(decompressed_data.as_slice());
437            // We need to ensure nbt_root lives as long as player_compound if we don't copy
438            // Since get_uuid_from_nbt takes a reference, this is fine.
439            if let Ok(simdnbt::borrow::Nbt::Some(nbt_root)) = simdnbt::borrow::read(&mut cursor) {
440                if let Some(player_compound) = nbt_root
441                    .compound(nbt_utils::NBT_KEY_PLAYER_DATA)
442                    .and_then(|data_compound| data_compound.compound(nbt_utils::NBT_KEY_PLAYER))
443                {
444                    return nbt_utils::get_uuid_from_nbt(&player_compound);
445                } else if cli_args.verbose {
446                    eprintln!(
447                        "Player NBT compound (Data.Player) not found in {}.",
448                        level_dat_path.display()
449                    );
450                }
451            }
452        }
453    }
454    None
455}
456
457/// Processes the NBT compound for a single player's data.
458fn process_player_nbt_compound(
459    player_nbt: simdnbt::borrow::NbtCompound,
460    task: &ScanTask,
461    item_queries: &[ItemFilter],
462    cli_args: &CliArgs,
463    counter: &mut Counter,
464    source_id: &str,
465    location_str: &str,
466) {
467    let mut summary_nodes = Vec::new();
468
469    if let Some(item_list) = player_nbt
470        .list(nbt_utils::NBT_KEY_INVENTORY)
471        .and_then(|l| l.compounds())
472    {
473        for item_compound in item_list {
474            collect_summary_node(
475                &item_compound,
476                cli_args,
477                item_queries,
478                &mut summary_nodes,
479                counter,
480            );
481        }
482    }
483
484    if let Some(item_list) = player_nbt
485        .list(nbt_utils::NBT_KEY_ENDER_ITEMS)
486        .and_then(|l| l.compounds())
487    {
488        for item_compound in item_list {
489            collect_summary_node(
490                &item_compound,
491                cli_args,
492                item_queries,
493                &mut summary_nodes,
494                counter,
495            );
496        }
497    }
498
499    if let Some(holder_compound) = player_nbt.compound(nbt_utils::NBT_KEY_EQUIPMENT) {
500        for (_key_in_holder, value_nbt) in holder_compound.iter() {
501            if let Some(actual_item_compound) = value_nbt.compound() {
502                collect_summary_node(
503                    &actual_item_compound,
504                    cli_args,
505                    item_queries,
506                    &mut summary_nodes,
507                    counter,
508                );
509            }
510        }
511    }
512
513    print_per_source_summary_if_enabled(
514        cli_args,
515        &task.scope.dimension,
516        source_id,
517        location_str,
518        summary_nodes,
519    );
520}
521
522/// Prints a per-source summary tree if the corresponding CLI flag is enabled.
523fn print_per_source_summary_if_enabled(
524    cli_args: &CliArgs,
525    dimension: &str,
526    source_id: &str,
527    source_location: &str,
528    summary_nodes: Vec<ItemSummaryNode>, // Consumes the nodes
529) {
530    if cli_args.per_source_summary && !summary_nodes.is_empty() {
531        let root_label = format!("[{dimension}] {source_id} @ {source_location}");
532        let mut root = ItemSummaryNode::new_root(root_label, summary_nodes);
533        root.collapse_leaves_recursive();
534        if let Err(e) = print_tree(&root) {
535            // Handle error from print_tree, e.g., by logging to stderr
536            eprintln!("Error printing tree summary for {source_id}: {e}");
537        }
538    }
539}
540
541/// Processes a single entity's NBT data.
542fn process_single_entity(
543    entity_nbt: simdnbt::borrow::NbtCompound,
544    task: &ScanTask,
545    queries: &[ItemFilter],
546    cli_args: &CliArgs,
547    counter: &mut Counter,
548) {
549    let Some(id_str) = entity_nbt.string(nbt_utils::NBT_KEY_ID) else {
550        return;
551    };
552    let id = id_str.to_string();
553    let pos_str =
554        get_entity_pos_string(&entity_nbt).unwrap_or_else(|| "Unknown Position".to_string());
555
556    let mut summary_nodes = Vec::new();
557    for list_field_name in &[nbt_utils::NBT_KEY_ITEMS, nbt_utils::NBT_KEY_INVENTORY] {
558        if let Some(item_list) = entity_nbt.list(list_field_name).and_then(|l| l.compounds()) {
559            for item_compound in item_list {
560                collect_summary_node(
561                    &item_compound,
562                    cli_args,
563                    queries,
564                    &mut summary_nodes,
565                    counter,
566                );
567            }
568        }
569    }
570
571    if let Some(item_compound) = entity_nbt.compound(nbt_utils::NBT_KEY_ITEM) {
572        collect_summary_node(
573            &item_compound,
574            cli_args,
575            queries,
576            &mut summary_nodes,
577            counter,
578        );
579    }
580
581    if let Some(holder_compound) = entity_nbt.compound(nbt_utils::NBT_KEY_EQUIPMENT) {
582        for (_key_in_holder, value_nbt) in holder_compound.iter() {
583            if let Some(actual_item_compound) = value_nbt.compound() {
584                collect_summary_node(
585                    &actual_item_compound,
586                    cli_args,
587                    queries,
588                    &mut summary_nodes,
589                    counter,
590                );
591            }
592        }
593    }
594
595    if let Some(passengers_list) = entity_nbt
596        .list(nbt_utils::NBT_KEY_PASSENGERS)
597        .and_then(|l| l.compounds())
598    {
599        for passenger_nbt in passengers_list {
600            // Recursively process each passenger.
601            // The passenger's items will be added to the current entity's summary_nodes
602            // and the global_counter. This is generally fine as the per-source summary
603            // is for the top-level entity being processed from the chunk.
604            process_single_entity(passenger_nbt, task, queries, cli_args, counter);
605        }
606    }
607
608    print_per_source_summary_if_enabled(
609        cli_args,
610        &task.scope.dimension,
611        &id,
612        &pos_str,
613        summary_nodes,
614    );
615}
616
617fn process_block_entity(
618    block_entity: simdnbt::borrow::NbtCompound,
619    task: &ScanTask,
620    item_queries: &[ItemFilter],
621    cli_args: &CliArgs,
622    counter: &mut Counter,
623) {
624    let id = block_entity
625        .string(nbt_utils::NBT_KEY_ID)
626        .unwrap()
627        .to_string();
628    let x = block_entity.int("x").unwrap();
629    let y = block_entity.int("y").unwrap();
630    let z = block_entity.int("z").unwrap();
631
632    let mut summary_nodes = Vec::new();
633    if let Some(items) = block_entity
634        .list(nbt_utils::NBT_KEY_ITEMS)
635        .and_then(|l| l.compounds())
636    {
637        for item in items {
638            collect_summary_node(&item, cli_args, item_queries, &mut summary_nodes, counter);
639        }
640    }
641
642    for single_item_field in &["item", "RecordItem", "Book"] {
643        if let Some(item) = block_entity.compound(single_item_field) {
644            collect_summary_node(&item, cli_args, item_queries, &mut summary_nodes, counter);
645        }
646    }
647
648    let location_str = format!("{x} {y} {z}");
649    print_per_source_summary_if_enabled(
650        cli_args,
651        &task.scope.dimension,
652        &id,
653        &location_str,
654        summary_nodes,
655    );
656}
657
658/// Recursively builds an `ItemSummaryNode` for `item_nbt` and all nested children (under `components -> minecraft:container` or `components -> minecraft:bundle_contents`),
659/// pushes leaves into `out_nodes`, and also updates the `global_counter`.
660fn collect_summary_node(
661    item_nbt: &simdnbt::borrow::NbtCompound,
662    cli_args: &CliArgs,
663    queries: &[ItemFilter],
664    out_nodes: &mut Vec<ItemSummaryNode>,
665    global_counter: &mut Counter,
666) {
667    let id = item_nbt.string(nbt_utils::NBT_KEY_ID).unwrap().to_string();
668    let count = item_nbt.int(nbt_utils::NBT_KEY_COUNT).unwrap_or(1) as u64;
669
670    let matches_filter = if queries.is_empty() {
671        true
672    } else {
673        let valence_nbt = convert_simdnbt_to_valence_nbt(item_nbt);
674        queries.iter().any(|q| {
675            let id_ok = q.id.as_ref().is_none_or(|qid| qid == &id);
676            let nbt_ok = q
677                .required_nbt
678                .as_ref()
679                .is_none_or(|req| nbt_is_subset(&valence_nbt, req));
680            id_ok && nbt_ok
681        })
682    };
683
684    let mut children = Vec::new();
685
686    if let Some(components) = item_nbt.compound(nbt_utils::NBT_KEY_COMPONENTS) {
687        if let Some(nested_list) = components
688            .list(nbt_utils::NBT_KEY_MINECRAFT_CONTAINER)
689            .and_then(|l| l.compounds())
690        {
691            for nested_entry in nested_list {
692                if let Some(nested_item) = nested_entry.compound("item") {
693                    collect_summary_node(
694                        &nested_item,
695                        cli_args,
696                        queries,
697                        &mut children,
698                        global_counter,
699                    );
700                }
701            }
702        }
703
704        if let Some(nested_list) = components
705            .list(nbt_utils::NBT_KEY_MINECRAFT_BUNDLE_CONTENTS)
706            .and_then(|l| l.compounds())
707        {
708            for nested_entry in nested_list {
709                collect_summary_node(
710                    &nested_entry,
711                    cli_args,
712                    queries,
713                    &mut children,
714                    global_counter,
715                );
716            }
717        }
718    }
719
720    if matches_filter {
721        let nbt_components = item_nbt
722            .compound(nbt_utils::NBT_KEY_COMPONENTS)
723            .as_ref()
724            .map(convert_simdnbt_to_valence_nbt);
725
726        global_counter.add(id.clone(), nbt_components.as_ref(), count);
727
728        let snbt = if cli_args.show_nbt {
729            nbt_components
730                .map(|c| valence_nbt::snbt::to_snbt_string(&c))
731                .as_deref()
732                .map(escape_nbt_string)
733        } else {
734            None
735        };
736
737        let node = ItemSummaryNode::new_item(id.clone(), count, snbt, children);
738        out_nodes.push(node);
739    } else if !children.is_empty() {
740        out_nodes.extend(children);
741    }
742}
743
744/// Returns `true` if `subset` is entirely contained within `superset`.
745/// Compounds require key-by-key subset checks; lists treat each element
746/// in `subset_list` as needing its own distinct match in `superset_list`.
747pub fn nbt_is_subset(superset: &Value, subset: &Value) -> bool {
748    match (superset, subset) {
749        // Compounds: every (key → sub_value) must match in sup_map
750        (Value::Compound(sup_map), Value::Compound(sub_map)) => {
751            sub_map.iter().all(|(field, sub_value)| {
752                sup_map
753                    .get(field)
754                    .is_some_and(|sup_value| nbt_is_subset(sup_value, sub_value))
755            })
756        }
757
758        // Lists with multiplicity: each sub_element must find a *distinct* match
759        // in superset_list, so we track which sup indices are already used.
760        (Value::List(superset_list), Value::List(subset_list)) => {
761            // track used sup elements
762            let mut used = vec![false; superset_list.len()];
763
764            subset_list.iter().all(|sub_element| {
765                // try to find an unused sup_element matching this sub_element
766                if let Some((idx, _)) = superset_list.iter().enumerate().find(|(i, sup_element)| {
767                    !used[*i] && nbt_is_subset(&sup_element.to_value(), &sub_element.to_value())
768                }) {
769                    used[idx] = true;
770                    true
771                } else {
772                    false
773                }
774            })
775        }
776
777        _ => superset == subset,
778    }
779}
780
781/// Escape control characters when printing SNBT
782pub fn escape_nbt_string(s: &str) -> String {
783    s.chars()
784        .flat_map(|c| match c {
785            '\\' => Some("\\\\".to_string()),
786            '\n' => Some("\\n".to_string()),
787            '\r' => Some("\\r".to_string()),
788            '\t' => Some("\\t".to_string()),
789            c if c.is_control() => Some(format!("\\u{:04x}", c as u32)),
790            _ => Some(c.to_string()),
791        })
792        .collect::<String>()
793}
794
795#[cfg(test)]
796mod tests {
797    use super::nbt_is_subset;
798    use valence_nbt::Value;
799    use valence_nbt::snbt::from_snbt_str;
800
801    fn parse(s: &str) -> Value {
802        from_snbt_str(s).expect("Failed to parse SNBT")
803    }
804
805    #[test]
806    fn simple_compound_subset() {
807        let sup = parse("{a:1, b:2, c:3}");
808        let sub = parse("{a:1, c:3}");
809        assert!(nbt_is_subset(&sup, &sub));
810    }
811
812    #[test]
813    fn compound_missing_key_should_fail() {
814        let sup = parse("{a:1, b:2}");
815        let sub = parse("{a:1, c:3}");
816        assert!(!nbt_is_subset(&sup, &sub));
817    }
818
819    #[test]
820    fn unordered_list_subset() {
821        let sup = parse("[1, 2, 3, 4]");
822        let sub = parse("[4, 2]");
823        assert!(nbt_is_subset(&sup, &sub));
824    }
825
826    #[test]
827    fn list_insufficient_elements_should_fail() {
828        let sup = parse("[1, 2, 2]");
829        let sub = parse("[2, 2, 2]");
830        assert!(!nbt_is_subset(&sup, &sub));
831    }
832
833    #[test]
834    fn nested_structures_subset() {
835        let sup = parse("{x:{y:[{z:1}, {z:2}]}, w:5}");
836        let sub = parse("{x:{y:[{z:2}]}}");
837        assert!(nbt_is_subset(&sup, &sub));
838    }
839
840    #[test]
841    fn primitive_equality_match_and_mismatch() {
842        let sup = parse("123");
843        let sub = parse("123");
844        assert!(nbt_is_subset(&sup, &sub));
845
846        let sup2 = parse("123");
847        let sub2 = parse("456");
848        assert!(!nbt_is_subset(&sup2, &sub2));
849    }
850
851    #[test]
852    fn mismatched_types_should_fail() {
853        let sup = parse("{a:1}");
854        let sub = parse("[1]");
855        assert!(!nbt_is_subset(&sup, &sub));
856    }
857
858    #[test]
859    fn empty_list_subset() {
860        let sup = parse("[1,2,3]");
861        let sub = parse("[]");
862        assert!(nbt_is_subset(&sup, &sub));
863    }
864
865    #[test]
866    fn non_empty_list_on_empty_should_fail() {
867        let sup = parse("[]");
868        let sub = parse("[1]");
869        assert!(!nbt_is_subset(&sup, &sub));
870    }
871
872    #[test]
873    fn empty_compound_subset() {
874        let sup = parse("{a:1}");
875        let sub = parse("{}");
876        assert!(nbt_is_subset(&sup, &sub));
877    }
878
879    #[test]
880    fn byte_array_exact_match() {
881        let sup = parse("[I;1,2,3]");
882        let sub = parse("[I;1,2,3]");
883        assert!(nbt_is_subset(&sup, &sub));
884    }
885
886    #[test]
887    fn byte_array_partial_should_fail() {
888        let sup = parse("[I;1,2,3]");
889        let sub = parse("[I;2,3]");
890        assert!(!nbt_is_subset(&sup, &sub));
891    }
892
893    #[test]
894    fn byte_array_missing_element_should_fail() {
895        let sup = parse("[I;1,2]");
896        let sub = parse("[I;1,2,3]");
897        assert!(!nbt_is_subset(&sup, &sub));
898    }
899
900    #[test]
901    fn mixed_list_types_should_fail_to_parse() {
902        let res = valence_nbt::snbt::from_snbt_str("[1, \"a\"]");
903        assert!(res.is_err(), "Mixed-type list unexpectedly parsed");
904    }
905
906    #[test]
907    fn int_array_vs_byte_array_should_fail() {
908        let sup = parse("[I;1,2,3]");
909        let sub = parse("[B;1b,2b,3b]");
910        assert!(!nbt_is_subset(&sup, &sub));
911    }
912
913    #[test]
914    fn nested_empty_compound() {
915        let sup = parse("{a:{b:{}}}");
916        let sub = parse("{a:{b:{}}}");
917        assert!(nbt_is_subset(&sup, &sub));
918    }
919
920    #[test]
921    fn deeply_nested_empty_list() {
922        let sup = parse("{a:{b:[[],[1]]}}");
923        let sub = parse("{a:{b:[[]]}}");
924        assert!(nbt_is_subset(&sup, &sub));
925    }
926
927    #[test]
928    fn numeric_type_coercion_should_fail() {
929        let res = valence_nbt::snbt::from_snbt_str("[1b, 2, 3s]");
930        assert!(
931            res.is_err(),
932            "Parser unexpectedly accepted mixed numeric types"
933        );
934    }
935
936    #[test]
937    fn long_array_partial_should_fail() {
938        let sup = parse("[L;9223372036854775807l,0l]");
939        let sub = parse("[L;0l]");
940        assert!(!nbt_is_subset(&sup, &sub));
941    }
942
943    #[test]
944    fn empty_string_vs_non_empty_should_fail() {
945        let sup = parse("{text:\"\"}");
946        let sub = parse("{text:\"something\"}");
947        assert!(!nbt_is_subset(&sup, &sub));
948    }
949
950    #[test]
951    fn float_vs_double_zero_should_fail() {
952        let sup = parse("{val:0.0f}");
953        let sub = parse("{val:0.0d}");
954        assert!(!nbt_is_subset(&sup, &sub));
955    }
956
957    #[test]
958    fn compound_with_empty_list_and_nested_empty_compound() {
959        let sup = parse("{data:{items:[], meta:{}}}");
960        let sub = parse("{data:{items:[]}}");
961        assert!(nbt_is_subset(&sup, &sub));
962    }
963
964    #[test]
965    fn unicode_string_match() {
966        let sup = parse("{msg:\"こんにちは\"}");
967        let sub = parse("{msg:\"こんにちは\"}");
968        assert!(nbt_is_subset(&sup, &sub));
969    }
970}