rotate_backup 0.1.2

Rotate you backups easily
Documentation
use super::*;

#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Default)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
pub enum RotateOutputType {
    #[default]
    /// Output files to keep
    Keep,
    /// Output files to delete
    Delete,
}

#[derive(Default)]
#[cfg_attr(feature = "cli", derive(clap::Args))]
/// Rotate your backup files
///
/// Rotate your backup files
pub struct RotateCommand {
    /// File paths to rotate
    ///
    /// if no parameters are provided, it will read the files from `stdin`. (requires `std` feature)
    /// Note: file names must be valid UTF8 strings
    pub files: Option<Vec<PathBuf>>,
    /// Format to parse date from provided files
    ///
    /// See the format used in [`parse_from_str` module](chrono::NaiveDate::parse_from_str)
    /// Note: The file name must start with the date.
    /// Default: %Y-%m-%d (ex: 2015-02-18)
    #[cfg_attr(feature = "cli", arg(long))]
    pub format: Option<String>,
    /// Base to use to compute exponential window sizes
    ///
    /// Note: Must be greater than 1.0
    /// Defaults to: 1.1
    #[cfg_attr(feature = "cli", arg(short, long))]
    pub base: Option<PartitionExponentBase>,
    /// Define what should be reported
    ///
    /// Default: keep
    #[cfg_attr(feature = "cli", arg(long, value_enum))]
    pub output: RotateOutputType,
}

#[derive(Eq, PartialEq, Hash, Debug, Default)]
pub struct RotateOutput<'a> {
    pub keep: Vec<&'a Path>,
    pub delete: Vec<&'a Path>,
}

pub fn rotate<'a>(
    files: &'a [PathBuf],
    base: PartitionExponentBase,
    format: &str,
) -> anyhow::Result<RotateOutput<'a>> {
    if files.is_empty() {
        return Ok(RotateOutput::default());
    }

    let mut dates = files
        .iter()
        .enumerate()
        .map(|(i, path)| {
            file_name(path)
                .and_then(|file_name| {
                    NaiveDate::parse_and_remainder(file_name, format)
                        .with_context(|| format!("While parsing file name: '{file_name}'"))
                })
                .map(|(date, _)| (i, date))
        })
        .collect::<Result<Vec<_>, _>>()?;

    dates.sort_by_key(|(_, d)| *d);
    // SAFETY: we checked that files is not empty
    let day0 = &dates[0].1;

    let result = unsafe {
        dates
            .iter()
            // SAFETY: dates are sorted ascendingly
            .partition_with_get(
                |index| PartitionSize::exponential(base, index),
                |(_, d)| (*d - *day0).num_days().cast_unsigned(),
            )
            .map(|(p_i, (i, _))| (p_i, *i))
            .fold(
                (RotateOutput::default(), None),
                |(mut result, mut current_index), (p_i, i)| {
                    if Some(p_i) != current_index {
                        // current partition changed, first entry is the one to keep
                        current_index = Some(p_i);
                        result.keep.push(&*files[i])
                    } else {
                        result.delete.push(&*files[i])
                    }

                    (result, current_index)
                },
            )
            .0
    };
    Ok(result)
}

fn file_name(path: &Path) -> anyhow::Result<&str> {
    #[cfg(feature = "std")]
    {
        path.file_name()
            .ok_or_else(|| anyhow::anyhow!("Expected a file, but received: {path:?}"))
            .and_then(|file_name| {
                file_name.to_str().ok_or_else(|| {
                    anyhow::anyhow!("Expected a UTF8 valid filename, but received: {path:?}")
                })
            })
    }
    #[cfg(not(feature = "std"))]
    {
        Ok(path
            .rfind(&['/', '\\'])
            .map(|index| &path[index..])
            .unwrap_or(path))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use alloc::{vec, vec::Vec};
    use chrono::{NaiveDate, TimeDelta};
    use std::format;
    use std::path::PathBuf;

    #[test]
    fn simple_test() {
        let day0 = NaiveDate::from_ymd_opt(2020, 01, 16).unwrap();
        let files = (0..60)
            .map(|i| day0 + TimeDelta::days(i))
            .map(|date| format!("{date}.tar"))
            .map(PathBuf::from)
            .collect::<Vec<_>>();
        let result = rotate::rotate(
            &files,
            PartitionExponentBase::new(1.3f32).unwrap(),
            "%Y-%m-%d",
        )
        .unwrap();
        assert_eq!(
            result.keep,
            vec![0, 1, 3, 5, 8, 11, 15, 20, 27, 36, 47]
                .into_iter()
                .map(|i| &files[i])
                .collect::<Vec<_>>()
        );
        assert_eq!(
            result.delete,
            vec![
                2, 4, 6, 7, 9, 10, 12, 13, 14, 16, 17, 18, 19, 21, 22, 23, 24, 25, 26, 28, 29, 30,
                31, 32, 33, 34, 35, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 48, 49, 50, 51, 52, 53,
                54, 55, 56, 57, 58, 59
            ]
            .into_iter()
            .map(|i| &files[i])
            .collect::<Vec<_>>()
        );
    }
}