use crate::config::{check_global_file, config_file_path, resolve_settings, settings_registry};
use eyre::{Context, Result, bail, eyre};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use toml_edit::{DocumentMut, Item, TableLike};
use usage_config::{PropMeta, Resolved, Scope, Ty, Value};
#[derive(usage::Args)]
pub(super) struct SettingsArgs {
#[usage(subcommand)]
pub(super) command: SettingsCommands,
}
#[derive(usage::Subcommands)]
pub(super) enum SettingsCommands {
Ls(LsArgs),
Get(KeyArgs),
Set(SetArgs),
Unset(KeyArgs),
}
#[derive(usage::Args)]
pub(super) struct LsArgs {
key: Option<String>,
}
#[derive(usage::Args)]
pub(super) struct KeyArgs {
key: String,
}
#[derive(usage::Args)]
pub(super) struct SetArgs {
key: String,
#[usage(allow_negative_numbers)]
value: String,
}
const SECRETS: &[&str] = &["remote.token"];
pub(super) fn run(args: SettingsArgs) -> Result<ExitCode> {
match args.command {
SettingsCommands::Ls(args) => list(args.key.as_deref()),
SettingsCommands::Get(args) => get(&args.key),
SettingsCommands::Set(args) => set(&args.key, &args.value),
SettingsCommands::Unset(args) => unset(&args.key),
}
.map(|()| ExitCode::SUCCESS)
}
fn list(filter: Option<&str>) -> Result<()> {
let resolved = resolved()?;
let registry = resolved.registry();
let mut ids = registry
.ids()
.filter(|id| {
let meta = registry.get(*id);
!meta.hide
&& meta.renamed_to.is_none()
&& filter.is_none_or(|filter| in_group(meta.key, filter))
})
.collect::<Vec<_>>();
if let Some(filter) = filter
&& ids.is_empty()
{
bail!("unknown setting: {filter}");
}
ids.sort_by_key(|id| registry.get(*id).key);
for id in ids {
let meta = registry.get(id);
match (resolved.get(id), meta.default_note) {
(Some(_), _) if SECRETS.contains(&meta.key) => {
println!(
"{} is set; `mbx settings get {}` prints it",
meta.key, meta.key
)
}
(Some(value), _) => println!("{} = {}", meta.key, toml_value(value)),
(None, Some(note)) => println!("{} is unset; default: {note}", meta.key),
(None, None) => println!("{} is unset", meta.key),
}
}
Ok(())
}
fn get(key: &str) -> Result<()> {
let meta = setting(key)?;
let resolved = resolved()?;
match resolved.get_key(meta.key) {
Some(Value::String(text)) => println!("{text}"),
Some(value @ (Value::List(_) | Value::Map(_))) => println!("{}", toml_value(value)),
Some(value) => println!("{value}"),
None => {}
}
Ok(())
}
fn set(key: &str, raw: &str) -> Result<()> {
let meta = setting(key)?;
let path = file_path()?;
let mut alone = DocumentMut::new();
set_value(&mut alone, meta, raw, &path)?;
let _lock = lock(&path)?;
let mut document = read(&path)?;
set_value(&mut document, meta, raw, &path)?;
save(&path, &document, Some(alone.to_string()))?;
note_environment(meta);
Ok(())
}
fn unset(key: &str) -> Result<()> {
let meta = match setting(key) {
Ok(meta) => Some(meta),
Err(error) if is_group(key) => return Err(error),
Err(_) => None,
};
let path = file_path()?;
let mut removed = false;
if path.try_exists()? {
let _lock = lock(&path)?;
let mut document = read(&path)?;
removed = remove_value(
&mut document,
meta.map_or(key, |meta| meta.key),
meta.is_some(),
&path,
)?;
if removed {
save(&path, &document, None)?;
}
}
match meta {
Some(meta) => note_environment(meta),
None if !removed => return setting(key).map(|_| ()),
None => {}
}
Ok(())
}
pub(super) fn set_value(
document: &mut DocumentMut,
meta: &PropMeta,
raw: &str,
path: &Path,
) -> Result<()> {
if meta.scope == Scope::Env {
match meta.envs.first() {
Some(env) => bail!(
"{} can only be set in the environment, with {env}",
meta.key
),
None => bail!("{} can only be set in the environment", meta.key),
}
}
if matches!(meta.ty.inner(), Ty::Map(_) | Ty::Object) {
bail!(
"{} is a table; edit {} to change it",
meta.key,
path.display()
);
}
let mut value = toml_value(&parse(meta, raw)?);
let (table, name, inline) =
parent_table(document, meta.key, true, path)?.expect("missing tables are created");
match table.get_mut(name) {
Some(Item::Value(previous)) => {
*value.decor_mut() = previous.decor().clone();
*previous = value;
}
_ => {
table.insert(name, Item::Value(value));
if inline {
table.fmt();
}
}
}
Ok(())
}
pub(super) fn remove_value(
document: &mut DocumentMut,
key: &str,
known: bool,
path: &Path,
) -> Result<bool> {
let Some((table, name, _)) = parent_table(document, key, false, path)? else {
return Ok(false);
};
match table.get(name) {
Some(_) if known => {}
Some(Item::Value(value)) if !value.is_inline_table() => {}
_ => return Ok(false),
}
table.remove(name);
remove_empty_group(document, key);
Ok(true)
}
pub(super) fn setting(key: &str) -> Result<&'static PropMeta> {
let registry = settings_registry();
if let Some(found) = registry.lookup(key) {
return Ok(registry.get(found.id));
}
if is_group(key) {
bail!("{key} is a group of settings; `mbx settings ls {key}` lists them");
}
bail!("unknown setting: {key}; `mbx settings ls` lists them")
}
fn is_group(key: &str) -> bool {
settings_registry()
.props
.iter()
.any(|meta| !meta.hide && meta.key != key && in_group(meta.key, key))
}
fn in_group(key: &str, group: &str) -> bool {
key.strip_prefix(group)
.is_some_and(|rest| rest.is_empty() || rest.starts_with('.'))
}
fn resolved() -> Result<Resolved> {
let resolved = resolve_settings()?;
for warning in &resolved.warnings {
match &warning.origin {
Some(origin) => log::warn!("{} ({})", warning.message, origin.describe()),
None => log::warn!("{}", warning.message),
}
}
Ok(resolved)
}
fn parse(meta: &PropMeta, raw: &str) -> Result<Value> {
let text = match meta.parse {
Some(parser) => parser.split(raw),
None => Value::String(raw.to_owned()),
};
let value = meta.ty.coerce(text).map_err(|error| {
eyre!(
"invalid {}: expected {}, found `{}`",
meta.key,
error.expected,
error.found
)
})?;
if let Some(refused) = meta.refuses(&value) {
bail!(
"invalid {}: `{refused}` is not one of {}",
meta.key,
meta.allowed()
);
}
Ok(value)
}
fn toml_value(value: &Value) -> toml_edit::Value {
match value {
Value::Bool(value) => (*value).into(),
Value::Int(value) => (*value).into(),
Value::Float(value) => (*value).into(),
Value::String(value) => value.as_str().into(),
Value::List(items) => items
.iter()
.map(toml_value)
.collect::<toml_edit::Array>()
.into(),
Value::Map(entries) => entries
.iter()
.map(|(key, value)| (key.as_str(), toml_value(value)))
.collect::<toml_edit::InlineTable>()
.into(),
}
}
fn file_path() -> Result<PathBuf> {
config_file_path()
.ok_or_else(|| eyre!("the platform configuration directory could not be located"))
}
fn read(path: &Path) -> Result<DocumentMut> {
let contents = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(error) => {
return Err(error).wrap_err_with(|| format!("failed to read {}", path.display()));
}
};
contents
.parse()
.wrap_err_with(|| format!("failed to parse {}", path.display()))
}
fn parent_table<'a>(
document: &'a mut DocumentMut,
key: &'a str,
create: bool,
path: &Path,
) -> Result<Option<(&'a mut dyn TableLike, &'a str, bool)>> {
let (groups, name) = match key.rsplit_once('.') {
Some((groups, name)) => (Some(groups), name),
None => (None, key),
};
let spaced = !document.as_table().is_empty();
let mut table: &mut dyn TableLike = document.as_table_mut();
let mut inline = false;
for group in groups.into_iter().flat_map(|groups| groups.split('.')) {
if create && !table.contains_key(group) {
let mut created = toml_edit::Table::new();
if spaced {
created.decor_mut().set_prefix("\n");
}
table.insert(group, Item::Table(created));
}
let Some(item) = table.get_mut(group) else {
return Ok(None);
};
inline = item.is_inline_table();
table = item
.as_table_like_mut()
.ok_or_else(|| eyre!("{group} in {} is not a table", path.display()))?;
}
Ok(Some((table, name, inline)))
}
fn remove_empty_group(document: &mut DocumentMut, key: &str) {
let Some((group, _)) = key.split_once('.') else {
return;
};
let empty = match document.get(group) {
Some(Item::Table(table)) => table.is_empty() && !commented(table.decor()),
Some(Item::Value(toml_edit::Value::InlineTable(table))) => {
let key_commented = document
.as_table()
.key(group)
.is_some_and(|key| commented(key.leaf_decor()));
table.is_empty() && !commented(table.decor()) && !key_commented
}
_ => false,
};
if empty {
document.remove(group);
}
}
fn commented(decor: &toml_edit::Decor) -> bool {
[decor.prefix(), decor.suffix()]
.into_iter()
.flatten()
.filter_map(|text| text.as_str())
.any(|text| text.contains('#'))
}
fn save(path: &Path, document: &DocumentMut, check: Option<String>) -> Result<()> {
let contents = document.to_string();
let target = link_target(path)?;
let created = !target.try_exists()?;
if created {
std::fs::write(&target, "")
.wrap_err_with(|| format!("failed to create {}", target.display()))?;
}
let written = (|| {
if let Some(check) = check {
check_global_file(path, check)
.map_err(|error| error.wrap_err(format!("{} was not changed", path.display())))?;
}
crate::util::write_atomic(&target, contents.as_bytes())
})();
if written.is_err() && created {
let _ = std::fs::remove_file(&target);
}
written?;
if let Err(error) = check_global_file(path, contents) {
log::warn!("{error:#}");
}
Ok(())
}
pub(super) fn link_target(path: &Path) -> Result<PathBuf> {
let mut current = path.to_path_buf();
for _ in 0..40 {
match std::fs::symlink_metadata(¤t) {
Ok(metadata) if metadata.file_type().is_symlink() => {
let next = std::fs::read_link(¤t)
.wrap_err_with(|| format!("failed to read {}", current.display()))?;
current = match current.parent() {
Some(parent) => parent.join(next),
None => next,
};
}
Ok(_) => return Ok(current),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(current),
Err(error) => {
return Err(error)
.wrap_err_with(|| format!("failed to read {}", current.display()));
}
}
}
bail!("{} is a chain of too many links", path.display())
}
fn lock(path: &Path) -> Result<fslock::LockFile> {
let target = link_target(path)?;
let (Some(parent), Some(name)) = (target.parent(), target.file_name()) else {
bail!("{} has no parent directory", target.display());
};
std::fs::create_dir_all(parent)
.wrap_err_with(|| format!("failed to create {}", parent.display()))?;
let lock_path = parent.join(format!(".{}.lock", name.to_string_lossy()));
let mut lock = fslock::LockFile::open(&lock_path)
.wrap_err_with(|| format!("failed to open {}", lock_path.display()))?;
lock.lock()
.wrap_err_with(|| format!("failed to lock {}", lock_path.display()))?;
Ok(lock)
}
fn note_environment(meta: &PropMeta) {
for env in meta.envs {
if std::env::var_os(env).is_some() {
log::warn!("{env} is set and takes precedence over the configuration file");
}
}
}