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 test_suite::AcceptMode;
17
use test_suite::TestConfig;
18
use test_suite::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
160
fn roundtrip_test(path: &str, filename: &str, extension: &str) -> Result<(), Box<dyn Error>> {
27
160
    let accept = AcceptMode::from_env().accepts_outputs();
28

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

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

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

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

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

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

            
70
160
    Ok(())
71
160
}
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
227
fn roundtrip_test_inner(
125
227
    path: &str,
126
227
    input_filename: &str,
127
227
    case_name: &str,
128
227
    extension: &str,
129
227
    parse: ParseFn,
130
227
    param_file: Option<&str>,
131
227
) -> Result<BTreeSet<String>, Box<dyn Error>> {
132
227
    let accept = AcceptMode::from_env().accepts_outputs();
133

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

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

            
140
227
    let initial_parse = match problem_model {
141
87
        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
83
            None => Ok(problem_model),
157
        },
158
140
        Err(e) => Err(e),
159
    };
160
227
    match initial_parse {
161
85
        Ok(initial_model) => {
162
85
            save_roundtrip_model_json(&initial_model, path, case_name, "generated")?;
163
85
            save_essence(&initial_model, path, case_name, "generated")?;
164

            
165
85
            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
85
            }
175

            
176
85
            if !accept
177
85
                && !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
85
            }
187

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

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

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

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

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

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

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

            
222
142
            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
142
            }
231

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

            
238
142
            return Ok(expected_roundtrip_files_for_case(case_name, false));
239
        }
240
    }
241
227
}
242

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

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

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

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

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

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

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

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

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