1
use conjure_cp::Model;
2
use conjure_cp::ast::SerdeModel;
3
use conjure_cp::context::Context;
4
use conjure_cp::parse::tree_sitter::errors::ParseErrorCollection;
5
use conjure_cp::parse::tree_sitter::{parse_essence_file, parse_essence_file_native};
6
use conjure_cp::settings::Parser;
7
use conjure_cp_cli::utils::testing::serialize_model;
8
use tests_integration::TestConfig;
9

            
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

            
17
use std::io::Write;
18

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

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

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

            
33
202
    if accept {
34
        clean_test_dir_for_accept(path)?;
35
202
    }
36

            
37
202
    let parsers = file_config
38
202
        .configured_parsers()
39
202
        .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?;
40

            
41
312
    for parser in parsers {
42
312
        let case_name = parser.to_string();
43
312
        let parse = match parser {
44
121
            Parser::TreeSitter => parse_essence_file_native,
45
191
            Parser::ViaConjure => parse_essence_file,
46
        };
47
312
        roundtrip_test_inner(path, filename, &case_name, extension, parse)?;
48
    }
49
197
    Ok(())
50
202
}
51

            
52
/// Removes generated and expected artefacts for a roundtrip test directory when `ACCEPT=true`.
53
///
54
/// Keeps source model files (`.essence`) and `config.toml`. Nested directories are not removed,
55
/// because each nested test directory performs its own cleanup when executed.
56
fn clean_test_dir_for_accept(path: &str) -> Result<(), std::io::Error> {
57
    for entry in std::fs::read_dir(path)? {
58
        let entry = entry?;
59
        let file_name = entry.file_name();
60
        let file_name = file_name.to_string_lossy();
61
        let entry_path = entry.path();
62

            
63
        if entry_path.is_dir() {
64
            continue;
65
        }
66

            
67
        let keep = if file_name == "config.toml" {
68
            true
69
        } else {
70
            let is_model_file = entry_path
71
                .extension()
72
                .and_then(|ext| ext.to_str())
73
                .is_some_and(|ext| ext == "essence");
74
            let is_generated_or_expected =
75
                file_name.contains(".generated") || file_name.contains(".expected");
76
            is_model_file && !is_generated_or_expected
77
        };
78

            
79
        if keep {
80
            continue;
81
        }
82

            
83
        std::fs::remove_file(entry_path)?;
84
    }
85

            
86
    Ok(())
87
}
88

            
89
/// Runs the roundtrip pipeline for a single parser case.
90
///
91
/// Algorithm sketch:
92
/// 1. Parse the input model file.
93
/// 2. If parsing succeeds:
94
/// 3. Save generated model JSON and generated Essence output.
95
/// 4. If `ACCEPT=true`, copy generated outputs to expected outputs.
96
/// 5. Load and compare generated vs expected model JSON.
97
/// 6. Load and compare generated vs expected Essence output.
98
/// 7. Parse generated Essence again, re-emit Essence, and assert roundtrip stability.
99
/// 8. If parsing fails:
100
/// 9. Save generated parse error output.
101
/// 10. If `ACCEPT=true`, copy generated error output to expected error output.
102
/// 11. Load and compare generated vs expected error output.
103
312
fn roundtrip_test_inner(
104
312
    path: &str,
105
312
    input_filename: &str,
106
312
    case_name: &str,
107
312
    extension: &str,
108
312
    parse: ParseFn,
109
312
) -> Result<(), Box<dyn Error>> {
110
312
    let accept = env::var("ACCEPT").unwrap_or("false".to_string()) == "true";
111

            
112
312
    let file_path = format!("{path}/{input_filename}.{extension}");
113
312
    let context: Arc<RwLock<Context<'static>>> = Default::default();
114

            
115
312
    let initial_parse = parse(&file_path, context.clone());
116
312
    match initial_parse {
117
102
        Ok(initial_model) => {
118
102
            save_roundtrip_model_json(&initial_model, path, case_name, "generated")?;
119
102
            save_essence(&initial_model, path, case_name, "generated")?;
120

            
121
102
            if accept {
122
                std::fs::copy(
123
                    roundtrip_model_json_path(path, case_name, "generated"),
124
                    roundtrip_model_json_path(path, case_name, "expected"),
125
                )?;
126
                std::fs::copy(
127
                    roundtrip_essence_path(path, case_name, "generated"),
128
                    roundtrip_essence_path(path, case_name, "expected"),
129
                )?;
130
102
            }
131

            
132
102
            if !accept
133
102
                && !Path::new(&roundtrip_model_json_path(path, case_name, "expected")).exists()
134
            {
135
                return Err(Box::new(std::io::Error::new(
136
                    std::io::ErrorKind::NotFound,
137
                    "Expected output file not found: Run with ACCEPT=true".to_string(),
138
                )));
139
102
            }
140

            
141
102
            let expected_model = read_roundtrip_model_json(&context, path, case_name, "expected")?;
142

            
143
99
            let generated_model =
144
99
                read_roundtrip_model_json(&context, path, case_name, "generated")?;
145
99
            assert_eq!(generated_model, expected_model);
146

            
147
99
            let expected_essence =
148
99
                fs::read_to_string(roundtrip_essence_path(path, case_name, "expected"))?;
149
99
            let generated_essence =
150
99
                fs::read_to_string(roundtrip_essence_path(path, case_name, "generated"))?;
151
99
            assert_eq!(expected_essence, generated_essence);
152

            
153
99
            let new_model = parse(
154
99
                &roundtrip_essence_path(path, case_name, "generated"),
155
99
                context.clone(),
156
99
            )?;
157
99
            save_essence(&new_model, path, case_name, "generated2")?;
158
99
            let new_generated_essence =
159
99
                fs::read_to_string(roundtrip_essence_path(path, case_name, "generated"))?;
160
99
            assert_eq!(generated_essence, new_generated_essence);
161
        }
162

            
163
210
        Err(parse_error) => {
164
210
            save_parse_error(&parse_error, path, case_name, "generated")?;
165

            
166
210
            if accept {
167
                std::fs::copy(
168
                    roundtrip_error_path(path, case_name, "generated"),
169
                    roundtrip_error_path(path, case_name, "expected"),
170
                )?;
171
210
            }
172

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

            
180
210
            let expected_error =
181
210
                fs::read_to_string(roundtrip_error_path(path, case_name, "expected"))?;
182
210
            let generated_error =
183
210
                fs::read_to_string(roundtrip_error_path(path, case_name, "generated"))?;
184
210
            assert_eq!(expected_error, generated_error);
185
        }
186
    }
187

            
188
307
    Ok(())
189
310
}
190

            
191
/// Returns the roundtrip model JSON path for a parser case and model type.
192
405
fn roundtrip_model_json_path(path: &str, case_name: &str, file_type: &str) -> String {
193
405
    format!("{path}/{case_name}.{file_type}.serialised.json")
194
405
}
195

            
196
/// Returns the roundtrip Essence path for a parser case and model type.
197
597
fn roundtrip_essence_path(path: &str, case_name: &str, file_type: &str) -> String {
198
597
    format!("{path}/{case_name}.{file_type}.essence")
199
597
}
200

            
201
/// Returns the roundtrip parser-error path for a parser case and model type.
202
840
fn roundtrip_error_path(path: &str, case_name: &str, file_type: &str) -> String {
203
840
    format!("{path}/{case_name}.{file_type}-error.txt")
204
840
}
205

            
206
/// Serialises and writes a generated model snapshot for roundtrip comparison.
207
102
fn save_roundtrip_model_json(
208
102
    model: &Model,
209
102
    path: &str,
210
102
    case_name: &str,
211
102
    file_type: &str,
212
102
) -> Result<(), std::io::Error> {
213
102
    let serialised = serialize_model(model).map_err(std::io::Error::other)?;
214
102
    fs::write(
215
102
        roundtrip_model_json_path(path, case_name, file_type),
216
102
        serialised,
217
    )?;
218
102
    Ok(())
219
102
}
220

            
221
/// Reads and initialises a saved roundtrip model snapshot.
222
201
fn read_roundtrip_model_json(
223
201
    context: &Arc<RwLock<Context<'static>>>,
224
201
    path: &str,
225
201
    case_name: &str,
226
201
    file_type: &str,
227
201
) -> Result<Model, std::io::Error> {
228
201
    let serialised = fs::read_to_string(roundtrip_model_json_path(path, case_name, file_type))?;
229
198
    let serde_model: SerdeModel =
230
201
        serde_json::from_str(&serialised).map_err(std::io::Error::other)?;
231
198
    serde_model
232
198
        .initialise(context.clone())
233
198
        .ok_or_else(|| std::io::Error::other("failed to initialise parsed SerdeModel"))
234
201
}
235

            
236
/// Saves a model as an Essence file.
237
201
fn save_essence(
238
201
    model: &Model,
239
201
    path: &str,
240
201
    test_name: &str,
241
201
    file_type: &str,
242
201
) -> Result<(), std::io::Error> {
243
201
    let filename = roundtrip_essence_path(path, test_name, file_type);
244
201
    let mut file = fs::File::create(&filename)?;
245
201
    write!(file, "{}", model)?;
246
201
    Ok(())
247
201
}
248

            
249
/// Saves a parse error message as a text file.
250
210
fn save_parse_error(
251
210
    error: &ParseErrorCollection,
252
210
    path: &str,
253
210
    test_name: &str,
254
210
    file_type: &str,
255
210
) -> Result<(), std::io::Error> {
256
210
    let filename = roundtrip_error_path(path, test_name, file_type);
257
210
    let mut file = fs::File::create(&filename)?;
258
210
    write!(file, "{}", error)?;
259
210
    Ok(())
260
210
}
261

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