odata_client_codegen 0.1.0

Strongly-typed OData client code generation
Documentation
use std::{
    fmt::Display,
    fs::{self, metadata},
    panic::{catch_unwind, UnwindSafe},
    path::Path,
    path::PathBuf,
};

/// Defines the procedure for running a test which reads input files and transforms them into some
/// type which can be verified against a corresponding output file.
/// Tests cases are produced by manually populating a target directory with input files.
/// `run_file_parse_tests` is run on this directory, which on first run will produce corresponding
/// output files. The developer should verify that the output files are valid for each input,
/// committing them if so. Running `run_file_parse_tests` again will verify that the produced
/// output then matches.
pub trait InputFileTest: UnwindSafe + Clone {
    /// The type produced when `parse_input_file` returns successfully.
    type Parsed;

    /// The type produced when `parse_input_file` returns unsuccessfully.
    type ParseError: Display;

    /// Extension of input files to read in the target directory.
    const INPUT_FILE_EXT: &'static str;

    /// Extension to append to output files which correspond to each input file.
    const OUTPUT_FILE_EXT: &'static str;

    /// Runs the functionality under test to produce either a success or failure model.
    fn parse_input_file(&mut self, file_contents: &str) -> Result<Self::Parsed, Self::ParseError>;

    /// Serialises the result of a successful test, to be written to an output file.
    fn write_test_result(&mut self, result: Self::Parsed) -> String;

    /// Compares the parse result of a test with the previously-written output file. The default
    /// implementation calls `write_test_result` and directly compares strings.
    fn compare_outputs(
        &mut self,
        result: Self::Parsed,
        prev_output_contents: String,
    ) -> Result<(), String> {
        let current_output = self.write_test_result(result);

        if prev_output_contents == current_output {
            Ok(())
        } else {
            Err(format!("Parsed value does not match:\n{}", current_output))
        }
    }
}

/// Runs output file verification tests over files in a directory, for an operation defined by an
/// implementor of `FileParseTest`.
pub fn run_file_parse_tests<F: InputFileTest>(mut test_spec: F, test_data_dir_path: &Path) {
    let TestDataCollection {
        with_results,
        without_results,
    } = read_test_data_dir::<F>(test_data_dir_path.as_ref());

    if !without_results.is_empty() {
        println!("New test files present without result files");

        generate_results(&mut test_spec, without_results);

        panic!("New test input data present; not running tests");
    } else {
        let mut fail_count = 0;

        for test in with_results {
            let passed = run_test(&mut test_spec, test);

            if !passed {
                fail_count += 1;
            }
        }

        if fail_count > 0 {
            panic!("{} parse tests failed", fail_count);
        }
    }
}

/// Runs an output file verification test on a single file, for an operation defined by an
/// implementor of `FileParseTest`.
pub fn run_file_parse_test_single<F: InputFileTest>(
    mut test_spec: F,
    input_data_filepath: PathBuf,
) {
    let test_data = read_test_data_single::<F>(input_data_filepath);

    match test_data {
        TestDataSingle::WithoutResult(test_without_result) => {
            println!("New test file present without result file");

            generate_results(&mut test_spec, vec![test_without_result]);

            panic!("New test input data present; not running test");
        }
        TestDataSingle::WithResult(test_with_result) => {
            let passed = run_test(&mut test_spec, test_with_result);

            if !passed {
                panic!("Parse test failed");
            }
        }
    }
}

struct TestDataCollection {
    with_results: Vec<TestWithResult>,
    without_results: Vec<TestWithoutResult>,
}

enum TestDataSingle {
    WithResult(TestWithResult),
    WithoutResult(TestWithoutResult),
}

struct TestWithResult {
    input_path: PathBuf,
    input: String,
    result: String,
}

#[derive(Debug)]
struct TestWithoutResult {
    input_path: PathBuf,
    input: String,
    result_path: PathBuf,
}

fn read_test_data_dir<F: InputFileTest>(test_data_dir_path: &Path) -> TestDataCollection {
    let mut tests_with_results = Vec::new();
    let mut tests_without_results = Vec::new();

    // Assumes test is running from crate root
    let test_files = test_data_dir_path
        .read_dir()
        .expect("Couldn't read test data dir")
        .map(|entry| entry.unwrap())
        .filter(|entry| {
            entry
                .file_name()
                .to_str()
                .map_or(false, |s| s.ends_with(F::INPUT_FILE_EXT))
        });

    for entry in test_files {
        match read_test_data_single::<F>(entry.path()) {
            TestDataSingle::WithResult(test_with_result) => {
                tests_with_results.push(test_with_result);
            }
            TestDataSingle::WithoutResult(test_without_result) => {
                tests_without_results.push(test_without_result);
            }
        }
    }

    TestDataCollection {
        with_results: tests_with_results,
        without_results: tests_without_results,
    }
}

fn read_test_data_single<F: InputFileTest>(input_filepath: PathBuf) -> TestDataSingle {
    if !input_filepath
        .as_os_str()
        .to_str()
        .unwrap()
        .ends_with(F::INPUT_FILE_EXT)
    {
        panic!(
            "Input filepath should have extension '{}' (path: {:?})",
            F::INPUT_FILE_EXT,
            &input_filepath
        );
    }

    if !input_filepath.exists() {
        panic!("Input file not present at path: {:?}", &input_filepath);
    }

    if !metadata(&input_filepath).unwrap().is_file() {
        panic!("Input filepath '{:?}' is not a file", &input_filepath);
    }

    let input = fs::read_to_string(&input_filepath).unwrap();

    let result_path = input_filepath.with_file_name(format!(
        "{}{}",
        input_filepath.file_name().unwrap().to_string_lossy(),
        F::OUTPUT_FILE_EXT
    ));

    if result_path.exists() {
        if !result_path.is_file() {
            panic!("Test result data filepath {:?} is not a file", result_path);
        }

        let result = fs::read_to_string(result_path).unwrap();

        TestDataSingle::WithResult(TestWithResult {
            input_path: input_filepath,
            input,
            result,
        })
    } else {
        TestDataSingle::WithoutResult(TestWithoutResult {
            input_path: input_filepath,
            input,
            result_path,
        })
    }
}

fn generate_results<F: InputFileTest>(test_spec: &mut F, tests: Vec<TestWithoutResult>) {
    let mut successful_gens = Vec::new();
    let mut failed_gens = Vec::new();

    for test in &tests {
        let mut test_spec_clone = test_spec.clone();
        match catch_unwind(move || test_spec_clone.parse_input_file(&test.input)) {
            Ok(Ok(result)) => {
                let result_buf = test_spec.write_test_result(result);

                match fs::write(&test.result_path, result_buf) {
                    Ok(()) => {
                        successful_gens.push(&test.result_path);
                    }
                    Err(e) => {
                        failed_gens.push((&test.input_path, e.to_string()));
                    }
                }
            }
            Ok(Err(e)) => {
                failed_gens.push((&test.input_path, format!("Failed to parse:\n{}", e)));
            }
            Err(e) => {
                let message = match e.downcast_ref::<String>() {
                    Some(s) => format!("Panicked while parsing: {}", s),
                    None => "Panicked while parsing".to_owned(),
                };

                failed_gens.push((&test.input_path, message));
            }
        }
    }

    if !successful_gens.is_empty() {
        println!(
            "The following result files have been written. Verify that they are correct and \
            commit or delete them:"
        );

        for gen in &successful_gens {
            println!("- {:?}", gen);
        }

        println!();
    }

    if !failed_gens.is_empty() {
        println!("Failed to generate result files for the following test input files:");

        for (path, err) in &failed_gens {
            println!("- {:?}\n{}\n", path, err);
        }
    }

    if failed_gens.is_empty() && !successful_gens.is_empty() {
        println!("Re-run `cargo test` to run tests.");
    }
}

/// Returns `true` if passed, `false` if failed
fn run_test<F: InputFileTest>(test_spec: &mut F, test: TestWithResult) -> bool {
    let TestWithResult {
        input_path,
        input,
        result: expected_str,
    } = test;

    print!("{:?}: ", input_path);
    let mut test_spec_clone = test_spec.clone();

    match catch_unwind(move || test_spec_clone.parse_input_file(&input)) {
        Ok(Ok(actual)) => match test_spec.compare_outputs(actual, expected_str) {
            Ok(()) => {
                println!("Pass");
                true
            }
            Err(msg) => {
                println!("Failed: {}\n", msg);
                false
            }
        },
        Ok(Err(e)) => {
            println!("Failed to parse:\n{}", e);
            false
        }
        Err(e) => {
            if let Some(panic_message) = e.downcast_ref::<String>() {
                println!("Panicked while parsing: {}", panic_message);
            } else {
                println!("Panicked while parsing");
            }
            false
        }
    }
}