Files
tkmind_go/crates/goose-mcp/src/computercontroller/xlsx_tool.rs
T
2025-03-10 10:29:00 +11:00

332 lines
10 KiB
Rust

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use umya_spreadsheet::{Spreadsheet, Worksheet};
#[derive(Debug, Serialize, Deserialize)]
pub struct WorksheetInfo {
name: String,
index: usize,
column_count: usize,
row_count: usize,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CellValue {
value: String,
formula: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RangeData {
start_row: u32,
end_row: u32,
start_col: u32,
end_col: u32,
// First dimension is rows, second dimension is columns: values[row_index][column_index]
values: Vec<Vec<CellValue>>,
}
pub struct XlsxTool {
workbook: Spreadsheet,
}
impl XlsxTool {
pub fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
let workbook =
umya_spreadsheet::reader::xlsx::read(path).context("Failed to read Excel file")?;
Ok(Self { workbook })
}
pub fn list_worksheets(&self) -> Result<Vec<WorksheetInfo>> {
let mut worksheets = Vec::new();
for (index, worksheet) in self.workbook.get_sheet_collection().iter().enumerate() {
let (column_count, row_count) = self.get_worksheet_dimensions(worksheet)?;
worksheets.push(WorksheetInfo {
name: worksheet.get_name().to_string(),
index,
column_count,
row_count,
});
}
Ok(worksheets)
}
pub fn get_worksheet_by_name(&self, name: &str) -> Result<&Worksheet> {
self.workbook
.get_sheet_by_name(name)
.context("Worksheet not found")
}
pub fn get_worksheet_by_index(&self, index: usize) -> Result<&Worksheet> {
self.workbook
.get_sheet_collection()
.get(index)
.context("Worksheet index out of bounds")
}
fn get_worksheet_dimensions(&self, worksheet: &Worksheet) -> Result<(usize, usize)> {
// Returns (column_count, row_count) for the worksheet
let mut max_col = 0;
let mut max_row = 0;
// Iterate through all rows
for row_num in 1..=worksheet.get_highest_row() {
for col_num in 1..=worksheet.get_highest_column() {
if let Some(cell) = worksheet.get_cell((row_num, col_num)) {
let coord = cell.get_coordinate();
max_col = max_col.max(*coord.get_col_num() as usize);
max_row = max_row.max(*coord.get_row_num() as usize);
}
}
}
Ok((max_col, max_row))
}
pub fn get_column_names(&self, worksheet: &Worksheet) -> Result<Vec<String>> {
let mut names = Vec::new();
for col_num in 1..=worksheet.get_highest_column() {
if let Some(cell) = worksheet.get_cell((1, col_num)) {
names.push(cell.get_value().into_owned());
} else {
names.push(String::new());
}
}
Ok(names)
}
pub fn get_range(&self, worksheet: &Worksheet, range: &str) -> Result<RangeData> {
let (start_col, start_row, end_col, end_row) = parse_range(range)?;
let mut values = Vec::new();
// Iterate through rows first, then columns
for row_idx in start_row..=end_row {
let mut row_values = Vec::new();
for col_idx in start_col..=end_col {
let cell_value = if let Some(cell) = worksheet.get_cell((row_idx, col_idx)) {
CellValue {
value: cell.get_value().into_owned(),
formula: if cell.get_formula().is_empty() {
None
} else {
Some(cell.get_formula().to_string())
},
}
} else {
CellValue {
value: String::new(),
formula: None,
}
};
row_values.push(cell_value);
}
values.push(row_values);
}
Ok(RangeData {
start_row,
end_row,
start_col,
end_col,
values,
})
}
pub fn update_cell(
&mut self,
worksheet_name: &str,
row: u32,
col: u32,
value: &str,
) -> Result<()> {
let worksheet = self
.workbook
.get_sheet_by_name_mut(worksheet_name)
.context("Worksheet not found")?;
worksheet
.get_cell_mut((row, col))
.set_value(value.to_string());
Ok(())
}
pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
umya_spreadsheet::writer::xlsx::write(&self.workbook, path)
.context("Failed to save Excel file")?;
Ok(())
}
pub fn find_in_worksheet(
&self,
worksheet: &Worksheet,
search_text: &str,
case_sensitive: bool,
) -> Result<Vec<(u32, u32)>> {
// Returns a vector of (row, column) coordinates where matches are found
let mut matches = Vec::new();
let search_text = if !case_sensitive {
search_text.to_lowercase()
} else {
search_text.to_string()
};
for row_num in 1..=worksheet.get_highest_row() {
for col_num in 1..=worksheet.get_highest_column() {
if let Some(cell) = worksheet.get_cell((row_num, col_num)) {
let cell_value = if !case_sensitive {
cell.get_value().to_lowercase()
} else {
cell.get_value().to_string()
};
if cell_value.contains(&search_text) {
let coord = cell.get_coordinate();
matches.push((*coord.get_row_num(), *coord.get_col_num()));
}
}
}
}
Ok(matches)
}
pub fn get_cell_value(&self, worksheet: &Worksheet, row: u32, col: u32) -> Result<CellValue> {
let cell = worksheet.get_cell((row, col)).context("Cell not found")?;
Ok(CellValue {
value: cell.get_value().into_owned(),
formula: if cell.get_formula().is_empty() {
None
} else {
Some(cell.get_formula().to_string())
},
})
}
}
fn parse_range(range: &str) -> Result<(u32, u32, u32, u32)> {
// Handle ranges like "A1:B10"
let parts: Vec<&str> = range.split(':').collect();
if parts.len() != 2 {
anyhow::bail!("Invalid range format. Expected format: 'A1:B10'");
}
let start = parse_cell_reference(parts[0])?;
let end = parse_cell_reference(parts[1])?;
Ok((start.0, start.1, end.0, end.1))
}
fn parse_cell_reference(reference: &str) -> Result<(u32, u32)> {
// Parse Excel cell reference (e.g., "A1") and return (column, row)
let mut col_str = String::new();
let mut row_str = String::new();
let mut parsing_row = false;
for c in reference.chars() {
if c.is_alphabetic() {
if parsing_row {
anyhow::bail!("Invalid cell reference format");
}
col_str.push(c.to_ascii_uppercase());
} else if c.is_numeric() {
parsing_row = true;
row_str.push(c);
} else {
anyhow::bail!("Invalid character in cell reference");
}
}
let col = column_letter_to_number(&col_str)?;
let row = row_str.parse::<u32>().context("Invalid row number")?;
Ok((col, row))
}
fn column_letter_to_number(column: &str) -> Result<u32> {
let mut result = 0u32;
for c in column.chars() {
if !c.is_ascii_alphabetic() {
anyhow::bail!("Invalid column letter");
}
result = result * 26 + (c.to_ascii_uppercase() as u32 - 'A' as u32 + 1);
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn get_test_file() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("src")
.join("computercontroller")
.join("tests")
.join("data")
.join("FinancialSample.xlsx")
}
#[test]
fn test_open_xlsx() -> Result<()> {
let xlsx = XlsxTool::new(get_test_file())?;
let worksheets = xlsx.list_worksheets()?;
assert!(!worksheets.is_empty());
Ok(())
}
#[test]
fn test_get_column_names() -> Result<()> {
let xlsx = XlsxTool::new(get_test_file())?;
let worksheet = xlsx.get_worksheet_by_index(0)?;
let columns = xlsx.get_column_names(worksheet)?;
assert!(!columns.is_empty());
println!("Columns: {:?}", columns);
Ok(())
}
#[test]
fn test_get_range() -> Result<()> {
let xlsx = XlsxTool::new(get_test_file())?;
let worksheet = xlsx.get_worksheet_by_index(0)?;
let range = xlsx.get_range(worksheet, "A1:C5")?;
assert_eq!(range.values.len(), 5);
println!("Range data: {:?}", range);
Ok(())
}
#[test]
fn test_find_in_worksheet() -> Result<()> {
let xlsx = XlsxTool::new(get_test_file())?;
let worksheet = xlsx.get_worksheet_by_index(0)?;
let matches = xlsx.find_in_worksheet(worksheet, "Government", false)?;
assert!(!matches.is_empty());
println!("Found matches at: {:?}", matches);
Ok(())
}
#[test]
fn test_get_cell_value() -> Result<()> {
let xlsx = XlsxTool::new(get_test_file())?;
let worksheet = xlsx.get_worksheet_by_index(0)?;
// Test header cell (known value from FinancialSample.xlsx)
let header_cell = xlsx.get_cell_value(worksheet, 1, 1)?;
assert_eq!(header_cell.value, "Segment");
assert!(header_cell.formula.is_none());
// Test data cell (known value from FinancialSample.xlsx)
let data_cell = xlsx.get_cell_value(worksheet, 2, 2)?;
assert_eq!(data_cell.value, "Canada");
assert!(data_cell.formula.is_none());
println!(
"Header cell: {:#?}\nData cell: {:#?}",
header_cell, data_cell
);
Ok(())
}
}