///|
priv struct CsvCell {
text : String
line : Int
column : Int
}
///|
fn csv_rows(text : String) -> Result[Array[Array[CsvCell]], Array[Diagnostic]] {
let chars = text.to_array()
let rows = []
let mut row = []
let builder = StringBuilder()
let mut i = if chars.length() > 0 && chars[0] == '\u{feff}' { 1 } else { 0 }
let mut line = 1
let mut column = 1
let mut start_line = 1
let mut start_column = 1
let mut quoted = false
let mut closed = false
let mut active = false
while i < chars.length() {
let c = chars[i]
if quoted {
if c == '"' {
if i + 1 < chars.length() && chars[i + 1] == '"' {
builder.write_char('"')
i += 2
column += 2
continue
}
quoted = false
closed = true
} else {
builder.write_char(c)
if c == '\r' {
if i + 1 < chars.length() && chars[i + 1] == '\n' {
builder.write_char('\n')
i += 1
}
line += 1
column = 0
} else if c == '\n' {
line += 1
column = 0
}
}
} else if c == ',' || c == '\n' || c == '\r' {
row.push({
text: builder.to_string(),
line: start_line,
column: start_column,
})
builder.reset()
closed = false
active = false
if c != ',' {
rows.push(row)
row = []
if c == '\r' && i + 1 < chars.length() && chars[i + 1] == '\n' {
i += 1
}
line += 1
column = 0
}
start_line = line
start_column = column + 1
} else if closed {
return Err([
diagnostic(
"csv_quoting",
"Only a delimiter or line ending may follow a closing quote.",
line~,
column~,
),
])
} else if c == '"' {
if active {
return Err([
diagnostic(
"csv_quoting",
"A quote may only open at the beginning of a field.",
line~,
column~,
),
])
}
quoted = true
active = true
} else {
builder.write_char(c)
active = true
}
i += 1
column += 1
}
if quoted {
return Err([
diagnostic(
"csv_quoting",
"Unterminated quoted field.",
line=start_line,
column=start_column,
),
])
}
if active || closed || !row.is_empty() {
row.push({
text: builder.to_string(),
line: start_line,
column: start_column,
})
rows.push(row)
}
Ok(rows)
}
///|
fn cell_error(
code : String,
message : String,
cell : CsvCell,
field : String,
) -> Diagnostic {
diagnostic(code, message, line=cell.line, column=cell.column, field~)
}
///|
/// Decode editable text cells using the clinical parser's quoting and locations.
/// Only CSV shape is checked here; clinical state/precision validation remains
/// in parse_followup_csv and preflight_inputs. The header is not returned.
pub fn parse_followup_cells(
text : String,
) -> Result[Array[Array[String]], Array[Diagnostic]] {
let rows = match csv_rows(text) {
Ok(rows) => rows
Err(errors) => return Err(errors)
}
let header = [
"visit_id", "date", "technique_id", "lesion_id", "state", "diameter_mm", "note",
]
if rows.is_empty() || rows[0].map(c => c.text) != header {
return Err([
diagnostic(
"csv_header",
"Expected header: " + header.join(","),
line=1,
column=1,
),
])
}
let cells = []
for row in rows[1:] {
if row.length() != header.length() {
return Err([
diagnostic(
"csv_columns",
"Expected seven fields.",
line=row[0].line,
column=1,
),
])
}
cells.push(row.map(c => c.text))
}
Ok(cells)
}
///|
/// Long-form CSV: one target observation per row. Visit rows must be contiguous;
/// the parser preserves order and rejects partial results on any parse error.
pub fn parse_followup_csv(
text : String,
) -> Result[Array[@visit.Visit], Array[Diagnostic]] {
let rows = match csv_rows(text) {
Ok(rows) => rows
Err(errors) => return Err(errors)
}
let header = [
"visit_id", "date", "technique_id", "lesion_id", "state", "diameter_mm", "note",
]
if rows.is_empty() {
return Err([
diagnostic(
"csv_header",
"CSV is empty; a header is required.",
line=1,
column=1,
),
])
}
if rows[0].length() != header.length() || rows[0].map(c => c.text) != header {
return Err([
diagnostic(
"csv_header",
"Expected header: " + header.join(","),
line=1,
column=1,
),
])
}
let visits : Array[@visit.Visit] = []
let errors = []
let closed_ids : Map[String, Bool] = Map([])
for row_index in 1.. {
if !note.is_empty() {
errors.push(
cell_error(
"csv_state",
"Measured rows must have an empty note.",
row[6],
"note",
),
)
}
match parse_diameter_mm(diameter) {
Ok(value) => Measured(value)
Err(message) => {
errors.push(
cell_error("csv_diameter", message, row[5], "diameter_mm"),
)
Missing
}
}
}
"absent" | "too_small" | "missing" | "not_evaluable" => {
if !diameter.is_empty() {
errors.push(
cell_error(
"csv_state",
"Only measured rows may contain diameter_mm.",
row[5],
"diameter_mm",
),
)
}
if state == "not_evaluable" {
if note.is_blank() {
errors.push(
cell_error(
"csv_state",
"not_evaluable requires a reason in note.",
row[6],
"note",
),
)
}
NotEvaluable(note)
} else {
if !note.is_empty() {
errors.push(
cell_error(
"csv_state",
"Only not_evaluable rows may contain a note.",
row[6],
"note",
),
)
}
match state {
"absent" => Absent
"too_small" => TooSmallToMeasure
_ => Missing
}
}
}
_ => {
errors.push(
cell_error(
"csv_state",
"Use measured, absent, too_small, missing or not_evaluable.",
row[4],
"state",
),
)
Missing
}
}
let measurement : @visit.Measurement = { lesion_id, observation, }
let last_index = visits.length() - 1
if last_index >= 0 && visits[last_index].id == id {
let previous = visits[last_index]
if previous.date != date || previous.technique_id != technique {
errors.push(
cell_error(
"csv_metadata",
"Rows of one visit must repeat identical date and technique_id.",
row[0],
"visit_id",
),
)
}
previous.measurements.push(measurement)
} else {
if last_index >= 0 {
closed_ids[visits[last_index].id] = true
}
if closed_ids.contains(id) {
errors.push(
cell_error(
"csv_visit_order",
"Rows for a completed visit must not reappear.",
row[0],
"visit_id",
),
)
}
visits.push({
id,
date,
technique_id: technique,
measurements: [measurement],
})
}
}
if errors.is_empty() {
Ok(visits)
} else {
Err(errors)
}
}