subplotlib 0.1.1

Utility functions and types for `subplot codegen` generated Rust based test suites. Relies on `subplotlib-derive` for associated macros.
Documentation
//! Library of steps for handling files in the data dir.
//!
//! The files step library is intended to help with standard operations which
//! people might need when writing subplot scenarios which use embedded files.

use std::collections::{HashMap, HashSet};
use std::ffi::OsString;
use std::fs::{self, Metadata, OpenOptions};
use std::io::{self, Write};
use std::path::PathBuf;
use std::time::{Duration, SystemTime};

use chrono::{TimeZone, Utc};
use filetime::FileTime;
use regex::Regex;

pub use crate::prelude::*;

pub use super::datadir::Datadir;

#[derive(Default)]
/// Context data for the `files` step library
///
/// This context contains a mapping from filename to metadata so that
/// the various steps remember metadata and then query it later can find it.
///
/// This context depends on, and will automatically register, the context for
/// the [`datadir`][crate::steplibrary::datadir] step library.
pub struct Files {
    metadata: HashMap<String, Metadata>,
}

impl ContextElement for Files {
    fn created(&mut self, scenario: &Scenario) {
        scenario.register_context_type::<Datadir>();
    }
}

/// Create a file on disk from an embedded file
///
/// # `given file {embedded_file}`
///
/// Create a file in the data dir from an embedded file.
///
/// This defers to [`create_from_embedded_with_other_name`]
#[step]
#[context(Datadir)]
pub fn create_from_embedded(context: &ScenarioContext, embedded_file: SubplotDataFile) {
    let filename_on_disk = format!("{}", embedded_file.name().display());
    create_from_embedded_with_other_name::call(context, &filename_on_disk, embedded_file)
}

/// Create a file on disk from an embedded file with a given name
///
/// # `given file {filename_on_disk} from {embedded_file}`
///
/// Creates a file in the data dir from an embedded file, but giving it a
/// potentially different name.
#[step]
pub fn create_from_embedded_with_other_name(
    context: &Datadir,
    filename_on_disk: &str,
    embedded_file: SubplotDataFile,
) {
    let filename_on_disk = PathBuf::from(filename_on_disk);
    let parentpath = filename_on_disk.parent().ok_or_else(|| {
        format!(
            "No parent directory found for {}",
            filename_on_disk.display()
        )
    })?;
    context.create_dir_all(parentpath)?;
    context
        .open_write(filename_on_disk)?
        .write_all(embedded_file.data())?;
}

#[step]
pub fn touch_with_timestamp(context: &Datadir, filename: &str, mtime: &str) {
    let ts = Utc.datetime_from_str(mtime, "%Y-%m-%d %H:%M:%S")?;
    let (secs, nanos) = (ts.timestamp(), ts.timestamp_subsec_nanos());
    let mtime = FileTime::from_unix_time(secs, nanos);
    let full_path = context.canonicalise_filename(filename)?;
    // If the file doesn't exist, create it
    drop(
        OpenOptions::new()
            .create(true)
            .write(true)
            .open(&full_path)?,
    );
    // And set its mtime
    filetime::set_file_mtime(full_path, mtime)?;
}

#[step]
pub fn create_from_text(context: &Datadir, text: &str, filename: &str) {
    context.open_write(filename)?.write_all(text.as_bytes())?;
}

#[step]
#[context(Datadir)]
#[context(Files)]
pub fn remember_metadata(context: &ScenarioContext, filename: &str) {
    let full_path = context.with(
        |context: &Datadir| context.canonicalise_filename(filename),
        false,
    )?;
    let metadata = fs::metadata(&full_path)?;
    context.with_mut(
        |context: &mut Files| {
            context.metadata.insert(filename.to_owned(), metadata);
            Ok(())
        },
        false,
    )
}

#[step]
pub fn touch(context: &Datadir, filename: &str) {
    let full_path = context.canonicalise_filename(filename)?;
    let now = FileTime::now();
    // If the file doesn't exist, create it
    drop(
        OpenOptions::new()
            .create(true)
            .write(true)
            .open(&full_path)?,
    );
    // And set its mtime
    filetime::set_file_mtime(full_path, now)?;
}

#[step]
pub fn file_exists(context: &Datadir, filename: &str) {
    let full_path = context.canonicalise_filename(filename)?;
    match fs::metadata(full_path) {
        Ok(_) => (),
        Err(e) => {
            if matches!(e.kind(), io::ErrorKind::NotFound) {
                throw!(format!("file '{}' was not found", filename))
            } else {
                throw!(e);
            }
        }
    }
}

#[step]
pub fn file_does_not_exist(context: &Datadir, filename: &str) {
    let full_path = context.canonicalise_filename(filename)?;
    match fs::metadata(full_path) {
        Ok(_) => {
            throw!(format!("file '{}' was unexpectedly found", filename))
        }
        Err(e) => {
            if !matches!(e.kind(), io::ErrorKind::NotFound) {
                throw!(e);
            }
        }
    }
}

#[step]
pub fn only_these_exist(context: &Datadir, filenames: &str) {
    let filenames: HashSet<OsString> = filenames
        .replace(',', "")
        .split_ascii_whitespace()
        .map(|s| s.into())
        .collect();
    let fnames: HashSet<OsString> = fs::read_dir(context.base_path())?
        .map(|entry| entry.map(|entry| entry.file_name()))
        .collect::<Result<_, _>>()?;
    assert_eq!(filenames, fnames);
}

#[step]
pub fn file_contains(context: &Datadir, filename: &str, data: &str) {
    let full_path = context.canonicalise_filename(filename)?;
    let body = fs::read_to_string(full_path)?;
    if !body.contains(data) {
        throw!("expected file content not found");
    }
}

#[step]
pub fn file_matches_regex(context: &Datadir, filename: &str, regex: &str) {
    let full_path = context.canonicalise_filename(filename)?;
    let regex = Regex::new(regex)?;
    let body = fs::read_to_string(full_path)?;
    if !regex.is_match(&body) {
        throw!("file content does not match given regex");
    }
}

#[step]
pub fn file_match(context: &Datadir, filename1: &str, filename2: &str) {
    let full_path1 = context.canonicalise_filename(filename1)?;
    let full_path2 = context.canonicalise_filename(filename2)?;
    let body1 = fs::read(full_path1)?;
    let body2 = fs::read(full_path2)?;
    if body1 != body2 {
        throw!("file contents do not match each other");
    }
}

#[step]
#[context(Datadir)]
#[context(Files)]
pub fn has_remembered_metadata(context: &ScenarioContext, filename: &str) {
    let full_path = context.with(
        |context: &Datadir| context.canonicalise_filename(filename),
        false,
    )?;
    let metadata = fs::metadata(&full_path)?;
    if let Some(remembered) = context.with(
        |context: &Files| Ok(context.metadata.get(filename).cloned()),
        false,
    )? {
        if metadata.permissions() != remembered.permissions()
            || metadata.modified()? != remembered.modified()?
            || metadata.len() != remembered.len()
            || metadata.is_file() != remembered.is_file()
        {
            throw!(format!("metadata change detected for {}", filename));
        }
    } else {
        throw!(format!("no remembered metadata for {}", filename));
    }
}

#[step]
#[context(Datadir)]
#[context(Files)]
pub fn has_different_metadata(context: &ScenarioContext, filename: &str) {
    let full_path = context.with(
        |context: &Datadir| context.canonicalise_filename(filename),
        false,
    )?;
    let metadata = fs::metadata(&full_path)?;
    if let Some(remembered) = context.with(
        |context: &Files| Ok(context.metadata.get(filename).cloned()),
        false,
    )? {
        if metadata.permissions() == remembered.permissions()
            && metadata.modified()? == remembered.modified()?
            && metadata.len() == remembered.len()
            && metadata.is_file() == remembered.is_file()
        {
            throw!(format!("metadata change not detected for {}", filename));
        }
    } else {
        throw!(format!("no remembered metadata for {}", filename));
    }
}

#[step]
pub fn mtime_is_recent(context: &Datadir, filename: &str) {
    let full_path = context.canonicalise_filename(filename)?;
    let metadata = fs::metadata(full_path)?;
    let mtime = metadata.modified()?;
    let diff = SystemTime::now().duration_since(mtime)?;
    if diff > (Duration::from_secs(5)) {
        throw!(format!("{} is older than 5 seconds", filename));
    }
}

#[step]
pub fn mtime_is_ancient(context: &Datadir, filename: &str) {
    let full_path = context.canonicalise_filename(filename)?;
    let metadata = fs::metadata(full_path)?;
    let mtime = metadata.modified()?;
    let diff = SystemTime::now().duration_since(mtime)?;
    if diff < (Duration::from_secs(39 * 365 * 24 * 3600)) {
        throw!(format!("{} is younger than 39 years", filename));
    }
}

#[step]
pub fn make_directory(context: &Datadir, path: &str) {
    context.create_dir_all(path)?;
}

#[step]
pub fn remove_directory(context: &Datadir, path: &str) {
    let full_path = context.canonicalise_filename(path)?;
    remove_dir_all::remove_dir_all(full_path)?;
}

#[step]
pub fn path_exists(context: &Datadir, path: &str) {
    let full_path = context.canonicalise_filename(path)?;
    if !fs::metadata(&full_path)?.is_dir() {
        throw!(format!(
            "{} exists but is not a directory",
            full_path.display()
        ))
    }
}

#[step]
pub fn path_does_not_exist(context: &Datadir, path: &str) {
    let full_path = context.canonicalise_filename(path)?;
    match fs::metadata(&full_path) {
        Ok(_) => throw!(format!("{} exists", full_path.display())),
        Err(e) => {
            if !matches!(e.kind(), io::ErrorKind::NotFound) {
                throw!(e);
            }
        }
    };
}

#[step]
pub fn path_is_empty(context: &Datadir, path: &str) {
    let full_path = context.canonicalise_filename(path)?;
    let mut iter = fs::read_dir(&full_path)?;
    match iter.next() {
        None => {}
        Some(Ok(_)) => throw!(format!("{} is not empty", full_path.display())),
        Some(Err(e)) => throw!(e),
    }
}

#[step]
pub fn path_is_not_empty(context: &Datadir, path: &str) {
    let full_path = context.canonicalise_filename(path)?;
    let mut iter = fs::read_dir(&full_path)?;
    match iter.next() {
        None => throw!(format!("{} is empty", full_path.display())),
        Some(Ok(_)) => {}
        Some(Err(e)) => throw!(e),
    }
}