use serde::Serialize;
use hwpforge::ops::{self, OpsError, ReadOptions};
use hwpforge_foundation::diagnostics::OpsCode;
use hwpforge_smithy_hwpx::{FieldInfo, ParagraphsView, TableView};
use crate::compat::{self, Tool};
use crate::output::{read_file_bytes, ToolErrorInfo, ToolWarningInfo};
#[derive(Debug, Serialize)]
pub struct ReadData {
#[serde(skip_serializing_if = "Option::is_none")]
pub paragraphs: Option<ParagraphsView>,
#[serde(skip_serializing_if = "Option::is_none")]
pub table: Option<TableView>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fields: Option<Vec<FieldInfo>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<ToolWarningInfo>,
}
impl ReadData {
pub fn summary(&self) -> String {
if let Some(p) = &self.paragraphs {
format!(
"section {}: paragraphs {}..={} ({})",
p.section,
p.from,
p.to,
p.paragraphs.len()
)
} else if let Some(t) = &self.table {
format!(
"table {}: {}x{} grid, {} anchor cell(s)",
t.ordinal,
t.rows,
t.cols,
t.cells.len()
)
} else if let Some(f) = &self.fields {
format!("{} field match(es)", f.len())
} else {
"empty read".to_string()
}
}
}
pub fn run_read(
file_path: &str,
section: Option<usize>,
paras: Option<&str>,
table: Option<usize>,
field: Option<&str>,
) -> Result<ReadData, ToolErrorInfo> {
let targets = usize::from(section.is_some())
+ usize::from(table.is_some())
+ usize::from(field.is_some());
if targets != 1 {
return Err(compat::tool_error(
Tool::Read,
OpsError::Rejected {
code: OpsCode::ReadTargetRequired,
reason: compat::READ_TARGET_REQUIRED_MESSAGE.into(),
},
));
}
if paras.is_some() && section.is_none() {
return Err(compat::tool_error(
Tool::Read,
OpsError::Rejected {
code: OpsCode::ReadParasWithoutSection,
reason: compat::READ_PARAS_WITHOUT_SECTION_MESSAGE.into(),
},
));
}
let bytes = read_file_bytes(file_path)?;
let mut opts = ReadOptions::default();
if let Some(section) = section {
opts = opts.with_section(section);
}
if let Some(paras) = paras {
opts = opts.with_paras(paras);
}
if let Some(table) = table {
opts = opts.with_table(table);
}
if let Some(field) = field {
opts = opts.with_field(field);
}
let out = ops::read(&bytes, &opts).map_err(|e| compat::tool_error(Tool::Read, e))?;
let warnings: Vec<ToolWarningInfo> = out.warnings.iter().map(compat::warning).collect();
Ok(ReadData { paragraphs: out.paragraphs, table: out.table, fields: out.fields, warnings })
}
#[cfg(test)]
mod tests {
use super::*;
fn probe_doc(dir: &tempfile::TempDir) -> String {
let path = dir.path().join("probe.hwpx");
crate::tools::convert::run_convert(
"# 사업 개요\n\n본문 문단입니다.\n\n| 항목 | 값 |\n| --- | --- |\n| 성명 | |",
false,
path.to_str().unwrap(),
"default",
)
.unwrap();
path.to_str().unwrap().to_string()
}
#[test]
fn read_section_via_mcp_surface_reports_kinds_and_markers() {
let dir = tempfile::tempdir().unwrap();
let path = probe_doc(&dir);
let data = run_read(&path, Some(0), None, None, None).unwrap();
assert!(data.warnings.is_empty(), "a clean document must not warn: {:?}", data.warnings);
let view = data.paragraphs.expect("paragraphs target");
assert!(!view.paragraphs.is_empty());
assert!(view.paragraphs.iter().any(|p| !p.contains.is_empty()), "table marker expected");
}
fn fixture(rel: &str) -> String {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/fixtures")
.join(rel)
.to_str()
.unwrap()
.to_string()
}
#[test]
fn read_surfaces_decode_warnings_for_every_target() {
let path = fixture("layout/stale-line-cache.hwpx");
let section = run_read(&path, Some(0), None, None, None).unwrap();
assert!(
section.warnings.iter().any(|w| w.code == "LAYOUT_CACHE_DROPPED"),
"section target must surface the decode warning: {:?}",
section.warnings
);
let table = run_read(&path, None, None, Some(0), None).unwrap();
assert!(
table.warnings.iter().any(|w| w.code == "LAYOUT_CACHE_DROPPED"),
"table target must surface the decode warning: {:?}",
table.warnings
);
let field = run_read(&path, None, None, None, Some("user_email")).unwrap();
assert!(
field.warnings.iter().any(|w| w.code == "LAYOUT_CACHE_DROPPED"),
"field target must surface the decode warning: {:?}",
field.warnings
);
let value = serde_json::to_value(§ion).unwrap();
assert_eq!(value["warnings"][0]["code"], "LAYOUT_CACHE_DROPPED");
assert!(!value["warnings"][0]["message"].as_str().unwrap_or_default().is_empty());
}
#[test]
fn read_table_via_mcp_surface_returns_grid() {
let dir = tempfile::tempdir().unwrap();
let path = probe_doc(&dir);
let data = run_read(&path, None, None, Some(0), None).unwrap();
let table = data.table.expect("table target");
assert_eq!((table.rows, table.cols), (2, 2));
assert!(table.cells.iter().any(|c| c.text.contains("성명")));
}
#[test]
fn read_rejects_zero_or_multiple_targets() {
let dir = tempfile::tempdir().unwrap();
let path = probe_doc(&dir);
let err = run_read(&path, None, None, None, None).unwrap_err();
assert_eq!(err.code, "READ_TARGET_REQUIRED");
assert_eq!(err.message, "Pass exactly one of section, table, field");
let err = run_read(&path, Some(0), None, Some(0), None).unwrap_err();
assert_eq!(err.code, "READ_TARGET_REQUIRED");
}
#[test]
fn argument_guards_run_before_file_access() {
let missing = "/nonexistent/e5-read-guard-probe.hwpx";
let err = run_read(missing, None, None, None, None).unwrap_err();
assert_eq!(err.code, "READ_TARGET_REQUIRED");
let err = run_read(missing, None, Some("0..1"), Some(0), None).unwrap_err();
assert_eq!(err.code, "READ_PARAS_WITHOUT_SECTION");
}
#[test]
fn read_paras_validation_and_range_errors() {
let dir = tempfile::tempdir().unwrap();
let path = probe_doc(&dir);
let err = run_read(&path, None, Some("0..1"), Some(0), None).unwrap_err();
assert_eq!(err.code, "READ_PARAS_WITHOUT_SECTION");
assert_eq!(err.message, "paras requires section");
let err = run_read(&path, Some(0), Some("abc"), None, None).unwrap_err();
assert_eq!(err.code, "READ_PARAS_INVALID");
let err = run_read(&path, Some(0), Some("5..1"), None, None).unwrap_err();
assert_eq!(err.code, "READ_PARA_RANGE_INVALID");
let err = run_read(&path, Some(99), None, None, None).unwrap_err();
assert_eq!(err.code, "READ_SECTION_OUT_OF_RANGE");
let view = run_read(&path, Some(0), Some("0..0"), None, None).unwrap().paragraphs.unwrap();
assert_eq!((view.from, view.to), (0, 0));
}
#[test]
fn read_field_and_table_error_mappings() {
let dir = tempfile::tempdir().unwrap();
let path = probe_doc(&dir);
let err = run_read(&path, None, None, Some(42), None).unwrap_err();
assert_eq!(err.code, "READ_TABLE_OUT_OF_RANGE");
let err = run_read(&path, None, None, None, Some("없는이름")).unwrap_err();
assert_eq!(err.code, "READ_FIELD_NOT_FOUND");
}
#[test]
fn read_non_hwpx_bytes_reports_decode_error() {
let dir = tempfile::tempdir().unwrap();
let garbage = dir.path().join("garbage.hwpx");
std::fs::write(&garbage, b"not a zip").unwrap();
let err = run_read(garbage.to_str().unwrap(), Some(0), None, None, None).unwrap_err();
assert_eq!(err.code, "DECODE_ERROR");
}
#[test]
fn summary_covers_every_target_shape() {
let dir = tempfile::tempdir().unwrap();
let path = probe_doc(&dir);
let s = run_read(&path, Some(0), None, None, None).unwrap().summary();
assert!(s.starts_with("section 0"), "summary: {s}");
let s = run_read(&path, None, None, Some(0), None).unwrap().summary();
assert!(s.starts_with("table 0"), "summary: {s}");
let empty = ReadData { paragraphs: None, table: None, fields: None, warnings: Vec::new() };
assert_eq!(empty.summary(), "empty read");
let fields = ReadData {
paragraphs: None,
table: None,
fields: Some(Vec::new()),
warnings: Vec::new(),
};
assert_eq!(fields.summary(), "0 field match(es)");
}
}