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::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
use tests_integration::TestConfig;
17

            
18
use std::io::Write;
19

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

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

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

            
34
84
    let param_file = std::fs::read_dir(path).ok().and_then(|entries| {
35
84
        entries
36
289
            .filter_map(|entry| entry.ok())
37
289
            .find(|entry| entry.path().extension().map_or(false, |ext| ext == "param"))
38
84
            .map(|entry| entry.file_name().to_string_lossy().to_string())
39
84
    });
40

            
41
84
    if accept {
42
        clean_test_dir_for_accept(path)?;
43
84
    }
44

            
45
84
    let parsers = file_config
46
84
        .configured_parsers()
47
84
        .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?;
48

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

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

            
78
        if entry_path.is_dir() {
79
            continue;
80
        }
81

            
82
        let keep = if file_name == "config.toml" {
83
            true
84
        } else {
85
            let is_model_file = entry_path
86
                .extension()
87
                .and_then(|ext| ext.to_str())
88
                .is_some_and(|ext| ext == "essence" || ext == "param");
89
            let is_generated_or_expected =
90
                file_name.contains(".generated") || file_name.contains(".expected");
91
            is_model_file && !is_generated_or_expected
92
        };
93

            
94
        if keep {
95
            continue;
96
        }
97

            
98
        std::fs::remove_file(entry_path)?;
99
    }
100

            
101
    Ok(())
102
}
103

            
104
/// Runs the roundtrip pipeline for a single parser case.
105
///
106
/// Algorithm sketch:
107
/// 1. Parse the input model file.
108
/// 2. If parsing succeeds:
109
/// 3. Save generated model JSON and generated Essence output.
110
/// 4. If `ACCEPT=true`, copy generated outputs to expected outputs.
111
/// 5. Load and compare generated vs expected model JSON.
112
/// 6. Load and compare generated vs expected Essence output.
113
/// 7. Parse generated Essence again, re-emit Essence, and assert roundtrip stability.
114
/// 8. If parsing fails:
115
/// 9. Save generated parse error output.
116
/// 10. If `ACCEPT=true`, copy generated error output to expected error output.
117
/// 11. Load and compare generated vs expected error output.
118
129
fn roundtrip_test_inner(
119
129
    path: &str,
120
129
    input_filename: &str,
121
129
    case_name: &str,
122
129
    extension: &str,
123
129
    parse: ParseFn,
124
129
    param_file: Option<&str>,
125
129
) -> Result<(), Box<dyn Error>> {
126
129
    let accept = env::var("ACCEPT").unwrap_or("false".to_string()) == "true";
127

            
128
129
    let file_path = format!("{path}/{input_filename}.{extension}");
129
129
    let context: Arc<RwLock<Context<'static>>> = Default::default();
130

            
131
    // let problem_model = parse(&global_args, Arc::clone(&context), essence_file_name)?;
132
129
    let problem_model = parse(&file_path, context.clone());
133

            
134
129
    let initial_parse = match problem_model {
135
36
        Ok(problem_model) => match param_file {
136
2
            Some(param_file_name) => {
137
2
                let param_file_path = format!("{path}/{}", param_file_name);
138
2
                let param_model = parse(&param_file_path, context.clone());
139
2
                match param_model {
140
2
                    Ok(param_model) => instantiate_model(problem_model, param_model).map_err(|e| {
141
1
                        Box::new(ParseErrorCollection::InstantiateModel(
142
1
                            InstantiateModelError {
143
1
                                msg: format!("{e}"),
144
1
                            },
145
1
                        ))
146
1
                    }),
147
                    Err(e) => Err(e),
148
                }
149
            }
150
34
            None => Ok(problem_model),
151
        },
152
93
        Err(e) => Err(e),
153
    };
154
129
    match initial_parse {
155
35
        Ok(initial_model) => {
156
35
            save_roundtrip_model_json(&initial_model, path, case_name, "generated")?;
157
35
            save_essence(&initial_model, path, case_name, "generated")?;
158

            
159
35
            if accept {
160
                std::fs::copy(
161
                    roundtrip_model_json_path(path, case_name, "generated"),
162
                    roundtrip_model_json_path(path, case_name, "expected"),
163
                )?;
164
                std::fs::copy(
165
                    roundtrip_essence_path(path, case_name, "generated"),
166
                    roundtrip_essence_path(path, case_name, "expected"),
167
                )?;
168
35
            }
169

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

            
179
35
            let expected_model = read_roundtrip_model_json(&context, path, case_name, "expected")?;
180

            
181
35
            let generated_model =
182
35
                read_roundtrip_model_json(&context, path, case_name, "generated")?;
183
35
            assert_eq!(generated_model, expected_model);
184

            
185
35
            let expected_essence =
186
35
                fs::read_to_string(roundtrip_essence_path(path, case_name, "expected"))?;
187
35
            let generated_essence =
188
35
                fs::read_to_string(roundtrip_essence_path(path, case_name, "generated"))?;
189
35
            assert_eq!(expected_essence, generated_essence);
190

            
191
35
            let new_model = parse(
192
35
                &roundtrip_essence_path(path, case_name, "generated"),
193
35
                context.clone(),
194
35
            )?;
195
35
            save_essence(&new_model, path, case_name, "generated2")?;
196
35
            let new_generated_essence =
197
35
                fs::read_to_string(roundtrip_essence_path(path, case_name, "generated"))?;
198
35
            assert_eq!(generated_essence, new_generated_essence);
199
        }
200

            
201
94
        Err(parse_error) => {
202
94
            save_parse_error(&parse_error, path, case_name, "generated")?;
203

            
204
94
            if accept {
205
                std::fs::copy(
206
                    roundtrip_error_path(path, case_name, "generated"),
207
                    roundtrip_error_path(path, case_name, "expected"),
208
                )?;
209
94
            }
210

            
211
94
            if !accept && !Path::new(&roundtrip_error_path(path, case_name, "expected")).exists() {
212
                return Err(Box::new(std::io::Error::new(
213
                    std::io::ErrorKind::NotFound,
214
                    "Expected output file not found: Run with ACCEPT=true".to_string(),
215
                )));
216
94
            }
217

            
218
94
            let expected_error =
219
94
                fs::read_to_string(roundtrip_error_path(path, case_name, "expected"))?;
220
94
            let generated_error =
221
94
                fs::read_to_string(roundtrip_error_path(path, case_name, "generated"))?;
222
94
            assert_eq!(expected_error, generated_error);
223
        }
224
    }
225

            
226
129
    Ok(())
227
129
}
228

            
229
/// Returns the roundtrip model JSON path for a parser case and model type.
230
140
fn roundtrip_model_json_path(path: &str, case_name: &str, file_type: &str) -> String {
231
140
    format!("{path}/{case_name}.{file_type}.serialised.json")
232
140
}
233

            
234
/// Returns the roundtrip Essence path for a parser case and model type.
235
210
fn roundtrip_essence_path(path: &str, case_name: &str, file_type: &str) -> String {
236
210
    format!("{path}/{case_name}.{file_type}.essence")
237
210
}
238

            
239
/// Returns the roundtrip parser-error path for a parser case and model type.
240
376
fn roundtrip_error_path(path: &str, case_name: &str, file_type: &str) -> String {
241
376
    format!("{path}/{case_name}.{file_type}-error.txt")
242
376
}
243

            
244
/// Serialises and writes a generated model snapshot for roundtrip comparison.
245
35
fn save_roundtrip_model_json(
246
35
    model: &Model,
247
35
    path: &str,
248
35
    case_name: &str,
249
35
    file_type: &str,
250
35
) -> Result<(), std::io::Error> {
251
35
    let serialised = serialize_model(model).map_err(std::io::Error::other)?;
252
35
    fs::write(
253
35
        roundtrip_model_json_path(path, case_name, file_type),
254
35
        serialised,
255
    )?;
256
35
    Ok(())
257
35
}
258

            
259
/// Reads and initialises a saved roundtrip model snapshot.
260
70
fn read_roundtrip_model_json(
261
70
    context: &Arc<RwLock<Context<'static>>>,
262
70
    path: &str,
263
70
    case_name: &str,
264
70
    file_type: &str,
265
70
) -> Result<Model, std::io::Error> {
266
70
    let serialised = fs::read_to_string(roundtrip_model_json_path(path, case_name, file_type))?;
267
70
    let serde_model: SerdeModel =
268
70
        serde_json::from_str(&serialised).map_err(std::io::Error::other)?;
269
70
    serde_model
270
70
        .initialise(context.clone())
271
70
        .ok_or_else(|| std::io::Error::other("failed to initialise parsed SerdeModel"))
272
70
}
273

            
274
/// Saves a model as an Essence file.
275
70
fn save_essence(
276
70
    model: &Model,
277
70
    path: &str,
278
70
    test_name: &str,
279
70
    file_type: &str,
280
70
) -> Result<(), std::io::Error> {
281
70
    let filename = roundtrip_essence_path(path, test_name, file_type);
282
70
    let mut file = fs::File::create(&filename)?;
283
70
    write!(file, "{}", model)?;
284
70
    Ok(())
285
70
}
286

            
287
/// Saves a parse error message as a text file.
288
94
fn save_parse_error(
289
94
    error: &ParseErrorCollection,
290
94
    path: &str,
291
94
    test_name: &str,
292
94
    file_type: &str,
293
94
) -> Result<(), std::io::Error> {
294
94
    let filename = roundtrip_error_path(path, test_name, file_type);
295
94
    let mut file = fs::File::create(&filename)?;
296
94
    write!(file, "{}", error)?;
297
94
    Ok(())
298
94
}
299

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