nbt_sniffer/
cli.rs

1use std::path::PathBuf;
2
3use clap::{ArgGroup, Parser, ValueEnum};
4use valence_nbt::Value;
5
6/// Count items in a Minecraft world, with optional per-item NBT filters and coordinates
7#[derive(Parser, Debug)]
8#[command(group(ArgGroup::new("mode").args(["all", "items"]).required(true)))]
9pub struct CliArgs {
10    #[arg(short, long, value_name = "PATH")]
11    pub world_path: PathBuf,
12
13    /// Count all items
14    #[arg(long, group = "mode")]
15    pub all: bool,
16
17    /// Specify items to count
18    #[arg(
19        short,
20        long = "item",
21        value_name = "ITEM",
22        group = "mode",
23        num_args = 1..,
24        long_help = "Specify items to count, each in the form: ITEM_ID{nbt}\n\nExamples:\n\n--item minecraft:diamond\n--item minecraft:shulker_box{components:{\"minecraft:item_name\":\"Portable Chest\"}}"
25    )]
26    pub items: Vec<String>,
27
28    /// Which summary format to display.
29    #[arg(short, long, value_enum, default_value_t = ViewMode::ById)]
30    pub view: ViewMode,
31
32    /// Show full NBT data in item summaries
33    #[arg(long)]
34    pub show_nbt: bool,
35
36    /// Show a tree summary per source
37    #[arg(long)]
38    pub per_source_summary: bool,
39
40    /// Show a summary per dimension in addition to the total counts across all dimensions
41    #[arg(long)]
42    pub per_dimension_summary: bool,
43
44    /// Show a summary per data type in addition to the total counts across all dimensions
45    #[arg(long)]
46    pub per_data_type_summary: bool,
47
48    /// Increase output verbosity
49    #[arg(long)]
50    pub verbose: bool,
51
52    /// Specify the output format
53    #[arg(short, long, value_enum, default_value_t = OutputFormat::Table)]
54    pub format: OutputFormat,
55}
56
57/// Which summary‐format to display.
58#[derive(Clone, Debug, ValueEnum, PartialEq, Eq)]
59pub enum ViewMode {
60    /// List every distinct (ID, NBT) combination
61    Detailed,
62
63    /// Summarize counts by item ID
64    ById,
65
66    /// Summarize counts by NBT only
67    ByNbt,
68}
69
70/// Which output format to use for the summary tables.
71#[derive(Clone, Debug, PartialEq, Eq, ValueEnum)]
72pub enum OutputFormat {
73    Table,
74    Json,
75    PrettyJson,
76}
77
78impl OutputFormat {
79    pub fn is_json(&self) -> bool {
80        matches!(self, OutputFormat::Json | OutputFormat::PrettyJson)
81    }
82}
83
84/// Represents a query for an item and its optional NBT filters
85#[derive(Debug)]
86pub struct ItemFilter {
87    pub id: Option<String>,
88    pub required_nbt: Option<Value>,
89}
90
91/// Parse raw CLI `item` arguments into `ItemFilter` structs
92/// Each entry is of form `ITEM_ID{nbt}`
93pub fn parse_item_args(raw_items: &[String]) -> Vec<ItemFilter> {
94    raw_items
95        .iter()
96        .map(|entry| {
97            let mut id_str = entry.as_str();
98            let mut nbt_query = None;
99
100            if let Some(start) = entry.find('{')
101                && let Some(end) = entry.rfind('}')
102            {
103                id_str = &entry[..start];
104                let nbt_str = &entry[start..=end];
105                if !nbt_str.is_empty() {
106                    match valence_nbt::snbt::from_snbt_str(nbt_str) {
107                        Ok(parsed) => nbt_query = Some(parsed),
108                        Err(e) => eprintln!("Failed to parse SNBT '{nbt_str}': {e}"),
109                    }
110                }
111            }
112
113            let id = if id_str.is_empty() {
114                None
115            } else if id_str.contains(':') {
116                Some(id_str.to_string())
117            } else {
118                Some(format!("minecraft:{id_str}"))
119            };
120
121            ItemFilter {
122                id,
123                required_nbt: nbt_query,
124            }
125        })
126        .collect()
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use valence_nbt::compound;
133
134    #[test]
135    fn test_parse_item_args_simple_id() {
136        let args = vec!["diamond".to_string()];
137        let filters = parse_item_args(&args);
138        assert_eq!(filters.len(), 1);
139        assert_eq!(filters[0].id, Some("minecraft:diamond".to_string()));
140        assert!(filters[0].required_nbt.is_none());
141    }
142
143    #[test]
144    fn test_parse_item_args_namespaced_id() {
145        let args = vec!["custom:item".to_string()];
146        let filters = parse_item_args(&args);
147        assert_eq!(filters.len(), 1);
148        assert_eq!(filters[0].id, Some("custom:item".to_string()));
149        assert!(filters[0].required_nbt.is_none());
150    }
151
152    #[test]
153    fn test_parse_item_args_id_with_simple_nbt() {
154        let args = vec!["stone{a:1b}".to_string()];
155        let filters = parse_item_args(&args);
156        assert_eq!(filters.len(), 1);
157        assert_eq!(filters[0].id, Some("minecraft:stone".to_string()));
158        assert_eq!(
159            filters[0].required_nbt,
160            Some(compound! { "a" => 1i8 }.into())
161        );
162    }
163
164    #[test]
165    fn test_parse_item_args_id_with_complex_nbt() {
166        let args = vec!["shulker_box{components:{\"minecraft:container\":[{slot:0b,item:{id:\"minecraft:diamond\",count:1b}}]}}".to_string()];
167        let filters = parse_item_args(&args);
168        assert_eq!(filters.len(), 1);
169        assert_eq!(filters[0].id, Some("minecraft:shulker_box".to_string()));
170        let expected_nbt = valence_nbt::snbt::from_snbt_str("{components:{\"minecraft:container\":[{slot:0b,item:{id:\"minecraft:diamond\",count:1b}}]}}").unwrap();
171        assert_eq!(filters[0].required_nbt, Some(expected_nbt));
172    }
173
174    #[test]
175    fn test_parse_item_args_nbt_only() {
176        let args = vec!["{components:{\"minecraft:custom_name\":\"Special\"}}".to_string()];
177        let filters = parse_item_args(&args);
178        assert_eq!(filters.len(), 1);
179        assert!(filters[0].id.is_none());
180        let expected_nbt = valence_nbt::snbt::from_snbt_str(
181            "{components:{\"minecraft:custom_name\":\"Special\"}}",
182        )
183        .unwrap();
184        assert_eq!(filters[0].required_nbt, Some(expected_nbt));
185    }
186
187    #[test]
188    fn test_parse_item_args_invalid_nbt_string() {
189        // This test relies on eprintln! for error indication, actual behavior is that NBT is None
190        let args = vec!["iron_ingot{invalid_nbt:}".to_string()];
191        let filters = parse_item_args(&args);
192        assert_eq!(filters.len(), 1);
193        assert_eq!(filters[0].id, Some("minecraft:iron_ingot".to_string()));
194        assert!(
195            filters[0].required_nbt.is_none(),
196            "NBT should be None for invalid SNBT"
197        );
198    }
199
200    #[test]
201    fn test_parse_item_args_multiple_items() {
202        let args = vec![
203            "diamond".to_string(),
204            "gold_ingot{components:{\"minecraft:custom_data\":{foo:\"bar\"}}}".to_string(),
205        ];
206        let filters = parse_item_args(&args);
207        assert_eq!(filters.len(), 2);
208        assert_eq!(filters[0].id, Some("minecraft:diamond".to_string()));
209        assert!(filters[0].required_nbt.is_none());
210        assert_eq!(filters[1].id, Some("minecraft:gold_ingot".to_string()));
211        let expected_nbt_for_gold = valence_nbt::snbt::from_snbt_str(
212            "{components:{\"minecraft:custom_data\":{foo:\"bar\"}}}",
213        )
214        .unwrap();
215        assert_eq!(filters[1].required_nbt, Some(expected_nbt_for_gold));
216    }
217}