#![allow(unsafe_code)]
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use async_trait::async_trait;
use clap::{Parser, Subcommand};
use directories::ProjectDirs;
use linkme::distributed_slice;
use miette::miette;
use rtb_app::app::App;
use rtb_app::command::{Command, CommandSpec, BUILTIN_COMMANDS};
use rtb_app::features::Feature;
use crate::render::{strip_global_output, OutputMode};
pub struct ConfigCmd;
#[async_trait]
impl Command for ConfigCmd {
fn spec(&self) -> &CommandSpec {
static SPEC: CommandSpec = CommandSpec {
name: "config",
about: "Show, query, mutate, and validate the user config (show / get / set / schema / validate)",
feature: Some(Feature::Config),
..CommandSpec::DEFAULT
};
&SPEC
}
fn subcommand_passthrough(&self) -> bool {
true
}
async fn run(&self, app: App) -> miette::Result<()> {
let mut args: Vec<OsString> = std::env::args_os().collect();
if args.len() >= 2 {
args.drain(..2);
}
args.insert(0, OsString::from("config"));
args = strip_global_output(args);
let cli = match ConfigCli::try_parse_from(args) {
Ok(c) => c,
Err(e) => {
use clap::error::ErrorKind;
if matches!(e.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion) {
print!("{e}");
return Ok(());
}
return Err(miette!("{e}"));
}
};
let mode = OutputMode::from_args_os();
let sub = cli.command.unwrap_or(ConfigSub::Show(ShowOpts {}));
match sub {
ConfigSub::Show(_) => run_show(&app),
ConfigSub::Get { path } => run_get(&app, &path, mode),
ConfigSub::Set { path, value, config_file } => {
run_set(&app, &path, &value, config_file.as_deref())
}
ConfigSub::Schema => run_schema(&app),
ConfigSub::Validate { config_file } => run_validate(&app, config_file.as_deref()),
}
}
}
#[distributed_slice(BUILTIN_COMMANDS)]
fn __register_config() -> Box<dyn Command> {
Box::new(ConfigCmd)
}
#[derive(Debug, Parser)]
#[command(name = "config", about = "Inspect, mutate, and validate config")]
struct ConfigCli {
#[command(subcommand)]
command: Option<ConfigSub>,
}
#[derive(Debug, Subcommand)]
enum ConfigSub {
Show(ShowOpts),
Get {
path: String,
},
Set {
path: String,
value: String,
#[arg(long, value_name = "PATH")]
config_file: Option<PathBuf>,
},
Schema,
Validate {
#[arg(long, value_name = "PATH")]
config_file: Option<PathBuf>,
},
}
#[derive(Debug, clap::Args)]
struct ShowOpts {}
fn run_show(app: &App) -> miette::Result<()> {
if let Some(value) = app.config_value() {
let yaml = serde_yaml::to_string(&value).map_err(|e| miette!("yaml: {e}"))?;
print!("{yaml}");
return Ok(());
}
println!("# no typed configuration is installed on this App");
println!("# (call `Application::builder().config(C)` to wire it)");
Ok(())
}
fn run_get(app: &App, path: &str, mode: OutputMode) -> miette::Result<()> {
let (value, source) = if let Some(value) = app.config_value() {
(value, "merged config".to_string())
} else {
let file = canonical_path(app)?;
let value = read_value(&file)?;
(value, format!("`{}`", file.display()))
};
let pointer = json_pointer(path);
let resolved =
value.pointer(&pointer).ok_or_else(|| miette!("path `{path}` not found in {source}"))?;
match mode {
OutputMode::Json => {
println!(
"{}",
serde_json::to_string_pretty(resolved).map_err(|e| miette!("serialise: {e}"))?,
);
}
OutputMode::Text => match resolved {
serde_json::Value::String(s) => println!("{s}"),
other => println!("{other}"),
},
}
Ok(())
}
fn run_set(app: &App, path: &str, value: &str, override_path: Option<&Path>) -> miette::Result<()> {
let file = match override_path {
Some(p) => p.to_path_buf(),
None => canonical_path(app)?,
};
let parsed: serde_json::Value = serde_json::from_str(value)
.unwrap_or_else(|_| serde_json::Value::String(value.to_string()));
let mut current = if file.exists() {
read_value(&file)?
} else {
serde_json::Value::Object(serde_json::Map::new())
};
let pointer = json_pointer(path);
set_pointer(&mut current, &pointer, parsed).map_err(|msg| miette!("{msg}"))?;
if let Some(schema) = app.config_schema() {
let validator =
jsonschema::validator_for(schema).map_err(|e| miette!("compile schema: {e}"))?;
if let Err(error) = validator.validate(¤t) {
return Err(miette!(
"config set rejected: candidate value at `{path}` fails the wired schema: {error}",
));
}
}
write_value(&file, ¤t)?;
println!("set `{path}` in `{}`", file.display());
Ok(())
}
fn run_schema(app: &App) -> miette::Result<()> {
if let Some(schema) = app.config_schema() {
let json = serde_json::to_string_pretty(schema).map_err(|e| miette!("json: {e}"))?;
println!("{json}");
return Ok(());
}
Err(miette!(
help = "wire your typed config via Application::builder().config(...). \
Without typed-config wiring there is no schema to emit; \
downstream tools that override the `config` command can call \
rtb_config::Config::schema()",
"config schema is not available without a typed-config integration"
))
}
fn run_validate(app: &App, override_path: Option<&Path>) -> miette::Result<()> {
let (value, label): (serde_json::Value, String) = if let Some(p) = override_path {
if !p.exists() {
return Err(miette!("config file `{}` does not exist", p.display()));
}
(read_value(p)?, format!("`{}`", p.display()))
} else if let Some(merged) = app.config_value() {
(merged, "merged config".to_string())
} else {
let p = canonical_path(app)?;
if !p.exists() {
return Err(miette!("config file `{}` does not exist", p.display()));
}
let label = format!("`{}`", p.display());
(read_value(&p)?, label)
};
if let Some(schema) = app.config_schema() {
let validator =
jsonschema::validator_for(schema).map_err(|e| miette!("compile schema: {e}"))?;
if let Err(error) = validator.validate(&value) {
return Err(miette!("config validation failed at {label}: {error}"));
}
println!("ok: {label} validates against the wired schema");
} else {
println!("ok: {label} parses cleanly (no schema wired — format check only)");
}
Ok(())
}
fn canonical_path(app: &App) -> miette::Result<PathBuf> {
let dirs = ProjectDirs::from("dev", "", &app.metadata.name).ok_or_else(|| {
miette!(
help = "rtb-cli could not derive a config directory; HOME may be unset",
"no config directory available for tool `{}`",
app.metadata.name
)
})?;
Ok(dirs.config_dir().join("config.yaml"))
}
fn read_value(path: &Path) -> miette::Result<serde_json::Value> {
let body =
std::fs::read_to_string(path).map_err(|e| miette!("read {}: {e}", path.display()))?;
let format = file_format(path);
match format {
Format::Yaml => {
let yaml: serde_yaml::Value = serde_yaml::from_str(&body)
.map_err(|e| miette!("parse yaml `{}`: {e}", path.display()))?;
serde_json::to_value(yaml).map_err(|e| miette!("yaml→json: {e}"))
}
Format::Json => {
serde_json::from_str(&body).map_err(|e| miette!("parse json `{}`: {e}", path.display()))
}
Format::Toml => {
let raw: toml::Value = toml::from_str(&body)
.map_err(|e| miette!("parse toml `{}`: {e}", path.display()))?;
serde_json::to_value(raw).map_err(|e| miette!("toml→json: {e}"))
}
}
}
fn write_value(path: &Path, value: &serde_json::Value) -> miette::Result<()> {
let format = file_format(path);
let serialised = match format {
Format::Yaml => serde_yaml::to_string(value).map_err(|e| miette!("yaml: {e}"))?,
Format::Json => serde_json::to_string_pretty(value).map_err(|e| miette!("json: {e}"))?,
Format::Toml => {
let toml_value: toml::Value =
toml::Value::try_from(value).map_err(|e| miette!("json→toml: {e}"))?;
toml::to_string_pretty(&toml_value).map_err(|e| miette!("toml: {e}"))?
}
};
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)
.map_err(|e| miette!("create parent {}: {e}", parent.display()))?;
}
}
std::fs::write(path, serialised).map_err(|e| miette!("write {}: {e}", path.display()))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Format {
Yaml,
Toml,
Json,
}
fn file_format(path: &Path) -> Format {
match path.extension().and_then(|e| e.to_str()) {
Some("toml") => Format::Toml,
Some("json") => Format::Json,
_ => Format::Yaml,
}
}
fn json_pointer(path: &str) -> String {
if path.starts_with('/') {
path.to_string()
} else if let Some(rest) = path.strip_prefix('.') {
format!("/{}", rest.replace('.', "/"))
} else {
format!("/{}", path.replace('.', "/"))
}
}
fn set_pointer(
target: &mut serde_json::Value,
pointer: &str,
value: serde_json::Value,
) -> Result<(), String> {
if pointer.is_empty() || pointer == "/" {
*target = value;
return Ok(());
}
let segments: Vec<&str> = pointer.trim_start_matches('/').split('/').collect();
let mut cursor = target;
for (i, segment) in segments.iter().enumerate() {
let last = i == segments.len() - 1;
if last {
if let serde_json::Value::Object(map) = cursor {
map.insert((*segment).to_string(), value);
return Ok(());
}
return Err(format!("cannot set `{segment}` on a non-object value"));
}
if !cursor.is_object() {
*cursor = serde_json::Value::Object(serde_json::Map::new());
}
let map = cursor.as_object_mut().expect("just-set object");
cursor = map
.entry((*segment).to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
}
Ok(())
}