Skip to main content

rns_ctl/cmd/
config.rs

1//! Inspect and update runtime configuration on a running daemon.
2
3use std::path::Path;
4use std::process;
5
6use crate::args::Args;
7use rns_net::config;
8use rns_net::pickle::PickleValue;
9use rns_net::rpc::derive_auth_key;
10use rns_net::storage;
11use rns_net::{RpcAddr, RpcClient};
12use serde_json::{json, Value};
13
14pub fn run(args: Args) {
15    if args.has("version") {
16        println!("rns-ctl {}", env!("FULL_VERSION"));
17        return;
18    }
19
20    if args.has("help") || args.positional.is_empty() {
21        print_usage();
22        return;
23    }
24
25    let json_output = args.has("j") || args.has("json");
26    let value_only = args.has("value-only");
27    let keys_only = args.has("keys-only");
28    let action = args
29        .positional
30        .first()
31        .map(|s| s.as_str())
32        .unwrap_or_default();
33
34    let mut client = connect(args.config_path());
35
36    match action {
37        "list" => {
38            let response = rpc_call(
39                &mut client,
40                PickleValue::Dict(vec![(
41                    PickleValue::String("get".into()),
42                    PickleValue::String("runtime_config".into()),
43                )]),
44            );
45            let response = if let Some(prefix) = args.get("prefix") {
46                filter_list_by_prefix(response, prefix)
47            } else {
48                response
49            };
50            if json_output {
51                println!(
52                    "{}",
53                    serde_json::to_string_pretty(&pickle_to_json(&response)).unwrap_or_default()
54                );
55            } else {
56                print_list(&response, keys_only);
57            }
58        }
59        "get" => {
60            let key = match args.positional.get(1) {
61                Some(key) => key,
62                None => {
63                    eprintln!("Missing runtime-config key");
64                    process::exit(1);
65                }
66            };
67            let response = rpc_call(
68                &mut client,
69                PickleValue::Dict(vec![
70                    (
71                        PickleValue::String("get".into()),
72                        PickleValue::String("runtime_config_entry".into()),
73                    ),
74                    (
75                        PickleValue::String("key".into()),
76                        PickleValue::String(key.clone()),
77                    ),
78                ]),
79            );
80            if json_output {
81                println!(
82                    "{}",
83                    serde_json::to_string_pretty(&pickle_to_json(&response)).unwrap_or_default()
84                );
85            } else {
86                print_entry_or_none(&response, key, value_only);
87            }
88        }
89        "set" => {
90            let key = match args.positional.get(1) {
91                Some(key) => key,
92                None => {
93                    eprintln!("Missing runtime-config key");
94                    process::exit(1);
95                }
96            };
97            let raw_value = match args.positional.get(2) {
98                Some(value) => value,
99                None => {
100                    eprintln!("Missing runtime-config value");
101                    process::exit(1);
102                }
103            };
104            let response = rpc_call(
105                &mut client,
106                PickleValue::Dict(vec![
107                    (
108                        PickleValue::String("set".into()),
109                        PickleValue::String("runtime_config".into()),
110                    ),
111                    (
112                        PickleValue::String("key".into()),
113                        PickleValue::String(key.clone()),
114                    ),
115                    (
116                        PickleValue::String("value".into()),
117                        parse_scalar_value(raw_value),
118                    ),
119                ]),
120            );
121            handle_mutation_response(&response, json_output, value_only);
122        }
123        "reset" => {
124            let key = match args.positional.get(1) {
125                Some(key) => key,
126                None => {
127                    eprintln!("Missing runtime-config key");
128                    process::exit(1);
129                }
130            };
131            let response = rpc_call(
132                &mut client,
133                PickleValue::Dict(vec![
134                    (
135                        PickleValue::String("reset".into()),
136                        PickleValue::String("runtime_config".into()),
137                    ),
138                    (
139                        PickleValue::String("key".into()),
140                        PickleValue::String(key.clone()),
141                    ),
142                ]),
143            );
144            handle_mutation_response(&response, json_output, value_only);
145        }
146        _ => {
147            eprintln!("Unknown config subcommand: {}", action);
148            print_usage();
149            process::exit(1);
150        }
151    }
152}
153
154fn connect(config_path: Option<&str>) -> RpcClient {
155    let config_dir = storage::resolve_config_dir(config_path.map(Path::new));
156    let config_file = config_dir.join("config");
157    let rns_config = if config_file.exists() {
158        match config::parse_file(&config_file) {
159            Ok(c) => c,
160            Err(e) => {
161                eprintln!("Error reading config: {}", e);
162                process::exit(1);
163            }
164        }
165    } else {
166        match config::parse("") {
167            Ok(c) => c,
168            Err(e) => {
169                eprintln!("Error: {}", e);
170                process::exit(1);
171            }
172        }
173    };
174
175    let paths = match storage::ensure_storage_dirs(&config_dir) {
176        Ok(p) => p,
177        Err(e) => {
178            eprintln!("Error: {}", e);
179            process::exit(1);
180        }
181    };
182
183    let identity = match storage::load_or_create_identity(&paths.identities) {
184        Ok(id) => id,
185        Err(e) => {
186            eprintln!("Error loading identity: {}", e);
187            process::exit(1);
188        }
189    };
190
191    let auth_key = derive_auth_key(&identity.get_private_key().unwrap_or([0u8; 64]));
192    let rpc_addr = RpcAddr::Tcp(
193        "127.0.0.1".into(),
194        rns_config.reticulum.instance_control_port,
195    );
196    match RpcClient::connect(&rpc_addr, &auth_key) {
197        Ok(client) => client,
198        Err(e) => {
199            eprintln!("Could not connect to rnsd: {}", e);
200            eprintln!("Is rnsd running?");
201            process::exit(1);
202        }
203    }
204}
205
206fn rpc_call(client: &mut RpcClient, request: PickleValue) -> PickleValue {
207    match client.call(&request) {
208        Ok(response) => response,
209        Err(e) => {
210            eprintln!("RPC error: {}", e);
211            process::exit(1);
212        }
213    }
214}
215
216fn parse_scalar_value(raw: &str) -> PickleValue {
217    match raw {
218        raw if raw.eq_ignore_ascii_case("null") => PickleValue::None,
219        raw if raw.eq_ignore_ascii_case("none") => PickleValue::None,
220        raw if raw.eq_ignore_ascii_case("true") => PickleValue::Bool(true),
221        raw if raw.eq_ignore_ascii_case("false") => PickleValue::Bool(false),
222        _ => {
223            if let Ok(v) = raw.parse::<i64>() {
224                PickleValue::Int(v)
225            } else if let Ok(v) = raw.parse::<f64>() {
226                PickleValue::Float(v)
227            } else {
228                PickleValue::String(raw.to_string())
229            }
230        }
231    }
232}
233
234fn print_list(response: &PickleValue, keys_only: bool) {
235    let Some(entries) = response.as_list() else {
236        eprintln!("Unexpected response");
237        process::exit(1);
238    };
239    let mut sorted_entries: Vec<&PickleValue> = entries.iter().collect();
240    sorted_entries.sort_by(|a, b| {
241        let akey = a.get("key").and_then(|v| v.as_str()).unwrap_or_default();
242        let bkey = b.get("key").and_then(|v| v.as_str()).unwrap_or_default();
243        akey.cmp(bkey)
244    });
245
246    if sorted_entries.is_empty() {
247        println!("No runtime config entries");
248        return;
249    }
250
251    if keys_only {
252        for entry in sorted_entries {
253            println!(
254                "{}",
255                entry
256                    .get("key")
257                    .and_then(|v| v.as_str())
258                    .unwrap_or("<unknown>")
259            );
260        }
261        return;
262    }
263
264    println!(
265        "{:<52} {:<16} {:<17} {:<20}",
266        "Key", "Value", "Source", "Apply"
267    );
268    println!("{}", "-".repeat(110));
269    for entry in sorted_entries {
270        print_list_entry(entry);
271    }
272}
273
274fn print_entry_or_none(response: &PickleValue, key: &str, value_only: bool) {
275    if matches!(response, PickleValue::None) {
276        println!("No runtime config entry for {}", key);
277        return;
278    }
279    print_entry(response, value_only);
280}
281
282fn filter_list_by_prefix(response: PickleValue, prefix: &str) -> PickleValue {
283    match response {
284        PickleValue::List(entries) => PickleValue::List(
285            entries
286                .into_iter()
287                .filter(|entry| {
288                    entry
289                        .get("key")
290                        .and_then(|v| v.as_str())
291                        .map(|key| key.starts_with(prefix))
292                        .unwrap_or(false)
293                })
294                .collect(),
295        ),
296        other => other,
297    }
298}
299
300fn handle_mutation_response(response: &PickleValue, json_output: bool, value_only: bool) {
301    if json_output {
302        println!(
303            "{}",
304            serde_json::to_string_pretty(&pickle_to_json(response)).unwrap_or_default()
305        );
306    } else if response.get("error").is_some() {
307        let message = response
308            .get("message")
309            .and_then(|v| v.as_str())
310            .unwrap_or("unknown runtime-config error");
311        eprintln!("{}", message);
312        process::exit(1);
313    } else {
314        print_entry(response, value_only);
315    }
316}
317
318fn print_list_entry(entry: &PickleValue) {
319    let key = entry
320        .get("key")
321        .and_then(|v| v.as_str())
322        .unwrap_or("<unknown>");
323    let value = format_pickle_scalar(entry.get("value").unwrap_or(&PickleValue::None));
324    let source = entry
325        .get("source")
326        .and_then(|v| v.as_str())
327        .unwrap_or("unknown");
328    let apply_mode = entry
329        .get("apply_mode")
330        .and_then(|v| v.as_str())
331        .unwrap_or("unknown");
332    println!(
333        "{:<52} {:<16} {:<17} {:<20}",
334        key, value, source, apply_mode
335    );
336}
337
338fn print_entry(entry: &PickleValue, value_only: bool) {
339    if value_only {
340        println!(
341            "{}",
342            format_pickle_scalar(entry.get("value").unwrap_or(&PickleValue::None))
343        );
344        return;
345    }
346
347    let key = entry
348        .get("key")
349        .and_then(|v| v.as_str())
350        .unwrap_or("<unknown>");
351    let value = format_pickle_scalar(entry.get("value").unwrap_or(&PickleValue::None));
352    let default = format_pickle_scalar(entry.get("default").unwrap_or(&PickleValue::None));
353    let source = entry
354        .get("source")
355        .and_then(|v| v.as_str())
356        .unwrap_or("unknown");
357    let apply_mode = entry
358        .get("apply_mode")
359        .and_then(|v| v.as_str())
360        .unwrap_or("unknown");
361    println!(
362        "{} = {}  [default: {}, source: {}, apply: {}]",
363        key, value, default, source, apply_mode
364    );
365    if let Some(description) = entry.get("description").and_then(|v| v.as_str()) {
366        println!("  {}", description);
367    }
368}
369
370fn format_pickle_scalar(value: &PickleValue) -> String {
371    match value {
372        PickleValue::None => "null".into(),
373        PickleValue::Bool(v) => v.to_string(),
374        PickleValue::Int(v) => v.to_string(),
375        PickleValue::Float(v) => v.to_string(),
376        PickleValue::String(v) => v.clone(),
377        _ => "<complex>".into(),
378    }
379}
380
381fn pickle_to_json(value: &PickleValue) -> Value {
382    match value {
383        PickleValue::None => Value::Null,
384        PickleValue::Bool(v) => json!(v),
385        PickleValue::Int(v) => json!(v),
386        PickleValue::Float(v) => json!(v),
387        PickleValue::String(v) => json!(v),
388        PickleValue::Bytes(v) => json!(v),
389        PickleValue::List(values) => Value::Array(values.iter().map(pickle_to_json).collect()),
390        PickleValue::Dict(pairs) => {
391            let mut obj = serde_json::Map::new();
392            for (k, v) in pairs {
393                let key = match k {
394                    PickleValue::String(s) => s.clone(),
395                    _ => format!("{:?}", k),
396                };
397                obj.insert(key, pickle_to_json(v));
398            }
399            Value::Object(obj)
400        }
401    }
402}
403
404fn print_usage() {
405    println!("Usage: rns-ctl config <COMMAND> [OPTIONS]");
406    println!();
407    println!("Commands:");
408    println!("    list [--prefix PREFIX]      List supported runtime config entries");
409    println!("    get <key>                   Get one runtime config entry");
410    println!("    set <key> <value>           Set one runtime config value");
411    println!("    reset <key>                 Reset one runtime config value");
412    println!();
413    println!("Options:");
414    println!("    -c, --config PATH           Config directory");
415    println!("    -j, --json                  JSON output");
416    println!("        --keys-only             Print only keys for list");
417    println!("        --value-only            Print only the effective value");
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    #[test]
425    fn parse_scalar_value_handles_case_insensitive_bools() {
426        assert_eq!(parse_scalar_value("TRUE"), PickleValue::Bool(true));
427        assert_eq!(parse_scalar_value("False"), PickleValue::Bool(false));
428    }
429
430    #[test]
431    fn parse_scalar_value_handles_null_aliases() {
432        assert_eq!(parse_scalar_value("null"), PickleValue::None);
433        assert_eq!(parse_scalar_value("NONE"), PickleValue::None);
434    }
435
436    #[test]
437    fn parse_scalar_value_prefers_int_over_float() {
438        assert_eq!(parse_scalar_value("42"), PickleValue::Int(42));
439        assert_eq!(parse_scalar_value("4.25"), PickleValue::Float(4.25));
440    }
441
442    #[test]
443    fn filter_list_by_prefix_keeps_matching_keys() {
444        let response = PickleValue::List(vec![
445            PickleValue::Dict(vec![(
446                PickleValue::String("key".into()),
447                PickleValue::String("global.tick_interval_ms".into()),
448            )]),
449            PickleValue::Dict(vec![(
450                PickleValue::String("key".into()),
451                PickleValue::String("backbone.public.idle_timeout_secs".into()),
452            )]),
453        ]);
454
455        let filtered = filter_list_by_prefix(response, "global.");
456        let PickleValue::List(entries) = filtered else {
457            panic!("expected list");
458        };
459        assert_eq!(entries.len(), 1);
460        assert_eq!(
461            entries[0].get("key").and_then(|v| v.as_str()),
462            Some("global.tick_interval_ms")
463        );
464    }
465
466    #[test]
467    fn format_pickle_scalar_renders_strings_without_quotes() {
468        assert_eq!(
469            format_pickle_scalar(&PickleValue::String("ask_app".into())),
470            "ask_app"
471        );
472    }
473}