Skip to main content

ppt_rs/core/
validation.rs

1//! Shared validation helpers used across generators, repair, and CLI tools.
2
3use crate::exc::{messages, PptxError, Result};
4use std::collections::HashSet;
5
6/// A required PPTX package part and its human-readable description.
7pub type RequiredPart = (&'static str, &'static str);
8
9/// Minimum parts required for a readable PPTX (used by CLI validate and tests).
10pub const REQUIRED_PARTS_MINIMAL: &[&'static str] = &[
11    "[Content_Types].xml",
12    "_rels/.rels",
13    "ppt/presentation.xml",
14    "docProps/core.xml",
15];
16
17/// Parts required for structural repair validation.
18pub const REQUIRED_PARTS_REPAIR: &[RequiredPart] = &[
19    ("[Content_Types].xml", "Content types definition"),
20    ("_rels/.rels", "Package relationships"),
21    ("ppt/presentation.xml", "Presentation document"),
22    (
23        "ppt/_rels/presentation.xml.rels",
24        "Presentation relationships",
25    ),
26];
27
28/// A validation issue found in a PPTX package or input value.
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub enum ValidationIssue {
31    MissingPart {
32        path: String,
33        description: Option<String>,
34    },
35    EmptyXml {
36        path: String,
37    },
38    InvalidXml {
39        path: String,
40        error: String,
41    },
42}
43
44impl ValidationIssue {
45    pub fn message(&self) -> String {
46        match self {
47            ValidationIssue::MissingPart { path, description } => match description {
48                Some(desc) => format!("Missing required part '{path}' ({desc})"),
49                None => messages::missing_part(path),
50            },
51            ValidationIssue::EmptyXml { path } => messages::empty_xml(path),
52            ValidationIssue::InvalidXml { path, error } => messages::invalid_xml(path, error),
53        }
54    }
55}
56
57/// Clamp a ratio to the 0.0–1.0 range.
58pub fn clamp_ratio(value: f64) -> f64 {
59    value.clamp(0.0, 1.0)
60}
61
62/// Clamp an opacity/alpha value to the 0.0–1.0 range.
63pub fn clamp_unit_interval(value: f64) -> f64 {
64    value.clamp(0.0, 1.0)
65}
66
67/// Validate that a string is non-empty after trimming.
68pub fn validate_non_empty_str(value: &str, field: &str) -> Result<()> {
69    if value.trim().is_empty() {
70        return Err(PptxError::InvalidValue(messages::must_not_be_empty(field)));
71    }
72    Ok(())
73}
74
75/// Validate that a collection is non-empty.
76pub fn validate_non_empty<T>(items: &[T], field: &str) -> Result<()> {
77    if items.is_empty() {
78        return Err(PptxError::InvalidState(messages::must_not_be_empty(field)));
79    }
80    Ok(())
81}
82
83/// Validate that a usize index is within `[0, count)`.
84pub fn validate_index(index: usize, count: usize, field: &str) -> Result<()> {
85    if index >= count {
86        return Err(PptxError::NotFound(messages::index_out_of_range(
87            field, index, count,
88        )));
89    }
90    Ok(())
91}
92
93/// Validate that a value is strictly positive.
94pub fn validate_positive(value: u32, field: &str) -> Result<()> {
95    if value == 0 {
96        return Err(PptxError::InvalidValue(messages::must_be_positive(field)));
97    }
98    Ok(())
99}
100
101/// Basic well-formedness check for XML / RELS content.
102pub fn validate_well_formed_xml(xml: &str) -> Result<()> {
103    let trimmed = xml.trim();
104    if trimmed.is_empty() {
105        return Err(PptxError::InvalidXml(messages::empty_xml_content()));
106    }
107
108    let mut in_tag = false;
109    let mut in_string = false;
110    let mut string_char = '"';
111
112    for ch in trimmed.chars() {
113        match ch {
114            '"' | '\'' if in_tag && !in_string => {
115                in_string = true;
116                string_char = ch;
117            }
118            c if in_string && c == string_char => {
119                in_string = false;
120            }
121            '<' if !in_string => {
122                in_tag = true;
123            }
124            '>' if !in_string => {
125                in_tag = false;
126            }
127            _ => {}
128        }
129    }
130
131    Ok(())
132}
133
134/// Check that all required part paths exist in `found`.
135pub fn check_required_parts(
136    found: &HashSet<String>,
137    required: &[&str],
138) -> Vec<ValidationIssue> {
139    required
140        .iter()
141        .filter(|path| !found.contains(**path))
142        .map(|path| ValidationIssue::MissingPart {
143            path: (*path).to_string(),
144            description: None,
145        })
146        .collect()
147}
148
149/// Check required parts with descriptions (repair workflow).
150pub fn check_required_parts_with_descriptions(
151    has_part: impl Fn(&str) -> bool,
152    required: &[RequiredPart],
153) -> Vec<ValidationIssue> {
154    required
155        .iter()
156        .filter(|(path, _)| !has_part(path))
157        .map(|(path, description)| ValidationIssue::MissingPart {
158            path: (*path).to_string(),
159            description: Some((*description).to_string()),
160        })
161        .collect()
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn test_clamp_ratio() {
170        assert_eq!(clamp_ratio(0.5), 0.5);
171        assert_eq!(clamp_ratio(-1.0), 0.0);
172        assert_eq!(clamp_ratio(2.0), 1.0);
173    }
174
175    #[test]
176    fn test_validate_non_empty_str() {
177        assert!(validate_non_empty_str("hello", "title").is_ok());
178        assert!(validate_non_empty_str("  ", "title").is_err());
179    }
180
181    #[test]
182    fn test_validate_index() {
183        assert!(validate_index(0, 3, "slide").is_ok());
184        assert!(validate_index(3, 3, "slide").is_err());
185    }
186
187    #[test]
188    fn test_validate_well_formed_xml() {
189        assert!(validate_well_formed_xml("<root/>").is_ok());
190        assert!(validate_well_formed_xml("").is_err());
191        assert!(validate_well_formed_xml("   ").is_err());
192    }
193
194    #[test]
195    fn test_check_required_parts() {
196        let found: HashSet<_> = ["a.xml", "b.xml"].into_iter().map(str::to_string).collect();
197        let issues = check_required_parts(&found, &["a.xml", "c.xml"]);
198        assert_eq!(issues.len(), 1);
199        assert_eq!(issues[0].message(), "Missing required part: c.xml");
200    }
201}