Skip to main content

sbol_fasta/
exporter.rs

1//! SBOL 3 → FASTA conversion engine.
2//!
3//! FASTA is a lossy target: it carries only a header and sequence body, so
4//! feature annotations, roles, and structure do not survive the trip. What the
5//! exporter guarantees is that every [`sbol3::Sequence`] with `elements`
6//! becomes one FASTA record, headed by the display ID (and description, when
7//! present) of the `Component` that references it.
8
9use std::collections::HashSet;
10use std::fs::File;
11use std::io::{BufWriter, Write};
12use std::path::{Path, PathBuf};
13
14use sbol3::{Document, Resource, SbolIdentified, SbolObject};
15
16/// Serializes SBOL 3 [`Document`]s to FASTA.
17///
18/// Records are emitted in document order: each `Component`'s referenced
19/// sequences first (headed by the component's display ID and description),
20/// then any sequences not referenced by a component (headed by their own
21/// display ID). Sequences without `elements` are skipped.
22#[derive(Clone, Debug)]
23pub struct FastaExporter {
24    line_width: usize,
25}
26
27impl Default for FastaExporter {
28    fn default() -> Self {
29        Self::new()
30    }
31}
32
33impl FastaExporter {
34    /// Builds an exporter that wraps sequence bodies at 70 columns.
35    pub fn new() -> Self {
36        Self { line_width: 70 }
37    }
38
39    /// Sets the column at which sequence bodies wrap. A width of 0 is treated
40    /// as 1 to avoid emitting empty lines.
41    pub fn with_line_width(mut self, width: usize) -> Self {
42        self.line_width = width.max(1);
43        self
44    }
45
46    /// Renders the document to a FASTA string.
47    pub fn to_string(&self, document: &Document) -> String {
48        let mut out = String::new();
49        for record in self.records(document) {
50            out.push('>');
51            out.push_str(&record.header);
52            out.push('\n');
53            wrap_into(&record.elements, self.line_width, &mut out);
54        }
55        out
56    }
57
58    /// Writes FASTA to an arbitrary writer, returning a tally of records.
59    pub fn write<W: Write>(
60        &self,
61        document: &Document,
62        writer: &mut W,
63    ) -> Result<ExportReport, ExportError> {
64        let text = self.to_string(document);
65        let records = text.bytes().filter(|byte| *byte == b'>').count();
66        writer
67            .write_all(text.as_bytes())
68            .map_err(|source| ExportError::Io {
69                path: PathBuf::from("<writer>"),
70                source,
71            })?;
72        Ok(ExportReport { records })
73    }
74
75    /// Writes FASTA to a file on disk.
76    pub fn write_path(
77        &self,
78        document: &Document,
79        path: impl AsRef<Path>,
80    ) -> Result<ExportReport, ExportError> {
81        let path = path.as_ref();
82        let file = File::create(path).map_err(|source| ExportError::Io {
83            path: path.to_path_buf(),
84            source,
85        })?;
86        let mut writer = BufWriter::new(file);
87        let report = self.write(document, &mut writer)?;
88        writer.flush().map_err(|source| ExportError::Io {
89            path: path.to_path_buf(),
90            source,
91        })?;
92        Ok(report)
93    }
94
95    fn records(&self, document: &Document) -> Vec<FastaRecord> {
96        let mut out = Vec::new();
97        let mut referenced: HashSet<Resource> = HashSet::new();
98
99        for component in document.components() {
100            for sequence_ref in &component.sequences {
101                let Some(SbolObject::Sequence(sequence)) = document.resolve(sequence_ref) else {
102                    continue;
103                };
104                referenced.insert(sequence.identity.clone());
105                let Some(elements) = sequence.elements.as_deref() else {
106                    continue;
107                };
108                if elements.is_empty() {
109                    continue;
110                }
111                out.push(FastaRecord {
112                    header: header(
113                        component.display_id(),
114                        &component.identity,
115                        component.description(),
116                    ),
117                    elements: elements.to_string(),
118                });
119            }
120        }
121
122        for sequence in document.sequences() {
123            if referenced.contains(&sequence.identity) {
124                continue;
125            }
126            let Some(elements) = sequence.elements.as_deref() else {
127                continue;
128            };
129            if elements.is_empty() {
130                continue;
131            }
132            out.push(FastaRecord {
133                header: header(
134                    sequence.display_id(),
135                    &sequence.identity,
136                    sequence.description(),
137                ),
138                elements: elements.to_string(),
139            });
140        }
141
142        out
143    }
144}
145
146struct FastaRecord {
147    header: String,
148    elements: String,
149}
150
151/// Builds a single-line FASTA header: an id token (display ID, else IRI)
152/// optionally followed by the description with newlines flattened to spaces.
153fn header(display_id: Option<&str>, identity: &Resource, description: Option<&str>) -> String {
154    let id = display_id
155        .map(str::to_string)
156        .unwrap_or_else(|| identity.to_string());
157    match description.map(str::trim).filter(|text| !text.is_empty()) {
158        Some(text) => format!("{id} {}", text.replace(['\n', '\r'], " ")),
159        None => id,
160    }
161}
162
163/// Appends `elements` to `out`, wrapping every `width` characters.
164fn wrap_into(elements: &str, width: usize, out: &mut String) {
165    let chars: Vec<char> = elements.chars().collect();
166    for chunk in chars.chunks(width) {
167        out.extend(chunk.iter());
168        out.push('\n');
169    }
170}
171
172/// Tally of what a [`FastaExporter`] run wrote.
173#[derive(Clone, Debug, Default)]
174#[non_exhaustive]
175pub struct ExportReport {
176    /// Number of FASTA records written.
177    pub records: usize,
178}
179
180/// Fatal errors from [`FastaExporter`].
181#[derive(Debug)]
182#[non_exhaustive]
183pub enum ExportError {
184    /// Filesystem or writer failure.
185    Io {
186        /// The path (or `<writer>`) that failed.
187        path: PathBuf,
188        /// The underlying I/O error.
189        source: std::io::Error,
190    },
191}
192
193impl std::fmt::Display for ExportError {
194    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195        match self {
196            Self::Io { path, source } => write!(f, "failed to write {}: {source}", path.display()),
197        }
198    }
199}
200
201impl std::error::Error for ExportError {
202    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
203        match self {
204            Self::Io { source, .. } => Some(source),
205        }
206    }
207}
208
209#[cfg(test)]
210mod tests;