1use std::collections::BTreeSet;
4use std::path::{Path, PathBuf};
5
6use serde_json::Value;
7
8const MAX_RANGE_VALUES: usize = 100_000;
9
10#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
12pub enum Error {
13 #[error("invalid one-based range component: {0:?}")]
15 InvalidRange(String),
16 #[error("range selection exceeds the maximum of {limit} values")]
18 RangeTooLarge { limit: usize },
19 #[error("JSON payload must be an object")]
21 JsonPayloadNotObject,
22 #[error("JSON payload must not define reserved field \"schema\"")]
24 ReservedSchemaField,
25}
26
27pub fn parse_range(input: &str) -> Result<Vec<usize>, Error> {
34 let mut values = BTreeSet::new();
35 let mut expansion_work = 0;
36
37 if input.trim().is_empty() {
38 return Err(Error::InvalidRange(input.to_owned()));
39 }
40
41 for raw_component in input.split(',') {
42 let component = raw_component.trim();
43 if component.is_empty() {
44 return Err(Error::InvalidRange(raw_component.to_owned()));
45 }
46
47 let hyphen_count = component.bytes().filter(|byte| *byte == b'-').count();
48 match hyphen_count {
49 0 => {
50 let value = parse_positive(component)?;
51 charge_expansion_work(&mut expansion_work, 1)?;
52 values.insert(value);
53 }
54 1 => {
55 let (start, end) = component
56 .split_once('-')
57 .expect("one counted hyphen must split");
58 let start = parse_positive(start.trim())?;
59 let end = parse_positive(end.trim())?;
60 if start > end {
61 return Err(Error::InvalidRange(component.to_owned()));
62 }
63 let cardinality = end
64 .checked_sub(start)
65 .and_then(|width| width.checked_add(1))
66 .ok_or(Error::RangeTooLarge {
67 limit: MAX_RANGE_VALUES,
68 })?;
69 charge_expansion_work(&mut expansion_work, cardinality)?;
70 values.extend(start..=end);
71 }
72 _ => return Err(Error::InvalidRange(component.to_owned())),
73 }
74 }
75
76 Ok(values.into_iter().collect())
77}
78
79fn charge_expansion_work(total: &mut usize, additional: usize) -> Result<(), Error> {
80 let charged = total.checked_add(additional).ok_or(Error::RangeTooLarge {
81 limit: MAX_RANGE_VALUES,
82 })?;
83 if charged > MAX_RANGE_VALUES {
84 return Err(Error::RangeTooLarge {
85 limit: MAX_RANGE_VALUES,
86 });
87 }
88 *total = charged;
89 Ok(())
90}
91
92fn parse_positive(value: &str) -> Result<usize, Error> {
93 let parsed = value
94 .parse::<usize>()
95 .map_err(|_| Error::InvalidRange(value.to_owned()))?;
96 if parsed == 0 {
97 return Err(Error::InvalidRange(value.to_owned()));
98 }
99 Ok(parsed)
100}
101
102pub fn default_output_path(input: &Path, extension: &str) -> PathBuf {
104 let mut output = input.to_path_buf();
105 output.set_extension(extension.trim_start_matches('.'));
106 output
107}
108
109pub fn json_envelope(mut payload: Value) -> Result<Value, Error> {
111 let Value::Object(object) = &mut payload else {
112 return Err(Error::JsonPayloadNotObject);
113 };
114 if object.contains_key("schema") {
115 return Err(Error::ReservedSchemaField);
116 }
117 object.insert("schema".to_owned(), Value::from(1));
118 Ok(payload)
119}
120
121#[cfg(test)]
122mod tests {
123 use std::path::Path;
124
125 use serde_json::json;
126
127 use super::*;
128
129 #[test]
130 fn range_2_4_through_6_is_the_expected_set() {
131 assert_eq!(parse_range("2,4-6").unwrap(), [2, 4, 5, 6]);
132 }
133
134 #[test]
135 fn invalid_ranges_are_rejected_and_duplicates_are_normalized() {
136 assert_eq!(parse_range("6, 2,4-6,2").unwrap(), [2, 4, 5, 6]);
137 for invalid in ["", "0", "1,,2", "6-4", "2-", "-2", "two", "1-2-3"] {
138 assert!(parse_range(invalid).is_err(), "accepted {invalid:?}");
139 }
140 }
141
142 #[test]
143 fn ranges_too_large_to_materialize_are_rejected() {
144 let expected = Err(Error::RangeTooLarge { limit: 100_000 });
145 assert_eq!(parse_range("1-100001"), expected);
146 assert_eq!(parse_range(&format!("1-{}", usize::MAX)), expected);
147 }
148
149 #[test]
150 fn exactly_one_hundred_thousand_requested_values_are_accepted() {
151 let values = parse_range("1-100000").unwrap();
152 assert_eq!(values.len(), 100_000);
153 assert_eq!(values.first(), Some(&1));
154 assert_eq!(values.last(), Some(&100_000));
155 }
156
157 #[test]
158 fn overlapping_ranges_cannot_amplify_expansion_work() {
159 assert_eq!(
160 parse_range("1-50001,1-50001"),
161 Err(Error::RangeTooLarge { limit: 100_000 })
162 );
163 }
164
165 #[test]
166 fn json_envelope_has_schema_one_and_preserves_payload_fields() {
167 let value = json_envelope(json!({"slides": 3, "metadata": {"title": "Deck"}}))
168 .expect("object payload");
169 assert_eq!(value["schema"], 1);
170 assert_eq!(value["slides"], 3);
171 assert_eq!(value["metadata"]["title"], "Deck");
172 assert!(json_envelope(json!({"schema": 9})).is_err());
173 assert!(json_envelope(json!([1, 2, 3])).is_err());
174 }
175
176 #[test]
177 fn output_paths_replace_or_add_only_the_extension() {
178 assert_eq!(
179 default_output_path(Path::new("relative/report.docx"), "pdf"),
180 Path::new("relative/report.pdf")
181 );
182 assert_eq!(
183 default_output_path(Path::new("relative/report"), ".html"),
184 Path::new("relative/report.html")
185 );
186 assert_eq!(
187 default_output_path(Path::new("relative/report.final.docx"), "md"),
188 Path::new("relative/report.final.md")
189 );
190 }
191}