///|
/// A validation finding tied to a FASTA record identifier.
pub struct FastaIssue {
id : String
code : String
message : String
position : Int
} derive(Debug, Eq)
///|
/// A strict batch validation result for FASTA input.
pub struct FastaValidationReport {
record_count : Int
total_bases : Int
duplicate_ids : Array[String]
issues : Array[FastaIssue]
valid : Bool
} derive(Debug, Eq)
///|
/// Parse FASTA and reject missing identifiers, duplicate identifiers, and
/// symbols outside the IUPAC alphabet instead of silently dropping them.
pub fn parse_fasta_strict(
text : String,
) -> Array[FastaRecord] raise GenBankError {
let records = parse_fasta(text)
let report = validate_fasta_records(records)
if !report.valid {
match report.issues.get(0) {
Some(issue) =>
raise GenBankError::MissingSection(issue.code + ": " + issue.message)
None => raise GenBankError::MissingSection("invalid FASTA")
}
}
records
}
///|
/// Validate parsed FASTA records without changing their sequences.
pub fn validate_fasta_records(
records : Array[FastaRecord],
) -> FastaValidationReport {
let issues : Array[FastaIssue] = []
let ids : Array[String] = []
let duplicate_ids : Array[String] = []
let mut total = 0
for record in records {
if record.id == "" {
issues.push({
id: "",
code: "EMPTY_ID",
message: "record has no identifier",
position: 0,
})
}
if ids.contains(record.id) {
if !duplicate_ids.contains(record.id) {
duplicate_ids.push(record.id)
}
issues.push({
id: record.id,
code: "DUPLICATE_ID",
message: "identifier occurs more than once",
position: 0,
})
} else {
ids.push(record.id)
}
total += record.sequence.length()
for position in invalid_base_positions(record.sequence) {
issues.push({
id: record.id,
code: "INVALID_BASE",
message: "symbol is outside the IUPAC nucleotide alphabet",
position,
})
}
}
{
record_count: records.length(),
total_bases: total,
duplicate_ids,
issues,
valid: issues.length() == 0,
}
}
///|
/// Validate FASTA text using the same parser used by production workflows.
pub fn validate_fasta_text(
text : String,
) -> FastaValidationReport raise GenBankError {
validate_fasta_records(parse_fasta(text))
}
///|
/// Sort records by descending sequence length, with source order as a tie-break.
pub fn sort_fasta_by_length(records : Array[FastaRecord]) -> Array[FastaRecord] {
let result = records.copy()
result.sort_by(fn(left, right) {
right.sequence.length() - left.sequence.length()
})
result
}
///|
/// Return the longest sequence in a FASTA batch.
pub fn longest_fasta_record(records : Array[FastaRecord]) -> FastaRecord? {
if records.length() == 0 {
return None
}
Some(sort_fasta_by_length(records)[0])
}
///|
/// Render a FASTA batch with explicit wrapping and normalized bases.
pub fn format_fasta_records(
records : Array[FastaRecord],
line_width : Int,
) -> String {
let width = if line_width <= 0 { 60 } else { line_width }
records.map(fn(record) { record.to_fasta(width) }).join("")
}
///|
/// Compute a length histogram using inclusive bins.
pub fn fasta_length_histogram(
records : Array[FastaRecord],
bin_width : Int,
) -> Array[(Int, Int)] {
let result : Array[(Int, Int)] = []
if bin_width <= 0 {
return result
}
for record in records {
let bin = record.sequence.length() / bin_width * bin_width
match result.search_by(fn(item) { item.0 == bin }) {
Some(index) => {
let item = result[index]
result[index] = (item.0, item.1 + 1)
}
None => result.push((bin, 1))
}
}
result
}
///|
/// Emit a compact validation report for logs and CI artifacts.
pub fn FastaValidationReport::to_json_string(
self : FastaValidationReport,
) -> String {
let issue_json = self.issues
.map(fn(item) {
"{\"id\":" +
json_string(item.id) +
",\"code\":" +
json_string(item.code) +
",\"message\":" +
json_string(item.message) +
",\"position\":" +
item.position.to_string() +
"}"
})
.join(",")
"{\"record_count\":" +
self.record_count.to_string() +
",\"total_bases\":" +
self.total_bases.to_string() +
",\"duplicate_ids\":[" +
self.duplicate_ids.map(json_string).join(",") +
"],\"issues\":[" +
issue_json +
"]" +
",\"valid\":" +
self.valid.to_string() +
"}"
}
///|
pub fn FastaIssue::to_text(self : FastaIssue) -> String {
self.id +
" " +
self.code +
" at " +
self.position.to_string() +
": " +
self.message
}