Skip to main content

mafft_io/
hat2.rs

1use std::io::{BufRead, BufReader, Read, Write};
2
3use crate::error::IoError;
4
5/// MAFFT's hat2 distance matrix format.
6///
7/// Stores an upper-triangular pairwise distance matrix with sequence names.
8/// The on-disk format is:
9/// ```text
10/// 1
11///    <nseq>
12///  <scaled_max>
13///    1. name1
14///    2. name2
15///    ...
16/// <d(0,1)> <d(0,2)> ... (12 per line, 6-char fixed-width fields)
17/// <d(1,2)> <d(1,3)> ...
18/// ```
19#[derive(Debug, Clone)]
20pub struct Hat2Matrix {
21    /// Sequence names.
22    pub names: Vec<String>,
23    /// Upper-triangular distances: `distances[i][j]` is the distance
24    /// between sequence `i` and sequence `i + j + 1`.
25    /// So row `i` has `nseq - i - 1` elements.
26    pub distances: Vec<Vec<f64>>,
27}
28
29impl Hat2Matrix {
30    pub fn nseq(&self) -> usize {
31        self.names.len()
32    }
33
34    /// Get distance between sequences i and j (i != j).
35    pub fn get(&self, i: usize, j: usize) -> f64 {
36        if i < j {
37            self.distances[i][j - i - 1]
38        } else if i > j {
39            self.distances[j][i - j - 1]
40        } else {
41            0.0
42        }
43    }
44}
45
46/// Values per line in hat2 output.
47const VALS_PER_LINE: usize = 12;
48
49/// Read a hat2 distance matrix.
50pub fn read_hat2<R: Read>(reader: R) -> Result<Hat2Matrix, IoError> {
51    let reader = BufReader::new(reader);
52    let mut lines = reader.lines();
53
54    // Line 1: format identifier (skip)
55    lines.next().ok_or(IoError::Hat2Format("empty file".into()))??;
56
57    // Line 2: nseq
58    let nseq_line = lines
59        .next()
60        .ok_or(IoError::Hat2Format("missing nseq line".into()))??;
61    let nseq: usize = nseq_line
62        .trim()
63        .parse()
64        .map_err(|_| IoError::Hat2Format(format!("invalid nseq: '{nseq_line}'")))?;
65
66    // Line 3: scaled max (informational, we don't need it)
67    lines.next().ok_or(IoError::Hat2Format("missing max line".into()))??;
68
69    // Lines 4..4+nseq: "   N. =name" or "   N. name".
70    let mut names = Vec::with_capacity(nseq);
71    for _ in 0..nseq {
72        let line = lines
73            .next()
74            .ok_or(IoError::Hat2Format("truncated name section".into()))??;
75        // Format: "   1. <name>". C MAFFT prepends `=` to every name on
76        // FASTA read (`io.c:1513,1547,...`), so the disk form is
77        // typically "   1. =name". Strip the `. ` separator first, then
78        // the leading `=` so callers get the user-facing name back.
79        let raw = if let Some(pos) = line.find(". ") {
80            &line[pos + 2..]
81        } else {
82            line.trim_start()
83        };
84        let name = raw.strip_prefix('=').unwrap_or(raw).to_string();
85        names.push(name);
86    }
87
88    // Distance values: upper triangle, 6-char fixed-width fields.
89    // Collect all remaining non-empty content into a flat token stream.
90    let mut all_values: Vec<f64> = Vec::new();
91    for line_result in lines {
92        let line = line_result?;
93        // Parse 6-char fixed-width fields, or fall back to whitespace splitting
94        if !line.trim().is_empty() {
95            for token in line.split_whitespace() {
96                let val: f64 = token
97                    .parse()
98                    .map_err(|_| IoError::Hat2Format(format!("invalid float: '{token}'")))?;
99                all_values.push(val);
100            }
101        }
102    }
103
104    // Distribute into upper-triangular rows
105    let expected = nseq * (nseq - 1) / 2;
106    if all_values.len() != expected {
107        return Err(IoError::Hat2Format(format!(
108            "expected {} distance values, got {}",
109            expected,
110            all_values.len()
111        )));
112    }
113
114    let mut distances = Vec::with_capacity(nseq);
115    let mut offset = 0;
116    for i in 0..nseq {
117        let row_len = nseq - i - 1;
118        distances.push(all_values[offset..offset + row_len].to_vec());
119        offset += row_len;
120    }
121
122    Ok(Hat2Matrix { names, distances })
123}
124
125/// Write a hat2 distance matrix.
126pub fn write_hat2<W: Write>(mat: &Hat2Matrix, writer: &mut W) -> Result<(), IoError> {
127    let nseq = mat.nseq();
128
129    // Find max distance for the header
130    let max_dist = mat
131        .distances
132        .iter()
133        .flat_map(|row| row.iter())
134        .cloned()
135        .fold(0.0_f64, f64::max);
136
137    // Header. C `io.c:2980-2982` writes:
138    //   fprintf(hat2p, "%5d\n", 1);
139    //   fprintf(hat2p, "%5d\n", locnjob);
140    //   fprintf(hat2p, " %#6.3f\n", max * 2.5);
141    // The third line has a literal leading space PLUS the `%#6.3f`
142    // field (`#` forces a decimal point; width 6 right-aligns a
143    // 5-char value like "4.691" with 1 leading space → total "  4.691").
144    writeln!(writer, "    1")?;
145    writeln!(writer, "{nseq:5}")?;
146    writeln!(writer, " {:>6.3}", max_dist * 2.5)?;
147
148    // Names. C `io.c:2984` writes `%4d. %s\n` where C prepends an `=`
149    // prefix to every name when reading FASTA (`io.c:1513,1547,...
150    // name[i][0]='='`), so the `=` appears in the output even though
151    // there's no literal `=` in the format string. We mirror that by
152    // inserting `=` between the index dot and the (un-prefixed) name
153    // we hold internally — the on-disk output is the same.
154    for (i, name) in mat.names.iter().enumerate() {
155        writeln!(writer, "{:>4}. ={name}", i + 1)?;
156    }
157
158    // Distance values: upper triangle, rows 0..nseq-1
159    for i in 0..nseq.saturating_sub(1) {
160        let row = &mat.distances[i];
161        for (col, val) in row.iter().enumerate() {
162            write!(writer, "{val:6.3}")?;
163            if (col + 1) % VALS_PER_LINE == 0 || col == row.len() - 1 {
164                writeln!(writer)?;
165            }
166        }
167    }
168
169    Ok(())
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use std::io::Cursor;
176
177    fn sample_matrix() -> Hat2Matrix {
178        Hat2Matrix {
179            names: vec!["seq1".into(), "seq2".into(), "seq3".into()],
180            distances: vec![
181                vec![0.123, 0.456], // (0,1), (0,2)
182                vec![0.789],        // (1,2)
183            ],
184        }
185    }
186
187    #[test]
188    fn roundtrip_hat2() {
189        let mat = sample_matrix();
190
191        let mut buf = Vec::new();
192        write_hat2(&mat, &mut buf).unwrap();
193
194        let parsed = read_hat2(Cursor::new(&buf)).unwrap();
195        assert_eq!(parsed.nseq(), 3);
196        assert_eq!(parsed.names, mat.names);
197        assert!((parsed.get(0, 1) - 0.123).abs() < 0.001);
198        assert!((parsed.get(0, 2) - 0.456).abs() < 0.001);
199        assert!((parsed.get(1, 2) - 0.789).abs() < 0.001);
200        // Symmetric access
201        assert!((parsed.get(2, 0) - 0.456).abs() < 0.001);
202    }
203
204    #[test]
205    fn self_distance_is_zero() {
206        let mat = sample_matrix();
207        assert_eq!(mat.get(0, 0), 0.0);
208        assert_eq!(mat.get(1, 1), 0.0);
209    }
210}