use crate::cli::{Cli, ConfigAction};
use crate::config::{spec, ConfigStore, KEYS};
use crate::error::{AppError, AppResult};
use crate::i18n::{t, Message};
use serde::Serialize;
use std::process::ExitCode;
#[derive(Serialize)]
struct ConfigEnvelope<'a> {
action: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
key: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
value: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
changed: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
entries: Option<Vec<ConfigEntry>>,
}
impl<'a> ConfigEnvelope<'a> {
fn new(action: &'a str) -> Self {
Self {
action,
path: None,
key: None,
value: None,
changed: None,
entries: None,
}
}
}
#[derive(Serialize)]
struct ConfigEntry {
key: &'static str,
#[serde(rename = "type")]
kind: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
doc: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
secret: Option<bool>,
set: bool,
#[serde(skip_serializing_if = "Option::is_none")]
value: Option<String>,
}
fn entries(store: &ConfigStore, only_set: bool, with_doc: bool) -> Vec<ConfigEntry> {
KEYS.iter()
.filter_map(|entry| {
let stored = store.get(entry.key);
if only_set && stored.is_none() {
return None;
}
Some(ConfigEntry {
key: entry.key,
kind: entry.kind.as_str(),
doc: with_doc.then_some(entry.doc),
secret: entry.secret.then_some(true),
set: stored.is_some(),
value: stored.filter(|_| !entry.secret).map(render),
})
})
.collect()
}
pub async fn run(cli: &Cli, action: &ConfigAction) -> AppResult<ExitCode> {
match action {
ConfigAction::Path => {
let path = crate::config::config_file_path()?;
if cli.json {
let mut env = ConfigEnvelope::new("path");
env.path = Some(path.display().to_string());
super::emit_envelope(cli, &env).await?;
} else {
emit(&format!("{}\n", path.display())).await?;
}
}
ConfigAction::Show => {
let store = ConfigStore::load()?;
if cli.json {
let mut env = ConfigEnvelope::new("show");
env.entries = Some(entries(&store, true, false));
super::emit_envelope(cli, &env).await?;
} else {
let mut out = String::new();
for (key, value) in store.flattened() {
out.push_str(&format!("{key} = {value}\n"));
}
emit(&out).await?;
}
}
ConfigAction::Get { key } => {
if spec(key).is_none() {
return Err(unknown(key));
}
let store = ConfigStore::load()?;
let value = store.get(key).ok_or_else(|| {
AppError::InvalidUsage(format!("`{key}` {}", t(Message::ConfigKeyNotSet)))
})?;
if cli.json {
let mut env = ConfigEnvelope::new("get");
env.key = Some(key);
env.value = Some(render(value));
super::emit_envelope(cli, &env).await?;
} else {
emit(&format!("{}\n", render(value))).await?;
}
}
ConfigAction::Set {
key,
value,
from_stdin,
} => {
let entry = spec(key).ok_or_else(|| unknown(key))?;
if entry.secret && !*from_stdin {
return Err(AppError::InvalidUsage(format!(
"`{key}` {}",
t(Message::ConfigKeyIsSecret)
)));
}
let raw = if *from_stdin {
read_secret_from_stdin().await?
} else {
value.clone().ok_or_else(|| {
AppError::InvalidUsage(format!(
"`config set {key}` {}",
t(Message::ConfigSetNeedsValue)
))
})?
};
let mut store = ConfigStore::load()?;
let before = store.get(key).map(render);
store.set(key, &raw)?;
let after = store.get(key).map(render);
store.save()?;
if cli.json {
let mut env = ConfigEnvelope::new("set");
env.key = Some(key);
env.changed = Some(before != after);
super::emit_envelope(cli, &env).await?;
} else {
emit(&format!("{key} set\n")).await?;
}
}
ConfigAction::Unset { key } => {
let mut store = ConfigStore::load()?;
let was_set = store.get(key).is_some();
if was_set && !cli.yes {
return Err(AppError::InvalidUsage(format!(
"`{key}` {}",
t(Message::ConfigKeySetNeedsYes)
)));
}
store.unset(key)?;
store.save()?;
if cli.json {
let mut env = ConfigEnvelope::new("unset");
env.key = Some(key);
env.changed = Some(was_set);
super::emit_envelope(cli, &env).await?;
} else {
emit(&format!("{key} unset\n")).await?;
}
}
ConfigAction::ListKeys => {
if cli.json {
let store = ConfigStore::load()?;
let mut env = ConfigEnvelope::new("list-keys");
env.entries = Some(entries(&store, false, true));
super::emit_envelope(cli, &env).await?;
} else {
let mut out = String::new();
for entry in KEYS {
out.push_str(&format!(
"{}\t{}\t{}\n",
entry.key,
entry.kind.as_str(),
entry.doc
));
}
emit(&out).await?;
}
}
}
Ok(ExitCode::SUCCESS)
}
async fn read_secret_from_stdin() -> AppResult<String> {
use tokio::io::{AsyncBufReadExt, BufReader};
let mut line = String::new();
let read = BufReader::new(tokio::io::stdin())
.read_line(&mut line)
.await
.map_err(|e| AppError::InvalidInput(format!("reading stdin: {e}")))?;
if read == 0 {
return Err(AppError::StdinEmpty);
}
Ok(line.trim_end_matches(['\r', '\n']).to_string())
}
fn render(value: &toml::Value) -> String {
match value {
toml::Value::String(s) => s.clone(),
toml::Value::Array(items) => items.iter().map(render).collect::<Vec<_>>().join(","),
other => other.to_string(),
}
}
fn unknown(key: &str) -> AppError {
crate::config::unknown_key(key)
}
async fn emit(text: &str) -> AppResult<()> {
crate::io::write_subtitle_to_stdout(text.as_bytes()).await
}
#[cfg(test)]
mod tests {
use super::*;
fn test_cli(json: bool) -> Cli {
use clap::Parser;
let mut argv = vec!["youtube-legend-cli"];
if json {
argv.push("--json");
}
argv.extend_from_slice(&["config", "path"]);
Cli::parse_from(argv)
}
#[tokio::test]
async fn unsetting_an_unset_key_needs_no_confirmation() {
let action = ConfigAction::Unset {
key: "cli.max_url_chars".to_string(),
};
let store = ConfigStore::load().expect("store loads");
if store.get("cli.max_url_chars").is_none() {
assert!(run(&test_cli(false), &action).await.is_ok());
}
}
#[test]
fn list_keys_entries_cover_the_whole_registry() {
let store = ConfigStore::load().expect("store loads");
let all = entries(&store, false, true);
assert_eq!(
all.len(),
KEYS.len(),
"list-keys must report every registry key, set or not"
);
assert!(
all.iter().all(|e| e.doc.is_some()),
"list-keys carries the registry doc for every key"
);
}
#[test]
fn show_entries_are_a_subset_that_is_actually_set() {
let store = ConfigStore::load().expect("store loads");
let shown = entries(&store, true, false);
assert!(shown.len() <= KEYS.len());
assert!(
shown.iter().all(|e| e.set),
"show reports only keys the operator persisted"
);
assert!(
shown.iter().all(|e| e.doc.is_none()),
"show reports stored values, not registry metadata"
);
}
#[test]
fn a_secret_key_never_carries_its_value_in_the_envelope() {
let store = ConfigStore::load().expect("store loads");
for entry in entries(&store, false, true) {
if entry.secret == Some(true) {
assert!(
entry.value.is_none(),
"`{}` is secret and must not publish its value",
entry.key
);
}
}
}
#[test]
fn envelope_omits_every_field_the_action_does_not_use() {
let env = ConfigEnvelope::new("path");
let json = serde_json::to_string(&env).expect("serialises");
assert_eq!(json, r#"{"action":"path"}"#);
}
#[test]
fn render_joins_a_string_list_with_commas() {
let value = toml::Value::Array(vec![
toml::Value::String("a".to_string()),
toml::Value::String("b".to_string()),
]);
assert_eq!(render(&value), "a,b");
}
#[test]
fn render_strips_quotes_from_a_plain_string() {
assert_eq!(render(&toml::Value::String("auto".to_string())), "auto");
}
#[test]
fn unknown_key_maps_to_a_usage_error() {
let err = unknown("nope");
assert!(matches!(err, AppError::InvalidUsage(_)));
assert_eq!(err.exit_code(), 64);
}
#[test]
fn no_registry_key_is_secret_today_so_none_needs_stdin() {
assert!(
KEYS.iter().all(|k| !k.secret),
"a secret key was added; cover its --from-stdin path"
);
}
}