use std::path::{Path, PathBuf};
use anyhow::Context as _;
use argonauts_core::KeyScheme;
use argonauts_core::app::AppEvent;
use argonauts_core::settings::{SettingScope, Settings};
use argonauts_registry::{remove_setting, write_setting};
use super::confirm::safety_lines;
use crate::controller::{Ctx, Effect};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Change {
Set(Vec<(String, String)>),
Reset(String),
}
impl Change {
pub(crate) fn keys(&self) -> Vec<String> {
match self {
Self::Set(pairs) => pairs.iter().map(|(key, _)| key.clone()).collect(),
Self::Reset(key) => vec![key.clone()],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TrustAfter {
Carry,
Approved,
Declined,
}
pub(crate) struct Before {
settings: Settings,
previous: Vec<Option<String>>,
}
pub(crate) fn before(ctx: &Ctx, keys: &[String]) -> Before {
Before {
settings: ctx.settings().clone(),
previous: keys.iter().map(|key| value_of(ctx, key)).collect(),
}
}
pub(crate) fn apply(
ctx: &mut Ctx,
change: &Change,
scope: SettingScope,
trust: TrustAfter,
) -> anyhow::Result<Vec<Effect>> {
let before = before(ctx, &change.keys());
make(ctx, change, scope, trust)?;
Ok(announce(ctx, before, change, scope))
}
pub(crate) fn make(
ctx: &mut Ctx,
change: &Change,
scope: SettingScope,
trust: TrustAfter,
) -> anyhow::Result<()> {
match (scope, change) {
(SettingScope::Session, Change::Set(pairs)) => {
let env = ctx.env.clone();
ctx.settings.set_session_all(&env, pairs)?;
}
(SettingScope::Session, Change::Reset(key)) => {
let env = ctx.env.clone();
if !ctx.settings.remove_session(&env, key)? {
anyhow::bail!("{key} has no override in this session");
}
}
(scope, change) => {
let file = file_for(ctx, scope).context("a file scope")?;
write_file(ctx, &file, change, scope != SettingScope::Global, trust)?;
}
}
Ok(())
}
pub(crate) fn announce(
ctx: &mut Ctx,
before: Before,
change: &Change,
scope: SettingScope,
) -> Vec<Effect> {
let keys = change.keys();
for (key, previous) in keys.iter().zip(before.previous) {
let (value, reset) = match change {
Change::Set(pairs) => (
pairs
.iter()
.find(|(set, _)| set == key)
.map(|(_, value)| value.clone())
.unwrap_or_default(),
false,
),
Change::Reset(_) => (value_of(ctx, key).unwrap_or_default(), true),
};
ctx.send(AppEvent::SettingChanged {
key: key.clone(),
value,
scope,
previous,
reset,
});
}
if ctx.settings().ui != before.settings.ui {
send_ui(ctx);
}
let mut effects = Vec::new();
if ctx.settings().crew.main != before.settings.crew.main {
effects.push(Effect::FreshConversation);
}
effects.push(Effect::SettingsApplied { keys });
effects
}
pub(crate) fn send_ui(ctx: &Ctx) {
let ui = &ctx.settings().ui;
ctx.send(AppEvent::UiSettings {
vim: ui.keys == KeyScheme::Vim,
verbosity: ui.verbosity,
ui: Some(Box::new(ui.clone())),
});
}
pub(crate) fn scope_for_change(ctx: &Ctx, key: &str, picked: Option<SettingScope>) -> SettingScope {
picked.unwrap_or_else(|| {
let layer = ctx.settings.layer(key);
let workflow_from = ctx.settings.workflow_from(&layer);
SettingScope::for_change(&layer, workflow_from.as_deref())
})
}
pub(crate) fn set_where_it_came_from(
ctx: &mut Ctx,
key: String,
value: String,
picked: Option<SettingScope>,
) -> anyhow::Result<Vec<Effect>> {
let scope = scope_for_change(ctx, &key, picked);
apply(
ctx,
&Change::Set(vec![(key, value)]),
scope,
TrustAfter::Carry,
)
}
pub(crate) fn file_for(ctx: &Ctx, scope: SettingScope) -> Option<PathBuf> {
match scope {
SettingScope::Session => None,
SettingScope::ProjectLocal => Some(ctx.env.project_local_config()),
SettingScope::Project => Some(ctx.env.project_config()),
SettingScope::Global => Some(ctx.env.global_config()),
}
}
pub(crate) fn value_of(ctx: &Ctx, key: &str) -> Option<String> {
ctx.settings
.entry(key)
.map(|entry| entry.value.clone())
.filter(|value| !value.is_empty())
}
pub(crate) fn file_sets(file: &Path, key: &str) -> bool {
let Some(table) = std::fs::read_to_string(file)
.ok()
.and_then(|text| text.parse::<toml::Table>().ok())
else {
return false;
};
let mut current = &table;
let segments: Vec<&str> = key.split('.').collect();
for (at, segment) in segments.iter().enumerate() {
match current.get(*segment) {
Some(toml::Value::Table(inner)) if at + 1 < segments.len() => current = inner,
Some(_) => return at + 1 == segments.len(),
None => return false,
}
}
false
}
fn write_file(
ctx: &mut Ctx,
file: &Path,
change: &Change,
project: bool,
trust: TrustAfter,
) -> anyhow::Result<()> {
let original = std::fs::read(file).ok();
let recorded = ctx.settings.trust().recorded(file);
let trust_after = project
&& match trust {
TrustAfter::Approved => true,
TrustAfter::Declined => false,
TrustAfter::Carry => {
original.is_none()
|| ctx.settings.trust().was_trusted(file)
|| safety_lines(file).is_empty()
}
};
let Err(err) = write_and_reload(ctx, file, change, trust_after) else {
return Ok(());
};
let restored = match &original {
Some(bytes) => std::fs::write(file, bytes),
None => std::fs::remove_file(file).or_else(|err| match err.kind() {
std::io::ErrorKind::NotFound => Ok(()),
_ => Err(err),
}),
};
let trust_restored = if trust_after {
ctx.settings.trust_mut().put_back(file, recorded)
} else {
Ok(())
};
let env = ctx.env.clone();
let _ = ctx.settings.reload(&env);
let mut err = err;
if let Err(restore) = restored {
err = err.context(format!(
"and putting {} back failed: {restore}",
ctx.env.shown(file)
));
}
if let Err(restore) = trust_restored {
err = err.context(format!("and putting its trust back failed: {restore:#}"));
}
Err(err)
}
fn write_and_reload(
ctx: &mut Ctx,
file: &Path,
change: &Change,
trust_after: bool,
) -> anyhow::Result<()> {
match change {
Change::Set(pairs) => {
for (key, value) in pairs {
write_setting(file, key, value)?;
}
}
Change::Reset(key) => {
if !remove_setting(file, key)? {
anyhow::bail!("{key} isn't set in {}", ctx.env.shown(file));
}
}
}
if trust_after {
ctx.settings.trust_mut().trust(file)?;
}
let env = ctx.env.clone();
ctx.settings.reload(&env)?;
if let Some(unknown) = ctx.settings.unknown_among(&change.keys()) {
anyhow::bail!("{unknown} isn't a setting; /config lists them");
}
Ok(())
}
#[cfg(test)]
mod tests {
use argonauts_core::settings::ui::ModName;
use super::super::tests::Fixture;
use super::*;
fn set(key: &str, value: &str) -> Change {
Change::Set(vec![(key.to_owned(), value.to_owned())])
}
#[test]
fn a_change_is_written_to_the_layer_its_value_came_from() {
let mut fixture = Fixture::new(false, |repo| {
repo.write(
".argonauts/config.toml",
"[ui.above_input]\nmods = [\"plan-usage\"]\n",
)
.unwrap();
});
let ctx = &mut fixture.ctx;
let project = ctx.env.project_config();
let global = ctx.env.global_config();
let pinned = "ui.above_input.mods";
let scope = scope_for_change(ctx, pinned, None);
assert_eq!(scope, SettingScope::Project);
make(
ctx,
&set(pinned, "[\"plan-usage\", \"context\"]"),
scope,
TrustAfter::Carry,
)
.unwrap();
assert!(file_sets(&project, pinned));
assert!(!file_sets(&global, pinned));
assert_eq!(
ctx.settings().ui.above_input.mods,
[ModName::PlanUsage, ModName::Context]
);
let parts = "ui.views.status.mods";
let scope = scope_for_change(ctx, parts, None);
assert_eq!(scope, SettingScope::Global);
make(ctx, &set(parts, "[\"runs\"]"), scope, TrustAfter::Carry).unwrap();
assert!(file_sets(&global, parts));
assert_eq!(ctx.settings().ui.views.status.mods, [ModName::Runs]);
assert_eq!(scope_for_change(ctx, parts, None), SettingScope::Global);
for picked in [
SettingScope::Global,
SettingScope::Project,
SettingScope::ProjectLocal,
] {
assert_eq!(scope_for_change(ctx, pinned, Some(picked)), picked);
}
let local = ctx.env.project_local_config();
let scope = scope_for_change(ctx, pinned, Some(SettingScope::ProjectLocal));
make(ctx, &set(pinned, "[]"), scope, TrustAfter::Carry).unwrap();
assert!(file_sets(&local, pinned));
assert!(ctx.settings().ui.above_input.mods.is_empty());
assert_eq!(
scope_for_change(ctx, pinned, None),
SettingScope::ProjectLocal
);
let shown = "ui.above_input.shown";
make(
ctx,
&set(shown, "false"),
SettingScope::Session,
TrustAfter::Carry,
)
.unwrap();
assert_eq!(scope_for_change(ctx, shown, None), SettingScope::Session);
assert!(!file_sets(&global, shown));
}
#[test]
fn pinning_edits_the_file_the_band_came_from() {
use argonauts_core::input::{ModPlaceChange, ModsAction, ViewAction};
use argonauts_core::settings::ui::Place;
use crate::controller::mod_places;
let mut fixture = Fixture::new(false, |repo| {
repo.write(
".argonauts/config.toml",
"[ui.above_input]\nmods = [\"plan-usage\"]\n",
)
.unwrap();
});
let ctx = &mut fixture.ctx;
let project = std::fs::read_to_string(ctx.env.project_config()).unwrap();
assert!(project.contains("[\"plan-usage\"]"), "{project}");
let pin = ViewAction::Pin { scope: None };
mod_places::tab(ctx, Place::Context, pin).unwrap();
assert_eq!(
ctx.settings().ui.above_input.mods,
[ModName::PlanUsage, ModName::Context]
);
let project = std::fs::read_to_string(ctx.env.project_config()).unwrap();
assert!(project.contains("\"context\""), "{project}");
assert!(!file_sets(&ctx.env.global_config(), "ui.above_input.mods"));
mod_places::tab(ctx, Place::Context, pin).unwrap();
assert_eq!(ctx.settings().ui.above_input.mods, [ModName::PlanUsage]);
let show = ModsAction::Change {
the_mod: ModName::RunLimits,
change: ModPlaceChange::Show {
place: Place::AboveInput,
},
};
mod_places::command(ctx, show, Some(SettingScope::Global)).unwrap();
let global = std::fs::read_to_string(ctx.env.global_config()).unwrap();
assert!(global.contains("\"run-limits\""), "{global}");
let fold = ModsAction::Change {
the_mod: ModName::PlanUsage,
change: ModPlaceChange::Fold,
};
mod_places::command(ctx, fold, None).unwrap();
assert!(file_sets(
&ctx.env.global_config(),
"ui.mods.plan-usage.open"
));
assert!(!ctx.settings().ui.the_mod(ModName::PlanUsage).open);
}
#[test]
fn a_file_sets_only_the_keys_it_names() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("config.toml");
std::fs::write(&file, "[ui]\ntheme = \"dark\"\n").unwrap();
assert!(file_sets(&file, "ui.theme"));
assert!(file_sets(&file, "ui"));
assert!(!file_sets(&file, "ui.keys"));
assert!(!file_sets(&file, "ui.theme.x"));
assert!(!file_sets(&dir.path().join("missing.toml"), "ui.theme"));
}
}