1use 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}