use crate::{apps::App, config, guard};
use anyhow::{Context, Result, bail};
use std::path::Path;
use toml_edit::{Array, DocumentMut, Item, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum List {
Close,
Protect,
Demote,
}
impl List {
fn location(self) -> (&'static str, &'static str) {
match self {
Self::Close => ("apps", "close"),
Self::Protect => ("apps", "protect"),
Self::Demote => ("focus", "demote"),
}
}
pub fn label(self) -> &'static str {
match self {
Self::Close => "close list",
Self::Protect => "protect list",
Self::Demote => "demote list",
}
}
}
#[derive(Debug, Clone)]
pub enum Change {
Added(String),
Removed(String),
AlreadyPresent(String),
NotPresent(String),
Pointless {
name: String,
why: String,
},
}
pub fn add(list: List, names: &[String], running: &[App]) -> Result<Vec<Change>> {
add_at(&config::path(), list, names, running)
}
pub fn add_at(path: &Path, list: List, names: &[String], running: &[App]) -> Result<Vec<Change>> {
edit(path, |doc| {
let mut changes = Vec::new();
for raw in names {
let (name, app) = resolve(raw, running);
if list != List::Protect
&& let Some(deny) = guard::protected_match(&identities(&name, app))
{
changes.push(Change::Pointless {
name,
why: format!("protected ({deny}) — it would never be touched"),
});
continue;
}
let arr = array_at(doc, list)?;
if contains(arr, &name) {
changes.push(Change::AlreadyPresent(name));
continue;
}
push_on_own_line(arr, &name);
changes.push(Change::Added(name));
}
Ok(changes)
})
}
pub fn remove(list: List, names: &[String], running: &[App]) -> Result<Vec<Change>> {
remove_at(&config::path(), list, names, running)
}
pub fn remove_at(
path: &Path,
list: List,
names: &[String],
running: &[App],
) -> Result<Vec<Change>> {
edit(path, |doc| {
let mut changes = Vec::new();
for raw in names {
let (name, _) = resolve(raw, running);
let arr = array_at(doc, list)?;
match position(arr, &name) {
Some(i) => {
arr.remove(i);
changes.push(Change::Removed(name));
}
None => changes.push(Change::NotPresent(name)),
}
}
Ok(changes)
})
}
pub fn set_within(list: List, chosen: &[String], candidates: &[String]) -> Result<Vec<Change>> {
set_within_at(&config::path(), list, chosen, candidates)
}
pub fn set_within_at(
path: &Path,
list: List,
chosen: &[String],
candidates: &[String],
) -> Result<Vec<Change>> {
edit(path, |doc| {
let mut changes = Vec::new();
let arr = array_at(doc, list)?;
for c in candidates {
let present = contains(arr, c);
let want = chosen.iter().any(|s| s.eq_ignore_ascii_case(c));
match (present, want) {
(false, true) => {
push_on_own_line(arr, c);
changes.push(Change::Added(c.clone()));
}
(true, false) => {
if let Some(i) = position(arr, c) {
arr.remove(i);
changes.push(Change::Removed(c.clone()));
}
}
_ => {}
}
}
Ok(changes)
})
}
fn edit<T>(path: &Path, f: impl FnOnce(&mut DocumentMut) -> Result<T>) -> Result<T> {
if !path.exists() {
config::init(path)?;
}
let text =
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
let mut doc: DocumentMut = text
.parse()
.with_context(|| format!("parsing {}", path.display()))?;
let out = f(&mut doc)?;
let rendered = doc.to_string();
toml::from_str::<config::Config>(&rendered)
.context("the edit would have produced an invalid config; nothing was written")?;
write_atomically(path, &rendered)?;
Ok(out)
}
fn write_atomically(path: &Path, contents: &str) -> Result<()> {
let tmp = path.with_extension("toml.tmp");
std::fs::write(&tmp, contents).with_context(|| format!("writing {}", tmp.display()))?;
std::fs::rename(&tmp, path).with_context(|| format!("replacing {}", path.display()))?;
Ok(())
}
fn array_at(doc: &mut DocumentMut, list: List) -> Result<&mut Array> {
let (table, key) = list.location();
let tbl = doc
.entry(table)
.or_insert(Item::Table(toml_edit::Table::new()))
.as_table_mut()
.with_context(|| format!("[{table}] is not a table"))?;
let item = tbl
.entry(key)
.or_insert(Item::Value(Value::Array(Array::new())));
match item.as_array_mut() {
Some(a) => Ok(a),
None => bail!("{table}.{key} is not a list"),
}
}
fn contains(arr: &Array, name: &str) -> bool {
position(arr, name).is_some()
}
fn position(arr: &Array, name: &str) -> Option<usize> {
arr.iter()
.position(|v| v.as_str().is_some_and(|s| s.eq_ignore_ascii_case(name)))
}
fn push_on_own_line(arr: &mut Array, name: &str) {
let mut v = Value::from(name);
v.decor_mut().set_prefix("\n ");
arr.push_formatted(v);
arr.set_trailing_comma(true);
let trailing = arr.trailing().as_str().unwrap_or_default().to_owned();
if trailing.trim().is_empty() {
arr.set_trailing("\n");
}
}
fn resolve<'a>(input: &str, running: &'a [App]) -> (String, Option<&'a App>) {
match running
.iter()
.find(|a| guard::identity_matches(&a.identities(), input))
{
Some(app) => (app.name.clone(), Some(app)),
None => (input.to_owned(), None),
}
}
fn identities<'a>(name: &'a str, app: Option<&'a App>) -> Vec<&'a str> {
match app {
Some(a) => a.identities(),
None => vec![name],
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Scratch(std::path::PathBuf);
impl Scratch {
fn new(tag: &str) -> Self {
let p = std::env::temp_dir().join(format!("amph-manage-{tag}.toml"));
std::fs::remove_file(&p).ok();
Self(p)
}
fn path(&self) -> &Path {
&self.0
}
fn read(&self) -> String {
std::fs::read_to_string(&self.0).unwrap()
}
fn cfg(&self) -> config::Config {
toml::from_str(&self.read()).unwrap()
}
}
impl Drop for Scratch {
fn drop(&mut self) {
std::fs::remove_file(&self.0).ok();
}
}
#[test]
fn add_creates_config_and_appends_without_eating_comments() {
let s = Scratch::new("comments");
let changes = add_at(s.path(), List::Close, &["Spotify".into()], &[]).unwrap();
assert!(matches!(changes[0], Change::Added(_)));
let text = s.read();
assert!(
text.contains("Nothing is closed or reprioritised until you list it below"),
"template prose was lost"
);
assert!(
text.contains("# \"Slack\","),
"commented examples were lost"
);
assert!(
text.contains("skip_running_apps"),
"unrelated sections were lost"
);
assert!(text.contains("\"Spotify\""));
assert_eq!(s.cfg().apps.close, vec!["Spotify"]);
}
#[test]
fn add_is_idempotent_and_case_insensitive() {
let s = Scratch::new("idem");
add_at(s.path(), List::Close, &["Spotify".into()], &[]).unwrap();
let again = add_at(s.path(), List::Close, &["spotify".into()], &[]).unwrap();
assert!(matches!(again[0], Change::AlreadyPresent(_)));
assert_eq!(s.cfg().apps.close.len(), 1, "duplicate entry was added");
}
#[test]
fn adding_a_protected_app_is_refused_rather_than_silently_useless() {
let s = Scratch::new("protected");
for name in ["Cursor", "Finder", "Docker"] {
let changes = add_at(s.path(), List::Close, &[name.into()], &[]).unwrap();
assert!(
matches!(changes[0], Change::Pointless { .. }),
"{name} should have been refused"
);
}
assert!(s.cfg().apps.close.is_empty());
}
#[test]
fn protected_apps_may_still_be_added_to_the_protect_list() {
let s = Scratch::new("protectlist");
let changes = add_at(s.path(), List::Protect, &["Slack".into()], &[]).unwrap();
assert!(matches!(changes[0], Change::Added(_)));
assert_eq!(s.cfg().apps.protect, vec!["Slack"]);
}
#[test]
fn remove_reports_absence_instead_of_failing() {
let s = Scratch::new("remove");
add_at(
s.path(),
List::Close,
&["Spotify".into(), "Slack".into()],
&[],
)
.unwrap();
let gone = remove_at(s.path(), List::Close, &["SPOTIFY".into()], &[]).unwrap();
assert!(matches!(gone[0], Change::Removed(_)));
let missing = remove_at(s.path(), List::Close, &["Nothing".into()], &[]).unwrap();
assert!(matches!(missing[0], Change::NotPresent(_)));
assert_eq!(s.cfg().apps.close, vec!["Slack"]);
}
#[test]
fn set_within_leaves_entries_it_was_not_shown() {
let s = Scratch::new("setwithin");
add_at(
s.path(),
List::Close,
&["Spotify".into(), "Discord".into()],
&[],
)
.unwrap();
let candidates = vec!["Spotify".to_string(), "Slack".to_string()];
set_within_at(s.path(), List::Close, &["Slack".into()], &candidates).unwrap();
let close = s.cfg().apps.close;
assert!(close.contains(&"Slack".to_string()), "selection not added");
assert!(
!close.contains(&"Spotify".to_string()),
"deselection not removed"
);
assert!(
close.contains(&"Discord".to_string()),
"an entry the picker never showed was dropped"
);
}
#[test]
fn demote_writes_to_the_focus_table() {
let s = Scratch::new("demote");
add_at(s.path(), List::Demote, &["Spotify".into()], &[]).unwrap();
let cfg = s.cfg();
assert_eq!(cfg.focus.demote, vec!["Spotify"]);
assert!(cfg.apps.close.is_empty(), "wrote to the wrong list");
}
#[test]
fn a_corrupt_edit_never_reaches_disk() {
let s = Scratch::new("corrupt");
add_at(s.path(), List::Close, &["Spotify".into()], &[]).unwrap();
let before = s.read();
let attempt = edit(s.path(), |doc| {
doc["apps"]["not_a_real_key"] = toml_edit::value("x");
Ok(())
});
assert!(attempt.is_err(), "invalid edit was accepted");
assert_eq!(s.read(), before, "file was modified despite the failure");
}
#[test]
fn repeated_edits_stay_parseable_and_readable() {
let s = Scratch::new("churn");
for name in ["Slack", "Spotify", "Messages", "Discord", "Telegram"] {
add_at(s.path(), List::Close, &[name.into()], &[]).unwrap();
}
remove_at(s.path(), List::Close, &["Messages".into()], &[]).unwrap();
add_at(s.path(), List::Close, &["Notion".into()], &[]).unwrap();
let cfg = s.cfg();
assert_eq!(cfg.apps.close.len(), 5);
assert!(!cfg.apps.close.contains(&"Messages".to_string()));
let text = s.read();
assert!(
text.contains("\n \"Notion\","),
"formatting degraded:\n{text}"
);
}
}