Skip to main content

readcon_core/
convert.rs

1//! Foreign path or CON → multi-frame CON write (migration surface).
2//!
3//! Used by the CLI (`readcon-core convert …`) and callable from Rust without
4//! inventing a second conversion stack. Non-CON inputs require the `chemfiles`
5//! feature at runtime ([`chemfiles_import::chemfiles_enabled`]).
6
7use std::fmt;
8use std::io;
9use std::path::Path;
10
11use crate::chemfiles_import::{self, ChemfilesImportError};
12use crate::compression;
13use crate::iterators::ConFrameIterator;
14use crate::types::ConFrame;
15use crate::writer::ConFrameWriter;
16
17/// Outcome of a successful convert.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct ConvertReport {
20    /// Number of frames written.
21    pub n_frames: usize,
22    /// Atom count of the last frame (0 if no frames — should not happen on success).
23    pub n_atoms_last: usize,
24    /// True when the input was treated as native CON/convel (no chemfiles).
25    pub native_con: bool,
26}
27
28/// Errors from path conversion.
29#[derive(Debug)]
30pub enum ConvertError {
31    /// Input path does not exist or is not a file.
32    InputMissing(String),
33    /// No frames could be read.
34    Empty,
35    /// I/O while reading or writing.
36    Io(io::Error),
37    /// CON parse failure.
38    Parse(String),
39    /// Chemfiles import failure (includes feature disabled).
40    Chemfiles(ChemfilesImportError),
41}
42
43impl fmt::Display for ConvertError {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        match self {
46            ConvertError::InputMissing(p) => write!(f, "input not found: {p}"),
47            ConvertError::Empty => write!(f, "no frames produced from input"),
48            ConvertError::Io(e) => write!(f, "I/O error: {e}"),
49            ConvertError::Parse(msg) => write!(f, "parse error: {msg}"),
50            ConvertError::Chemfiles(e) => write!(f, "{e}"),
51        }
52    }
53}
54
55impl std::error::Error for ConvertError {
56    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
57        match self {
58            ConvertError::Io(e) => Some(e),
59            ConvertError::Chemfiles(e) => Some(e),
60            _ => None,
61        }
62    }
63}
64
65impl From<io::Error> for ConvertError {
66    fn from(e: io::Error) -> Self {
67        ConvertError::Io(e)
68    }
69}
70
71impl From<ChemfilesImportError> for ConvertError {
72    fn from(e: ChemfilesImportError) -> Self {
73        ConvertError::Chemfiles(e)
74    }
75}
76
77/// True when the path looks like native CON/convel (including compressed suffixes).
78pub fn path_looks_like_con(path: &Path) -> bool {
79    let name = path
80        .file_name()
81        .and_then(|s| s.to_str())
82        .unwrap_or("")
83        .to_ascii_lowercase();
84    // strip compression
85    let base = name
86        .strip_suffix(".gz")
87        .or_else(|| name.strip_suffix(".zst"))
88        .unwrap_or(&name);
89    base.ends_with(".con") || base.ends_with(".convel")
90}
91
92/// Read frames from a path: native CON/convel via the hot-path iterator, else chemfiles.
93pub fn read_frames_for_convert(input: &Path) -> Result<(Vec<ConFrame>, bool), ConvertError> {
94    if !input.is_file() {
95        return Err(ConvertError::InputMissing(input.display().to_string()));
96    }
97    if path_looks_like_con(input) {
98        // gzip/zstd-aware (same path as library readers)
99        let contents = compression::read_file_contents(input)
100            .map_err(|e| ConvertError::Io(io::Error::other(e.to_string())))?;
101        let text = contents
102            .as_str()
103            .map_err(|e| ConvertError::Parse(format!("input is not valid UTF-8: {e}")))?;
104        let mut frames = Vec::new();
105        for item in ConFrameIterator::new(text) {
106            match item {
107                Ok(f) => frames.push(f),
108                Err(e) => {
109                    return Err(ConvertError::Parse(e.to_string()));
110                }
111            }
112        }
113        if frames.is_empty() {
114            return Err(ConvertError::Empty);
115        }
116        Ok((frames, true))
117    } else {
118        if !chemfiles_import::chemfiles_enabled() {
119            return Err(ConvertError::Chemfiles(
120                ChemfilesImportError::FeatureDisabled,
121            ));
122        }
123        let frames = chemfiles_import::con_frames_from_trajectory_path(input)?;
124        if frames.is_empty() {
125            return Err(ConvertError::Empty);
126        }
127        Ok((frames, false))
128    }
129}
130
131/// Convert `input` (CON or chemfiles-readable foreign format) to CON at `output`.
132///
133/// Returns a [`ConvertReport`]. Fails if the foreign path needs chemfiles and
134/// this build is lean, or if zero frames are produced. This is the call the
135/// `readcon-core convert` binary makes.
136///
137/// ```
138/// use readcon_core::convert::convert_path_to_con;
139/// use std::path::Path;
140///
141/// let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/test/tiny_cuh2.con");
142/// let dir = std::env::temp_dir().join(format!("readcon-convert-doc-{}", std::process::id()));
143/// std::fs::create_dir_all(&dir).unwrap();
144/// let out = dir.join("out.con");
145/// let report = convert_path_to_con(&src, &out).unwrap();
146/// assert!(report.native_con);
147/// assert_eq!(report.n_frames, 1);
148/// assert_eq!(report.n_atoms_last, 4);
149/// let _ = std::fs::remove_dir_all(dir);
150/// ```
151pub fn convert_path_to_con(input: &Path, output: &Path) -> Result<ConvertReport, ConvertError> {
152    let (frames, native_con) = read_frames_for_convert(input)?;
153    let n_frames = frames.len();
154    let n_atoms_last = frames.last().map(|f| f.atom_data.len()).unwrap_or(0);
155    let mut writer = ConFrameWriter::from_path(output)?;
156    writer
157        .extend(frames.iter())
158        .map_err(|e| ConvertError::Io(io::Error::other(e.to_string())))?;
159    Ok(ConvertReport {
160        n_frames,
161        n_atoms_last,
162        native_con,
163    })
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use std::fs;
170    use std::path::PathBuf;
171
172    fn fixture(name: &str) -> PathBuf {
173        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
174            .join("resources/test")
175            .join(name)
176    }
177
178    #[test]
179    fn path_looks_like_con_suffixes() {
180        assert!(path_looks_like_con(Path::new("a.con")));
181        assert!(path_looks_like_con(Path::new("a.convel")));
182        assert!(path_looks_like_con(Path::new("a.con.gz")));
183        assert!(!path_looks_like_con(Path::new("a.xyz")));
184        assert!(!path_looks_like_con(Path::new("a.pdb")));
185    }
186
187    #[test]
188    fn convert_native_con_roundtrip() {
189        let dir = tempfile_dir();
190        let out = dir.join("out.con");
191        let report = convert_path_to_con(&fixture("tiny_multi_cuh2.con"), &out).unwrap();
192        assert!(report.native_con);
193        assert_eq!(report.n_frames, 2);
194        assert_eq!(report.n_atoms_last, 4);
195        let (back, native) = read_frames_for_convert(&out).unwrap();
196        assert!(native);
197        assert_eq!(back.len(), 2);
198        assert_eq!(back[0].atom_data.len(), 4);
199        assert_eq!(back[0].atom_data[0].atom_id, 0);
200    }
201
202    #[test]
203    #[cfg(feature = "chemfiles")]
204    fn convert_xyz_via_chemfiles() {
205        let dir = tempfile_dir();
206        let xyz = dir.join("water.xyz");
207        fs::write(
208            &xyz,
209            "3\nwater migrate\nO 0 0 0\nH 0.96 0 0\nH -0.24 0.93 0\n",
210        )
211        .unwrap();
212        let out = dir.join("water.con");
213        let report = convert_path_to_con(&xyz, &out).unwrap();
214        assert!(!report.native_con);
215        assert_eq!(report.n_frames, 1);
216        assert_eq!(report.n_atoms_last, 3);
217        let (back, _) = read_frames_for_convert(&out).unwrap();
218        assert_eq!(back[0].atom_data.len(), 3);
219        let symbols: Vec<_> = back[0]
220            .atom_data
221            .iter()
222            .map(|a| a.symbol.as_ref())
223            .collect();
224        assert!(symbols.contains(&"O"));
225        assert_eq!(symbols.iter().filter(|s| **s == "H").count(), 2);
226    }
227
228    #[test]
229    #[cfg(not(feature = "chemfiles"))]
230    fn convert_xyz_fails_without_chemfiles() {
231        let dir = tempfile_dir();
232        let xyz = dir.join("water.xyz");
233        fs::write(&xyz, "3\nx\nO 0 0 0\nH 1 0 0\nH 0 1 0\n").unwrap();
234        let out = dir.join("water.con");
235        let err = convert_path_to_con(&xyz, &out).unwrap_err();
236        assert!(matches!(
237            err,
238            ConvertError::Chemfiles(ChemfilesImportError::FeatureDisabled)
239        ));
240    }
241
242    fn tempfile_dir() -> PathBuf {
243        let dir = std::env::temp_dir().join(format!(
244            "readcon-convert-{}-{}",
245            std::process::id(),
246            std::time::SystemTime::now()
247                .duration_since(std::time::UNIX_EPOCH)
248                .unwrap()
249                .as_nanos()
250        ));
251        fs::create_dir_all(&dir).unwrap();
252        dir
253    }
254}