Skip to main content

oxml_cli_support/
lib.rs

1//! Shared command-line contracts for OOXML tools.
2
3use std::collections::BTreeSet;
4use std::path::{Path, PathBuf};
5
6use serde_json::Value;
7
8const MAX_RANGE_VALUES: usize = 100_000;
9
10/// An invalid shared command-line value.
11#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
12pub enum Error {
13    /// A range expression or one of its components is invalid.
14    #[error("invalid one-based range component: {0:?}")]
15    InvalidRange(String),
16    /// A range expression would materialize too many values.
17    #[error("range selection exceeds the maximum of {limit} values")]
18    RangeTooLarge { limit: usize },
19    /// A JSON envelope payload was not an object.
20    #[error("JSON payload must be an object")]
21    JsonPayloadNotObject,
22    /// A JSON envelope payload tried to define the reserved schema field.
23    #[error("JSON payload must not define reserved field \"schema\"")]
24    ReservedSchemaField,
25}
26
27/// Parses positive one-based values and inclusive ranges.
28///
29/// Components are comma-separated. Whitespace around components and range
30/// endpoints is ignored. The result is sorted and deduplicated. At most
31/// 100,000 values may be requested across all components before
32/// deduplication.
33pub 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
102/// Replaces or adds the requested extension while preserving the input path.
103pub 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
109/// Adds the versioned CLI schema field to an object payload.
110pub 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}