use anyhow::{anyhow, Result};
use regex::Regex;
use std::io::Read;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UnsupportedFeature {
MergedCells {
sheet: String,
range: String,
},
PivotTable {
sheet: String,
},
DataValidation {
sheet: String,
range: String,
},
ConditionalFormatting {
sheet: String,
},
ArrayFormulas {
sheet: String,
},
ProtectedSheet {
sheet: String,
password_protected: bool,
},
ExternalReferences {
sheet: String,
},
Charts {
sheet: String,
count: usize,
},
EmbeddedObjects {
sheet: String,
object_type: String,
},
}
impl UnsupportedFeature {
pub fn description(&self) -> String {
match self {
Self::MergedCells { sheet, range } => {
format!(
"Merged cells detected in sheet '{}' range '{}'. Merged cells will be read as individual cells. Merge structure will be lost on write.",
sheet, range
)
}
Self::PivotTable { sheet } => {
format!(
"Pivot table detected in sheet '{}'. Pivot tables are not fully supported - data will be read as static values. Pivot structure, filters, and calculations will be lost.",
sheet
)
}
Self::DataValidation { sheet, range } => {
format!(
"Data validation detected in sheet '{}' range '{}'. Validation rules will be lost when modifying or writing this file.",
sheet, range
)
}
Self::ConditionalFormatting { sheet } => {
format!(
"Conditional formatting detected in sheet '{}'. Formatting rules will be preserved on read but may not be editable through xls-rs.",
sheet
)
}
Self::ArrayFormulas { sheet } => {
format!(
"Array formulas detected in sheet '{}'. Array formulas may be read as static values. Dynamic calculation behavior may be lost.",
sheet
)
}
Self::ProtectedSheet { sheet, password_protected } => {
if *password_protected {
format!(
"Sheet '{}' is password protected. Content is readable but cannot be modified. Remove protection to enable editing.",
sheet
)
} else {
format!(
"Sheet '{}' is protected. Content is readable but editing may be limited.",
sheet
)
}
}
Self::ExternalReferences { sheet } => {
format!(
"External references detected in sheet '{}'. External links may be broken or not accessible. Consider consolidating data.",
sheet
)
}
Self::Charts { sheet, count } => {
format!(
"Charts detected in sheet '{}' ({} chart(s)). Charts are read-only through xls-rs - data is visible but chart configuration cannot be modified.",
sheet, count
)
}
Self::EmbeddedObjects { sheet, object_type } => {
format!(
"Embedded {} detected in sheet '{}'. Visual elements like images and shapes are not fully supported - they may be lost on read/write.",
object_type, sheet
)
}
}
}
pub fn severity(&self) -> FeatureSeverity {
match self {
Self::MergedCells { .. } => FeatureSeverity::Warning,
Self::PivotTable { .. } => FeatureSeverity::Limitation,
Self::DataValidation { .. } => FeatureSeverity::Warning,
Self::ConditionalFormatting { .. } => FeatureSeverity::Warning,
Self::ArrayFormulas { .. } => FeatureSeverity::Limitation,
Self::ProtectedSheet { password_protected: true, .. } => FeatureSeverity::Error,
Self::ProtectedSheet { password_protected: false, .. } => FeatureSeverity::Warning,
Self::ExternalReferences { .. } => FeatureSeverity::Warning,
Self::Charts { .. } => FeatureSeverity::Warning,
Self::EmbeddedObjects { .. } => FeatureSeverity::Limitation,
}
}
pub fn guidance(&self) -> Option<String> {
match self {
Self::MergedCells { .. } => Some(
"To preserve merged cells, consider using Excel directly or exporting to a format that maintains merge structure.".to_string()
),
Self::PivotTable { .. } => Some(
"For full pivot table support, use Excel directly. To work with pivot data, consider flattening the pivot table to static values first.".to_string()
),
Self::DataValidation { .. } => Some(
"Data validation rules can be re-applied after modification using the conditional-format command.".to_string()
),
Self::ProtectedSheet { password_protected: true, .. } => Some(
"Unprotect the sheet in Excel with the password to enable full editing capabilities.".to_string()
),
Self::ExternalReferences { .. } => Some(
"Replace external references with static values or consolidate external data into the workbook.".to_string()
),
_ => None,
}
}
pub fn to_error(&self) -> anyhow::Error {
let desc = self.description();
let severity = self.severity();
let guidance = self.guidance();
let msg = if let Some(g) = guidance {
format!("[{}] {}. Guidance: {}", severity, desc, g)
} else {
format!("[{}] {}", severity, desc)
};
anyhow!(msg)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum FeatureSeverity {
Info,
Warning,
Limitation,
Error,
}
impl std::fmt::Display for FeatureSeverity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FeatureSeverity::Info => write!(f, "INFO"),
FeatureSeverity::Warning => write!(f, "WARNING"),
FeatureSeverity::Limitation => write!(f, "LIMITATION"),
FeatureSeverity::Error => write!(f, "ERROR"),
}
}
}
pub struct FeatureDetector;
impl FeatureDetector {
pub fn detect_potential_issues(path: &str) -> Result<Vec<UnsupportedFeature>> {
let mut issues = Self::heuristic_check(path);
if !path.to_lowercase().ends_with(".xlsx") {
return Ok(issues);
}
let file = std::fs::File::open(path).map_err(|e| anyhow!(e))?;
let mut archive = zip::ZipArchive::new(file).map_err(|e| anyhow!(e))?;
let mut workbook_xml = String::new();
{
let mut e = archive
.by_name("xl/workbook.xml")
.map_err(|_| anyhow!("invalid xlsx: missing xl/workbook.xml"))?;
e.read_to_string(&mut workbook_xml)
.map_err(|e| anyhow!(e))?;
}
let re_name = Regex::new(r#"<sheet[^>]+name="([^"]+)""#).expect("sheet name regex");
let sheet_names: Vec<String> = re_name
.captures_iter(&workbook_xml)
.map(|c| c[1].to_string())
.collect();
let re_sheet_num = Regex::new(r"sheet(\d+)\.xml$").expect("sheet file regex");
let ref_cell = Regex::new(r#"ref="([^"]+)""#).expect("ref attr regex");
let ref_sq = Regex::new(r#"sqref="([^"]+)""#).expect("sqref attr regex");
let mut chart_count: usize = 0;
for i in 0..archive.len() {
let mut entry = archive.by_index(i).map_err(|e| anyhow!(e))?;
let ename = entry.name().to_string();
if ename.starts_with("xl/charts/chart") && ename.ends_with(".xml") {
chart_count += 1;
continue;
}
if !ename.starts_with("xl/worksheets/") || !ename.ends_with(".xml") {
continue;
}
let mut data = String::new();
entry
.read_to_string(&mut data)
.map_err(|e| anyhow!(e))?;
let sheet_idx = re_sheet_num
.captures(&ename)
.and_then(|c| c[1].parse::<usize>().ok())
.unwrap_or(1);
let sheet = sheet_names
.get(sheet_idx.saturating_sub(1))
.cloned()
.unwrap_or_else(|| "unknown".to_string());
if data.contains("mergeCells") || data.contains("mergeCell") {
let range = ref_cell
.captures(&data)
.map(|c| c[1].to_string())
.unwrap_or_else(|| "unknown".to_string());
issues.push(UnsupportedFeature::MergedCells {
sheet: sheet.clone(),
range,
});
}
if data.contains("pivotCache") || data.contains("pivotTable") {
issues.push(UnsupportedFeature::PivotTable { sheet: sheet.clone() });
}
if data.contains("dataValidation") {
let range = ref_sq
.captures(&data)
.map(|c| c[1].to_string())
.unwrap_or_else(|| "unknown".to_string());
issues.push(UnsupportedFeature::DataValidation {
sheet: sheet.clone(),
range,
});
}
if data.contains("conditionalFormatting") {
issues.push(UnsupportedFeature::ConditionalFormatting {
sheet: sheet.clone(),
});
}
if data.contains("sheetProtection") {
let password_protected = data.contains("password");
issues.push(UnsupportedFeature::ProtectedSheet {
sheet,
password_protected,
});
}
}
if chart_count > 0 {
let sheet = sheet_names
.first()
.cloned()
.unwrap_or_else(|| "unknown".to_string());
issues.push(UnsupportedFeature::Charts {
sheet,
count: chart_count,
});
}
Ok(issues)
}
pub fn heuristic_check(path: &str) -> Vec<UnsupportedFeature> {
let mut issues = Vec::new();
let path_lower = path.to_lowercase();
if let Ok(metadata) = std::fs::metadata(path) {
if metadata.len() > 10 * 1024 * 1024 {
issues.push(UnsupportedFeature::PivotTable {
sheet: "unknown".to_string(),
});
}
}
if path_lower.ends_with(".ods") {
}
issues
}
pub fn validate_for_write(path: &str) -> Result<()> {
let issues = Self::detect_potential_issues(path)?;
let errors: Vec<_> = issues
.into_iter()
.filter(|f| f.severity() == FeatureSeverity::Error)
.collect();
if !errors.is_empty() {
let error_messages: Vec<String> = errors
.iter()
.map(|f| f.description())
.collect();
return Err(anyhow!(
"File contains features that prevent write operations:\n{}",
error_messages.join("\n")
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_unsupported_feature_description() {
let feature = UnsupportedFeature::MergedCells {
sheet: "Sheet1".to_string(),
range: "A1:B2".to_string(),
};
let desc = feature.description();
assert!(desc.contains("Merged cells"));
assert!(desc.contains("Sheet1"));
assert!(desc.contains("A1:B2"));
}
#[test]
fn test_feature_severity() {
assert_eq!(
UnsupportedFeature::MergedCells {
sheet: "S".to_string(),
range: "A1".to_string(),
}
.severity(),
FeatureSeverity::Warning
);
assert_eq!(
UnsupportedFeature::ProtectedSheet {
sheet: "S".to_string(),
password_protected: true,
}
.severity(),
FeatureSeverity::Error
);
}
#[test]
fn test_to_error() {
let feature = UnsupportedFeature::PivotTable {
sheet: "Sheet1".to_string(),
};
let error = feature.to_error();
assert!(error.to_string().contains("Pivot table"));
}
#[test]
fn detect_simple_written_xlsx_has_no_error_severity_flags() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("t.xlsx");
let handler = crate::ExcelHandler::new();
let data = vec![vec!["a".to_string()]];
handler
.write_styled(
p.to_str().unwrap(),
&data,
&crate::WriteOptions::default(),
)
.unwrap();
let issues = FeatureDetector::detect_potential_issues(p.to_str().unwrap()).unwrap();
let errors: Vec<_> = issues
.iter()
.filter(|i| i.severity() == FeatureSeverity::Error)
.collect();
assert!(
errors.is_empty(),
"expected no error-level workbook features, got {errors:?}"
);
}
}