1
use conjure_cp::Model;
2
use conjure_cp::ast::SerdeModel;
3
use conjure_cp::context::Context;
4
use conjure_cp::instantiate::instantiate_model;
5
use conjure_cp::parse::tree_sitter::errors::InstantiateModelError;
6
use conjure_cp::parse::tree_sitter::errors::ParseErrorCollection;
7
use conjure_cp::parse::tree_sitter::{parse_essence_file, parse_essence_file_native};
8
use conjure_cp::settings::Parser;
9
use conjure_cp_cli::utils::testing::serialize_model;
10
use std::collections::BTreeSet;
11
use std::env;
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 tests_integration::TestConfig;
18
use tests_integration::golden_files::assert_no_redundant_expected_files;
19

            
20
use std::io::Write;
21

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

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

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

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

            
43
103
    if accept {
44
        clean_test_dir_for_accept(path)?;
45
103
    }
46

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

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

            
68
103
    assert_no_redundant_expected_files(Path::new(path), &allowed_expected_files, None)?;
69
103
    Ok(())
70
103
}
71

            
72
/// Removes generated and expected artefacts for a roundtrip test directory when `ACCEPT=true`.
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=true`, 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=true`, copy generated error output to expected error output.
122
/// 11. Load and compare generated vs expected error output.
123
159
fn roundtrip_test_inner(
124
159
    path: &str,
125
159
    input_filename: &str,
126
159
    case_name: &str,
127
159
    extension: &str,
128
159
    parse: ParseFn,
129
159
    param_file: Option<&str>,
130
159
) -> Result<BTreeSet<String>, Box<dyn Error>> {
131
159
    let accept = env::var("ACCEPT").unwrap_or("false".to_string()) == "true";
132

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

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

            
139
159
    let initial_parse = match problem_model {
140
41
        Ok(problem_model) => match param_file {
141
4
            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
37
            None => Ok(problem_model),
156
        },
157
118
        Err(e) => Err(e),
158
    };
159
159
    match initial_parse {
160
39
        Ok(initial_model) => {
161
39
            save_roundtrip_model_json(&initial_model, path, case_name, "generated")?;
162
39
            save_essence(&initial_model, path, case_name, "generated")?;
163

            
164
39
            if accept {
165
                std::fs::copy(
166
                    roundtrip_model_json_path(path, case_name, "generated"),
167
                    roundtrip_model_json_path(path, case_name, "expected"),
168
                )?;
169
                std::fs::copy(
170
                    roundtrip_essence_path(path, case_name, "generated"),
171
                    roundtrip_essence_path(path, case_name, "expected"),
172
                )?;
173
39
            }
174

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

            
184
39
            let expected_model = read_roundtrip_model_json(&context, path, case_name, "expected")?;
185

            
186
39
            let generated_model =
187
39
                read_roundtrip_model_json(&context, path, case_name, "generated")?;
188
39
            assert_eq!(generated_model, expected_model);
189

            
190
39
            let expected_essence =
191
39
                fs::read_to_string(roundtrip_essence_path(path, case_name, "expected"))?;
192
39
            let generated_essence =
193
39
                fs::read_to_string(roundtrip_essence_path(path, case_name, "generated"))?;
194
39
            assert_eq!(expected_essence, generated_essence);
195

            
196
39
            let new_model = parse(
197
39
                &roundtrip_essence_path(path, case_name, "generated"),
198
39
                context.clone(),
199
39
            )?;
200
39
            save_essence(&new_model, path, case_name, "generated2")?;
201
39
            let new_generated_essence =
202
39
                fs::read_to_string(roundtrip_essence_path(path, case_name, "generated"))?;
203
39
            assert_eq!(generated_essence, new_generated_essence);
204

            
205
39
            return Ok(expected_roundtrip_files_for_case(case_name, true));
206
        }
207

            
208
120
        Err(parse_error) => {
209
120
            save_parse_error(&parse_error, path, case_name, "generated")?;
210

            
211
120
            if accept {
212
                std::fs::copy(
213
                    roundtrip_error_path(path, case_name, "generated"),
214
                    roundtrip_error_path(path, case_name, "expected"),
215
                )?;
216
120
            }
217

            
218
120
            if !accept && !Path::new(&roundtrip_error_path(path, case_name, "expected")).exists() {
219
                return Err(Box::new(std::io::Error::new(
220
                    std::io::ErrorKind::NotFound,
221
                    "Expected output file not found: Run with ACCEPT=true".to_string(),
222
                )));
223
120
            }
224

            
225
120
            let expected_error =
226
120
                fs::read_to_string(roundtrip_error_path(path, case_name, "expected"))?;
227
120
            let generated_error =
228
120
                fs::read_to_string(roundtrip_error_path(path, case_name, "generated"))?;
229
120
            assert_eq!(expected_error, generated_error);
230

            
231
120
            return Ok(expected_roundtrip_files_for_case(case_name, false));
232
        }
233
    }
234
159
}
235

            
236
/// Returns the roundtrip model JSON path for a parser case and model type.
237
156
fn roundtrip_model_json_path(path: &str, case_name: &str, file_type: &str) -> String {
238
156
    format!("{path}/{case_name}.{file_type}.serialised.json")
239
156
}
240

            
241
/// Returns the roundtrip Essence path for a parser case and model type.
242
234
fn roundtrip_essence_path(path: &str, case_name: &str, file_type: &str) -> String {
243
234
    format!("{path}/{case_name}.{file_type}.essence")
244
234
}
245

            
246
/// Returns the roundtrip parser-error path for a parser case and model type.
247
480
fn roundtrip_error_path(path: &str, case_name: &str, file_type: &str) -> String {
248
480
    format!("{path}/{case_name}.{file_type}-error.txt")
249
480
}
250

            
251
/// Returns the expected snapshot files for a roundtrip parser case outcome.
252
159
fn expected_roundtrip_files_for_case(case_name: &str, parse_succeeded: bool) -> BTreeSet<String> {
253
159
    if parse_succeeded {
254
39
        BTreeSet::from([
255
39
            format!("{case_name}.expected.serialised.json"),
256
39
            format!("{case_name}.expected.essence"),
257
39
        ])
258
    } else {
259
120
        BTreeSet::from([format!("{case_name}.expected-error.txt")])
260
    }
261
159
}
262

            
263
/// Serialises and writes a generated model snapshot for roundtrip comparison.
264
39
fn save_roundtrip_model_json(
265
39
    model: &Model,
266
39
    path: &str,
267
39
    case_name: &str,
268
39
    file_type: &str,
269
39
) -> Result<(), std::io::Error> {
270
39
    let serialised = serialize_model(model).map_err(std::io::Error::other)?;
271
39
    fs::write(
272
39
        roundtrip_model_json_path(path, case_name, file_type),
273
39
        serialised,
274
    )?;
275
39
    Ok(())
276
39
}
277

            
278
/// Reads and initialises a saved roundtrip model snapshot.
279
78
fn read_roundtrip_model_json(
280
78
    context: &Arc<RwLock<Context<'static>>>,
281
78
    path: &str,
282
78
    case_name: &str,
283
78
    file_type: &str,
284
78
) -> Result<Model, std::io::Error> {
285
78
    let serialised = fs::read_to_string(roundtrip_model_json_path(path, case_name, file_type))?;
286
78
    let serde_model: SerdeModel =
287
78
        serde_json::from_str(&serialised).map_err(std::io::Error::other)?;
288
78
    serde_model
289
78
        .initialise(context.clone())
290
78
        .ok_or_else(|| std::io::Error::other("failed to initialise parsed SerdeModel"))
291
78
}
292

            
293
/// Saves a model as an Essence file.
294
78
fn save_essence(
295
78
    model: &Model,
296
78
    path: &str,
297
78
    test_name: &str,
298
78
    file_type: &str,
299
78
) -> Result<(), std::io::Error> {
300
78
    let filename = roundtrip_essence_path(path, test_name, file_type);
301
78
    let mut file = fs::File::create(&filename)?;
302
78
    write!(file, "{model}")?;
303
78
    Ok(())
304
78
}
305

            
306
/// Saves a parse error message as a text file.
307
120
fn save_parse_error(
308
120
    error: &ParseErrorCollection,
309
120
    path: &str,
310
120
    test_name: &str,
311
120
    file_type: &str,
312
120
) -> Result<(), std::io::Error> {
313
120
    let filename = roundtrip_error_path(path, test_name, file_type);
314
120
    let mut file = fs::File::create(&filename)?;
315
120
    write!(file, "{error}")?;
316
120
    Ok(())
317
120
}
318

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