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