rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 heartwooddae14f8b18582e650c17c12dd5a003e88cf1e307
{
"request": "trigger",
"version": 1,
"event_type": "patch",
"repository": {
"id": "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5",
"name": "heartwood",
"description": "Radicle Heartwood Protocol & Stack",
"private": false,
"default_branch": "master",
"delegates": [
"did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT",
"did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz"
]
},
"action": "Created",
"patch": {
"id": "af2103d6c857571a51a76590d3cb7e1712cecc73",
"author": {
"id": "did:key:z6MkgFWv1K9Lqvi5VHm4mtfDo5MvanWVNYU1gkvT9Rwr1Lu7",
"alias": "Kuehle"
},
"title": "Add a function to programmatically modify the configuration",
"state": {
"status": "merged",
"conflicts": []
},
"before": "54551b117435a006acddefbf3850b02d6ee84ffa",
"after": "dae14f8b18582e650c17c12dd5a003e88cf1e307",
"commits": [
"dae14f8b18582e650c17c12dd5a003e88cf1e307",
"0eb91d824c49201fc86751dbf9002e3949f12ab0",
"89137954b96b921c8760cb989cad4f11d94e2bdf"
],
"target": "919d5c780c4da0b5b9f18f1549bc59dffd7125af",
"labels": [],
"assignees": [],
"revisions": [
{
"id": "af2103d6c857571a51a76590d3cb7e1712cecc73",
"author": {
"id": "did:key:z6MkgFWv1K9Lqvi5VHm4mtfDo5MvanWVNYU1gkvT9Rwr1Lu7",
"alias": "Kuehle"
},
"description": "Use new function to add commands for modifying configuration values.\n\nNote that the changes will affect the configuration file, but not the\nrunning instances. For changes to be reflected in the running instances\nthe node would need to be restarted.",
"base": "54551b117435a006acddefbf3850b02d6ee84ffa",
"oid": "c872328206cd3a689303eb8a3ddaf933d7ce1034",
"timestamp": 1720957036
},
{
"id": "0cf08119da7545b68e9cd70b7c8594e2e045c6e6",
"author": {
"id": "did:key:z6MkgFWv1K9Lqvi5VHm4mtfDo5MvanWVNYU1gkvT9Rwr1Lu7",
"alias": "Kuehle"
},
"description": "Add a function to programmatically modify the configuration\n\nNote that the changes will affect the configuration file, but not the\nrunning instances. For changes to be reflected in the running instances\nthe node would need to be restarted.\nAdd commands for modifying configuration values.",
"base": "54551b117435a006acddefbf3850b02d6ee84ffa",
"oid": "e79eae5cbcf9cfc6d89b9283ead5fe527e99c862",
"timestamp": 1720976731
},
{
"id": "056c638e7037a82d3b3d1dcd1fd52a92bd17a1f2",
"author": {
"id": "did:key:z6MkgFWv1K9Lqvi5VHm4mtfDo5MvanWVNYU1gkvT9Rwr1Lu7",
"alias": "Kuehle"
},
"description": "Add programmatic config changes\n\nEnsure the config changes don't overwrite existing additional values.",
"base": "54551b117435a006acddefbf3850b02d6ee84ffa",
"oid": "62b5b1d1844d604a3c0bb4f90c6f6e255e9098f4",
"timestamp": 1721140116
},
{
"id": "e463769dcb37106070269ae15a6141ea15689cfd",
"author": {
"id": "did:key:z6MkgFWv1K9Lqvi5VHm4mtfDo5MvanWVNYU1gkvT9Rwr1Lu7",
"alias": "Kuehle"
},
"description": "Add a function to programmatically modify the configuration\n\nNote that the changes will affect the configuration file, but not the\nrunning instances. For changes to be reflected in the running instances\nthe node would need to be restarted.",
"base": "54551b117435a006acddefbf3850b02d6ee84ffa",
"oid": "11bc0cda33a677bd46ce62090bde90750ebb43ff",
"timestamp": 1721153183
},
{
"id": "2b3d51dad9d6073f8c53dca4eeab694d17cbdd12",
"author": {
"id": "did:key:z6MkgFWv1K9Lqvi5VHm4mtfDo5MvanWVNYU1gkvT9Rwr1Lu7",
"alias": "Kuehle"
},
"description": "",
"base": "54551b117435a006acddefbf3850b02d6ee84ffa",
"oid": "edf156a45bbc0602128f35644c1cce7d08d7fad4",
"timestamp": 1721153994
},
{
"id": "2aa69d827da8be686fda708003c1de9d4377858d",
"author": {
"id": "did:key:z6MkgFWv1K9Lqvi5VHm4mtfDo5MvanWVNYU1gkvT9Rwr1Lu7",
"alias": "Kuehle"
},
"description": "",
"base": "54551b117435a006acddefbf3850b02d6ee84ffa",
"oid": "bf50a8b2f000e0cdcf87c0bca6646b9271c37140",
"timestamp": 1721378650
},
{
"id": "7e6b4f40ab5ecb5e226d6d163af71e838ddf82fa",
"author": {
"id": "did:key:z6MkgFWv1K9Lqvi5VHm4mtfDo5MvanWVNYU1gkvT9Rwr1Lu7",
"alias": "Kuehle"
},
"description": "",
"base": "54551b117435a006acddefbf3850b02d6ee84ffa",
"oid": "7b8305257d74233c3fef5192787f1e6b80c7f417",
"timestamp": 1721423850
},
{
"id": "19ed0c708a13bf9195233866251ed985a8d86d40",
"author": {
"id": "did:key:z6MkgFWv1K9Lqvi5VHm4mtfDo5MvanWVNYU1gkvT9Rwr1Lu7",
"alias": "Kuehle"
},
"description": "",
"base": "54551b117435a006acddefbf3850b02d6ee84ffa",
"oid": "a266e989396f97f688b1301e60815e64b5ccd820",
"timestamp": 1721503357
},
{
"id": "a136a0eafa1c00d4f47710d31ba9f5f56fc9cb22",
"author": {
"id": "did:key:z6MkgFWv1K9Lqvi5VHm4mtfDo5MvanWVNYU1gkvT9Rwr1Lu7",
"alias": "Kuehle"
},
"description": "",
"base": "54551b117435a006acddefbf3850b02d6ee84ffa",
"oid": "74504594800a9bd89b112147769a662c2e992eb5",
"timestamp": 1721504381
},
{
"id": "e15a2b7c592d7bd1996d02241c1084b4c995aa99",
"author": {
"id": "did:key:z6MkgFWv1K9Lqvi5VHm4mtfDo5MvanWVNYU1gkvT9Rwr1Lu7",
"alias": "Kuehle"
},
"description": "",
"base": "54551b117435a006acddefbf3850b02d6ee84ffa",
"oid": "c969d83d07e857f1f32de731c03cee006cf76f1a",
"timestamp": 1721507846
},
{
"id": "74cc06848439fa857671470f2d97cd533969d5aa",
"author": {
"id": "did:key:z6MkgFWv1K9Lqvi5VHm4mtfDo5MvanWVNYU1gkvT9Rwr1Lu7",
"alias": "Kuehle"
},
"description": "",
"base": "54551b117435a006acddefbf3850b02d6ee84ffa",
"oid": "27e76c65b867f1bdec9e26bac5148f456c1088e3",
"timestamp": 1721508655
},
{
"id": "0bcf3c1a0e027a9de94ff224a9d9d48219be1de4",
"author": {
"id": "did:key:z6MkgFWv1K9Lqvi5VHm4mtfDo5MvanWVNYU1gkvT9Rwr1Lu7",
"alias": "Kuehle"
},
"description": "",
"base": "54551b117435a006acddefbf3850b02d6ee84ffa",
"oid": "e8e85dfdfc0d26963bab08f2c4c2a792f5daa6c0",
"timestamp": 1721509049
},
{
"id": "3c328a7b79605e29f6b34612a4bb8ca4e8be1451",
"author": {
"id": "did:key:z6MkgFWv1K9Lqvi5VHm4mtfDo5MvanWVNYU1gkvT9Rwr1Lu7",
"alias": "Kuehle"
},
"description": "Use `.iter()` instead of `.into_iter()` to avoid cloning.\n\nRemove Action design and offer methods directly.\n\nMove Validation to write method of TempConfig.\n\nUse functions directly in cli.",
"base": "54551b117435a006acddefbf3850b02d6ee84ffa",
"oid": "89137954b96b921c8760cb989cad4f11d94e2bdf",
"timestamp": 1722165756
},
{
"id": "1761e43ce39bb43c9cbf8799aaa9159f48d47fa7",
"author": {
"id": "did:key:z6MkgFWv1K9Lqvi5VHm4mtfDo5MvanWVNYU1gkvT9Rwr1Lu7",
"alias": "Kuehle"
},
"description": "WIP: code review\n\n* Moved config related code into a `config` module so that it's easier to work\n with\n* Created a `ModifyError` enum for handling the modification errors\n* Some wording changes on the error messages\n* There was a bug where `rad config add` of a non-existent path would not create\n an array. This was fixed and a test case was added\n* Changed `iter` to return the trait object instead\n* Changed `last` to return a reference\n* Use `split_last` to construct the `ConfigPath::parent`\n* Left a TODO since the API is technically unsafe since someone can construct a\n `ConfigValue::Float` with NaN if they construct it directly",
"base": "54551b117435a006acddefbf3850b02d6ee84ffa",
"oid": "0eb91d824c49201fc86751dbf9002e3949f12ab0",
"timestamp": 1722269595
},
{
"id": "97fc3acccadea93f22d52f11fd5575fb9304446a",
"author": {
"id": "did:key:z6MkgFWv1K9Lqvi5VHm4mtfDo5MvanWVNYU1gkvT9Rwr1Lu7",
"alias": "Kuehle"
},
"description": "",
"base": "54551b117435a006acddefbf3850b02d6ee84ffa",
"oid": "93022dd3abb67bce8ee5f6334a57aa8c958efe1a",
"timestamp": 1722862906
},
{
"id": "48a12786dc0d66cf861b378676390e9f5f7a248f",
"author": {
"id": "did:key:z6MkgFWv1K9Lqvi5VHm4mtfDo5MvanWVNYU1gkvT9Rwr1Lu7",
"alias": "Kuehle"
},
"description": "",
"base": "54551b117435a006acddefbf3850b02d6ee84ffa",
"oid": "37ad416bbfe7c7937d6f4090dc1ba696a81de460",
"timestamp": 1722863313
},
{
"id": "f7b19153443fb1005b9e692eeefc9b1c308258f9",
"author": {
"id": "did:key:z6MkgFWv1K9Lqvi5VHm4mtfDo5MvanWVNYU1gkvT9Rwr1Lu7",
"alias": "Kuehle"
},
"description": "",
"base": "54551b117435a006acddefbf3850b02d6ee84ffa",
"oid": "dae14f8b18582e650c17c12dd5a003e88cf1e307",
"timestamp": 1722863642
},
{
"id": "a18be31b1566204da940d0179e3c2e5b03656899",
"author": {
"id": "did:key:z6MkgFWv1K9Lqvi5VHm4mtfDo5MvanWVNYU1gkvT9Rwr1Lu7",
"alias": "Kuehle"
},
"description": "REVIEW\n\n- Use `term::args::string` to parse key/value inputs\n- No need to print anything when deleting a key\n- Print `null` value\n- Remove `last` and `parent` methods in favour of `split_last` method\n- General cleanup and formatting of docs\n- Leave FIXME for corner case of `ConfigPath` that may want to refer to a key\n with a `.` in it",
"base": "54551b117435a006acddefbf3850b02d6ee84ffa",
"oid": "41738fcd2ebc352aa9b826bf8727e054443dd008",
"timestamp": 1723207229
},
{
"id": "82adfa70baa417ef1ff7052cd8811ad046951b22",
"author": {
"id": "did:key:z6MkgFWv1K9Lqvi5VHm4mtfDo5MvanWVNYU1gkvT9Rwr1Lu7",
"alias": "Kuehle"
},
"description": "A few small improvements and changes in the API.\n\n`delete` -> `unset`\n`add` -> `push`",
"base": "a838c3ea01dc0741b22236c9d2aed7f6cb0c1d86",
"oid": "61bceaed534afddd215ce095cd8fd742adea02fd",
"timestamp": 1729696729
},
{
"id": "3e77a3df652daa0d6b3f94d5e0751d575bbdbd0e",
"author": {
"id": "did:key:z6MkgFWv1K9Lqvi5VHm4mtfDo5MvanWVNYU1gkvT9Rwr1Lu7",
"alias": "Kuehle"
},
"description": "Fix trailing line.",
"base": "a838c3ea01dc0741b22236c9d2aed7f6cb0c1d86",
"oid": "0c9a7419dcfd265b6c1efe85c915d126feaef649",
"timestamp": 1729696782
}
]
}
}
{
"response": "triggered",
"run_id": {
"id": "8099bb9c-e5bc-4211-8022-5c3b3846f48a"
},
"info_url": "https://cci.rad.levitte.org//8099bb9c-e5bc-4211-8022-5c3b3846f48a.html"
}
Started at: 2026-09-07 16:08:36.984466+02:00
Commands:
$ rad clone rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 .
✓ Creating checkout in ./...
✓ Remote cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT added
✓ Remote-tracking branch cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT/master created for z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT
✓ Remote fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM added
✓ Remote-tracking branch fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM/master created for z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM
✓ Remote lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz added
✓ Remote-tracking branch lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz/master created for z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz
✓ Repository successfully cloned under /opt/radcis/ci.rad.levitte.org/cci/state/8099bb9c-e5bc-4211-8022-5c3b3846f48a/w/
╭────────────────────────────────────╮
│ heartwood │
│ Radicle Heartwood Protocol & Stack │
│ 189 issues · 37 patches │
╰────────────────────────────────────╯
Run `cd ./.` to go to the repository directory.
Exit code: 0
$ rad patch checkout af2103d6c857571a51a76590d3cb7e1712cecc73
✓ Switched to branch patch/af2103d at revision f7b1915
✓ Branch patch/af2103d setup to track rad/patches/af2103d6c857571a51a76590d3cb7e1712cecc73
Exit code: 0
$ git config advice.detachedHead false
Exit code: 0
$ git checkout dae14f8b18582e650c17c12dd5a003e88cf1e307
HEAD is now at dae14f8b Encapsulate config value behaviour
Exit code: 0
$ rad patch show af2103d6c857571a51a76590d3cb7e1712cecc73 -p
╭────────────────────────────────────────────────────────────────────────────────╮
│ Title Add a function to programmatically modify the configuration │
│ Patch af2103d6c857571a51a76590d3cb7e1712cecc73 │
│ Author Kuehle z6MkgFW…Rwr1Lu7 │
│ Head dae14f8b18582e650c17c12dd5a003e88cf1e307 │
│ Base 54551b117435a006acddefbf3850b02d6ee84ffa │
│ Target master │
│ Branches patch/af2103d │
│ Commits ahead 3, behind 1250 │
│ Status merged │
│ │
│ 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. │
├────────────────────────────────────────────────────────────────────────────────┤
│ dae14f8 Encapsulate config value behaviour │
│ 0eb91d8 WIP: code review │
│ 8913795 cli: Add a function to programmatically modify the configuration │
├────────────────────────────────────────────────────────────────────────────────┤
│ ● Revision af2103d @ 54551b1..c872328 by Kuehle z6MkgFW…Rwr1Lu7 2 years ago │
│ ↑ Revision 0cf0811 @ 54551b1..e79eae5 by Kuehle z6MkgFW…Rwr1Lu7 2 years ago │
│ ↑ Revision 056c638 @ 54551b1..62b5b1d by Kuehle z6MkgFW…Rwr1Lu7 2 years ago │
│ ↑ Revision e463769 @ 54551b1..11bc0cd by Kuehle z6MkgFW…Rwr1Lu7 2 years ago │
│ ↑ Revision 2b3d51d @ 54551b1..edf156a by Kuehle z6MkgFW…Rwr1Lu7 2 years ago │
│ ↑ Revision 2aa69d8 @ 54551b1..bf50a8b by Kuehle z6MkgFW…Rwr1Lu7 2 years ago │
│ ↑ Revision 7e6b4f4 @ 54551b1..7b83052 by Kuehle z6MkgFW…Rwr1Lu7 2 years ago │
│ ↑ Revision 19ed0c7 @ 54551b1..a266e98 by Kuehle z6MkgFW…Rwr1Lu7 2 years ago │
│ ↑ Revision a136a0e @ 54551b1..7450459 by Kuehle z6MkgFW…Rwr1Lu7 2 years ago │
│ ↑ Revision e15a2b7 @ 54551b1..c969d83 by Kuehle z6MkgFW…Rwr1Lu7 2 years ago │
│ ↑ Revision 74cc068 @ 54551b1..27e76c6 by Kuehle z6MkgFW…Rwr1Lu7 2 years ago │
│ ↑ Revision 0bcf3c1 @ 54551b1..e8e85df by Kuehle z6MkgFW…Rwr1Lu7 2 years ago │
│ ↑ Revision 3c328a7 @ 54551b1..8913795 by Kuehle z6MkgFW…Rwr1Lu7 2 years ago │
│ ↑ Revision 1761e43 @ 54551b1..0eb91d8 by fintohaps z6Mkire…SQZ3voM 2 years ago │
│ ↑ Revision 97fc3ac @ 54551b1..93022dd by Kuehle z6MkgFW…Rwr1Lu7 2 years ago │
│ ↑ Revision 48a1278 @ 54551b1..37ad416 by Kuehle z6MkgFW…Rwr1Lu7 2 years ago │
│ ↑ Revision f7b1915 @ 54551b1..dae14f8 by Kuehle z6MkgFW…Rwr1Lu7 2 years ago │
│ ↑ Revision a18be31 @ 54551b1..41738fc by fintohaps z6Mkire…SQZ3voM 2 years ago │
│ ↑ Revision 82adfa7 @ a838c3e..61bceae by cloudhead z6MksFq…bS9wzpT 1 year ago │
│ ↑ Revision 3e77a3d @ a838c3e..0c9a741 by cloudhead z6MksFq…bS9wzpT 1 year ago │
│ └─ ✓ merged by cloudhead z6MksFq…bS9wzpT │
╰────────────────────────────────────────────────────────────────────────────────╯
commit dae14f8b18582e650c17c12dd5a003e88cf1e307
Author: Johannes Kühlewindt <johannes.kuehlewindt@gmail.com>
Date: Mon Aug 5 14:59:23 2024 +0200
Encapsulate config value behaviour
diff --git a/radicle/src/profile/config.rs b/radicle/src/profile/config.rs
index 84c864686..d575a6c53 100644
--- a/radicle/src/profile/config.rs
+++ b/radicle/src/profile/config.rs
@@ -267,9 +267,14 @@ impl TryInto<Config> for TempConfig {
}
}
-// TODO: this being pub means one can construct an invalid `ConfigValue::Float`
+/// 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)]
-pub enum ConfigValue {
+enum RawConfigValue {
Integer(i64),
Float(f64),
Bool(bool),
@@ -280,17 +285,18 @@ 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)
+ ConfigValue(RawConfigValue::Bool(b))
} else if let Ok(n) = value.parse::<i64>() {
- ConfigValue::Integer(n)
+ 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::Float(n)
+ ConfigValue(RawConfigValue::Float(n))
} else {
- ConfigValue::String(n.to_string())
+ ConfigValue(RawConfigValue::String(value.to_string()))
}
} else {
- ConfigValue::String(value.to_string())
+ ConfigValue(RawConfigValue::String(value.to_string()))
}
}
}
@@ -304,13 +310,13 @@ impl From<String> for ConfigValue {
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) => {
+ 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::String(v) => json::Value::String(v),
+ ConfigValue(RawConfigValue::String(v)) => json::Value::String(v),
}
}
}
commit 0eb91d824c49201fc86751dbf9002e3949f12ab0
Author: Fintan Halpenny <fintan.halpenny@gmail.com>
Date: Mon Jul 29 18:06:13 2024 +0200
WIP: code review
* Moved config related code into a `config` module so that it's easier to work
with
* Created a `ModifyError` enum for handling the modification errors
* Some wording changes on the error messages
* There was a bug where `rad config add` of a non-existent path would not create
an array. This was fixed and a test case was added
* Changed `iter` to return the trait object instead
* Changed `last` to return a reference
* Use `split_last` to construct the `ConfigPath::parent`
* Left a TODO since the API is technically unsafe since someone can construct a
`ConfigValue::Float` with NaN if they construct it directly
diff --git a/radicle-cli/examples/rad-config.md b/radicle-cli/examples/rad-config.md
index 29252a64f..b7b0c26eb 100644
--- a/radicle-cli/examples/rad-config.md
+++ b/radicle-cli/examples/rad-config.md
@@ -107,6 +107,14 @@ $ 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
@@ -125,5 +133,6 @@ 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
+✗ Error: the element at the path 'node.alias' is not a JSON array
```
+
diff --git a/radicle/src/profile.rs b/radicle/src/profile.rs
index f25a8e25a..fa8e89de0 100644
--- a/radicle/src/profile.rs
+++ b/radicle/src/profile.rs
@@ -10,32 +10,28 @@
//! node/
//! control.sock # Node control socket
//!
-use std::fmt;
-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 {
@@ -187,343 +183,6 @@ pub enum Error {
Storage(#[from] storage::Error),
}
-#[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);
-
-/// 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,
@@ -967,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'
//
diff --git a/radicle/src/profile/config.rs b/radicle/src/profile/config.rs
new file mode 100644
index 000000000..84c864686
--- /dev/null
+++ b/radicle/src/profile/config.rs
@@ -0,0 +1,346 @@
+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)
+ }
+}
+
+// TODO: this being pub means one can construct an invalid `ConfigValue::Float`
+#[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> {
+ 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)
+ }
+}
commit 89137954b96b921c8760cb989cad4f11d94e2bdf
Author: Johannes Kuehlewindt <johannes.kuehlewindt@gmail.com>
Date: Sat Jul 13 13:37:15 2024 +0200
cli: Add a function to programmatically modify the configuration
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.
diff --git a/radicle-cli/examples/rad-config.md b/radicle-cli/examples/rad-config.md
index e47467da6..29252a64f 100644
--- a/radicle-cli/examples/rad-config.md
+++ b/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
+```
diff --git a/radicle-cli/src/commands/config.rs b/radicle-cli/src/commands/config.rs
index 568a70891..8ee40d8e8 100644
--- a/radicle-cli/src/commands/config.rs
+++ b/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 {
diff --git a/radicle/src/profile.rs b/radicle/src/profile.rs
index 08ef97e75..f25a8e25a 100644
--- a/radicle/src/profile.rs
+++ b/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.
@@ -285,6 +282,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,
Exit code: 0
shell: 'cargo --version rustc --version cargo fmt --check cargo clippy --all-targets --workspace -- --deny clippy::all cargo build --all-targets --workspace cargo doc --workspace cargo test --workspace --no-fail-fast '
Commands:
$ podman run --name 8099bb9c-e5bc-4211-8022-5c3b3846f48a -v /opt/radcis/ci.rad.levitte.org/cci/state/8099bb9c-e5bc-4211-8022-5c3b3846f48a/s:/8099bb9c-e5bc-4211-8022-5c3b3846f48a/s:ro -v /opt/radcis/ci.rad.levitte.org/cci/state/8099bb9c-e5bc-4211-8022-5c3b3846f48a/w:/8099bb9c-e5bc-4211-8022-5c3b3846f48a/w -w /8099bb9c-e5bc-4211-8022-5c3b3846f48a/w -v /opt/radcis/ci.rad.levitte.org/.radicle:/${id}/.radicle:ro -e RAD_HOME=/${id}/.radicle rust:trixie bash /8099bb9c-e5bc-4211-8022-5c3b3846f48a/s/script.sh
+ cargo --version
info: syncing channel updates for '1.77-x86_64-unknown-linux-gnu'
info: latest update on 2024-04-09, rust version 1.77.2 (25ef9e3d8 2024-04-09)
info: downloading component 'cargo'
info: downloading component 'rust-std'
info: downloading component 'rustc'
info: installing component 'cargo'
info: installing component 'rust-std'
info: installing component 'rustc'
cargo 1.77.2 (e52e36006 2024-03-26)
+ rustc --version
rustc 1.77.2 (25ef9e3d8 2024-04-09)
+ cargo fmt --check
error: 'cargo-fmt' is not installed for the toolchain '1.77-x86_64-unknown-linux-gnu'.
To install, run `rustup component add --toolchain 1.77-x86_64-unknown-linux-gnu rustfmt`
Exit code: 1
{
"response": "finished",
"result": "failure"
}