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 aliases: &[],
56 feature: Some(Feature::Config),
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_yaml::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 yaml: serde_yaml::Value = serde_yaml::from_str(&body)
328 .map_err(|e| miette!("parse yaml `{}`: {e}", path.display()))?;
329 serde_json::to_value(yaml).map_err(|e| miette!("yaml→json: {e}"))
332 }
333 Format::Json => {
334 serde_json::from_str(&body).map_err(|e| miette!("parse json `{}`: {e}", path.display()))
335 }
336 Format::Toml => {
337 let raw: toml::Value = toml::from_str(&body)
338 .map_err(|e| miette!("parse toml `{}`: {e}", path.display()))?;
339 serde_json::to_value(raw).map_err(|e| miette!("toml→json: {e}"))
340 }
341 }
342}
343
344fn write_value(path: &Path, value: &serde_json::Value) -> miette::Result<()> {
345 let format = file_format(path);
346 let serialised = match format {
347 Format::Yaml => serde_yaml::to_string(value).map_err(|e| miette!("yaml: {e}"))?,
348 Format::Json => serde_json::to_string_pretty(value).map_err(|e| miette!("json: {e}"))?,
349 Format::Toml => {
350 let toml_value: toml::Value =
353 toml::Value::try_from(value).map_err(|e| miette!("json→toml: {e}"))?;
354 toml::to_string_pretty(&toml_value).map_err(|e| miette!("toml: {e}"))?
355 }
356 };
357 if let Some(parent) = path.parent() {
358 if !parent.as_os_str().is_empty() {
359 std::fs::create_dir_all(parent)
360 .map_err(|e| miette!("create parent {}: {e}", parent.display()))?;
361 }
362 }
363 std::fs::write(path, serialised).map_err(|e| miette!("write {}: {e}", path.display()))
364}
365
366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367enum Format {
368 Yaml,
369 Toml,
370 Json,
371}
372
373fn file_format(path: &Path) -> Format {
374 match path.extension().and_then(|e| e.to_str()) {
375 Some("toml") => Format::Toml,
376 Some("json") => Format::Json,
377 _ => Format::Yaml,
378 }
379}
380
381fn json_pointer(path: &str) -> String {
386 if path.starts_with('/') {
387 path.to_string()
388 } else if let Some(rest) = path.strip_prefix('.') {
389 format!("/{}", rest.replace('.', "/"))
390 } else {
391 format!("/{}", path.replace('.', "/"))
392 }
393}
394
395fn set_pointer(
400 target: &mut serde_json::Value,
401 pointer: &str,
402 value: serde_json::Value,
403) -> Result<(), String> {
404 if pointer.is_empty() || pointer == "/" {
405 *target = value;
406 return Ok(());
407 }
408 let segments: Vec<&str> = pointer.trim_start_matches('/').split('/').collect();
409 let mut cursor = target;
410 for (i, segment) in segments.iter().enumerate() {
411 let last = i == segments.len() - 1;
412 if last {
413 if let serde_json::Value::Object(map) = cursor {
414 map.insert((*segment).to_string(), value);
415 return Ok(());
416 }
417 return Err(format!("cannot set `{segment}` on a non-object value"));
418 }
419 if !cursor.is_object() {
420 *cursor = serde_json::Value::Object(serde_json::Map::new());
421 }
422 let map = cursor.as_object_mut().expect("just-set object");
423 cursor = map
424 .entry((*segment).to_string())
425 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
426 }
427 Ok(())
428}