1
use conjure_cp::Model;
2
use conjure_cp::ast::SerdeModel;
3
use conjure_cp::context::Context;
4
use conjure_cp::parse::tree_sitter::errors::ParseErrorCollection;
5
use conjure_cp::parse::tree_sitter::{parse_essence_file, parse_essence_file_native};
6
use conjure_cp::settings::Parser;
7
use conjure_cp_cli::utils::testing::serialize_model;
8
use tests_integration::TestConfig;
9

            
10
use std::env;
11
use std::error::Error;
12
use std::fs;
13
use std::path::Path;
14
use std::sync::Arc;
15
use std::sync::RwLock;
16

            
17
use std::io::Write;
18

            
19
/// Parser function used by roundtrip tests.
20
type ParseFn = fn(&str, Arc<RwLock<Context<'static>>>) -> Result<Model, Box<ParseErrorCollection>>;
21

            
22
/// Runs a roundtrip parse test for one input model using the parsers configured in `config.toml`.
23
52
fn roundtrip_test(path: &str, filename: &str, extension: &str) -> Result<(), Box<dyn Error>> {
24
52
    let accept = env::var("ACCEPT").unwrap_or("false".to_string()) == "true";
25

            
26
52
    let file_config: TestConfig =
27
52
        if let Ok(config_contents) = fs::read_to_string(format!("{path}/config.toml")) {
28
30
            toml::from_str(&config_contents).unwrap()
29
        } else {
30
22
            Default::default()
31
        };
32

            
33
52
    if accept {
34
        clean_test_dir_for_accept(path)?;
35
52
    }
36

            
37
52
    let parsers = file_config
38
52
        .configured_parsers()
39
52
        .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?;
40

            
41
74
    for parser in parsers {
42
74
        let case_name = parser.to_string();
43
74
        let parse = match parser {
44
25
            Parser::TreeSitter => parse_essence_file_native,
45
49
            Parser::ViaConjure => parse_essence_file,
46
        };
47
74
        roundtrip_test_inner(path, filename, &case_name, extension, parse)?;
48
    }
49
52
    Ok(())
50
52
}
51

            
52
/// Removes generated and expected artefacts for a roundtrip test directory when `ACCEPT=true`.
53
///
54
/// Keeps source model files (`.essence`) and `config.toml`. Nested directories are not removed,
55
/// because each nested test directory performs its own cleanup when executed.
56
fn clean_test_dir_for_accept(path: &str) -> Result<(), std::io::Error> {
57
    for entry in std::fs::read_dir(path)? {
58
        let entry = entry?;
59
        let file_name = entry.file_name();
60
        let file_name = file_name.to_string_lossy();
61
        let entry_path = entry.path();
62

            
63
        if entry_path.is_dir() {
64
            continue;
65
        }
66

            
67
        let keep = if file_name == "config.toml" {
68
            true
69
        } else {
70
            let is_model_file = entry_path
71
                .extension()
72
                .and_then(|ext| ext.to_str())
73
                .is_some_and(|ext| ext == "essence");
74
            let is_generated_or_expected =
75
                file_name.contains(".generated") || file_name.contains(".expected");
76
            is_model_file && !is_generated_or_expected
77
        };
78

            
79
        if keep {
80
            continue;
81
        }
82

            
83
        std::fs::remove_file(entry_path)?;
84
    }
85

            
86
    Ok(())
87
}
88

            
89
/// Runs the roundtrip pipeline for a single parser case.
90
///
91
/// Algorithm sketch:
92
/// 1. Parse the input model file.
93
/// 2. If parsing succeeds:
94
/// 3. Save generated model JSON and generated Essence output.
95
/// 4. If `ACCEPT=true`, copy generated outputs to expected outputs.
96
/// 5. Load and compare generated vs expected model JSON.
97
/// 6. Load and compare generated vs expected Essence output.
98
/// 7. Parse generated Essence again, re-emit Essence, and assert roundtrip stability.
99
/// 8. If parsing fails:
100
/// 9. Save generated parse error output.
101
/// 10. If `ACCEPT=true`, copy generated error output to expected error output.
102
/// 11. Load and compare generated vs expected error output.
103
74
fn roundtrip_test_inner(
104
74
    path: &str,
105
74
    input_filename: &str,
106
74
    case_name: &str,
107
74
    extension: &str,
108
74
    parse: ParseFn,
109
74
) -> Result<(), Box<dyn Error>> {
110
74
    let accept = env::var("ACCEPT").unwrap_or("false".to_string()) == "true";
111

            
112
74
    let file_path = format!("{path}/{input_filename}.{extension}");
113
74
    let context: Arc<RwLock<Context<'static>>> = Default::default();
114

            
115
74
    let initial_parse = parse(&file_path, context.clone());
116
74
    match initial_parse {
117
30
        Ok(initial_model) => {
118
30
            save_roundtrip_model_json(&initial_model, path, case_name, "generated")?;
119
30
            save_essence(&initial_model, path, case_name, "generated")?;
120

            
121
30
            if accept {
122
                std::fs::copy(
123
                    roundtrip_model_json_path(path, case_name, "generated"),
124
                    roundtrip_model_json_path(path, case_name, "expected"),
125
                )?;
126
                std::fs::copy(
127
                    roundtrip_essence_path(path, case_name, "generated"),
128
                    roundtrip_essence_path(path, case_name, "expected"),
129
                )?;
130
30
            }
131

            
132
30
            if !accept
133
30
                && !Path::new(&roundtrip_model_json_path(path, case_name, "expected")).exists()
134
            {
135
                return Err(Box::new(std::io::Error::new(
136
                    std::io::ErrorKind::NotFound,
137
                    "Expected output file not found: Run with ACCEPT=true".to_string(),
138
                )));
139
30
            }
140

            
141
30
            let expected_model = read_roundtrip_model_json(&context, path, case_name, "expected")?;
142

            
143
30
            let generated_model =
144
30
                read_roundtrip_model_json(&context, path, case_name, "generated")?;
145
30
            assert_eq!(generated_model, expected_model);
146

            
147
30
            let expected_essence =
148
30
                fs::read_to_string(roundtrip_essence_path(path, case_name, "expected"))?;
149
30
            let generated_essence =
150
30
                fs::read_to_string(roundtrip_essence_path(path, case_name, "generated"))?;
151
30
            assert_eq!(expected_essence, generated_essence);
152

            
153
30
            let new_model = parse(
154
30
                &roundtrip_essence_path(path, case_name, "generated"),
155
30
                context.clone(),
156
30
            )?;
157
30
            save_essence(&new_model, path, case_name, "generated2")?;
158
30
            let new_generated_essence =
159
30
                fs::read_to_string(roundtrip_essence_path(path, case_name, "generated"))?;
160
30
            assert_eq!(generated_essence, new_generated_essence);
161
        }
162

            
163
44
        Err(parse_error) => {
164
44
            save_parse_error(&parse_error, path, case_name, "generated")?;
165

            
166
44
            if accept {
167
                std::fs::copy(
168
                    roundtrip_error_path(path, case_name, "generated"),
169
                    roundtrip_error_path(path, case_name, "expected"),
170
                )?;
171
44
            }
172

            
173
44
            if !accept && !Path::new(&roundtrip_error_path(path, case_name, "expected")).exists() {
174
                return Err(Box::new(std::io::Error::new(
175
                    std::io::ErrorKind::NotFound,
176
                    "Expected output file not found: Run with ACCEPT=true".to_string(),
177
                )));
178
44
            }
179

            
180
44
            let expected_error =
181
44
                fs::read_to_string(roundtrip_error_path(path, case_name, "expected"))?;
182
44
            let generated_error =
183
44
                fs::read_to_string(roundtrip_error_path(path, case_name, "generated"))?;
184
44
            assert_eq!(expected_error, generated_error);
185
        }
186
    }
187

            
188
74
    Ok(())
189
74
}
190

            
191
/// Returns the roundtrip model JSON path for a parser case and model type.
192
120
fn roundtrip_model_json_path(path: &str, case_name: &str, file_type: &str) -> String {
193
120
    format!("{path}/{case_name}.{file_type}.serialised.json")
194
120
}
195

            
196
/// Returns the roundtrip Essence path for a parser case and model type.
197
180
fn roundtrip_essence_path(path: &str, case_name: &str, file_type: &str) -> String {
198
180
    format!("{path}/{case_name}.{file_type}.essence")
199
180
}
200

            
201
/// Returns the roundtrip parser-error path for a parser case and model type.
202
176
fn roundtrip_error_path(path: &str, case_name: &str, file_type: &str) -> String {
203
176
    format!("{path}/{case_name}.{file_type}-error.txt")
204
176
}
205

            
206
/// Serialises and writes a generated model snapshot for roundtrip comparison.
207
30
fn save_roundtrip_model_json(
208
30
    model: &Model,
209
30
    path: &str,
210
30
    case_name: &str,
211
30
    file_type: &str,
212
30
) -> Result<(), std::io::Error> {
213
30
    let serialised = serialize_model(model).map_err(std::io::Error::other)?;
214
30
    fs::write(
215
30
        roundtrip_model_json_path(path, case_name, file_type),
216
30
        serialised,
217
    )?;
218
30
    Ok(())
219
30
}
220

            
221
/// Reads and initialises a saved roundtrip model snapshot.
222
60
fn read_roundtrip_model_json(
223
60
    context: &Arc<RwLock<Context<'static>>>,
224
60
    path: &str,
225
60
    case_name: &str,
226
60
    file_type: &str,
227
60
) -> Result<Model, std::io::Error> {
228
60
    let serialised = fs::read_to_string(roundtrip_model_json_path(path, case_name, file_type))?;
229
60
    let serde_model: SerdeModel =
230
60
        serde_json::from_str(&serialised).map_err(std::io::Error::other)?;
231
60
    serde_model
232
60
        .initialise(context.clone())
233
60
        .ok_or_else(|| std::io::Error::other("failed to initialise parsed SerdeModel"))
234
60
}
235

            
236
/// Saves a model as an Essence file.
237
60
fn save_essence(
238
60
    model: &Model,
239
60
    path: &str,
240
60
    test_name: &str,
241
60
    file_type: &str,
242
60
) -> Result<(), std::io::Error> {
243
60
    let filename = roundtrip_essence_path(path, test_name, file_type);
244
60
    let mut file = fs::File::create(&filename)?;
245
60
    write!(file, "{}", model)?;
246
60
    Ok(())
247
60
}
248

            
249
/// Saves a parse error message as a text file.
250
44
fn save_parse_error(
251
44
    error: &ParseErrorCollection,
252
44
    path: &str,
253
44
    test_name: &str,
254
44
    file_type: &str,
255
44
) -> Result<(), std::io::Error> {
256
44
    let filename = roundtrip_error_path(path, test_name, file_type);
257
44
    let mut file = fs::File::create(&filename)?;
258
44
    write!(file, "{}", error)?;
259
44
    Ok(())
260
44
}
261

            
262
include!(concat!(env!("OUT_DIR"), "/gen_tests_roundtrip.rs"));