///|
/// One lexical CSV row before domain conversion.
pub(all) struct CsvRow {
line : Int
fields : Array[String]
} derive(Debug, Eq)
///|
/// Low-level CSV result. Syntax diagnostics are retained instead of raised.
pub(all) struct CsvDocument {
rows : Array[CsvRow]
diagnostics : Array[Diagnostic]
} derive(Debug, Eq)
///|
/// Finish one field and reset the mutable field buffer.
fn csv_finish_field(fields : Array[String], builder : StringBuilder) -> Unit {
fields.push(builder.to_string())
builder.reset()
}
///|
/// Finish a CSV row. Completely empty trailing lines are ignored.
fn csv_finish_row(
rows : Array[CsvRow],
fields : Array[String],
builder : StringBuilder,
line : Int,
) -> Unit {
csv_finish_field(fields, builder)
if fields.length() == 1 && fields[0].is_empty() {
fields.clear()
return
}
rows.push({ line, fields: copy_array(fields) })
fields.clear()
}
///|
/// Parse the RFC 4180 features needed by common temperature logger exports:
/// commas, quoted fields, doubled quotes and CRLF/LF line endings.
pub fn parse_csv_document(input : String) -> CsvDocument {
let rows : Array[CsvRow] = []
let diagnostics : Array[Diagnostic] = []
let fields : Array[String] = []
let field = StringBuilder::new()
let chars : Array[Char] = []
for char in input {
chars.push(char)
}
let mut index = 0
let mut line = 1
let mut row_line = 1
let mut quoted = false
let mut after_quote = false
while index < chars.length() {
let char = chars[index]
if quoted {
if char == '"' {
if index + 1 < chars.length() && chars[index + 1] == '"' {
field.write_char('"')
index = index + 2
continue
}
quoted = false
after_quote = true
} else {
field.write_char(char)
if char == '\n' {
line = line + 1
}
}
index = index + 1
continue
}
if after_quote {
if char == ',' {
csv_finish_field(fields, field)
after_quote = false
} else if char == '\n' {
csv_finish_row(rows, fields, field, row_line)
after_quote = false
line = line + 1
row_line = line
} else if char == '\r' {
()
} else if char == ' ' || char == '\t' {
()
} else {
diagnostics.push(
warning_diagnostic(
"csv.content_after_quote", "unexpected content after a closing quote",
).at_line(line),
)
field.write_char(char)
after_quote = false
}
index = index + 1
continue
}
if char == '"' {
if field.is_empty() {
quoted = true
} else {
diagnostics.push(
warning_diagnostic(
"csv.quote_in_unquoted_field", "quote appeared inside an unquoted field",
).at_line(line),
)
field.write_char(char)
}
} else if char == ',' {
csv_finish_field(fields, field)
} else if char == '\n' {
csv_finish_row(rows, fields, field, row_line)
line = line + 1
row_line = line
} else if char != '\r' {
field.write_char(char)
}
index = index + 1
}
if quoted {
diagnostics.push(
error_diagnostic(
"csv.unclosed_quote", "quoted field was not closed before end of input",
).at_line(row_line),
)
}
if !field.is_empty() || fields.length() > 0 || after_quote {
csv_finish_row(rows, fields, field, row_line)
}
{ rows, diagnostics }
}
///|
/// Remove UTF-8 BOM materialized as U+FEFF from the first header.
fn strip_header_bom(value : String) -> String {
let chars : Array[Char] = []
for char in value {
chars.push(char)
}
if chars.length() > 0 && chars[0] == '\u{FEFF}' {
let builder = StringBuilder::new()
for index = 1; index < chars.length(); index = index + 1 {
builder.write_char(chars[index])
}
builder.to_string()
} else {
value
}
}
///|
/// Normalize a header for matching.
fn normalize_header(value : String) -> String {
strip_header_bom(value).trim().to_owned().to_lower()
}
///|
/// Find any of the accepted aliases in a header row.
fn find_header(header : Array[String], aliases : Array[String]) -> Int? {
for index = 0; index < header.length(); index = index + 1 {
let candidate = normalize_header(header[index])
for accepted_name in aliases {
if candidate == accepted_name {
return Some(index)
}
}
}
None
}
///|
/// Read a cell by index, returning an empty string for short rows.
fn csv_cell(row : CsvRow, index : Int) -> String {
if index < 0 || index >= row.fields.length() {
""
} else {
row.fields[index].trim().to_owned()
}
}
///|
/// Parse a strict decimal without exponent notation.
pub fn parse_decimal(text : String) -> Double? {
let view = text.trim()
if view.is_empty() {
return None
}
let chars : Array[Char] = []
for char in view {
chars.push(char)
}
let mut index = 0
let mut sign = 1.0
if chars[index] == '-' {
sign = -1.0
index = index + 1
} else if chars[index] == '+' {
index = index + 1
}
if index >= chars.length() {
return None
}
let mut integer = 0.0
let mut fraction = 0.0
let mut scale = 0.1
let mut digits = 0
let mut decimal_seen = false
while index < chars.length() {
let char = chars[index]
if char == '.' {
if decimal_seen {
return None
}
decimal_seen = true
} else {
let digit = match char {
'0' => 0
'1' => 1
'2' => 2
'3' => 3
'4' => 4
'5' => 5
'6' => 6
'7' => 7
'8' => 8
'9' => 9
_ => return None
}
digits = digits + 1
if decimal_seen {
fraction = fraction + digit.to_double() * scale
scale = scale * 0.1
} else {
integer = integer * 10.0 + digit.to_double()
}
}
index = index + 1
}
if digits == 0 {
None
} else {
Some(sign * (integer + fraction))
}
}
///|
/// Parse an optional percentage and validate its physical range.
fn parse_optional_percent(
text : String,
field_name : String,
line : Int,
diagnostics : Array[Diagnostic],
) -> Double? {
if text.trim().is_empty() {
return None
}
match parse_decimal(text) {
None => {
diagnostics.push(
warning_diagnostic(
"csv.invalid_optional_number",
"invalid numeric value in \{field_name}",
).at_line(line),
)
None
}
Some(value) => {
if value < 0.0 || value > 100.0 {
diagnostics.push(
warning_diagnostic(
"csv.percent_out_of_range",
"\{field_name} should be between 0 and 100",
).at_line(line),
)
}
Some(value)
}
}
}
///|
/// Parse logger CSV data into domain readings.
pub fn parse_readings_csv(input : String) -> ReadingBatch {
let document = parse_csv_document(input)
let diagnostics = copy_array(document.diagnostics)
let readings : Array[Reading] = []
if document.rows.length() == 0 {
diagnostics.push(
error_diagnostic("csv.empty", "CSV input contains no rows"),
)
return { readings, diagnostics }
}
let header = document.rows[0].fields
let timestamp_index = find_header(header, ["timestamp", "time", "datetime"])
let sensor_index = find_header(header, ["sensor_id", "sensor", "device_id"])
let temperature_index = find_header(header, [
"temperature_c", "temperature", "temp_c", "temp",
])
let humidity_index = find_header(header, [
"humidity_percent", "humidity", "rh_percent",
])
let battery_index = find_header(header, [
"battery_percent", "battery", "battery_pct",
])
let status_index = find_header(header, ["status", "state"])
if timestamp_index is None {
diagnostics.push(
error_diagnostic(
"csv.missing_timestamp_header", "timestamp column is required",
),
)
}
if sensor_index is None {
diagnostics.push(
error_diagnostic(
"csv.missing_sensor_header", "sensor_id column is required",
),
)
}
if temperature_index is None {
diagnostics.push(
error_diagnostic(
"csv.missing_temperature_header", "temperature_c column is required",
),
)
}
if timestamp_index is None ||
sensor_index is None ||
temperature_index is None {
return { readings, diagnostics }
}
let timestamp_column = timestamp_index.unwrap()
let sensor_column = sensor_index.unwrap()
let temperature_column = temperature_index.unwrap()
for row_index = 1
row_index < document.rows.length()
row_index = row_index + 1 {
let row = document.rows[row_index]
let timestamp_text = csv_cell(row, timestamp_column)
let sensor_id = csv_cell(row, sensor_column)
let temperature_text = csv_cell(row, temperature_column)
if timestamp_text.is_empty() ||
sensor_id.is_empty() ||
temperature_text.is_empty() {
diagnostics.push(
error_diagnostic(
"csv.missing_required_value", "timestamp, sensor_id and temperature_c are required",
).at_line(row.line),
)
continue
}
let timestamp = match parse_iso8601(timestamp_text) {
Ok(parsed) => parsed.unix_seconds
Err(error) => {
diagnostics.push(
error_diagnostic(
"csv.invalid_timestamp",
timestamp_error_message(error),
)
.at_line(row.line)
.for_sensor(sensor_id),
)
continue
}
}
let temperature = match parse_decimal(temperature_text) {
Some(value) => value
None => {
diagnostics.push(
error_diagnostic(
"csv.invalid_temperature", "temperature_c must be a decimal number",
)
.at_line(row.line)
.for_sensor(sensor_id),
)
continue
}
}
let humidity = match humidity_index {
Some(column) =>
parse_optional_percent(
csv_cell(row, column),
"humidity_percent",
row.line,
diagnostics,
)
None => None
}
let battery = match battery_index {
Some(column) =>
parse_optional_percent(
csv_cell(row, column),
"battery_percent",
row.line,
diagnostics,
)
None => None
}
let status = match status_index {
Some(column) => {
let value = csv_cell(row, column)
if value.is_empty() {
"ok"
} else {
value
}
}
None => "ok"
}
readings.push({
timestamp,
sensor_id,
temperature_c: temperature,
humidity_percent: humidity,
battery_percent: battery,
status,
origin: Recorded,
flags: [],
source_line: Some(row.line),
})
}
{ readings, diagnostics }
}
///|
/// Escape one CSV cell according to RFC 4180.
pub fn csv_escape(value : String) -> String {
let mut needs_quotes = false
for char in value {
if char == ',' || char == '"' || char == '\n' || char == '\r' {
needs_quotes = true
}
}
if !needs_quotes {
return value
}
let builder = StringBuilder::new()
builder.write_char('"')
for char in value {
if char == '"' {
builder.write_string("\"\"")
} else {
builder.write_char(char)
}
}
builder.write_char('"')
builder.to_string()
}
///|
/// Serialize normalized readings to a stable CSV representation.
pub fn readings_to_csv(readings : Array[Reading]) -> String {
let builder = StringBuilder::new()
builder.write_string(
"timestamp,sensor_id,temperature_c,humidity_percent,battery_percent,status,origin,flags\n",
)
for sample in readings {
builder.write_string(format_iso8601(sample.timestamp))
builder.write_char(',')
builder.write_string(csv_escape(sample.sensor_id))
builder.write_char(',')
builder.write_object(sample.temperature_c)
builder.write_char(',')
match sample.humidity_percent {
Some(value) => builder.write_object(value)
None => ()
}
builder.write_char(',')
match sample.battery_percent {
Some(value) => builder.write_object(value)
None => ()
}
builder.write_char(',')
builder.write_string(csv_escape(sample.status))
builder.write_char(',')
builder.write_string(
match sample.origin {
Recorded => "recorded"
Interpolated => "interpolated"
Simulated => "simulated"
},
)
builder.write_char(',')
for index = 0; index < sample.flags.length(); index = index + 1 {
if index > 0 {
builder.write_char('|')
}
builder.write_string(quality_flag_name(sample.flags[index]))
}
builder.write_char('\n')
}
builder.to_string()
}