use anyhow::{Context, Result};
use serde_json::Value;
pub fn has_workmux_hooks(settings: &Value) -> bool {
let Some(hooks) = settings.get("hooks").and_then(|v| v.as_object()) else {
return false;
};
for (_event, groups) in hooks {
let Some(groups_arr) = groups.as_array() else {
continue;
};
for group in groups_arr {
let Some(hook_list) = group.get("hooks").and_then(|v| v.as_array()) else {
continue;
};
for hook in hook_list {
if let Some(cmd) = hook.get("command").and_then(|v| v.as_str())
&& cmd.contains("workmux set-window-status")
{
return true;
}
}
}
}
false
}
pub fn remove_workmux_hooks(settings: &mut Value) -> bool {
let Some(hooks) = settings.get_mut("hooks").and_then(|v| v.as_object_mut()) else {
return false;
};
let mut modified = false;
let mut events_to_remove: Vec<String> = Vec::new();
for (event, groups) in hooks.iter_mut() {
let Some(groups_arr) = groups.as_array_mut() else {
continue;
};
for group in groups_arr.iter_mut() {
if let Some(hooks_list) = group.get_mut("hooks").and_then(|h| h.as_array_mut()) {
let len_before = hooks_list.len();
hooks_list.retain(|e| {
!e.get("command")
.and_then(|c| c.as_str())
.is_some_and(|c| c.contains("workmux set-window-status"))
});
if hooks_list.len() < len_before {
modified = true;
}
}
}
groups_arr.retain(|group| {
group
.get("hooks")
.and_then(|h| h.as_array())
.is_some_and(|h| !h.is_empty())
});
if groups_arr.is_empty() {
events_to_remove.push(event.clone());
}
}
for event in &events_to_remove {
hooks.remove(event);
}
modified
}
pub fn remove_workmux_plugins(settings: &mut Value) -> bool {
let Some(plugins) = settings
.get_mut("enabledPlugins")
.and_then(|v| v.as_object_mut())
else {
return false;
};
let keys: Vec<String> = plugins
.keys()
.filter(|k| k.starts_with("workmux-status@"))
.cloned()
.collect();
let modified = !keys.is_empty();
for key in &keys {
plugins.remove(key);
}
modified
}
pub fn remove_empty_hooks_wrapper(settings: &mut Value) -> bool {
let root = settings.as_object_mut().map(|o| {
let mut modified = false;
if let Some(hooks) = o.get("hooks")
&& hooks.as_object().is_some_and(|m| m.is_empty())
{
o.remove("hooks");
modified = true;
}
if let Some(plugins) = o.get("enabledPlugins")
&& plugins.as_object().is_some_and(|m| m.is_empty())
{
o.remove("enabledPlugins");
modified = true;
}
modified
});
root.unwrap_or(false)
}
pub fn merge_hook_groups(config_root: &mut Value, hooks_to_add: &Value) -> Result<()> {
let config_obj = config_root
.as_object_mut()
.context("config root is not an object")?;
if !config_obj.contains_key("hooks") {
config_obj.insert("hooks".to_string(), Value::Object(serde_json::Map::new()));
}
let existing_hooks = config_obj
.get_mut("hooks")
.and_then(|v| v.as_object_mut())
.context("hooks value is not an object")?;
let hooks_map = hooks_to_add
.as_object()
.context("hooks to add is not an object")?;
for (event, hook_groups) in hooks_map {
let Some(new_groups) = hook_groups.as_array() else {
continue;
};
if let Some(existing_groups) = existing_hooks.get_mut(event) {
let arr = existing_groups
.as_array_mut()
.with_context(|| format!("hooks.{event} is not an array"))?;
for group in new_groups {
if !arr.contains(group) {
arr.push(group.clone());
}
}
} else {
existing_hooks.insert(event.clone(), hook_groups.clone());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_has_workmux_hooks_empty() {
let settings = json!({});
assert!(!has_workmux_hooks(&settings));
}
#[test]
fn test_has_workmux_hooks_present() {
let settings = json!({
"hooks": {
"Stop": [{
"hooks": [{
"type": "command",
"command": "workmux set-window-status done"
}]
}]
}
});
assert!(has_workmux_hooks(&settings));
}
#[test]
fn test_has_workmux_hooks_other_hooks_only() {
let settings = json!({
"hooks": {
"Stop": [{
"hooks": [{
"type": "command",
"command": "afplay /System/Library/Sounds/Glass.aiff"
}]
}]
}
});
assert!(!has_workmux_hooks(&settings));
}
#[test]
fn test_remove_workmux_hooks_mixed() {
let mut settings = json!({
"hooks": {
"Stop": [{
"hooks": [{ "type": "command", "command": "workmux set-window-status done" }]
}, {
"hooks": [{ "type": "command", "command": "afplay /System/Library/Sounds/Glass.aiff" }]
}]
},
"enabledPlugins": {
"workmux-status@workmux": true,
"other-plugin@1.0": true
}
});
assert!(remove_workmux_hooks(&mut settings));
assert!(remove_workmux_plugins(&mut settings));
remove_empty_hooks_wrapper(&mut settings);
let stop = settings["hooks"]["Stop"].as_array().unwrap();
assert_eq!(stop.len(), 1);
assert!(
stop[0]["hooks"][0]["command"]
.as_str()
.unwrap()
.contains("Glass")
);
assert!(
settings["enabledPlugins"]
.as_object()
.unwrap()
.contains_key("other-plugin@1.0")
);
assert!(
!settings["enabledPlugins"]
.as_object()
.unwrap()
.contains_key("workmux-status@workmux")
);
}
#[test]
fn test_remove_workmux_hooks_only_workmux() {
let mut settings = json!({
"hooks": {
"Stop": [{
"hooks": [{ "type": "command", "command": "workmux set-window-status done" }]
}]
}
});
assert!(remove_workmux_hooks(&mut settings));
remove_empty_hooks_wrapper(&mut settings);
assert!(settings.get("hooks").is_none());
}
#[test]
fn test_remove_workmux_hooks_mixed_in_same_group() {
let mut settings = json!({
"hooks": {
"Stop": [{
"hooks": [
{ "type": "command", "command": "workmux set-window-status done" },
{ "type": "command", "command": "afplay /System/Library/Sounds/Glass.aiff" },
{ "type": "command", "command": "echo user-hook" }
]
}]
}
});
assert!(remove_workmux_hooks(&mut settings));
let stop = settings["hooks"]["Stop"].as_array().unwrap();
assert_eq!(stop.len(), 1);
let hooks = stop[0]["hooks"].as_array().unwrap();
assert_eq!(hooks.len(), 2);
assert!(hooks[0]["command"].as_str().unwrap().contains("Glass"));
assert!(hooks[1]["command"].as_str().unwrap().contains("echo"));
}
#[test]
fn test_remove_workmux_hooks_idempotent() {
let mut settings = json!({
"hooks": {
"Stop": [{
"hooks": [{ "type": "command", "command": "workmux set-window-status done" }]
}]
}
});
assert!(remove_workmux_hooks(&mut settings));
assert!(!remove_workmux_hooks(&mut settings));
}
#[test]
fn test_remove_workmux_hooks_empty_settings() {
let mut settings = json!({});
assert!(!remove_workmux_hooks(&mut settings));
}
#[test]
fn test_remove_workmux_plugins_empty() {
let mut settings = json!({});
assert!(!remove_workmux_plugins(&mut settings));
}
#[test]
fn test_remove_workmux_plugins_only_workmux() {
let mut settings = json!({
"enabledPlugins": {
"workmux-status@workmux": true
}
});
assert!(remove_workmux_plugins(&mut settings));
assert!(settings["enabledPlugins"].as_object().unwrap().is_empty());
}
#[test]
fn test_remove_workmux_plugins_idempotent() {
let mut settings = json!({
"enabledPlugins": {
"workmux-status@workmux": true
}
});
assert!(remove_workmux_plugins(&mut settings));
assert!(!remove_workmux_plugins(&mut settings));
}
#[test]
fn test_remove_empty_hooks_wrapper_none() {
let mut settings = json!({
"hooks": {
"Stop": [{"hooks": [{"command": "echo hi"}]}]
}
});
assert!(!remove_empty_hooks_wrapper(&mut settings));
}
#[test]
fn test_remove_empty_hooks_wrapper_empty_hooks() {
let mut settings = json!({ "hooks": {} });
assert!(remove_empty_hooks_wrapper(&mut settings));
assert!(settings.get("hooks").is_none());
}
#[test]
fn test_remove_empty_hooks_wrapper_empty_plugins() {
let mut settings = json!({ "enabledPlugins": {} });
assert!(remove_empty_hooks_wrapper(&mut settings));
assert!(settings.get("enabledPlugins").is_none());
}
#[test]
fn test_merge_hook_groups_into_empty() {
let mut config = json!({ "hooks": {} });
let hooks_to_add = json!({
"Stop": [{"hooks": [{"type": "command", "command": "workmux set-window-status done"}]}]
});
merge_hook_groups(&mut config, &hooks_to_add).unwrap();
let hooks = config["hooks"].as_object().unwrap();
assert_eq!(hooks.len(), 1);
assert_eq!(
config["hooks"]["Stop"][0]["hooks"][0]["command"],
"workmux set-window-status done"
);
}
#[test]
fn test_merge_hook_groups_into_empty_root_creates_hooks() {
let mut config = json!({});
let hooks_to_add = json!({
"Stop": [{"hooks": [{"type": "command", "command": "workmux set-window-status done"}]}]
});
merge_hook_groups(&mut config, &hooks_to_add).unwrap();
let hooks = config["hooks"].as_object().unwrap();
assert_eq!(hooks.len(), 1);
}
#[test]
fn test_merge_hook_groups_deduplicates() {
let mut config = json!({
"hooks": {
"Stop": [{
"hooks": [{"type": "command", "command": "workmux set-window-status done"}]
}]
}
});
let hooks_to_add = json!({
"Stop": [{"hooks": [{"type": "command", "command": "workmux set-window-status done"}]}]
});
merge_hook_groups(&mut config, &hooks_to_add).unwrap();
let stop = config["hooks"]["Stop"].as_array().unwrap();
assert_eq!(stop.len(), 1);
}
#[test]
fn test_merge_hook_groups_preserves_existing() {
let mut config = json!({
"hooks": {
"Stop": [{
"hooks": [{"type": "command", "command": "python3 my-hook.py"}]
}]
}
});
let hooks_to_add = json!({
"Stop": [{"hooks": [{"type": "command", "command": "workmux set-window-status done"}]}]
});
merge_hook_groups(&mut config, &hooks_to_add).unwrap();
let stop = config["hooks"]["Stop"].as_array().unwrap();
assert_eq!(stop.len(), 2);
}
#[test]
fn test_merge_hook_groups_adds_new_event() {
let mut config = json!({
"hooks": {
"Stop": [{
"hooks": [{"type": "command", "command": "python3 my-hook.py"}]
}]
}
});
let hooks_to_add = json!({
"UserPromptSubmit": [{
"hooks": [{"type": "command", "command": "workmux set-window-status working"}]
}]
});
merge_hook_groups(&mut config, &hooks_to_add).unwrap();
assert!(
config["hooks"]
.as_object()
.unwrap()
.contains_key("UserPromptSubmit")
);
assert!(config["hooks"].as_object().unwrap().contains_key("Stop"));
}
#[test]
fn test_merge_hook_groups_skips_non_array() {
let mut config = json!({ "hooks": {} });
let hooks_to_add = json!({
"Stop": [{"hooks": [{"type": "command", "command": "workmux set-window-status done"}]}],
"InvalidEvent": "not an array"
});
merge_hook_groups(&mut config, &hooks_to_add).unwrap();
assert!(config["hooks"].as_object().unwrap().contains_key("Stop"));
assert!(
!config["hooks"]
.as_object()
.unwrap()
.contains_key("InvalidEvent")
);
}
#[test]
fn test_merge_hook_groups_errors_on_non_array_existing() {
let mut config = json!({
"hooks": {
"Stop": "not an array"
}
});
let hooks_to_add = json!({
"Stop": [{"hooks": [{"type": "command", "command": "workmux set-window-status done"}]}]
});
let result = merge_hook_groups(&mut config, &hooks_to_add);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("hooks.Stop is not an array")
);
}
#[test]
fn test_merge_hook_groups_errors_on_non_object_root() {
let mut config = json!("not an object");
let hooks_to_add = json!({});
let result = merge_hook_groups(&mut config, &hooks_to_add);
assert!(result.is_err());
}
}