Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
cli: Add a function to programmatically modify the configuration
Johannes Kuehlewindt committed 1 year ago
commit 89137954b96b921c8760cb989cad4f11d94e2bdf
parent 54551b117435a006acddefbf3850b02d6ee84ffa
3 files changed +378 -30
modified radicle-cli/examples/rad-config.md
@@ -68,3 +68,62 @@ z6MksmpU5b1dS7oaqF2bHXhQi1DWy2hB7Mh9CuN7y1DN6QSz@seed.radicle.xyz:8776
$ rad config get node.limits.routingMaxSize
1000
```
+

+
You can set scalar values by path.
+

+
```
+
$ rad config set node.alias bob
+
bob
+
```
+

+
You can add a value to a collection by path.
+

+
```
+
$ rad config add web.pinned.repositories rad:z3TajuiHXifEDEX4qbJxe8nXr9ufi
+
rad:z3TajuiHXifEDEX4qbJxe8nXr9ufi
+
```
+

+
You can remove a value from a collection by path.
+

+
```
+
$ rad config remove web.pinned.repositories rad:z3TajuiHXifEDEX4qbJxe8nXr9ufi
+
$ rad config get web.pinned.repositories
+
```
+

+
Values that are not strictly required for a working configuration, such as optional values or additional user-defined values, can be deleted.
+

+
```
+
$ rad config set web.name alice
+
alice
+
$ rad config delete web.name
+
```
+

+
Values along the path will be created if necessary.
+

+
```
+
$ rad config set value.a.future.update.might.add.value 5
+
5
+
$ rad config add value.a.future.update.might.add.collection 1
+
1
+
```
+

+
Values that are required for a valid config can't be deleted.
+

+
```fail
+
$ rad config delete node.alias
+
✗ Error: configuration JSON error: missing field `alias`
+
```
+

+
Values for changes are being validated.
+

+
```fail
+
$ rad config set web.pinned.repositories 5
+
✗ Error: configuration JSON error: invalid type: integer `5`, expected a sequence
+
```
+

+
The type of the operation is validated.
+

+
```fail
+
$ rad config add node.alias eve
+
✗ Error: configuration error: node.alias is not a collection, can't add to it
+
```
modified radicle-cli/src/commands/config.rs
@@ -6,7 +6,7 @@ use std::str::FromStr;

use anyhow::anyhow;
use radicle::node::Alias;
-
use radicle::profile::Config;
+
use radicle::profile::{Config, ConfigError, ConfigPath, TempConfig};

use crate::terminal as term;
use crate::terminal::args::{Args, Error, Help};
@@ -24,6 +24,10 @@ Usage
    rad config init --alias <alias> [<option>...]
    rad config edit [<option>...]
    rad config get <key> [<option>...]
+
    rad config set <key> <value> [<option>...]
+
    rad config add <key> <value> [<option>...]
+
    rad config remove <key> <value> [<option>...]
+
    rad config delete <key> [<option>...]

    If no argument is specified, prints the current radicle configuration as JSON.
    To initialize a new configuration file, use `rad config init`.
@@ -40,6 +44,10 @@ enum Operation {
    #[default]
    Show,
    Get(String),
+
    Set(String, String),
+
    Add(String, String),
+
    Remove(String, String),
+
    Delete(String),
    Init,
    Edit,
}
@@ -75,10 +83,38 @@ impl Args for Options {
                    "edit" => op = Some(Operation::Edit),
                    "init" => op = Some(Operation::Init),
                    "get" => {
+
                        let key = parser.value()?;
+
                        let key = key.to_string_lossy();
+
                        op = Some(Operation::Get(key.to_string()));
+
                    }
+
                    "set" => {
+
                        let key = parser.value()?;
+
                        let key = key.to_string_lossy();
                        let value = parser.value()?;
-
                        let key = value.to_string_lossy();
+
                        let value = value.to_string_lossy();

-
                        op = Some(Operation::Get(key.to_string()));
+
                        op = Some(Operation::Set(key.to_string(), value.to_string()));
+
                    }
+
                    "add" => {
+
                        let key = parser.value()?;
+
                        let key = key.to_string_lossy();
+
                        let value = parser.value()?;
+
                        let value = value.to_string_lossy();
+

+
                        op = Some(Operation::Add(key.to_string(), value.to_string()));
+
                    }
+
                    "remove" => {
+
                        let key = parser.value()?;
+
                        let key = key.to_string_lossy();
+
                        let value = parser.value()?;
+
                        let value = value.to_string_lossy();
+

+
                        op = Some(Operation::Remove(key.to_string(), value.to_string()));
+
                    }
+
                    "delete" => {
+
                        let key = parser.value()?;
+
                        let key = key.to_string_lossy();
+
                        op = Some(Operation::Delete(key.to_string()));
                    }
                    unknown => anyhow::bail!("unknown operation '{unknown}'"),
                },
@@ -106,11 +142,36 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            term::json::to_pretty(&profile.config, path.as_path())?.print();
        }
        Operation::Get(key) => {
-
            let profile = ctx.profile()?;
-
            let data = serde_json::to_value(profile.config)?;
-
            if let Some(value) = get_value(&data, &key) {
-
                print_value(value)?;
-
            }
+
            let mut temp_config = TempConfig::from_file(&path)?;
+
            let key: ConfigPath = key.into();
+
            let value = temp_config
+
                .get_mut(&key)
+
                .ok_or_else(|| ConfigError::Custom(format!("{key} does not exist")))?;
+
            print_value(value)?;
+
        }
+
        Operation::Set(key, value) => {
+
            let mut temp_config = TempConfig::from_file(&path)?;
+
            let value = temp_config.set(&key.into(), value.into())?;
+
            temp_config.write(&path)?;
+
            print_value(&value)?;
+
        }
+
        Operation::Add(key, value) => {
+
            let mut temp_config = TempConfig::from_file(&path)?;
+
            let value = temp_config.add(&key.into(), value.into())?;
+
            temp_config.write(&path)?;
+
            print_value(&value)?;
+
        }
+
        Operation::Remove(key, value) => {
+
            let mut temp_config = TempConfig::from_file(&path)?;
+
            let value = temp_config.remove(&key.into(), value.into())?;
+
            temp_config.write(&path)?;
+
            print_value(&value)?;
+
        }
+
        Operation::Delete(key) => {
+
            let mut temp_config = TempConfig::from_file(&path)?;
+
            let value = temp_config.delete(&key.into())?;
+
            temp_config.write(&path)?;
+
            print_value(&value)?;
        }
        Operation::Init => {
            if path.try_exists()? {
@@ -144,17 +205,6 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
    Ok(())
}

-
/// Get JSON value under a path.
-
fn get_value<'a>(data: &'a serde_json::Value, path: &'a str) -> Option<&'a serde_json::Value> {
-
    path.split('.').try_fold(data, |acc, key| {
-
        if let serde_json::Value::Object(obj) = acc {
-
            obj.get(key)
-
        } else {
-
            None
-
        }
-
    })
-
}
-

/// Print a JSON Value.
fn print_value(value: &serde_json::Value) -> anyhow::Result<()> {
    match value {
modified radicle/src/profile.rs
@@ -10,6 +10,7 @@
//!     node/
//!       control.sock                           # Node control socket
//!
+
use std::fmt;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::{fs, io};
@@ -188,10 +189,12 @@ pub enum Error {

#[derive(Debug, Error)]
pub enum ConfigError {
-
    #[error("failed to load configuration from {0}: {1}")]
-
    Io(PathBuf, io::Error),
-
    #[error("failed to load configuration from {0}: {1}")]
-
    Load(PathBuf, serde_json::Error),
+
    #[error("configuration I/O error: {0}")]
+
    Io(#[from] io::Error),
+
    #[error("configuration JSON error: {0}")]
+
    Json(#[from] json::Error),
+
    #[error("configuration error: {0}")]
+
    Custom(String),
}

/// Local radicle configuration.
@@ -233,18 +236,12 @@ impl Config {
    pub fn init(alias: Alias, path: &Path) -> io::Result<Self> {
        let cfg = Config::new(alias);
        cfg.write(path)?;
-

        Ok(cfg)
    }

    /// Load a configuration from the given path.
    pub fn load(path: &Path) -> Result<Self, ConfigError> {
-
        let mut cfg: Self = match fs::File::open(path) {
-
            Ok(cfg) => {
-
                json::from_reader(cfg).map_err(|e| ConfigError::Load(path.to_path_buf(), e))?
-
            }
-
            Err(e) => return Err(ConfigError::Io(path.to_path_buf(), e)),
-
        };
+
        let mut cfg: Self = json::from_reader(fs::File::open(path)?)?;

        // Handle deprecated policy configuration.
        // Nb. This will override "seedingPolicy" if set! This code should be removed after 1.0.
@@ -286,6 +283,248 @@ impl Config {
}

#[derive(Debug, Clone)]
+
pub struct TempConfig(json::Value);
+

+
/// Offers utility functions for editing the configuration. Validates on write.
+
impl TempConfig {
+
    /// Creates a temporary configuration, by reading a configuration file from disk.
+
    pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
+
        let file = fs::File::open(path)?;
+
        let config = json::from_reader(file)?;
+
        Ok(TempConfig(config))
+
    }
+

+
    /// Get a mutable reference to a configuration value by path, if it exists.
+
    pub fn get_mut(&mut self, config_path: &ConfigPath) -> Option<&mut json::Value> {
+
        config_path
+
            .iter()
+
            .try_fold(&mut self.0, |current, part| current.get_mut(part))
+
    }
+

+
    /// Delete the value at the the specified path.
+
    pub fn delete(&mut self, config_path: &ConfigPath) -> Result<json::Value, ConfigError> {
+
        let last = config_path.last().ok_or_else(|| {
+
            ConfigError::Custom(format!(
+
                "path {} is to short to identify an element to delete",
+
                &config_path
+
            ))
+
        })?;
+

+
        let parent = match config_path.parent() {
+
            Some(parent_path) => self.get_mut(&parent_path).ok_or_else(|| {
+
                ConfigError::Custom(format!(
+
                    "couldn't find an element at the path {}",
+
                    &config_path
+
                ))
+
            })?,
+
            None => &mut self.0,
+
        };
+

+
        parent
+
            .as_object_mut()
+
            .ok_or_else(|| {
+
                ConfigError::Custom(format!(
+
                    "path {} is not pointing to a valid deletion target",
+
                    &config_path,
+
                ))
+
            })?
+
            .remove(&last);
+

+
        Ok(json::Value::Null)
+
    }
+

+
    /// Create an element at the given path, if it doesn't exist yet.
+
    fn create_element(
+
        &mut self,
+
        config_path: &ConfigPath,
+
        value: ConfigValue,
+
    ) -> Result<json::Value, ConfigError> {
+
        let mut current = &mut self.0;
+

+
        for key in config_path.iter() {
+
            current = match current {
+
                json::Value::Object(ref mut map) => {
+
                    map.entry(key.clone()).or_insert_with(|| json::json!({}))
+
                }
+
                _ => Err(ConfigError::Custom(format!(
+
                    "parent of {key} is not an Object"
+
                )))?,
+
            }
+
        }
+

+
        *current = value.into();
+
        Ok(current.clone())
+
    }
+

+
    pub fn add(
+
        &mut self,
+
        config_path: &ConfigPath,
+
        value: ConfigValue,
+
    ) -> Result<json::Value, ConfigError> {
+
        if let Some(element) = self.get_mut(config_path) {
+
            let mut arr = element
+
                .as_array()
+
                .ok_or_else(|| {
+
                    ConfigError::Custom(format!(
+
                        "{} is not a collection, can't add to it",
+
                        &config_path
+
                    ))
+
                })?
+
                .to_vec();
+
            arr.push(value.into());
+
            *element = json::Value::Array(arr);
+
            Ok(element.clone())
+
        } else {
+
            self.create_element(config_path, value)
+
        }
+
    }
+

+
    pub fn remove(
+
        &mut self,
+
        config_path: &ConfigPath,
+
        value: ConfigValue,
+
    ) -> Result<json::Value, ConfigError> {
+
        if let Some(element) = self.get_mut(config_path) {
+
            let arr = element.as_array_mut().ok_or_else(|| {
+
                ConfigError::Custom(format!(
+
                    "{} is not a collection, can't remove from it",
+
                    &config_path
+
                ))
+
            })?;
+
            arr.retain(|el| {
+
                el != &<ConfigValue as std::convert::Into<json::Value>>::into(value.clone())
+
            });
+
            *element = json::Value::Array(arr.to_owned());
+
            Ok(element.clone())
+
        } else {
+
            Err(ConfigError::Custom(format!(
+
                "{} does not exist, can't remove from it",
+
                &config_path
+
            )))
+
        }
+
    }
+

+
    pub fn set(
+
        &mut self,
+
        config_path: &ConfigPath,
+
        value: ConfigValue,
+
    ) -> Result<json::Value, ConfigError> {
+
        if let Some(element) = self.get_mut(config_path) {
+
            *element = value.into();
+
            Ok(element.clone())
+
        } else {
+
            self.create_element(config_path, value)
+
        }
+
    }
+

+
    /// Writes the configuration, including extra values, to disk. Errors if the config is not
+
    /// valid.
+
    pub fn write(&self, path: &Path) -> Result<(), ConfigError> {
+
        let _valid_config: Config = self.clone().try_into()?;
+

+
        let mut file = fs::OpenOptions::new()
+
            .create(true)
+
            .write(true)
+
            .truncate(true)
+
            .open(path)?;
+
        let formatter = json::ser::PrettyFormatter::with_indent(b"  ");
+
        let mut serializer = json::Serializer::with_formatter(&file, formatter);
+

+
        self.0.serialize(&mut serializer)?;
+
        file.write_all(b"\n")?;
+
        file.sync_all()?;
+

+
        Ok(())
+
    }
+
}
+

+
impl TryInto<Config> for TempConfig {
+
    type Error = json::Error;
+

+
    fn try_into(self) -> Result<Config, Self::Error> {
+
        json::from_value(self.0)
+
    }
+
}
+

+
#[derive(Debug, Clone)]
+
pub enum ConfigValue {
+
    Integer(i64),
+
    Float(f64),
+
    Bool(bool),
+
    String(String),
+
}
+

+
impl From<&str> for ConfigValue {
+
    /// Guess the type of a Value.
+
    fn from(value: &str) -> Self {
+
        if let Ok(b) = value.parse::<bool>() {
+
            ConfigValue::Bool(b)
+
        } else if let Ok(n) = value.parse::<i64>() {
+
            ConfigValue::Integer(n)
+
        } else if let Ok(n) = value.parse::<f64>() {
+
            if n.is_finite() {
+
                ConfigValue::Float(n)
+
            } else {
+
                ConfigValue::String(n.to_string())
+
            }
+
        } else {
+
            ConfigValue::String(value.to_string())
+
        }
+
    }
+
}
+

+
impl From<String> for ConfigValue {
+
    fn from(value: String) -> Self {
+
        value.as_str().into()
+
    }
+
}
+

+
impl From<ConfigValue> for json::Value {
+
    fn from(value: ConfigValue) -> Self {
+
        match value {
+
            ConfigValue::Bool(v) => json::Value::Bool(v),
+
            ConfigValue::Integer(v) => json::Value::Number(v.into()),
+
            ConfigValue::Float(v) => {
+
                // Safety: ConfigValue ensures the Float won't be Infinite or NaN
+
                json::Value::Number(json::Number::from_f64(v).unwrap())
+
            }
+
            ConfigValue::String(v) => json::Value::String(v),
+
        }
+
    }
+
}
+

+
#[derive(Default, Debug, Clone)]
+
pub struct ConfigPath(Vec<String>);
+

+
impl ConfigPath {
+
    fn parent(&self) -> Option<Self> {
+
        let mut elements = self.0.clone();
+
        elements.pop().map(|_| Self(elements))
+
    }
+

+
    fn last(&self) -> Option<String> {
+
        self.0.last().cloned()
+
    }
+

+
    fn iter(&self) -> std::slice::Iter<'_, String> {
+
        self.0.iter()
+
    }
+
}
+

+
impl fmt::Display for ConfigPath {
+
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+
        write!(f, "{}", self.0.join("."))
+
    }
+
}
+

+
impl From<String> for ConfigPath {
+
    fn from(value: String) -> Self {
+
        let parts: Vec<String> = value.split('.').map(|s| s.to_string()).collect();
+
        ConfigPath(parts)
+
    }
+
}
+

+
#[derive(Debug, Clone)]
pub struct Profile {
    pub home: Home,
    pub storage: Storage,