1
use conjure_cp::Model;
2
use conjure_cp::context::Context;
3
use conjure_cp::instantiate::{instantiate_model, validate_instantiation_conditions};
4
use conjure_cp::parse::tree_sitter::errors::InstantiateModelError;
5
use conjure_cp::parse::tree_sitter::errors::ParseErrorCollection;
6
use conjure_cp::parse::tree_sitter::{parse_essence_file, parse_essence_file_native};
7
use conjure_cp::settings::Parser;
8
use conjure_cp_cli::utils::testing::{
9
    DEFAULT_TEXT_SNAPSHOT_CHARACTER_LIMIT, serialize_model, truncate_to_first_chars,
10
};
11
use std::collections::BTreeSet;
12
use std::error::Error;
13
use std::fs;
14
use std::path::Path;
15
use std::sync::Arc;
16
use std::sync::RwLock;
17
use test_suite::AcceptMode;
18
use test_suite::TestConfig;
19
use test_suite::golden_files::assert_no_redundant_expected_files;
20

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

            
24
/// Runs a roundtrip parse test for one input model using the parsers configured in `config.toml`.
25
163
fn roundtrip_test(path: &str, filename: &str, extension: &str) -> Result<(), Box<dyn Error>> {
26
163
    let accept = AcceptMode::from_env().accepts_outputs();
27

            
28
    let file_config: TestConfig =
29
163
        if let Ok(config_contents) = fs::read_to_string(format!("{path}/config.toml")) {
30
100
            toml::from_str(&config_contents).unwrap()
31
        } else {
32
63
            Default::default()
33
        };
34

            
35
163
    let param_file = fs::read_dir(path).ok().and_then(|entries| {
36
163
        entries
37
642
            .filter_map(|entry| entry.ok())
38
642
            .find(|entry| entry.path().extension().is_some_and(|ext| ext == "param"))
39
3
            .map(|entry| entry.file_name().to_string_lossy().to_string())
40
163
    });
41

            
42
163
    if accept {
43
        clean_test_dir_for_accept(path)?;
44
163
    }
45

            
46
163
    let parsers = file_config
47
163
        .configured_parsers()
48
        .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?;
49
163
    let mut allowed_expected_files = BTreeSet::new();
50

            
51
163
    for parser in parsers {
52
259
        let case_name = parser.to_string();
53
259
        let parse = match parser {
54
100
            Parser::TreeSitter => parse_essence_file_native,
55
159
            Parser::ViaConjure => parse_essence_file,
56
        };
57
259
        allowed_expected_files.extend(roundtrip_test_inner(
58
259
            path,
59
259
            filename,
60
259
            &case_name,
61
259
            extension,
62
259
            parse,
63
259
            param_file.as_deref(),
64
259
        )?);
65
    }
66

            
67
163
    assert_no_redundant_expected_files(Path::new(path), &allowed_expected_files, None)?;
68

            
69
163
    Ok(())
70
163
}
71

            
72
/// Removes generated and expected artefacts for a roundtrip test directory when accept mode is enabled.
73
///
74
/// Keeps source model files (`.essence`, `.param`) and `config.toml`. Nested directories are not removed,
75
/// because each nested test directory performs its own cleanup when executed.
76
fn clean_test_dir_for_accept(path: &str) -> Result<(), std::io::Error> {
77
    for entry in std::fs::read_dir(path)? {
78
        let entry = entry?;
79
        let file_name = entry.file_name();
80
        let file_name = file_name.to_string_lossy();
81
        let entry_path = entry.path();
82

            
83
        if entry_path.is_dir() {
84
            continue;
85
        }
86

            
87
        let keep = if file_name == "config.toml" || file_name == "notes.txt" {
88
            true
89
        } else {
90
            let is_model_file = entry_path
91
                .extension()
92
                .and_then(|ext| ext.to_str())
93
                .is_some_and(|ext| ext == "essence" || ext == "param");
94
            let is_generated_or_expected =
95
                file_name.contains(".generated") || file_name.contains(".expected");
96
            is_model_file && !is_generated_or_expected
97
        };
98

            
99
        if keep {
100
            continue;
101
        }
102

            
103
        std::fs::remove_file(entry_path)?;
104
    }
105

            
106
    Ok(())
107
}
108

            
109
/// Runs the roundtrip pipeline for a single parser case.
110
///
111
/// Algorithm sketch:
112
/// 1. Parse the input model file.
113
/// 2. If parsing succeeds:
114
/// 3. Save generated model JSON and generated Essence output.
115
/// 4. If accept mode is enabled, copy generated outputs to expected outputs.
116
/// 5. Load and compare generated vs expected model JSON.
117
/// 6. Load and compare generated vs expected Essence output.
118
/// 7. Parse generated Essence again, re-emit Essence, and assert roundtrip stability.
119
/// 8. If parsing fails:
120
/// 9. Save generated parse error output.
121
/// 10. If accept mode is enabled, copy generated error output to expected error output.
122
/// 11. Load and compare generated vs expected error output.
123
259
fn roundtrip_test_inner(
124
259
    path: &str,
125
259
    input_filename: &str,
126
259
    case_name: &str,
127
259
    extension: &str,
128
259
    parse: ParseFn,
129
259
    param_file: Option<&str>,
130
259
) -> Result<BTreeSet<String>, Box<dyn Error>> {
131
259
    let accept = AcceptMode::from_env().accepts_outputs();
132

            
133
259
    let file_path = format!("{path}/{input_filename}.{extension}");
134
259
    let context: Arc<RwLock<Context<'static>>> = Default::default();
135

            
136
    // let problem_model = parse(&global_args, Arc::clone(&context), essence_file_name)?;
137
259
    let problem_model = parse(&file_path, context.clone());
138

            
139
259
    let initial_parse = match problem_model {
140
118
        Ok(problem_model) => match param_file {
141
            Some(param_file_name) => {
142
4
                let param_file_path = format!("{path}/{param_file_name}");
143
4
                let param_model = parse(&param_file_path, context.clone());
144
4
                match param_model {
145
4
                    Ok(param_model) => instantiate_model(problem_model, param_model).map_err(|e| {
146
2
                        Box::new(ParseErrorCollection::InstantiateModel(
147
2
                            InstantiateModelError {
148
2
                                msg: format!("{e}"),
149
2
                            },
150
2
                        ))
151
2
                    }),
152
                    Err(e) => Err(e),
153
                }
154
            }
155
            None => {
156
114
                let mut problem_model = problem_model;
157
114
                validate_instantiation_conditions(&mut problem_model).map_err(|e| {
158
                    Box::new(ParseErrorCollection::InstantiateModel(
159
                        InstantiateModelError {
160
                            msg: format!("{e}"),
161
                        },
162
                    )) as Box<dyn Error>
163
                })?;
164
114
                Ok(problem_model)
165
            }
166
        },
167
141
        Err(e) => Err(e),
168
    };
169
259
    match initial_parse {
170
        Ok(initial_model) => {
171
116
            save_roundtrip_model_json(&initial_model, path, case_name, "generated")?;
172
116
            save_essence(&initial_model, path, case_name, "generated")?;
173

            
174
116
            if accept {
175
                std::fs::copy(
176
                    roundtrip_model_json_path(path, case_name, "generated"),
177
                    roundtrip_model_json_path(path, case_name, "expected"),
178
                )?;
179
                std::fs::copy(
180
                    roundtrip_essence_path(path, case_name, "generated"),
181
                    roundtrip_essence_path(path, case_name, "expected"),
182
                )?;
183
116
            }
184

            
185
116
            if !accept
186
116
                && !Path::new(&roundtrip_model_json_path(path, case_name, "expected")).exists()
187
            {
188
                return Err(Box::new(std::io::Error::new(
189
                    std::io::ErrorKind::NotFound,
190
                    format!(
191
                        "Expected output file not found: {}",
192
                        AcceptMode::refresh_hint()
193
                    ),
194
                )));
195
116
            }
196

            
197
            let expected_model_json =
198
116
                read_roundtrip_model_json_snapshot(path, case_name, "expected")?;
199
            let generated_model_json =
200
116
                read_roundtrip_model_json_snapshot(path, case_name, "generated")?;
201
116
            assert_eq!(generated_model_json, expected_model_json);
202

            
203
            let expected_essence =
204
116
                fs::read_to_string(roundtrip_essence_path(path, case_name, "expected"))?;
205
            let generated_essence =
206
116
                fs::read_to_string(roundtrip_essence_path(path, case_name, "generated"))?;
207
116
            assert_eq!(expected_essence, generated_essence);
208

            
209
116
            let new_model = parse(
210
116
                &roundtrip_essence_path(path, case_name, "generated"),
211
116
                context.clone(),
212
116
            )?;
213
116
            save_essence(&new_model, path, case_name, "generated2")?;
214
            let new_generated_essence =
215
116
                fs::read_to_string(roundtrip_essence_path(path, case_name, "generated"))?;
216
116
            assert_eq!(generated_essence, new_generated_essence);
217

            
218
116
            return Ok(expected_roundtrip_files_for_case(case_name, true));
219
        }
220

            
221
        Err(parse_error) => {
222
143
            save_parse_error(&parse_error, path, case_name, "generated")?;
223

            
224
143
            if accept {
225
                std::fs::copy(
226
                    roundtrip_error_path(path, case_name, "generated"),
227
                    roundtrip_error_path(path, case_name, "expected"),
228
                )?;
229
143
            }
230

            
231
143
            if !accept && !Path::new(&roundtrip_error_path(path, case_name, "expected")).exists() {
232
                return Err(Box::new(std::io::Error::new(
233
                    std::io::ErrorKind::NotFound,
234
                    format!(
235
                        "Expected output file not found: {}",
236
                        AcceptMode::refresh_hint()
237
                    ),
238
                )));
239
143
            }
240

            
241
            let expected_error =
242
143
                fs::read_to_string(roundtrip_error_path(path, case_name, "expected"))?;
243
            let generated_error =
244
143
                fs::read_to_string(roundtrip_error_path(path, case_name, "generated"))?;
245
143
            assert_eq!(expected_error, generated_error);
246

            
247
143
            return Ok(expected_roundtrip_files_for_case(case_name, false));
248
        }
249
    }
250
259
}
251

            
252
/// Returns the roundtrip model JSON path for a parser case and model type.
253
464
fn roundtrip_model_json_path(path: &str, case_name: &str, file_type: &str) -> String {
254
464
    format!("{path}/{case_name}.{file_type}.serialised.json")
255
464
}
256

            
257
/// Returns the roundtrip Essence path for a parser case and model type.
258
696
fn roundtrip_essence_path(path: &str, case_name: &str, file_type: &str) -> String {
259
696
    format!("{path}/{case_name}.{file_type}.essence")
260
696
}
261

            
262
/// Returns the roundtrip parser-error path for a parser case and model type.
263
572
fn roundtrip_error_path(path: &str, case_name: &str, file_type: &str) -> String {
264
572
    format!("{path}/{case_name}.{file_type}-error.txt")
265
572
}
266

            
267
/// Returns the expected snapshot files for a roundtrip parser case outcome.
268
259
fn expected_roundtrip_files_for_case(case_name: &str, parse_succeeded: bool) -> BTreeSet<String> {
269
259
    if parse_succeeded {
270
116
        BTreeSet::from([
271
116
            format!("{case_name}.expected.serialised.json"),
272
116
            format!("{case_name}.expected.essence"),
273
116
        ])
274
    } else {
275
143
        BTreeSet::from([format!("{case_name}.expected-error.txt")])
276
    }
277
259
}
278

            
279
/// Serialises and writes a generated model snapshot for roundtrip comparison.
280
116
fn save_roundtrip_model_json(
281
116
    model: &Model,
282
116
    path: &str,
283
116
    case_name: &str,
284
116
    file_type: &str,
285
116
) -> Result<(), std::io::Error> {
286
116
    let serialised = serialize_model(model).map_err(std::io::Error::other)?;
287
116
    let serialised = truncate_to_first_chars(&serialised, DEFAULT_TEXT_SNAPSHOT_CHARACTER_LIMIT);
288
116
    fs::write(
289
116
        roundtrip_model_json_path(path, case_name, file_type),
290
116
        format!("{serialised}\n"),
291
116
    )?;
292
116
    Ok(())
293
116
}
294

            
295
/// Reads a saved roundtrip model JSON snapshot.
296
232
fn read_roundtrip_model_json_snapshot(
297
232
    path: &str,
298
232
    case_name: &str,
299
232
    file_type: &str,
300
232
) -> Result<String, std::io::Error> {
301
232
    let serialised = fs::read_to_string(roundtrip_model_json_path(path, case_name, file_type))?;
302
232
    Ok(truncate_to_first_chars(
303
232
        &serialised,
304
232
        DEFAULT_TEXT_SNAPSHOT_CHARACTER_LIMIT,
305
232
    ))
306
232
}
307

            
308
/// Saves a model as an Essence file.
309
232
fn save_essence(
310
232
    model: &Model,
311
232
    path: &str,
312
232
    test_name: &str,
313
232
    file_type: &str,
314
232
) -> Result<(), std::io::Error> {
315
232
    let filename = roundtrip_essence_path(path, test_name, file_type);
316
232
    fs::write(filename, format!("{model}\n"))?;
317
232
    Ok(())
318
232
}
319

            
320
/// Saves a parse error message as a text file.
321
143
fn save_parse_error(
322
143
    error: &ParseErrorCollection,
323
143
    path: &str,
324
143
    test_name: &str,
325
143
    file_type: &str,
326
143
) -> Result<(), std::io::Error> {
327
143
    let filename = roundtrip_error_path(path, test_name, file_type);
328
143
    fs::write(filename, format!("{error}\n"))?;
329
143
    Ok(())
330
143
}
331

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