Skip to main content

rhythm_open_exchange/codec/
traits.rs

1//! Encoder and Decoder traits for format conversion.
2
3use std::path::Path;
4
5use crate::error::RoxResult;
6use crate::model::RoxChart;
7
8/// Trait for decoding from external formats to ROX.
9pub trait Decoder {
10    /// Decode a chart from raw bytes.
11    ///
12    /// # Errors
13    ///
14    /// Returns an error if the data is invalid or cannot be parsed.
15    fn decode(data: &[u8]) -> RoxResult<RoxChart>;
16
17    /// Decode a chart from a file path.
18    ///
19    /// # Errors
20    ///
21    /// Returns an error if the file cannot be read or contains invalid data.
22    fn decode_from_path(path: impl AsRef<Path>) -> RoxResult<RoxChart> {
23        let data = std::fs::read(path)?;
24        Self::decode(&data)
25    }
26}
27
28/// Trait for encoding from ROX to external formats.
29pub trait Encoder {
30    /// Encode a chart to raw bytes.
31    ///
32    /// # Errors
33    ///
34    /// Returns an error if the chart is invalid or encoding fails.
35    fn encode(chart: &RoxChart) -> RoxResult<Vec<u8>>;
36
37    /// Encode a chart to a file path.
38    ///
39    /// # Errors
40    ///
41    /// Returns an error if encoding fails or the file cannot be written.
42    fn encode_to_path(chart: &RoxChart, path: impl AsRef<Path>) -> RoxResult<()> {
43        let data = Self::encode(chart)?;
44        std::fs::write(path, data)?;
45        Ok(())
46    }
47
48    /// Encode a chart to a String (for text-based formats like .osu).
49    ///
50    /// # Errors
51    ///
52    /// Returns an error if encoding fails or the output is not valid UTF-8.
53    fn encode_to_string(chart: &RoxChart) -> RoxResult<String> {
54        let data = Self::encode(chart)?;
55        String::from_utf8(data)
56            .map_err(|e| crate::error::RoxError::InvalidFormat(format!("Invalid UTF-8: {e}")))
57    }
58}
59
60/// Trait for formats that support specific file extensions.
61/// Implement this trait to enable auto-detection based on file extension.
62pub trait Format {
63    /// List of supported file extensions (lowercase, without leading dot).
64    /// Example: `["osu"]` or `["sm", "ssc"]`
65    const EXTENSIONS: &'static [&'static str];
66
67    /// Check if this format supports the given extension.
68    #[must_use]
69    fn supports_extension(ext: &str) -> bool {
70        let ext_lower = ext.to_lowercase();
71        Self::EXTENSIONS.iter().any(|&e| e == ext_lower)
72    }
73}
74
75/// Convert data from one format to another using ROX as the intermediate format.
76///
77/// # Example
78/// ```ignore
79/// use rox::codec::{convert, formats::{OsuDecoder, SmEncoder}};
80///
81/// let osu_bytes = std::fs::read("chart.osu")?;
82/// let sm_bytes = convert::<OsuDecoder, SmEncoder>(&osu_bytes)?;
83/// ```
84///
85/// # Errors
86///
87/// Returns an error if decoding or encoding fails.
88pub fn convert<D: Decoder, E: Encoder>(data: &[u8]) -> RoxResult<Vec<u8>> {
89    let chart = D::decode(data)?;
90    E::encode(&chart)
91}
92
93/// Convert a file from one format to another using ROX as the intermediate format.
94///
95/// # Example
96/// ```ignore
97/// use rox::codec::{convert_file, formats::{OsuDecoder, SmEncoder}};
98///
99/// convert_file::<OsuDecoder, SmEncoder>("chart.osu", "chart.sm")?;
100/// ```
101///
102/// # Errors
103///
104/// Returns an error if reading, decoding, encoding, or writing fails.
105pub fn convert_file<D: Decoder, E: Encoder>(
106    input: impl AsRef<Path>,
107    output: impl AsRef<Path>,
108) -> RoxResult<()> {
109    let chart = D::decode_from_path(input)?;
110    E::encode_to_path(&chart, output)
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::codec::formats::{OsuDecoder, OsuEncoder};
117    use std::fs;
118    use tempfile::tempdir;
119
120    #[test]
121    fn test_convert() {
122        let data = crate::test_utils::get_test_asset("osu/mania_7k.osu");
123        let result = convert::<OsuDecoder, OsuEncoder>(&data).unwrap();
124        assert!(!result.is_empty());
125        assert!(String::from_utf8_lossy(&result).contains("osu file format v14"));
126    }
127
128    #[test]
129    fn test_convert_file() {
130        let dir = tempdir().unwrap();
131        let input_path = dir.path().join("input.osu");
132        let output_path = dir.path().join("output.osu");
133
134        let data = crate::test_utils::get_test_asset("osu/mania_7k.osu");
135        fs::write(&input_path, &data).unwrap();
136
137        convert_file::<OsuDecoder, OsuEncoder>(&input_path, &output_path).unwrap();
138
139        assert!(output_path.exists());
140        let result = fs::read(&output_path).unwrap();
141        assert!(String::from_utf8_lossy(&result).contains("osu file format v14"));
142    }
143
144    #[test]
145    fn test_decoder_from_path() {
146        let dir = tempdir().unwrap();
147        let path = dir.path().join("test.osu");
148        let data = crate::test_utils::get_test_asset("osu/mania_7k.osu");
149        fs::write(&path, &data).unwrap();
150
151        let chart = OsuDecoder::decode_from_path(&path).unwrap();
152        assert_eq!(chart.key_count(), 7);
153    }
154
155    #[test]
156    fn test_encoder_to_string() {
157        let data = crate::test_utils::get_test_asset("osu/mania_7k.osu");
158        let chart = OsuDecoder::decode(&data).unwrap();
159        let s = OsuEncoder::encode_to_string(&chart).unwrap();
160        assert!(s.contains("Artist:Iced Blade"));
161    }
162}