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::error::Error;
12
use std::fs;
13
use std::path::Path;
14
use std::sync::Arc;
15
use std::sync::RwLock;
16
use tests_integration::AcceptMode;
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
159
fn roundtrip_test(path: &str, filename: &str, extension: &str) -> Result<(), Box<dyn Error>> {
27
159
    let accept = AcceptMode::from_env().accepts_outputs();
28

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

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

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

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

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

            
68
159
    assert_no_redundant_expected_files(Path::new(path), &allowed_expected_files, None)?;
69

            
70
159
    Ok(())
71
159
}
72

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

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

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

            
100
        if keep {
101
            continue;
102
        }
103

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

            
107
    Ok(())
108
}
109

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

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

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

            
140
225
    let initial_parse = match problem_model {
141
89
        Ok(problem_model) => match param_file {
142
4
            Some(param_file_name) => {
143
4
                let param_file_path = format!("{path}/{param_file_name}");
144
4
                let param_model = parse(&param_file_path, context.clone());
145
4
                match param_model {
146
4
                    Ok(param_model) => instantiate_model(problem_model, param_model).map_err(|e| {
147
2
                        Box::new(ParseErrorCollection::InstantiateModel(
148
2
                            InstantiateModelError {
149
2
                                msg: format!("{e}"),
150
2
                            },
151
2
                        ))
152
2
                    }),
153
                    Err(e) => Err(e),
154
                }
155
            }
156
85
            None => Ok(problem_model),
157
        },
158
136
        Err(e) => Err(e),
159
    };
160
225
    match initial_parse {
161
87
        Ok(initial_model) => {
162
87
            save_roundtrip_model_json(&initial_model, path, case_name, "generated")?;
163
87
            save_essence(&initial_model, path, case_name, "generated")?;
164

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

            
176
87
            if !accept
177
87
                && !Path::new(&roundtrip_model_json_path(path, case_name, "expected")).exists()
178
            {
179
                return Err(Box::new(std::io::Error::new(
180
                    std::io::ErrorKind::NotFound,
181
                    format!(
182
                        "Expected output file not found: {}",
183
                        AcceptMode::refresh_hint()
184
                    ),
185
                )));
186
87
            }
187

            
188
87
            let expected_model = read_roundtrip_model_json(&context, path, case_name, "expected")?;
189

            
190
87
            let generated_model =
191
87
                read_roundtrip_model_json(&context, path, case_name, "generated")?;
192
87
            assert_eq!(generated_model, expected_model);
193

            
194
87
            let expected_essence =
195
87
                fs::read_to_string(roundtrip_essence_path(path, case_name, "expected"))?;
196
87
            let generated_essence =
197
87
                fs::read_to_string(roundtrip_essence_path(path, case_name, "generated"))?;
198
87
            assert_eq!(expected_essence, generated_essence);
199

            
200
87
            let new_model = parse(
201
87
                &roundtrip_essence_path(path, case_name, "generated"),
202
87
                context.clone(),
203
87
            )?;
204
87
            save_essence(&new_model, path, case_name, "generated2")?;
205
87
            let new_generated_essence =
206
87
                fs::read_to_string(roundtrip_essence_path(path, case_name, "generated"))?;
207
87
            assert_eq!(generated_essence, new_generated_essence);
208

            
209
87
            return Ok(expected_roundtrip_files_for_case(case_name, true));
210
        }
211

            
212
138
        Err(parse_error) => {
213
138
            save_parse_error(&parse_error, path, case_name, "generated")?;
214

            
215
138
            if accept {
216
                std::fs::copy(
217
                    roundtrip_error_path(path, case_name, "generated"),
218
                    roundtrip_error_path(path, case_name, "expected"),
219
                )?;
220
138
            }
221

            
222
138
            if !accept && !Path::new(&roundtrip_error_path(path, case_name, "expected")).exists() {
223
                return Err(Box::new(std::io::Error::new(
224
                    std::io::ErrorKind::NotFound,
225
                    format!(
226
                        "Expected output file not found: {}",
227
                        AcceptMode::refresh_hint()
228
                    ),
229
                )));
230
138
            }
231

            
232
138
            let expected_error =
233
138
                fs::read_to_string(roundtrip_error_path(path, case_name, "expected"))?;
234
138
            let generated_error =
235
138
                fs::read_to_string(roundtrip_error_path(path, case_name, "generated"))?;
236
138
            assert_eq!(expected_error, generated_error);
237

            
238
138
            return Ok(expected_roundtrip_files_for_case(case_name, false));
239
        }
240
    }
241
225
}
242

            
243
/// Returns the roundtrip model JSON path for a parser case and model type.
244
348
fn roundtrip_model_json_path(path: &str, case_name: &str, file_type: &str) -> String {
245
348
    format!("{path}/{case_name}.{file_type}.serialised.json")
246
348
}
247

            
248
/// Returns the roundtrip Essence path for a parser case and model type.
249
522
fn roundtrip_essence_path(path: &str, case_name: &str, file_type: &str) -> String {
250
522
    format!("{path}/{case_name}.{file_type}.essence")
251
522
}
252

            
253
/// Returns the roundtrip parser-error path for a parser case and model type.
254
552
fn roundtrip_error_path(path: &str, case_name: &str, file_type: &str) -> String {
255
552
    format!("{path}/{case_name}.{file_type}-error.txt")
256
552
}
257

            
258
/// Returns the expected snapshot files for a roundtrip parser case outcome.
259
225
fn expected_roundtrip_files_for_case(case_name: &str, parse_succeeded: bool) -> BTreeSet<String> {
260
225
    if parse_succeeded {
261
87
        BTreeSet::from([
262
87
            format!("{case_name}.expected.serialised.json"),
263
87
            format!("{case_name}.expected.essence"),
264
87
        ])
265
    } else {
266
138
        BTreeSet::from([format!("{case_name}.expected-error.txt")])
267
    }
268
225
}
269

            
270
/// Serialises and writes a generated model snapshot for roundtrip comparison.
271
87
fn save_roundtrip_model_json(
272
87
    model: &Model,
273
87
    path: &str,
274
87
    case_name: &str,
275
87
    file_type: &str,
276
87
) -> Result<(), std::io::Error> {
277
87
    let serialised = serialize_model(model).map_err(std::io::Error::other)?;
278
87
    fs::write(
279
87
        roundtrip_model_json_path(path, case_name, file_type),
280
87
        serialised,
281
    )?;
282
87
    Ok(())
283
87
}
284

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

            
300
/// Saves a model as an Essence file.
301
174
fn save_essence(
302
174
    model: &Model,
303
174
    path: &str,
304
174
    test_name: &str,
305
174
    file_type: &str,
306
174
) -> Result<(), std::io::Error> {
307
174
    let filename = roundtrip_essence_path(path, test_name, file_type);
308
174
    let mut file = fs::File::create(&filename)?;
309
174
    write!(file, "{model}")?;
310
174
    Ok(())
311
174
}
312

            
313
/// Saves a parse error message as a text file.
314
138
fn save_parse_error(
315
138
    error: &ParseErrorCollection,
316
138
    path: &str,
317
138
    test_name: &str,
318
138
    file_type: &str,
319
138
) -> Result<(), std::io::Error> {
320
138
    let filename = roundtrip_error_path(path, test_name, file_type);
321
138
    let mut file = fs::File::create(&filename)?;
322
138
    write!(file, "{error}")?;
323
138
    Ok(())
324
138
}
325

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