subplotlib/steplibrary/
files.rs

1//! Library of steps for handling files in the data dir.
2//!
3//! The files step library is intended to help with standard operations which
4//! people might need when writing subplot scenarios which use embedded files.
5
6use std::collections::{HashMap, HashSet};
7use std::ffi::OsString;
8use std::fs::{self, Metadata, OpenOptions};
9use std::io::{self, Write};
10use std::path::{Path, PathBuf};
11use std::time::{Duration, SystemTime};
12
13use filetime::FileTime;
14use regex::Regex;
15use time::macros::format_description;
16use time::OffsetDateTime;
17
18pub use crate::prelude::*;
19
20pub use super::datadir::Datadir;
21
22#[derive(Debug, Default)]
23/// Context data for the `files` step library
24///
25/// This context contains a mapping from filename to metadata so that
26/// the various steps remember metadata and then query it later can find it.
27///
28/// This context depends on, and will automatically register, the context for
29/// the [`datadir`][crate::steplibrary::datadir] step library.
30///
31/// Because files can typically only be named in Subplot documents, we assume they
32/// all have names which can be rendered as utf-8 strings.
33pub struct Files {
34    metadata: HashMap<PathBuf, Metadata>,
35}
36
37impl ContextElement for Files {
38    fn created(&mut self, scenario: &Scenario) {
39        scenario.register_context_type::<Datadir>();
40    }
41}
42
43/// Create a file on disk from an embedded file
44///
45/// # `given file {embedded_file}`
46///
47/// Create a file in the data dir from an embedded file.
48///
49/// This defers to [`create_from_embedded_with_other_name`]
50#[step]
51#[context(Datadir)]
52pub fn create_from_embedded(context: &ScenarioContext, embedded_file: SubplotDataFile) {
53    let filename_on_disk = PathBuf::from(format!("{}", embedded_file.name().display()));
54    create_from_embedded_with_other_name::call(context, &filename_on_disk, embedded_file)?;
55}
56
57/// Create a file on disk from an embedded file with a given name
58///
59/// # `given file {filename_on_disk} from {embedded_file}`
60///
61/// Creates a file in the data dir from an embedded file, but giving it a
62/// potentially different name.
63#[step]
64pub fn create_from_embedded_with_other_name(
65    context: &Datadir,
66    filename_on_disk: &Path,
67    embedded_file: SubplotDataFile,
68) {
69    let filename_on_disk = PathBuf::from(filename_on_disk);
70    let parentpath = filename_on_disk.parent().ok_or_else(|| {
71        format!(
72            "No parent directory found for {}",
73            filename_on_disk.display()
74        )
75    })?;
76    context.create_dir_all(parentpath)?;
77    context
78        .open_write(filename_on_disk)?
79        .write_all(embedded_file.data())?;
80}
81
82/// Remove a file (not directory) from disk.
83///
84/// # `when I remove file {path}`
85///
86/// This is the equivalent of `rm` within the data directory for the scenario
87#[step]
88pub fn remove_file(context: &Datadir, filename: &Path) {
89    let filename = PathBuf::from(filename);
90    context.remove_file(filename)?;
91}
92
93/// Touch a file to have a specific timestamp as its modified time
94///
95/// # `given file (?P<filename>\S+) has modification time (?P<mtime>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})`
96///
97/// Sets the modification time for the given filename to the provided mtime.
98/// If the file does not exist, it will be created.
99#[step]
100pub fn touch_with_timestamp(context: &Datadir, filename: &Path, mtime: &str) {
101    let fd = format_description!(
102        "[year]-[month]-[day] [hour]:[minute]:[second] [offset_hour]:[offset_minute]"
103    );
104    let full_time = format!("{mtime} +00:00");
105    let ts = OffsetDateTime::parse(&full_time, &fd)?;
106    let (secs, nanos) = (ts.unix_timestamp(), 0);
107    let mtime = FileTime::from_unix_time(secs, nanos);
108    let full_path = context.canonicalise_filename(filename)?;
109    // If the file doesn't exist, create it
110    drop(
111        OpenOptions::new()
112            .create(true)
113            .truncate(false)
114            .write(true)
115            .open(&full_path)?,
116    );
117    // And set its mtime
118    filetime::set_file_mtime(full_path, mtime)?;
119}
120
121/// Create a file with some given text as its content
122///
123/// # `when I write "(?P<text>.*)" to file (?P<filename>\S+)`
124///
125/// Create/replace the given file with the given content.
126#[step]
127pub fn create_from_text(context: &Datadir, text: &str, filename: &Path) {
128    context.open_write(filename)?.write_all(text.as_bytes())?;
129}
130
131/// Examine the given file and remember its metadata for later
132///
133/// # `when I remember metadata for file {filename}`
134///
135/// This step stores the metadata (mtime etc) for the given file into the
136/// context so that it can be retrieved later for testing against.
137#[step]
138#[context(Datadir)]
139#[context(Files)]
140pub fn remember_metadata(context: &ScenarioContext, filename: &Path) {
141    let full_path = context.with(
142        |context: &Datadir| context.canonicalise_filename(filename),
143        false,
144    )?;
145    let metadata = fs::metadata(full_path)?;
146    context.with_mut(
147        |context: &mut Files| {
148            context.metadata.insert(filename.to_owned(), metadata);
149            Ok(())
150        },
151        false,
152    )?;
153}
154
155/// Touch a given file
156///
157/// # `when I touch file {filename}`
158///
159/// This will create the named file if it does not exist, and then it will ensure that the
160/// file's modification time is set to the current time.
161#[step]
162pub fn touch(context: &Datadir, filename: &Path) {
163    let full_path = context.canonicalise_filename(filename)?;
164    let now = FileTime::now();
165    // If the file doesn't exist, create it
166    drop(
167        OpenOptions::new()
168            .create(true)
169            .truncate(false)
170            .write(true)
171            .open(&full_path)?,
172    );
173    // And set its mtime
174    filetime::set_file_mtime(full_path, now)?;
175}
176
177/// Check for a file
178///
179/// # `then file {filename} exists`
180///
181/// This simple step will succeed if the given filename exists in some sense.
182#[step]
183pub fn file_exists(context: &Datadir, filename: &Path) {
184    let full_path = context.canonicalise_filename(filename)?;
185    match fs::metadata(full_path) {
186        Ok(_) => (),
187        Err(e) => {
188            if matches!(e.kind(), io::ErrorKind::NotFound) {
189                throw!(format!("file '{}' was not found", filename.display()))
190            } else {
191                throw!(e);
192            }
193        }
194    }
195}
196
197/// Check for absence of a file
198///
199/// # `then file {filename} does not exist`
200///
201/// This simple step will succeed if the given filename does not exist in any sense.
202#[step]
203pub fn file_does_not_exist(context: &Datadir, filename: &Path) {
204    let full_path = context.canonicalise_filename(filename)?;
205    match fs::metadata(full_path) {
206        Ok(_) => {
207            throw!(format!(
208                "file '{}' was unexpectedly found",
209                filename.display()
210            ))
211        }
212        Err(e) => {
213            if !matches!(e.kind(), io::ErrorKind::NotFound) {
214                throw!(e);
215            }
216        }
217    }
218}
219
220/// Check if a set of files are the only files in the datadir
221///
222/// # `then only files (?P<filenames>.+) exist`
223///
224/// This step iterates the data directory and checks that **only** the named files exist.
225///
226/// Note: `filenames` is whitespace-separated, though any commas are removed as well.
227/// As such you cannot use this to test for filenames which contain commas.
228#[step]
229pub fn only_these_exist(context: &Datadir, filenames: &str) {
230    let filenames: HashSet<OsString> = filenames
231        .replace(',', "")
232        .split_ascii_whitespace()
233        .map(|s| s.into())
234        .collect();
235    let fnames: HashSet<OsString> = fs::read_dir(context.base_path())?
236        .map(|entry| entry.map(|entry| entry.file_name()))
237        .collect::<Result<_, _>>()?;
238    assert_eq!(filenames, fnames);
239}
240
241/// Check if a file contains a given sequence of characters
242///
243/// # `then file (?P<filename>\S+) contains "(?P<data>.*)"`
244///
245/// This will load the content of the named file and ensure it contains the given string.
246/// Note: this assumes everything is utf-8 encoded.  If not, things will fail.
247#[step]
248pub fn file_contains(context: &Datadir, filename: &Path, data: &str) {
249    let full_path = context.canonicalise_filename(filename)?;
250    let body = fs::read_to_string(full_path)?;
251    if !body.contains(data) {
252        println!("file {} contains:\n{}", filename.display(), body);
253        throw!("expected file content not found");
254    }
255}
256
257/// Check if a file lacks a given sequence of characters
258///
259/// # `then file (?P<filename>\S+) does not contain "(?P<data>.*)"`
260///
261/// This will load the content of the named file and ensure it lacks the given string.
262/// Note: this assumes everything is utf-8 encoded.  If not, things will fail.
263#[step]
264pub fn file_doesnt_contain(context: &Datadir, filename: &Path, data: &str) {
265    let full_path = context.canonicalise_filename(filename)?;
266    let body = fs::read_to_string(full_path)?;
267    if body.contains(data) {
268        println!("file {} contains:\n{}", filename.display(), body);
269        throw!("unexpected file content found");
270    }
271}
272
273/// Check if a file's content matches the given regular expression
274///
275/// # `then file (?P<filename>\S+) matches regex /(?P<regex>.*)/`
276///
277/// This will load the content of th enamed file and ensure it contains data which
278/// matches the given regular expression.  This step will fail if the file is not utf-8
279/// encoded, or if the regex fails to compile
280#[step]
281pub fn file_matches_regex(context: &Datadir, filename: &Path, regex: &str) {
282    let full_path = context.canonicalise_filename(filename)?;
283    let regex = Regex::new(regex)?;
284    let body = fs::read_to_string(full_path)?;
285    if !regex.is_match(&body) {
286        println!("file {} contains:\n{}", filename.display(), body);
287        throw!("file content does not match given regex");
288    }
289}
290
291/// Check if two files match
292///
293/// # `then files {filename1} and {filename2} match`
294///
295/// This loads the content of the given two files as **bytes** and checks they mach.
296#[step]
297pub fn file_match(context: &Datadir, filename1: &Path, filename2: &Path) {
298    let full_path1 = context.canonicalise_filename(filename1)?;
299    let full_path2 = context.canonicalise_filename(filename2)?;
300    let body1 = fs::read(full_path1)?;
301    let body2 = fs::read(full_path2)?;
302    if body1 != body2 {
303        println!(
304            "file {} contains:\n{}",
305            filename1.display(),
306            String::from_utf8_lossy(&body1)
307        );
308        println!(
309            "file {} contains:\n{}",
310            filename2.display(),
311            String::from_utf8_lossy(&body2)
312        );
313        throw!("file contents do not match each other");
314    }
315}
316
317/// Verify two files do not match
318///
319/// # `then files {filename1} and {filename2} are different`
320///
321/// This loads the content of the given two files as **bytes** and
322/// checks they don't mach.
323#[step]
324pub fn file_do_not_match(context: &Datadir, filename1: &Path, filename2: &Path) {
325    let full_path1 = context.canonicalise_filename(filename1)?;
326    let full_path2 = context.canonicalise_filename(filename2)?;
327    let body1 = fs::read(full_path1)?;
328    let body2 = fs::read(full_path2)?;
329    if body1 == body2 {
330        println!(
331            "file {} contains:\n{}",
332            filename1.display(),
333            String::from_utf8_lossy(&body1)
334        );
335        println!(
336            "file {} contains:\n{}",
337            filename2.display(),
338            String::from_utf8_lossy(&body2)
339        );
340        throw!("file contents do not differ");
341    }
342}
343
344/// Check if file on disk and an embedded file match
345///
346/// # `then file {filename1} on disk and embedded file {filename2} are identical`
347///
348/// This loads the content of the given two files as **bytes** and checks they mach.
349#[step]
350pub fn file_and_embedded_file_match(context: &Datadir, filename: &Path, embedded: SubplotDataFile) {
351    let full_path = context.canonicalise_filename(filename)?;
352    let body1 = fs::read(full_path)?;
353
354    let body2 = embedded.data();
355    if body1 != body2 {
356        println!(
357            "file {} contains:\n{}",
358            filename.display(),
359            String::from_utf8_lossy(&body1)
360        );
361        println!(
362            "embedded file {} contains:\n{}",
363            embedded.name().display(),
364            String::from_utf8_lossy(body2)
365        );
366        throw!("file contents do not match each other");
367    }
368}
369
370/// Check if file on disk and an embedded file do not match
371///
372/// # `then file {filename1} on disk and embedded file {filename2} are different`
373///
374/// This loads the content of the given two files as **bytes** and checks they do not match.
375#[step]
376pub fn file_and_embedded_file_do_not_match(
377    context: &Datadir,
378    filename: &Path,
379    embedded: SubplotDataFile,
380) {
381    let full_path = context.canonicalise_filename(filename)?;
382    let body1 = fs::read(full_path)?;
383
384    let body2 = embedded.data();
385    if body1 == body2 {
386        println!(
387            "file {} contains:\n{}",
388            filename.display(),
389            String::from_utf8_lossy(&body1)
390        );
391        println!(
392            "embedded file {} contains:\n{}",
393            embedded.name().display(),
394            String::from_utf8_lossy(body2)
395        );
396        throw!("file contents match each other");
397    }
398}
399
400/// Check if a given file's metadata matches our memory of it
401///
402/// # `then file {filename} has same metadata as before`
403///
404/// This confirms that the metadata we remembered for the given filename
405/// matches.  Specifically this checks:
406///
407/// * Are the permissions the same
408/// * Are the modification times the same
409/// * Is the file's length the same
410/// * Is the file's type (file/dir) the same
411#[step]
412#[context(Datadir)]
413#[context(Files)]
414pub fn has_remembered_metadata(context: &ScenarioContext, filename: &Path) {
415    let full_path = context.with(
416        |context: &Datadir| context.canonicalise_filename(filename),
417        false,
418    )?;
419    let metadata = fs::metadata(full_path)?;
420    if let Some(remembered) = context.with(
421        |context: &Files| Ok(context.metadata.get(filename).cloned()),
422        false,
423    )? {
424        if metadata.permissions() != remembered.permissions()
425            || metadata.modified()? != remembered.modified()?
426            || metadata.len() != remembered.len()
427            || metadata.is_file() != remembered.is_file()
428        {
429            throw!(format!(
430                "metadata change detected for {}",
431                filename.display()
432            ));
433        }
434    } else {
435        throw!(format!("no remembered metadata for {}", filename.display()));
436    }
437}
438
439/// Check that a given file's metadata has changed since we remembered it
440///
441/// # `then file {filename} has different metadata from before`
442///
443/// This confirms that the metadata we remembered for the given filename
444/// does not matche.  Specifically this checks:
445///
446/// * Are the permissions the same
447/// * Are the modification times the same
448/// * Is the file's length the same
449/// * Is the file's type (file/dir) the same
450#[step]
451#[context(Datadir)]
452#[context(Files)]
453pub fn has_different_metadata(context: &ScenarioContext, filename: &Path) {
454    let full_path = context.with(
455        |context: &Datadir| context.canonicalise_filename(filename),
456        false,
457    )?;
458    let metadata = fs::metadata(full_path)?;
459    if let Some(remembered) = context.with(
460        |context: &Files| Ok(context.metadata.get(filename).cloned()),
461        false,
462    )? {
463        if metadata.permissions() == remembered.permissions()
464            && metadata.modified()? == remembered.modified()?
465            && metadata.len() == remembered.len()
466            && metadata.is_file() == remembered.is_file()
467        {
468            throw!(format!(
469                "metadata change not detected for {}",
470                filename.display()
471            ));
472        }
473    } else {
474        throw!(format!("no remembered metadata for {}", filename.display()));
475    }
476}
477
478/// Check if the given file has been modified "recently"
479///
480/// # `then file {filename} has a very recent modification time`
481///
482/// Specifically this checks that the given file has been modified in the past 5 seconds.
483#[step]
484pub fn mtime_is_recent(context: &Datadir, filename: &Path) {
485    let full_path = context.canonicalise_filename(filename)?;
486    let metadata = fs::metadata(full_path)?;
487    let mtime = metadata.modified()?;
488    let diff = SystemTime::now().duration_since(mtime)?;
489    if diff > (Duration::from_secs(5)) {
490        throw!(format!("{} is older than 5 seconds", filename.display()));
491    }
492}
493
494/// Check if the given file is very old
495///
496/// # `then file {filename} has a very old modification time`
497///
498/// Specifically this checks that the file was modified at least 39 years ago.
499#[step]
500pub fn mtime_is_ancient(context: &Datadir, filename: &Path) {
501    let full_path = context.canonicalise_filename(filename)?;
502    let metadata = fs::metadata(full_path)?;
503    let mtime = metadata.modified()?;
504    let diff = SystemTime::now().duration_since(mtime)?;
505    if diff < (Duration::from_secs(39 * 365 * 24 * 3600)) {
506        throw!(format!("{} is younger than 39 years", filename.display()));
507    }
508}
509
510/// Make a directory
511///
512/// # `given a directory {path}`
513///
514/// This is the equivalent of `mkdir -p` within the data directory for the scenario.
515#[step]
516pub fn make_directory(context: &Datadir, path: &Path) {
517    context.create_dir_all(path)?;
518}
519
520/// Remove a directory
521///
522/// # `when I remove directory {path}`
523///
524/// This is the equivalent of `rm -rf` within the data directory for the scenario.
525#[step]
526pub fn remove_directory(context: &Datadir, path: &Path) {
527    let full_path = context.canonicalise_filename(path)?;
528    remove_dir_all::remove_dir_all(full_path)?;
529}
530
531/// Remove an empty directory
532///
533/// # `when I remove empty directory {path}`
534///
535/// This is the equivalent of `rmdir` within the data directory for the scenario.
536#[step]
537pub fn remove_empty_directory(context: &Datadir, path: &Path) {
538    let full_path = context.canonicalise_filename(path)?;
539    std::fs::remove_dir(full_path)?;
540}
541
542/// Check that a directory exists
543///
544/// # `then directory {path} exists`
545///
546/// This ensures that the given path exists in the data directory for the scenario and
547/// that it is a directory itself.
548#[step]
549pub fn path_exists(context: &Datadir, path: &Path) {
550    let full_path = context.canonicalise_filename(path)?;
551    if !fs::metadata(&full_path)?.is_dir() {
552        throw!(format!(
553            "{} exists but is not a directory",
554            full_path.display()
555        ))
556    }
557}
558
559/// Check that a directory does not exist
560///
561/// # `then directory {path} does not exist`
562///
563/// This ensures that the given path does not exist in the data directory.  If it exists
564/// and is not a directory, then this will also fail.
565#[step]
566pub fn path_does_not_exist(context: &Datadir, path: &Path) {
567    let full_path = context.canonicalise_filename(path)?;
568    match fs::metadata(&full_path) {
569        Ok(_) => throw!(format!("{} exists", full_path.display())),
570        Err(e) => {
571            if !matches!(e.kind(), io::ErrorKind::NotFound) {
572                throw!(e);
573            }
574        }
575    };
576}
577
578/// Check that a directory exists and is empty
579///
580/// # `then directory {path} is empty`
581///
582/// This checks that the given path inside the data directory exists and is an
583/// empty directory itself.
584#[step]
585pub fn path_is_empty(context: &Datadir, path: &Path) {
586    let full_path = context.canonicalise_filename(path)?;
587    let mut iter = fs::read_dir(&full_path)?;
588    match iter.next() {
589        None => {}
590        Some(Ok(_)) => throw!(format!("{} is not empty", full_path.display())),
591        Some(Err(e)) => throw!(e),
592    }
593}
594
595/// Check that a directory exists and is not empty
596///
597/// # `then directory {path} is not empty`
598///
599/// This checks that the given path inside the data directory exists and is a
600/// directory itself.  The step also asserts that the given directory contains at least
601/// one entry.
602#[step]
603pub fn path_is_not_empty(context: &Datadir, path: &Path) {
604    let full_path = context.canonicalise_filename(path)?;
605    let mut iter = fs::read_dir(&full_path)?;
606    match iter.next() {
607        None => throw!(format!("{} is empty", full_path.display())),
608        Some(Ok(_)) => {}
609        Some(Err(e)) => throw!(e),
610    }
611}