ockam_api 0.48.0

Ockam's request-response API
Documentation
use crate::cli_state::{file_stem, CliState, CliStateError};
use fs2::FileExt;
use ockam_core::errcode::{Kind, Origin};
use ockam_core::{async_trait, Error};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

use super::Result;
pub const DATA_DIR_NAME: &str = "data";

/// Represents the directory of a type of state. This directory contains a list of items, uniquely
/// identified by a name, and represented by the same `Item` type.
///
/// One item can be set as the "default" item, which is used in some CLI commands when no
/// argument is provided for that type of `Item`.
#[async_trait]
pub trait StateDirTrait: Sized + Send + Sync {
    type Item: StateItemTrait;
    const DEFAULT_FILENAME: &'static str;
    const DIR_NAME: &'static str;
    const HAS_DATA_DIR: bool;

    fn new(root_path: &Path) -> Self;

    fn default_filename() -> &'static str {
        Self::DEFAULT_FILENAME
    }
    fn build_dir(root_path: &Path) -> PathBuf {
        root_path.join(Self::DIR_NAME)
    }
    fn has_data_dir() -> bool {
        Self::HAS_DATA_DIR
    }

    /// Load the root configuration
    /// and migrate each entry if necessary
    async fn init(root_path: &Path) -> Result<Self> {
        let root = Self::load(root_path)?;
        for path in root.list_items_paths()? {
            root.migrate(path.as_path()).await?;
        }
        Ok(root)
    }

    /// Do not run any migration by default
    async fn migrate(&self, _path: &Path) -> Result<()> {
        Ok(())
    }

    fn load(root_path: &Path) -> Result<Self> {
        Self::create_dirs(root_path)?;
        Ok(Self::new(root_path))
    }

    /// Recreate all the state directories
    fn reset(&self, root_path: &Path) -> Result<PathBuf> {
        Self::create_dirs(root_path)
    }

    /// Create all the state directories
    fn create_dirs(root_path: &Path) -> Result<PathBuf> {
        let dir = Self::build_dir(root_path);
        if Self::has_data_dir() {
            std::fs::create_dir_all(dir.join(DATA_DIR_NAME))?;
        } else {
            std::fs::create_dir_all(&dir)?;
        };
        Ok(dir)
    }

    fn dir(&self) -> &PathBuf;
    fn dir_as_string(&self) -> String {
        self.dir().to_string_lossy().to_string()
    }

    fn path(&self, name: impl AsRef<str>) -> PathBuf {
        self.dir().join(format!("{}.json", name.as_ref()))
    }

    fn overwrite(
        &self,
        name: impl AsRef<str>,
        config: <<Self as StateDirTrait>::Item as StateItemTrait>::Config,
    ) -> Result<Self::Item> {
        let path = self.path(&name);
        let state = with_lock(&path, || Self::Item::new(path.clone(), config))?;
        if !self.default_path()?.exists() {
            self.set_default(&name)?;
        }
        Ok(state)
    }

    fn create(
        &self,
        name: impl AsRef<str>,
        config: <<Self as StateDirTrait>::Item as StateItemTrait>::Config,
    ) -> Result<Self::Item> {
        debug!(name = %name.as_ref(), "Creating new config resource");
        if self.exists(&name) {
            return Err(CliStateError::AlreadyExists {
                resource: Self::default_filename().to_string(),
                name: name.as_ref().to_string(),
            });
        }
        trace!(name = %name.as_ref(), "Creating config resource instance");
        let state = Self::Item::new(self.path(&name), config)?;
        if !self.default_path()?.exists() {
            self.set_default(&name)?;
        }
        info!(name = %name.as_ref(), "Created new config resource");
        Ok(state)
    }

    fn get(&self, name: impl AsRef<str>) -> Result<Self::Item> {
        if !self.exists(&name) {
            return Err(CliStateError::ResourceNotFound {
                resource: Self::default_filename().to_string(),
                name: name.as_ref().to_string(),
            });
        }
        Self::Item::load(self.path(&name))
    }

    fn list(&self) -> Result<Vec<Self::Item>> {
        let mut items = Vec::default();
        for name in self.list_items_names()? {
            if let Ok(item) = self.get(name) {
                items.push(item);
            }
        }
        Ok(items)
    }

    fn list_items_names(&self) -> Result<Vec<String>> {
        let mut items = Vec::default();
        let iter = std::fs::read_dir(self.dir()).map_err(|e| {
            let dir = self.dir().as_path().to_string_lossy();
            error!(%dir, %e, "Unable to read state directory");
            CliStateError::InvalidOperation(format!("Unable to read state from directory {dir}"))
        })?;
        for entry in iter {
            let entry_path = entry?.path();
            if self.is_item_path(&entry_path)? {
                items.push(file_stem(&entry_path)?);
            }
        }
        Ok(items)
    }

    // If a path has been created with the self.path function
    // then we know that the current name is an item name
    fn is_item_path(&self, path: &PathBuf) -> Result<bool> {
        let name = file_stem(path)?;
        Ok(path.eq(&self.path(name)))
    }

    fn list_items_paths(&self) -> Result<Vec<PathBuf>> {
        let mut items = Vec::default();
        for name in self.list_items_names()? {
            let path = self.path(name);
            items.push(path);
        }
        Ok(items)
    }

    // TODO: move to StateItemTrait
    fn delete(&self, name: impl AsRef<str>) -> Result<()> {
        // Retrieve state. If doesn't exist do nothing.
        let s = match self.get(&name) {
            Ok(project) => project,
            Err(CliStateError::ResourceNotFound { .. }) => return Ok(()),
            Err(e) => return Err(e),
        };
        // If it's the default, remove link
        if let Ok(default) = self.default() {
            if default.path() == s.path() {
                let _ = std::fs::remove_file(self.default_path()?);
            }
        }
        // Remove state data
        s.delete()
    }

    fn default_path(&self) -> Result<PathBuf> {
        let root_path = self.dir().parent().expect("Should have parent");
        Ok(CliState::defaults_dir(root_path)?.join(Self::default_filename()))
    }

    fn default(&self) -> Result<Self::Item> {
        let path = std::fs::canonicalize(self.default_path()?)?;
        Self::Item::load(path)
    }

    fn set_default(&self, name: impl AsRef<str>) -> Result<()> {
        debug!(name = %name.as_ref(), "Setting default item");
        if !self.exists(&name) {
            return Err(CliStateError::ResourceNotFound {
                resource: Self::default_filename().to_string(),
                name: name.as_ref().to_string(),
            });
        }
        let original = self.path(&name);
        let link = self.default_path()?;
        info!("removing link {:?}", link);
        // Remove link if it exists
        let _ = std::fs::remove_file(&link);
        info!("symlink to {:?}", original);
        // Create link to the default item
        std::fs::create_dir_all(link.parent().unwrap())
            .map_err(|e| Error::new(Origin::Node, Kind::Io, e))?;
        std::os::unix::fs::symlink(original, link)?;
        info!(name = %name.as_ref(), "Set default item");
        Ok(())
    }

    fn is_default(&self, name: impl AsRef<str>) -> Result<bool> {
        if !self.exists(&name) {
            return Ok(false);
        }
        let default_name = {
            let path = std::fs::canonicalize(self.default_path()?)?;
            file_stem(&path)?
        };
        Ok(default_name.eq(name.as_ref()))
    }

    fn is_empty(&self) -> Result<bool> {
        for entry in std::fs::read_dir(self.dir())? {
            let name = file_stem(&entry?.path())?;
            if self.get(name).is_ok() {
                return Ok(false);
            }
        }
        Ok(true)
    }

    fn exists(&self, name: impl AsRef<str>) -> bool {
        self.path(&name).exists()
    }
}

/// This trait defines the methods to retrieve an item from a state directory.
/// The details of the item are defined in the `Config` type.
#[async_trait]
pub trait StateItemTrait: Sized + Send {
    type Config: Serialize + for<'a> Deserialize<'a> + Send;

    /// Create a new item with the given config.
    fn new(path: PathBuf, config: Self::Config) -> Result<Self>;

    /// Load an item from the given path.
    fn load(path: PathBuf) -> Result<Self>;

    /// Persist the item to disk after updating the config.
    fn persist(&self) -> Result<()> {
        with_lock(self.path(), || {
            let contents = serde_json::to_string(self.config())?;
            std::fs::write(self.path(), contents)?;
            Ok(())
        })
    }

    fn delete(&self) -> Result<()> {
        with_lock(self.path(), || {
            std::fs::remove_file(self.path())?;
            Ok(())
        })?;
        let _ = std::fs::remove_file(self.path().with_extension("lock"));
        Ok(())
    }

    fn path(&self) -> &PathBuf;
    fn config(&self) -> &Self::Config;
}

fn with_lock<T>(path: &Path, f: impl FnOnce() -> Result<T>) -> Result<T> {
    let lock_file = std::fs::OpenOptions::new()
        .write(true)
        .read(true)
        .create(true)
        .open(path.with_extension("lock"))?;
    lock_file.lock_exclusive()?;
    let res = f();
    lock_file.unlock()?;
    res
}

#[cfg(test)]
mod tests {
    use crate::cli_state::{StateDirTrait, StateItemTrait};
    use std::path::{Path, PathBuf};

    #[test]
    fn test_is_item_path() {
        let config = TestConfig::new(Path::new("dir"));
        let path = config.path("name");
        assert!(config.is_item_path(&path).unwrap())
    }

    /// Dummy configuration
    struct TestConfig {
        dir: PathBuf,
    }
    impl StateDirTrait for TestConfig {
        type Item = TestConfigItem;
        const DEFAULT_FILENAME: &'static str = "test";
        const DIR_NAME: &'static str = "test";
        const HAS_DATA_DIR: bool = false;

        fn new(root_path: &Path) -> Self {
            let dir = Self::build_dir(root_path);
            std::fs::create_dir_all(&dir).unwrap();
            Self { dir }
        }

        fn dir(&self) -> &PathBuf {
            &self.dir
        }
    }

    struct TestConfigItem {
        path: PathBuf,
        config: u32,
    }
    impl StateItemTrait for TestConfigItem {
        type Config = u32;

        fn new(path: PathBuf, config: Self::Config) -> crate::cli_state::Result<Self> {
            let contents = serde_json::to_string(&config)?;
            std::fs::write(&path, contents)?;
            Ok(Self { path, config })
        }

        fn load(path: PathBuf) -> crate::cli_state::Result<Self> {
            let contents = std::fs::read_to_string(&path)?;
            let config = serde_json::from_str(&contents)?;
            Ok(TestConfigItem { path, config })
        }

        fn path(&self) -> &PathBuf {
            &self.path
        }

        fn config(&self) -> &Self::Config {
            &self.config
        }
    }
}