1#![allow(unsafe_code)]
31
32use std::ffi::OsString;
33use std::path::{Path, PathBuf};
34
35use async_trait::async_trait;
36use clap::{Parser, Subcommand};
37use directories::ProjectDirs;
38use linkme::distributed_slice;
39use miette::miette;
40use rtb_app::app::App;
41use rtb_app::command::{Command, CommandSpec, BUILTIN_COMMANDS};
42use rtb_app::features::Feature;
43
44use crate::render::{strip_global_output, OutputMode};
45
46pub struct ConfigCmd;
48
49#[async_trait]
50impl Command for ConfigCmd {
51 fn spec(&self) -> &CommandSpec {
52 static SPEC: CommandSpec = CommandSpec {
53 name: "config",
54 about: "Show, query, mutate, and validate the user config (show / get / set / schema / validate)",
55 feature: Some(Feature::Config),
56 ..CommandSpec::DEFAULT
57 };
58 &SPEC
59 }
60
61 fn subcommand_passthrough(&self) -> bool {
62 true
63 }
64
65 async fn run(&self, app: App) -> miette::Result<()> {
66 let mut args: Vec<OsString> = std::env::args_os().collect();
67 if args.len() >= 2 {
68 args.drain(..2);
69 }
70 args.insert(0, OsString::from("config"));
71 args = strip_global_output(args);
72
73 let cli = match ConfigCli::try_parse_from(args) {
74 Ok(c) => c,
75 Err(e) => {
76 use clap::error::ErrorKind;
77 if matches!(e.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion) {
78 print!("{e}");
79 return Ok(());
80 }
81 return Err(miette!("{e}"));
82 }
83 };
84
85 let mode = OutputMode::from_args_os();
86 let sub = cli.command.unwrap_or(ConfigSub::Show(ShowOpts {}));
87 match sub {
88 ConfigSub::Show(_) => run_show(&app),
89 ConfigSub::Get { path } => run_get(&app, &path, mode),
90 ConfigSub::Set { path, value, config_file } => {
91 run_set(&app, &path, &value, config_file.as_deref())
92 }
93 ConfigSub::Schema => run_schema(&app),
94 ConfigSub::Validate { config_file } => run_validate(&app, config_file.as_deref()),
95 }
96 }
97}
98
99#[distributed_slice(BUILTIN_COMMANDS)]
100fn __register_config() -> Box<dyn Command> {
101 Box::new(ConfigCmd)
102}
103
104#[derive(Debug, Parser)]
109#[command(name = "config", about = "Inspect, mutate, and validate config")]
110struct ConfigCli {
111 #[command(subcommand)]
112 command: Option<ConfigSub>,
113}
114
115#[derive(Debug, Subcommand)]
116enum ConfigSub {
117 Show(ShowOpts),
119 Get {
121 path: String,
123 },
124 Set {
126 path: String,
128 value: String,
130 #[arg(long, value_name = "PATH")]
132 config_file: Option<PathBuf>,
133 },
134 Schema,
136 Validate {
138 #[arg(long, value_name = "PATH")]
140 config_file: Option<PathBuf>,
141 },
142}
143
144#[derive(Debug, clap::Args)]
145struct ShowOpts {}
146
147fn run_show(app: &App) -> miette::Result<()> {
152 if let Some(value) = app.config_value() {
153 let yaml = serde_saphyr::to_string(&value).map_err(|e| miette!("yaml: {e}"))?;
155 print!("{yaml}");
156 return Ok(());
157 }
158 println!("# no typed configuration is installed on this App");
163 println!("# (call `Application::builder().config(C)` to wire it)");
164 Ok(())
165}
166
167fn run_get(app: &App, path: &str, mode: OutputMode) -> miette::Result<()> {
172 let (value, source) = if let Some(value) = app.config_value() {
177 (value, "merged config".to_string())
178 } else {
179 let file = canonical_path(app)?;
180 let value = read_value(&file)?;
181 (value, format!("`{}`", file.display()))
182 };
183 let pointer = json_pointer(path);
184 let resolved =
185 value.pointer(&pointer).ok_or_else(|| miette!("path `{path}` not found in {source}"))?;
186 match mode {
187 OutputMode::Json => {
188 println!(
189 "{}",
190 serde_json::to_string_pretty(resolved).map_err(|e| miette!("serialise: {e}"))?,
191 );
192 }
193 OutputMode::Text => match resolved {
194 serde_json::Value::String(s) => println!("{s}"),
195 other => println!("{other}"),
196 },
197 }
198 Ok(())
199}
200
201fn run_set(app: &App, path: &str, value: &str, override_path: Option<&Path>) -> miette::Result<()> {
206 let file = match override_path {
207 Some(p) => p.to_path_buf(),
208 None => canonical_path(app)?,
209 };
210 let parsed: serde_json::Value = serde_json::from_str(value)
214 .unwrap_or_else(|_| serde_json::Value::String(value.to_string()));
215
216 let mut current = if file.exists() {
217 read_value(&file)?
218 } else {
219 serde_json::Value::Object(serde_json::Map::new())
220 };
221 let pointer = json_pointer(path);
222 set_pointer(&mut current, &pointer, parsed).map_err(|msg| miette!("{msg}"))?;
223
224 if let Some(schema) = app.config_schema() {
229 let validator =
230 jsonschema::validator_for(schema).map_err(|e| miette!("compile schema: {e}"))?;
231 if let Err(error) = validator.validate(¤t) {
232 return Err(miette!(
233 "config set rejected: candidate value at `{path}` fails the wired schema: {error}",
234 ));
235 }
236 }
237
238 write_value(&file, ¤t)?;
239 println!("set `{path}` in `{}`", file.display());
240 Ok(())
241}
242
243fn run_schema(app: &App) -> miette::Result<()> {
248 if let Some(schema) = app.config_schema() {
249 let json = serde_json::to_string_pretty(schema).map_err(|e| miette!("json: {e}"))?;
250 println!("{json}");
251 return Ok(());
252 }
253 Err(miette!(
255 help = "wire your typed config via Application::builder().config(...). \
256 Without typed-config wiring there is no schema to emit; \
257 downstream tools that override the `config` command can call \
258 rtb_config::Config::schema()",
259 "config schema is not available without a typed-config integration"
260 ))
261}
262
263fn run_validate(app: &App, override_path: Option<&Path>) -> miette::Result<()> {
268 let (value, label): (serde_json::Value, String) = if let Some(p) = override_path {
278 if !p.exists() {
279 return Err(miette!("config file `{}` does not exist", p.display()));
280 }
281 (read_value(p)?, format!("`{}`", p.display()))
282 } else if let Some(merged) = app.config_value() {
283 (merged, "merged config".to_string())
284 } else {
285 let p = canonical_path(app)?;
286 if !p.exists() {
287 return Err(miette!("config file `{}` does not exist", p.display()));
288 }
289 let label = format!("`{}`", p.display());
290 (read_value(&p)?, label)
291 };
292
293 if let Some(schema) = app.config_schema() {
294 let validator =
295 jsonschema::validator_for(schema).map_err(|e| miette!("compile schema: {e}"))?;
296 if let Err(error) = validator.validate(&value) {
297 return Err(miette!("config validation failed at {label}: {error}"));
298 }
299 println!("ok: {label} validates against the wired schema");
300 } else {
301 println!("ok: {label} parses cleanly (no schema wired — format check only)");
302 }
303 Ok(())
304}
305
306fn canonical_path(app: &App) -> miette::Result<PathBuf> {
311 let dirs = ProjectDirs::from("dev", "", &app.metadata.name).ok_or_else(|| {
312 miette!(
313 help = "rtb-cli could not derive a config directory; HOME may be unset",
314 "no config directory available for tool `{}`",
315 app.metadata.name
316 )
317 })?;
318 Ok(dirs.config_dir().join("config.yaml"))
319}
320
321fn read_value(path: &Path) -> miette::Result<serde_json::Value> {
322 let body =
323 std::fs::read_to_string(path).map_err(|e| miette!("read {}: {e}", path.display()))?;
324 let format = file_format(path);
325 match format {
326 Format::Yaml => {
327 let options = serde_saphyr::options! {
329 strict_booleans: true,
330 merge_keys: serde_saphyr::MergeKeyPolicy::AsOrdinary,
331 with_snippet: false,
332 };
333 serde_saphyr::from_str_with_options(&body, options)
334 .map_err(|e| miette!("parse yaml `{}`: {e}", path.display()))
335 }
336 Format::Json => {
337 serde_json::from_str(&body).map_err(|e| miette!("parse json `{}`: {e}", path.display()))
338 }
339 Format::Toml => {
340 let raw: toml::Value = toml::from_str(&body)
341 .map_err(|e| miette!("parse toml `{}`: {e}", path.display()))?;
342 serde_json::to_value(raw).map_err(|e| miette!("toml→json: {e}"))
343 }
344 }
345}
346
347fn write_value(path: &Path, value: &serde_json::Value) -> miette::Result<()> {
348 let format = file_format(path);
349 let serialised = match format {
350 Format::Yaml => serde_saphyr::to_string(value).map_err(|e| miette!("yaml: {e}"))?,
351 Format::Json => serde_json::to_string_pretty(value).map_err(|e| miette!("json: {e}"))?,
352 Format::Toml => {
353 let toml_value: toml::Value =
356 toml::Value::try_from(value).map_err(|e| miette!("json→toml: {e}"))?;
357 toml::to_string_pretty(&toml_value).map_err(|e| miette!("toml: {e}"))?
358 }
359 };
360 if let Some(parent) = path.parent() {
361 if !parent.as_os_str().is_empty() {
362 std::fs::create_dir_all(parent)
363 .map_err(|e| miette!("create parent {}: {e}", parent.display()))?;
364 }
365 }
366 std::fs::write(path, serialised).map_err(|e| miette!("write {}: {e}", path.display()))
367}
368
369#[derive(Debug, Clone, Copy, PartialEq, Eq)]
370enum Format {
371 Yaml,
372 Toml,
373 Json,
374}
375
376fn file_format(path: &Path) -> Format {
377 match path.extension().and_then(|e| e.to_str()) {
378 Some("toml") => Format::Toml,
379 Some("json") => Format::Json,
380 _ => Format::Yaml,
381 }
382}
383
384fn json_pointer(path: &str) -> String {
389 if path.starts_with('/') {
390 path.to_string()
391 } else if let Some(rest) = path.strip_prefix('.') {
392 format!("/{}", rest.replace('.', "/"))
393 } else {
394 format!("/{}", path.replace('.', "/"))
395 }
396}
397
398fn set_pointer(
403 target: &mut serde_json::Value,
404 pointer: &str,
405 value: serde_json::Value,
406) -> Result<(), String> {
407 if pointer.is_empty() || pointer == "/" {
408 *target = value;
409 return Ok(());
410 }
411 let segments: Vec<&str> = pointer.trim_start_matches('/').split('/').collect();
412 let mut cursor = target;
413 for (i, segment) in segments.iter().enumerate() {
414 let last = i == segments.len() - 1;
415 if last {
416 if let serde_json::Value::Object(map) = cursor {
417 map.insert((*segment).to_string(), value);
418 return Ok(());
419 }
420 return Err(format!("cannot set `{segment}` on a non-object value"));
421 }
422 if !cursor.is_object() {
423 *cursor = serde_json::Value::Object(serde_json::Map::new());
424 }
425 let map = cursor.as_object_mut().expect("just-set object");
426 cursor = map
427 .entry((*segment).to_string())
428 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
429 }
430 Ok(())
431}
432
433#[cfg(test)]
434mod tests {
435 use super::{read_value, write_value};
436
437 #[test]
438 fn yaml_round_trips_and_keeps_yaml_one_one_booleans_as_strings() {
439 let dir = std::env::temp_dir().join(format!("rtb-cli-config-yaml-{}", std::process::id()));
440 let path = dir.join("config.yaml");
441 std::fs::create_dir_all(&dir).expect("mkdir");
442 std::fs::write(&path, "answer: yes\nflag: true\nbase: &b {p: 1}\nmerged:\n <<: *b\n")
443 .expect("write");
444
445 let value = read_value(&path).expect("read");
446 assert_eq!(value["answer"], serde_json::json!("yes"));
447 assert_eq!(value["flag"], serde_json::json!(true));
448 assert_eq!(value["merged"], serde_json::json!({"<<": {"p": 1}}));
449
450 write_value(&path, &value).expect("write back");
451 assert_eq!(read_value(&path).expect("re-read"), value);
452 std::fs::remove_dir_all(&dir).expect("cleanup");
453 }
454}