nsys-rs-utils 0.3.0

Miscellaneous Rust utilities
Documentation
//! File utilities
#![expect(clippy::module_name_repetitions)]

use std::{fs, io, path};

/// Calls `file_new_append` on the path returned by feeding the file path to
/// `file_path_incremental`.
///
/// # Errors
///
/// - Invalid unicode (☞ see [`is_file`](fn.is_file.html))
/// - Not a file (☞ see [`file_path_incremental`](fn.file_path_incremental.html))

pub fn file_new_append_incremental (file_path : &path::Path)
  -> Result <(path::PathBuf, fs::File), io::Error>
{
  let file_pathbuf = file_path_incremental (file_path)?;
  let file         = file_new_append (file_pathbuf.as_path())?;
  Ok ((file_pathbuf, file))
}

/// Opens a new file at specified path for writing in append mode, recursively creating
/// parent directories.
///
/// # Errors
///
/// - Invalid unicode (&#x261e; see [`is_file`](fn.is_file.html))
/// - Not a file:
///
/// ```
/// # use std::error::Error; use std::io::ErrorKind; use std::path::Path;
/// # use rs_utils::file::file_new_append;
/// let e = file_new_append (Path::new ("somepath/")).err().unwrap();
/// assert_eq!(e.kind(), ErrorKind::InvalidInput);
/// assert_eq!(e.to_string(), "not a file");
/// ```
///
/// - File already exists:
///
/// ```
/// use tempfile;
/// # use std::error::Error; use std::io::ErrorKind; use std::path::Path;
/// # use rs_utils::file::file_new_append;
/// # fn main () {
///
/// let temp_dir = tempfile::Builder::new().prefix ("tmp").tempdir().unwrap();
/// let file_path = temp_dir.path().join (Path::new ("somefile"));
/// let file_path = file_path.as_path();
/// assert! (!file_path.exists());
/// file_new_append (file_path).unwrap();
/// let e = file_new_append (file_path).err().unwrap();
/// assert_eq!(e.kind(), ErrorKind::AlreadyExists);
/// assert_eq!(e.to_string(), "File exists (os error 17)");
/// # }
/// ```

pub fn file_new_append (file_path : &path::Path) -> Result <fs::File, io::Error> {
  if !is_file (file_path)? {
    return Err (io::Error::new (io::ErrorKind::InvalidInput, "not a file"))
  }

  let dir = file_path.parent().unwrap_or_else (|| path::Path::new (""));
  fs::create_dir_all (dir)?;

  fs::OpenOptions::new().append (true).create_new (true).open (file_path)
}

/// Returns the file path appended with suffix `-N` where `N` gives the first available
/// non-pre-existing filename starting from `0`.
///
/// This function only queries for the next available filename, no directories or files
/// are created.
///
/// # Examples
///
/// ```
/// # use std::path::Path;
/// # use rs_utils::file::file_path_incremental;
/// let file_path = Path::new ("somedir/somefile");
/// assert_eq!(
///   file_path_incremental (file_path).unwrap().to_str().unwrap(),
///   "somedir/somefile-0"
/// );
/// ```
///
/// # Errors
///
/// - Invalid unicode (&#x261e; see [`is_file`](fn.is_file.html))
/// - Not a file:
///
/// ```
/// # use std::error::Error; use std::io::ErrorKind; use std::path::Path;
/// # use rs_utils::file::file_path_incremental;
/// let e = file_path_incremental (Path::new ("somepath/")).err().unwrap();
/// assert_eq!(e.kind(), ErrorKind::InvalidInput);
/// assert_eq!(e.to_string(), "not a file");
/// ```

pub fn file_path_incremental (file_path : &path::Path)
  -> Result <path::PathBuf, io::Error>
{
  if !is_file (file_path)? {
    return Err (io::Error::new (io::ErrorKind::InvalidInput, "not a file"))
  }
  // unwrap failure should have been caught by `is_file` test
  let file_name = file_path.file_name().expect ("fatal: path should be a valid file")
    .to_str().unwrap_or_else (||
      panic!("fatal: `file_path.file_name()` returned invalid os str: {:?}",
        file_path.file_name()));
  let dir = file_path.parent().unwrap_or_else (|| path::Path::new (""));
  for i in 0.. {
    let name = String::from (file_name) + &format!("-{i}");
    let fp   = dir.join (name);
    if !fp.exists() {
      return Ok (fp)
    }
  }
  unreachable!("fatal: incremental file name loop should have returned")
}

/// Like file path incremental but preserves the file extension if one is present.
pub fn file_path_incremental_with_extension (file_path : &path::Path)
  -> Result <path::PathBuf, io::Error>
{
  if !is_file (file_path)? {
    return Err (io::Error::new (io::ErrorKind::InvalidInput, "not a file"))
  }
  if file_path.extension().is_none() {
    return file_path_incremental (file_path)
  }
  let extension = file_path.extension().unwrap().to_str().unwrap();
  // unwrap failure should have been caught by `is_file` test
  let file_stem = file_path.file_stem()
    .expect ("fatal: path should be a valid file").to_str()
    .unwrap_or_else (||
      panic!("fatal: `file_path.file_name()` returned invalid os str: {:?}",
        file_path.file_name()));
  let dir = file_path.parent().unwrap_or_else (|| path::Path::new (""));
  for i in 0.. {
    let name = &format!("{file_stem}-{i}.{extension}");
    let fp   = dir.join (name);
    if !fp.exists() {
      return Ok (fp)
    }
  }
  unreachable!("fatal: incremental file name loop should have returned")
}

/// If this returns true then `std::fs::File::create` will not fail with "is a
/// directory" error.
///
/// This is *not* the same as `std::path::Path::is_file` which also tests whether the
/// file actually exists.
///
/// # Examples
///
/// ```
/// # use std::path::Path; use rs_utils::file::is_file;
/// assert!(is_file (Path::new ("path/to/file")).unwrap());
/// assert!(!is_file (Path::new ("path/to/directory/")).unwrap());
/// assert!(!is_file (Path::new ("..")).unwrap());
/// ```
///
/// # Errors
///
/// - Invalid unicode:
///
/// ```
/// # use std::error::Error; use std::io::ErrorKind;
/// # use std::path::Path; use std::ffi::OsStr;
/// # use rs_utils::file::is_file;
/// use std::os::unix::ffi::OsStrExt;
/// let garbage = [192u8, 192u8, 192u8, 192u8];
/// let garbage_path = Path::new (OsStr::from_bytes (&garbage));
/// let e = is_file (garbage_path).err().unwrap();
/// assert_eq!(e.kind(), ErrorKind::InvalidInput);
/// assert_eq!(e.to_string(), "not valid unicode");
/// ```

pub fn is_file (file_path : &path::Path) -> Result <bool, io::Error> {
  let s = file_path.to_str().ok_or (io::Error::new (
    io::ErrorKind::InvalidInput, "not valid unicode"))?;
  if s.ends_with (path::MAIN_SEPARATOR) {
    return Ok (false)
  }
  if path::Path::new (file_path).file_name().is_none() {
    return Ok (false)
  }
  Ok (true)
}

#[cfg(test)]
mod tests {
  use tempfile;
  use quickcheck;
  use super::*;

  // test that is_file() implies file creation will not give an "is a directory" error:
  // as of Rust 1.16 (2017-01-23) this error is simply indicated by an ErrorKind::Other
  // (other os error)
  #[ignore] // to run test use `cargo test -- --ignored`
  #[quickcheck_macros::quickcheck]
  fn prop_is_file_implies_not_directory (file_path : String) -> quickcheck::TestResult {
    let file_path = path::Path::new (file_path.as_str());
    if !is_file (file_path).unwrap() {
      return quickcheck::TestResult::discard()
    }
    if let Some (s) = file_path.parent() && !s.to_str().unwrap().is_empty() {
      return quickcheck::TestResult::discard()
    }
    let temp_dir  = tempfile::Builder::new().prefix ("tmp").tempdir().unwrap();
    let file_path = temp_dir.path().join (file_path);
    quickcheck::TestResult::from_bool (
      if let Err(e) = fs::OpenOptions::new().append (true).create (true)
        .open (file_path)
      {
        e.kind() != io::ErrorKind::Other
      } else {
        true
      }
    )
  }
}