Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
Add a function to programmatically modify the configuration
Merged did:key:z6MkgFWv...1Lu7 opened 1 year ago

Use new function to add commands for modifying configuration values.

Note that the changes will affect the configuration file, but not the running instances. For changes to be reflected in the running instances the node would need to be restarted.

4 files changed +499 -128 a838c3ea 0c9a7419
modified radicle-cli/examples/rad-config.md
@@ -68,3 +68,71 @@ 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
+
```
+

+
```
+
$ rad config add node.array a
+
a
+
$ rad config add node.array b
+
a
+
b
+
```
+

+
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: the element at the path 'node.alias' is not a JSON array
+
```
+

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,31 +10,28 @@
//!     node/
//!       control.sock                           # Node control socket
//!
-
use std::io::Write;
+

+
pub mod config;
+
pub use config::{Config, ConfigError, ConfigPath, TempConfig};
+

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

use localtime::LocalTime;
-
use serde::Serialize;
-
use serde_json as json;
use thiserror::Error;

use crate::crypto::ssh::agent::Agent;
use crate::crypto::ssh::{keystore, Keystore, Passphrase};
use crate::crypto::{PublicKey, Signer};
-
use crate::explorer::Explorer;
-
use crate::node::config::DefaultSeedingPolicy;
use crate::node::policy::config::store::Read;
use crate::node::{
-
    notifications, policy,
-
    policy::{Policy, Scope},
-
    Alias, AliasStore, Handle as _, Node, UserAgent,
+
    notifications, policy, policy::Scope, Alias, AliasStore, Handle as _, Node, UserAgent,
};
use crate::prelude::{Did, NodeId, RepoId};
use crate::storage::git::transport;
use crate::storage::git::Storage;
use crate::storage::ReadRepository;
-
use crate::{cli, cob, git, node, storage, web};
+
use crate::{cob, git, node, storage};

/// Environment variables used by radicle.
pub mod env {
@@ -186,105 +183,6 @@ pub enum Error {
    Storage(#[from] storage::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),
-
}
-

-
/// Local radicle configuration.
-
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
-
#[serde(rename_all = "camelCase")]
-
pub struct Config {
-
    /// Public explorer. This is used for generating links.
-
    #[serde(default)]
-
    pub public_explorer: Explorer,
-
    /// Preferred seeds. These seeds will be used for explorer links
-
    /// and in other situations when a seed needs to be chosen.
-
    #[serde(default)]
-
    pub preferred_seeds: Vec<node::config::ConnectAddress>,
-
    /// Web configuration.
-
    #[serde(default)]
-
    pub web: web::Config,
-
    /// CLI configuration.
-
    #[serde(default)]
-
    pub cli: cli::Config,
-
    /// Node configuration.
-
    pub node: node::Config,
-
}
-

-
impl Config {
-
    /// Create a new, default configuration.
-
    pub fn new(alias: Alias) -> Self {
-
        let node = node::Config::new(alias);
-

-
        Self {
-
            public_explorer: Explorer::default(),
-
            preferred_seeds: node.network.public_seeds(),
-
            web: web::Config::default(),
-
            cli: cli::Config::default(),
-
            node,
-
        }
-
    }
-

-
    /// Initialize a new configuration. Fails if the path already exists.
-
    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)),
-
        };
-

-
        // Handle deprecated policy configuration.
-
        // Nb. This will override "seedingPolicy" if set! This code should be removed after 1.0.
-
        if let (Some(p), Some(s)) = (cfg.node.extra.get("policy"), cfg.node.extra.get("scope")) {
-
            if let (Ok(policy), Ok(scope)) = (
-
                json::from_value::<Policy>(p.clone()),
-
                json::from_value::<Scope>(s.clone()),
-
            ) {
-
                log::warn!(target: "radicle", "Overwriting `seedingPolicy` configuration");
-
                cfg.node.seeding_policy = match policy {
-
                    Policy::Allow => DefaultSeedingPolicy::Allow { scope },
-
                    Policy::Block => DefaultSeedingPolicy::Block,
-
                }
-
            }
-
        }
-
        Ok(cfg)
-
    }
-

-
    /// Write configuration to disk.
-
    pub fn write(&self, path: &Path) -> Result<(), io::Error> {
-
        let mut file = fs::OpenOptions::new()
-
            .create_new(true)
-
            .write(true)
-
            .open(path)?;
-
        let formatter = json::ser::PrettyFormatter::with_indent(b"  ");
-
        let mut serializer = json::Serializer::with_formatter(&file, formatter);
-

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

-
        Ok(())
-
    }
-

-
    /// Get the user alias.
-
    pub fn alias(&self) -> &Alias {
-
        &self.node.alias
-
    }
-
}
-

#[derive(Debug, Clone)]
pub struct Profile {
    pub home: Home,
@@ -728,9 +626,12 @@ impl Home {
#[cfg(test)]
#[cfg(not(target_os = "macos"))]
mod test {
-
    use super::*;
    use std::fs;

+
    use serde_json as json;
+

+
    use super::*;
+

    // Checks that if we have:
    // '/run/user/1000/.tmpqfK6ih/../.tmpqfK6ih/Radicle/Home'
    //
added radicle/src/profile/config.rs
@@ -0,0 +1,352 @@
+
use std::io::Write;
+
use std::path::Path;
+
use std::{fmt, fs, io};
+

+
use serde::Serialize as _;
+
use serde_json as json;
+
use thiserror::Error;
+

+
use crate::explorer::Explorer;
+
use crate::node::config::DefaultSeedingPolicy;
+
use crate::node::policy::{Policy, Scope};
+
use crate::node::Alias;
+
use crate::{cli, node, web};
+

+
#[derive(Debug, Error)]
+
pub enum ConfigError {
+
    #[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.
+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
pub struct Config {
+
    /// Public explorer. This is used for generating links.
+
    #[serde(default)]
+
    pub public_explorer: Explorer,
+
    /// Preferred seeds. These seeds will be used for explorer links
+
    /// and in other situations when a seed needs to be chosen.
+
    #[serde(default)]
+
    pub preferred_seeds: Vec<node::config::ConnectAddress>,
+
    /// Web configuration.
+
    #[serde(default)]
+
    pub web: web::Config,
+
    /// CLI configuration.
+
    #[serde(default)]
+
    pub cli: cli::Config,
+
    /// Node configuration.
+
    pub node: node::Config,
+
}
+

+
impl Config {
+
    /// Create a new, default configuration.
+
    pub fn new(alias: Alias) -> Self {
+
        let node = node::Config::new(alias);
+

+
        Self {
+
            public_explorer: Explorer::default(),
+
            preferred_seeds: node.network.public_seeds(),
+
            web: web::Config::default(),
+
            cli: cli::Config::default(),
+
            node,
+
        }
+
    }
+

+
    /// Initialize a new configuration. Fails if the path already exists.
+
    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 = 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.
+
        if let (Some(p), Some(s)) = (cfg.node.extra.get("policy"), cfg.node.extra.get("scope")) {
+
            if let (Ok(policy), Ok(scope)) = (
+
                json::from_value::<Policy>(p.clone()),
+
                json::from_value::<Scope>(s.clone()),
+
            ) {
+
                log::warn!(target: "radicle", "Overwriting `seedingPolicy` configuration");
+
                cfg.node.seeding_policy = match policy {
+
                    Policy::Allow => DefaultSeedingPolicy::Allow { scope },
+
                    Policy::Block => DefaultSeedingPolicy::Block,
+
                }
+
            }
+
        }
+
        Ok(cfg)
+
    }
+

+
    /// Write configuration to disk.
+
    pub fn write(&self, path: &Path) -> Result<(), io::Error> {
+
        let mut file = fs::OpenOptions::new()
+
            .create_new(true)
+
            .write(true)
+
            .open(path)?;
+
        let formatter = json::ser::PrettyFormatter::with_indent(b"  ");
+
        let mut serializer = json::Serializer::with_formatter(&file, formatter);
+

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

+
        Ok(())
+
    }
+

+
    /// Get the user alias.
+
    pub fn alias(&self) -> &Alias {
+
        &self.node.alias
+
    }
+
}
+

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

+
#[derive(Debug, Error)]
+
pub enum ModifyError {
+
    #[error("the path provided was empty")]
+
    EmptyPath,
+
    #[error("could not find an element at the path '{path}'")]
+
    NotFound { path: ConfigPath },
+
    #[error("the element at the path '{path}' is not a JSON object")]
+
    NotObject { path: ConfigPath },
+
    #[error("the element at the path '{path}' is not a JSON array")]
+
    NotArray { path: ConfigPath },
+
    #[error("the parent element of '{key}' is not a JSON object")]
+
    Upsert { key: String },
+
}
+

+
/// 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, ModifyError> {
+
        let last = config_path.last().ok_or(ModifyError::EmptyPath)?;
+
        let parent = match config_path.parent() {
+
            Some(parent_path) => {
+
                self.get_mut(&parent_path)
+
                    .ok_or_else(|| ModifyError::NotFound {
+
                        path: config_path.clone(),
+
                    })?
+
            }
+
            None => &mut self.0,
+
        };
+

+
        parent
+
            .as_object_mut()
+
            .ok_or_else(|| ModifyError::NotObject {
+
                path: config_path.clone(),
+
            })?
+
            .remove(last);
+

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

+
    pub fn add(
+
        &mut self,
+
        config_path: &ConfigPath,
+
        value: ConfigValue,
+
    ) -> Result<json::Value, ModifyError> {
+
        if let Some(element) = self.get_mut(config_path) {
+
            let mut arr = element
+
                .as_array()
+
                .ok_or_else(|| ModifyError::NotArray {
+
                    path: config_path.clone(),
+
                })?
+
                .to_vec();
+
            arr.push(value.into());
+
            *element = json::Value::Array(arr);
+
            Ok(element.clone())
+
        } else {
+
            self.upsert(config_path, json::Value::Array(vec![value.into()]))
+
        }
+
    }
+

+
    pub fn remove(
+
        &mut self,
+
        config_path: &ConfigPath,
+
        value: ConfigValue,
+
    ) -> Result<json::Value, ModifyError> {
+
        let element = self
+
            .get_mut(config_path)
+
            .ok_or_else(|| ModifyError::NotFound {
+
                path: config_path.clone(),
+
            })?;
+
        let arr = element
+
            .as_array_mut()
+
            .ok_or_else(|| ModifyError::NotArray {
+
                path: config_path.clone(),
+
            })?;
+
        let value = json::Value::from(value);
+
        arr.retain(|el| el != &value);
+
        *element = json::Value::Array(arr.to_owned());
+
        Ok(element.clone())
+
    }
+

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

+
    /// Create an element at the given path, if it doesn't exist yet.
+
    fn upsert(
+
        &mut self,
+
        config_path: &ConfigPath,
+
        value: impl Into<json::Value>,
+
    ) -> Result<json::Value, ModifyError> {
+
        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!({}))
+
                }
+
                _ => return Err(ModifyError::Upsert { key: key.clone() }),
+
            }
+
        }
+

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

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

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

+
/// A struct that ensures all values are safe for JSON serialization, including handling special
+
/// floating point values like `NaN` and `Infinity`. Use the `From<&str>` implementation to create an instance.
+
pub struct ConfigValue(RawConfigValue);
+

+
/// This enum represents raw configuration values and should not be used directly.
+
/// Use the `ConfigValue` type, which validates values using its `From<&str>` implementation.
+
#[derive(Debug, Clone)]
+
enum RawConfigValue {
+
    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(RawConfigValue::Bool(b))
+
        } else if let Ok(n) = value.parse::<i64>() {
+
            ConfigValue(RawConfigValue::Integer(n))
+
        } else if let Ok(n) = value.parse::<f64>() {
+
            // NaN and Infinite can't be properly serialized to JSON
+
            if n.is_finite() {
+
                ConfigValue(RawConfigValue::Float(n))
+
            } else {
+
                ConfigValue(RawConfigValue::String(value.to_string()))
+
            }
+
        } else {
+
            ConfigValue(RawConfigValue::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(RawConfigValue::Bool(v)) => json::Value::Bool(v),
+
            ConfigValue(RawConfigValue::Integer(v)) => json::Value::Number(v.into()),
+
            ConfigValue(RawConfigValue::Float(v)) => {
+
                // Safety: ConfigValue ensures the Float won't be Infinite or NaN
+
                json::Value::Number(json::Number::from_f64(v).unwrap())
+
            }
+
            ConfigValue(RawConfigValue::String(v)) => json::Value::String(v),
+
        }
+
    }
+
}
+

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

+
impl ConfigPath {
+
    fn parent(&self) -> Option<Self> {
+
        self.0.split_last().map(|(_, tail)| Self(tail.to_vec()))
+
    }
+

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

+
    fn iter(&self) -> impl Iterator<Item = &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)
+
    }
+
}