Skip to main content

rtb_cli/
config_cmd.rs

1//! `config` CLI subtree — `show / get / set / schema / validate`.
2//!
3//! Operates on the canonical user-file path
4//! `<ProjectDirs::config_dir()>/<tool>/config.yaml` (overridable via
5//! `--config-file PATH`). Format is determined by the file's
6//! extension on read and kept consistent on write:
7//!
8//! | Extension | Format |
9//! |---|---|
10//! | `.yml`, `.yaml` (or no extension) | YAML |
11//! | `.toml` | TOML |
12//! | `.json` | JSON |
13//!
14//! # Design note (v0.4)
15//!
16//! The framework's `App` currently carries `Arc<Config<()>>` — the
17//! typed-config generic `App<C>` is post-v0.1 work. So the v0.4
18//! subtree operates on the file directly rather than the typed
19//! `Config<C>`. `get` / `set` walk the parsed value as a
20//! `serde_yaml::Value`; `schema` errors with a helpful message
21//! pointing at the typed-config integration gap. The behaviour
22//! upgrades non-disruptively when `App<C>` lands.
23//!
24//! # Lint exception
25//!
26//! `linkme::distributed_slice` emits `#[link_section]` which Rust
27//! 1.95+ flags under `unsafe_code`. Allowed at module level — no
28//! hand-rolled `unsafe` blocks anywhere in the module.
29
30#![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
46/// The `config` subcommand.
47pub 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// ---------------------------------------------------------------------
105// clap surface
106// ---------------------------------------------------------------------
107
108#[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    /// Print the currently-resolved configuration. Default subcommand.
118    Show(ShowOpts),
119    /// Read a JSON-pointer path against the user config file.
120    Get {
121        /// JSON-pointer path (`/foo/bar` style). Leading `/` optional.
122        path: String,
123    },
124    /// Write a value to a JSON-pointer path in the user config file.
125    Set {
126        /// JSON-pointer path to write to.
127        path: String,
128        /// Value to write. Parsed as JSON; falls back to a string.
129        value: String,
130        /// Override the canonical user-file path.
131        #[arg(long, value_name = "PATH")]
132        config_file: Option<PathBuf>,
133    },
134    /// Print the JSON Schema for the tool's typed config.
135    Schema,
136    /// Validate a candidate config file. Defaults to the merged result.
137    Validate {
138        /// Validate this file instead of the merged config.
139        #[arg(long, value_name = "PATH")]
140        config_file: Option<PathBuf>,
141    },
142}
143
144#[derive(Debug, clap::Args)]
145struct ShowOpts {}
146
147// ---------------------------------------------------------------------
148// `show`
149// ---------------------------------------------------------------------
150
151fn run_show(app: &App) -> miette::Result<()> {
152    if let Some(value) = app.config_value() {
153        // Typed config wired — render as YAML for human reading.
154        let yaml = serde_yaml::to_string(&value).map_err(|e| miette!("yaml: {e}"))?;
155        print!("{yaml}");
156        return Ok(());
157    }
158    // No typed config wired — keep the v0.4 placeholder text.
159    // Downstream tools that don't call
160    // `Application::builder().config(...)` see this; tools that do
161    // hit the typed path above.
162    println!("# no typed configuration is installed on this App");
163    println!("# (call `Application::builder().config(C)` to wire it)");
164    Ok(())
165}
166
167// ---------------------------------------------------------------------
168// `get`
169// ---------------------------------------------------------------------
170
171fn run_get(app: &App, path: &str, mode: OutputMode) -> miette::Result<()> {
172    // Prefer the typed-config-rendered value when wired — it
173    // reflects the merged Config<C> that the tool actually sees,
174    // including embedded defaults and env-var overlays. Fall back
175    // to the v0.4 user-file walk for tools without typed wiring.
176    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
201// ---------------------------------------------------------------------
202// `set`
203// ---------------------------------------------------------------------
204
205fn 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    // Parse `<value>` as JSON; fall back to a string literal so the
211    // common case of a bare scalar (`config set .timeout 30`,
212    // `config set .name alice`) does not require quoting.
213    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    // Schema-aware write: when typed config is wired, refuse to
225    // persist a candidate the schema rejects. The check runs
226    // against the merged value rather than the user-file alone so
227    // a bad set against env-driven defaults still gets caught.
228    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(&current) {
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, &current)?;
239    println!("set `{path}` in `{}`", file.display());
240    Ok(())
241}
242
243// ---------------------------------------------------------------------
244// `schema`
245// ---------------------------------------------------------------------
246
247fn 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    // No typed config wired — same fallback as v0.4.
254    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
263// ---------------------------------------------------------------------
264// `validate`
265// ---------------------------------------------------------------------
266
267fn run_validate(app: &App, override_path: Option<&Path>) -> miette::Result<()> {
268    // Three sources for the candidate value, in order of preference:
269    //
270    // 1. `--config-file <PATH>` — explicit override always wins; an
271    //    absent file is an error.
272    // 2. No override + typed config wired — validate the in-memory
273    //    merged value (matches the spec "Defaults to the merged
274    //    result" wording).
275    // 3. No override + no typed config — fall back to the canonical
276    //    user file; absent file is an error (the v0.4 untyped path).
277    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
306// ---------------------------------------------------------------------
307// shared
308// ---------------------------------------------------------------------
309
310fn 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            // YAML → JSON Value: serde-cross-format. `serde_json::to_value`
330            // works for any `Serialize` source.
331            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            // Round-trip through `toml::Value` so non-table tops get a
351            // proper error rather than a panic.
352            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
381/// Convert a user-supplied path into a `serde_json` JSON-pointer
382/// string. Accepts both `/foo/bar` (canonical) and `.foo.bar`
383/// (UX-friendly) forms; the canonical form is what
384/// `serde_json::Value::pointer` expects.
385fn 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
395/// Write `value` at the given JSON pointer. Creates intermediate
396/// `Object` containers as needed. Returns an error string when an
397/// intermediate is a non-object (`/foo/bar` against a value where
398/// `foo` is a number).
399fn 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}