putter 0.1.0

A tool to put files in the right place
Documentation
use serde::{Deserialize, Serialize};
use std::error::Error;
use std::io;
use std::time::SystemTime;

use std::path::{Path, PathBuf};

use std::collections::BTreeMap;

/// The versioned state injects a "version" field into the state object. This is
/// intended to be used to support migration of old state whenever the state
/// object is updated in a backwards incompatible way.
///
/// Note, this is intentionally not public, we will only export the latest
/// version. Users of the library that do not want to use the file based state
/// storage will have to implement their own migrations.
#[derive(Serialize, Deserialize)]
#[serde(tag = "version")]
pub(crate) enum FilesStateVersioned {
    #[serde(rename = "1")]
    V1(FilesState),
}

/// A description of the current state of applied files.
#[derive(Debug, PartialEq, Deserialize, Serialize)]
pub struct FilesState {
    /// The files moved into place during an application.
    pub files: BTreeMap<PathBuf, FileState>,
}

/// The state recorded for a single managed file.
#[derive(Debug, PartialEq, Deserialize, Serialize)]
pub struct FileState {
    /// Modification time stamp after moving into place.
    pub modified: SystemTime,
    /// The original source file path.
    pub source: PathBuf,
}

/// Possible errors when working with Putter file states.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum StateError {
    #[error("IO error")]
    IoError(#[from] io::Error),

    #[error("error loading file state database {0}")]
    LoadFail(PathBuf, Box<dyn Error + Send + Sync>),

    #[error("unable to write file state database {0}")]
    SaveFail(PathBuf, serde_json::Error),
}

impl FilesState {
    /// The empty file state, i.e., where no managed file is recorded.
    pub const EMPTY: FilesState = FilesState {
        files: BTreeMap::new(),
    };

    /// Load file state in JSON format from the given file path.
    pub fn load<P: AsRef<Path>>(path: P) -> Result<FilesState, StateError> {
        let path = path.as_ref();
        let file = std::fs::File::open(path)
            .map_err(|e| StateError::LoadFail(path.to_path_buf(), Box::new(e)))?;
        let reader = io::BufReader::new(file);
        let result: FilesStateVersioned = serde_json::from_reader(reader)
            .map_err(|e| StateError::LoadFail(path.to_path_buf(), Box::new(e)))?;
        match result {
            FilesStateVersioned::V1(r) => Ok(r),
        }
    }

    /// Save file state in JSON format to the given file path.
    pub fn save<P: AsRef<Path>>(self, path: P) -> Result<(), StateError> {
        let path = path.as_ref();
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }

        let file = std::fs::File::create(path)?;
        let writer = io::BufWriter::new(file);
        let state = FilesStateVersioned::V1(self);
        serde_json::to_writer_pretty(writer, &state)
            .map_err(|e| StateError::SaveFail(path.to_path_buf(), e))?;
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use assert_fs::prelude::{FileWriteStr, PathAssert, PathChild};

    #[test]
    fn load_from_json() -> anyhow::Result<()> {
        let dir = assert_fs::TempDir::new()?;
        let state_file = dir.child("state.json");

        state_file.write_str(
            r#"
{
  "version": "1",
  "files": {
    "/path/to/file": {
      "source": "/path/to/source",
      "modified": {
        "secs_since_epoch": 0,
        "nanos_since_epoch": 0
      }
    }
  }
}
"#,
        )?;

        let state = FilesState::load(state_file)?;

        let expected = FilesState {
            files: BTreeMap::from([(
                PathBuf::from("/path/to/file"),
                FileState {
                    modified: SystemTime::UNIX_EPOCH,
                    source: PathBuf::from("/path/to/source"),
                },
            )]),
        };

        assert_eq!(state, expected);

        dir.close()?;

        Ok(())
    }

    #[test]
    fn fail_load_from_missing_json() -> anyhow::Result<()> {
        let dir = assert_fs::TempDir::new()?;
        let state_file = dir.child("missing-state.json");

        let result = FilesState::load(state_file);

        assert!(matches!(result, Err(StateError::LoadFail(_, _))));

        dir.close()?;

        Ok(())
    }

    #[test]
    fn fail_load_from_invalid_json() -> anyhow::Result<()> {
        let dir = assert_fs::TempDir::new()?;
        let state_file = dir.child("invalid-state.json");

        state_file.write_str("bad json")?;

        let result = FilesState::load(state_file);

        assert!(matches!(result, Err(StateError::LoadFail(_, _))));

        dir.close()?;

        Ok(())
    }

    #[test]
    fn save_to_json() -> anyhow::Result<()> {
        let dir = assert_fs::TempDir::new()?;
        let state_file = dir.child("state.json");

        let state = FilesState {
            files: BTreeMap::from([(
                PathBuf::from("/path/to/file"),
                FileState {
                    modified: SystemTime::UNIX_EPOCH,
                    source: PathBuf::from("/path/to/source"),
                },
            )]),
        };

        state.save(&state_file)?;

        state_file.assert(
            r#"{
  "version": "1",
  "files": {
    "/path/to/file": {
      "modified": {
        "secs_since_epoch": 0,
        "nanos_since_epoch": 0
      },
      "source": "/path/to/source"
    }
  }
}"#,
        );

        dir.close()?;

        Ok(())
    }

    #[test]
    fn save_to_json_missing_parent_dir() -> anyhow::Result<()> {
        let dir = assert_fs::TempDir::new()?;
        let state_file = dir.child("parent/state.json");

        let state = FilesState {
            files: BTreeMap::from([(
                PathBuf::from("/path/to/file"),
                FileState {
                    modified: SystemTime::UNIX_EPOCH,
                    source: PathBuf::from("/path/to/source"),
                },
            )]),
        };

        state.save(&state_file)?;

        state_file.assert(
            r#"{
  "version": "1",
  "files": {
    "/path/to/file": {
      "modified": {
        "secs_since_epoch": 0,
        "nanos_since_epoch": 0
      },
      "source": "/path/to/source"
    }
  }
}"#,
        );

        dir.close()?;

        Ok(())
    }
}