1use 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#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct ConvertReport {
20 pub n_frames: usize,
22 pub n_atoms_last: usize,
24 pub native_con: bool,
26}
27
28#[derive(Debug)]
30pub enum ConvertError {
31 InputMissing(String),
33 Empty,
35 Io(io::Error),
37 Parse(String),
39 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
77pub 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 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
92pub 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 let contents = compression::read_file_contents(input).map_err(|e| {
100 ConvertError::Io(io::Error::other(e.to_string()))
101 })?;
102 let text = contents
103 .as_str()
104 .map_err(|e| ConvertError::Parse(format!("input is not valid UTF-8: {e}")))?;
105 let mut frames = Vec::new();
106 for item in ConFrameIterator::new(text) {
107 match item {
108 Ok(f) => frames.push(f),
109 Err(e) => {
110 return Err(ConvertError::Parse(e.to_string()));
111 }
112 }
113 }
114 if frames.is_empty() {
115 return Err(ConvertError::Empty);
116 }
117 Ok((frames, true))
118 } else {
119 if !chemfiles_import::chemfiles_enabled() {
120 return Err(ConvertError::Chemfiles(
121 ChemfilesImportError::FeatureDisabled,
122 ));
123 }
124 let frames = chemfiles_import::con_frames_from_trajectory_path(input)?;
125 if frames.is_empty() {
126 return Err(ConvertError::Empty);
127 }
128 Ok((frames, false))
129 }
130}
131
132pub fn convert_path_to_con(input: &Path, output: &Path) -> Result<ConvertReport, ConvertError> {
137 let (frames, native_con) = read_frames_for_convert(input)?;
138 let n_frames = frames.len();
139 let n_atoms_last = frames.last().map(|f| f.atom_data.len()).unwrap_or(0);
140 let mut writer = ConFrameWriter::from_path(output)?;
141 writer
142 .extend(frames.iter())
143 .map_err(|e| ConvertError::Io(io::Error::other(e.to_string())))?;
144 Ok(ConvertReport {
145 n_frames,
146 n_atoms_last,
147 native_con,
148 })
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use std::fs;
155 use std::path::PathBuf;
156
157 fn fixture(name: &str) -> PathBuf {
158 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
159 .join("resources/test")
160 .join(name)
161 }
162
163 #[test]
164 fn path_looks_like_con_suffixes() {
165 assert!(path_looks_like_con(Path::new("a.con")));
166 assert!(path_looks_like_con(Path::new("a.convel")));
167 assert!(path_looks_like_con(Path::new("a.con.gz")));
168 assert!(!path_looks_like_con(Path::new("a.xyz")));
169 assert!(!path_looks_like_con(Path::new("a.pdb")));
170 }
171
172 #[test]
173 fn convert_native_con_roundtrip() {
174 let dir = tempfile_dir();
175 let out = dir.join("out.con");
176 let report = convert_path_to_con(&fixture("tiny_multi_cuh2.con"), &out).unwrap();
177 assert!(report.native_con);
178 assert_eq!(report.n_frames, 2);
179 assert_eq!(report.n_atoms_last, 4);
180 let (back, native) = read_frames_for_convert(&out).unwrap();
181 assert!(native);
182 assert_eq!(back.len(), 2);
183 assert_eq!(back[0].atom_data.len(), 4);
184 assert_eq!(back[0].atom_data[0].atom_id, 0);
185 }
186
187 #[test]
188 #[cfg(feature = "chemfiles")]
189 fn convert_xyz_via_chemfiles() {
190 let dir = tempfile_dir();
191 let xyz = dir.join("water.xyz");
192 fs::write(
193 &xyz,
194 "3\nwater migrate\nO 0 0 0\nH 0.96 0 0\nH -0.24 0.93 0\n",
195 )
196 .unwrap();
197 let out = dir.join("water.con");
198 let report = convert_path_to_con(&xyz, &out).unwrap();
199 assert!(!report.native_con);
200 assert_eq!(report.n_frames, 1);
201 assert_eq!(report.n_atoms_last, 3);
202 let (back, _) = read_frames_for_convert(&out).unwrap();
203 assert_eq!(back[0].atom_data.len(), 3);
204 let symbols: Vec<_> = back[0]
205 .atom_data
206 .iter()
207 .map(|a| a.symbol.as_ref())
208 .collect();
209 assert!(symbols.contains(&"O"));
210 assert_eq!(symbols.iter().filter(|s| **s == "H").count(), 2);
211 }
212
213 #[test]
214 #[cfg(not(feature = "chemfiles"))]
215 fn convert_xyz_fails_without_chemfiles() {
216 let dir = tempfile_dir();
217 let xyz = dir.join("water.xyz");
218 fs::write(&xyz, "3\nx\nO 0 0 0\nH 1 0 0\nH 0 1 0\n").unwrap();
219 let out = dir.join("water.con");
220 let err = convert_path_to_con(&xyz, &out).unwrap_err();
221 assert!(matches!(
222 err,
223 ConvertError::Chemfiles(ChemfilesImportError::FeatureDisabled)
224 ));
225 }
226
227 fn tempfile_dir() -> PathBuf {
228 let dir = std::env::temp_dir().join(format!(
229 "readcon-convert-{}-{}",
230 std::process::id(),
231 std::time::SystemTime::now()
232 .duration_since(std::time::UNIX_EPOCH)
233 .unwrap()
234 .as_nanos()
235 ));
236 fs::create_dir_all(&dir).unwrap();
237 dir
238 }
239}