use serde::Deserialize;
use std::path::{Path, PathBuf};
pub const ALLOWED_COLORS: &[&str] = &[
"red", "orange", "yellow", "green", "blue", "cyan", "teal", "purple", "pink", "comment",
"magenta", "fg", "bg2", "white", "black",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OsNotifyPolicy {
#[default]
Never,
ErrorOnly,
Always,
}
#[derive(Debug, Clone, Deserialize)]
pub struct IntegrationManifest {
pub id: String,
pub label: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub binary: Option<String>,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub homepage: Option<String>,
#[serde(default)]
pub docs: Option<String>,
#[serde(default)]
pub repository: Option<String>,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub chip: Option<ChipSpec>,
#[serde(default)]
pub commands: Vec<CommandSpec>,
#[serde(default)]
pub context_menu: Vec<ContextMenuEntry>,
#[serde(default)]
pub menu_bar: Vec<MenuBarEntry>,
#[serde(default)]
pub statusline: Option<StatuslineSpec>,
#[serde(default)]
pub values_sources: Vec<ValuesSource>,
#[serde(default)]
pub statusline_segments: Vec<StatuslineSegment>,
#[serde(default)]
pub prefetch: Vec<PrefetchSource>,
#[serde(default)]
pub settings: Vec<SettingsPage>,
#[serde(default)]
pub notifications: Option<NotificationsSpec>,
#[serde(default)]
pub requires: Option<Requires>,
#[serde(default)]
pub auth: Vec<AuthField>,
#[serde(skip)]
pub source_path: PathBuf,
#[serde(skip)]
pub override_env: std::collections::HashMap<String, String>,
#[serde(skip)]
pub override_auth_values: std::collections::HashMap<String, String>,
#[serde(skip)]
pub auto_update_override: Option<bool>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AuthField {
pub key: String,
pub label: String,
#[serde(default = "default_kind")]
pub kind: String,
#[serde(default)]
pub env_fallback: Option<String>,
#[serde(default)]
pub help_url: Option<String>,
#[serde(default)]
pub help: Option<String>,
#[serde(default)]
pub required: bool,
}
fn default_kind() -> String {
"text".to_string()
}
#[derive(Debug, Clone, Deserialize)]
pub struct ChipSpec {
pub glyph: String,
pub fallback: String,
pub color: String,
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default)]
pub in_palette_bar: bool,
#[serde(default)]
pub badge_key: Option<String>,
#[serde(default)]
pub glyph_svg: Option<String>,
#[serde(default)]
pub glyph_codepoint: Option<String>,
}
fn default_enabled() -> bool {
true
}
#[derive(Debug, Clone, Deserialize)]
pub struct CommandSpec {
pub id: String,
pub title: String,
#[serde(default)]
pub group: Option<String>,
#[serde(default)]
pub keys: Vec<String>,
pub run: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ContextMenuEntry {
pub target: String,
pub title: String,
pub command: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct MenuBarEntry {
pub path: String,
pub command: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct StatuslineSpec {
#[serde(default = "default_side")]
pub side: String,
pub segment_id: String,
#[serde(default)]
pub initial_text: String,
#[serde(default)]
pub initial_color: Option<String>,
#[serde(default)]
pub click_command: Option<String>,
#[serde(default = "default_priority")]
pub priority: u8,
#[serde(default = "default_min_width")]
pub min_width: u16,
#[serde(default = "default_max_width")]
pub max_width: u16,
}
fn default_side() -> String {
"right".to_string()
}
fn default_priority() -> u8 {
100
}
fn default_min_width() -> u16 {
4
}
fn default_max_width() -> u16 {
30
}
#[derive(Debug, Clone, Deserialize)]
pub struct ValuesSource {
pub id: String,
pub command: String,
#[serde(default)]
pub poll_interval_secs: Option<u64>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PrefetchSource {
pub id: String,
pub command: String,
#[serde(default)]
pub poll_interval_secs: Option<u64>,
#[serde(default)]
pub for_pane_kind: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct StatuslineSegment {
pub id: String,
pub source: String,
pub glyph: String,
pub color: String,
pub format: String,
#[serde(default)]
pub tooltip: Option<String>,
#[serde(default)]
pub click_command: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SettingsPage {
pub section: String,
pub label: String,
#[serde(default)]
pub help: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NotificationsSpec {
#[serde(default)]
pub os_notify_on: OsNotifyPolicy,
#[serde(default = "default_rate_limit")]
pub os_rate_limit_sec: u64,
}
fn default_rate_limit() -> u64 {
5
}
#[derive(Debug, Clone, Deserialize)]
pub struct Requires {
#[serde(default)]
pub env: Vec<String>,
#[serde(default)]
pub binary: Option<String>,
}
pub fn load_all(workspace: &Path) -> Vec<IntegrationManifest> {
load_all_with_user_base(workspace, user_dir())
}
pub fn load_all_with_user_base(
workspace: &Path,
user_base: Option<PathBuf>,
) -> Vec<IntegrationManifest> {
let mut out: Vec<IntegrationManifest> = Vec::new();
if let Some(dir) = user_base {
scan_dir(&dir, &mut out);
}
scan_dir(&workspace.join(".mnml").join("integrations"), &mut out);
let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let mut keep = vec![true; out.len()];
for (i, m) in out.iter().enumerate() {
if let Some(&prev) = seen.get(&m.id) {
keep[prev] = false;
}
seen.insert(m.id.clone(), i);
}
out.into_iter()
.enumerate()
.filter_map(|(i, m)| if keep[i] { Some(m) } else { None })
.collect()
}
pub fn user_dir() -> Option<PathBuf> {
Some(crate::data_root::data_root().join("integrations"))
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct IntegrationManifestOverride {
pub id: String,
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub chip: Option<ChipOverride>,
#[serde(default)]
pub env: Option<std::collections::HashMap<String, String>>,
#[serde(default)]
pub auth_values: Option<std::collections::HashMap<String, String>>,
#[serde(default)]
pub auto_update: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ChipOverride {
#[serde(default)]
pub glyph: Option<String>,
#[serde(default)]
pub fallback: Option<String>,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub in_palette_bar: Option<bool>,
}
impl IntegrationManifestOverride {
pub fn apply_to(self, base: &mut IntegrationManifest) {
if let Some(v) = self.label {
base.label = v;
}
if let Some(v) = self.description {
base.description = Some(v);
}
if let Some(o) = self.chip
&& let Some(chip) = base.chip.as_mut()
{
if let Some(v) = o.glyph {
chip.glyph = v;
}
if let Some(v) = o.fallback {
chip.fallback = v;
}
if let Some(v) = o.color
&& ALLOWED_COLORS.contains(&v.as_str())
{
chip.color = v;
}
if let Some(v) = o.enabled {
chip.enabled = v;
}
if let Some(v) = o.in_palette_bar {
chip.in_palette_bar = v;
}
}
if let Some(env) = self.env {
base.override_env.extend(env);
}
if let Some(av) = self.auth_values {
base.override_auth_values.extend(av);
}
if let Some(au) = self.auto_update {
base.auto_update_override = Some(au);
}
}
}
fn scan_dir(dir: &Path, out: &mut Vec<IntegrationManifest>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
let mut manifests_added: Vec<usize> = Vec::new();
let mut overrides: Vec<(String, IntegrationManifestOverride)> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
let Some(fname) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
if let Some(stem) = fname.strip_suffix(".override.toml") {
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(_) => continue,
};
match toml::from_str::<IntegrationManifestOverride>(&text) {
Ok(ov) => {
if ov.id != stem {
continue;
}
overrides.push((stem.to_string(), ov));
}
Err(_) => continue,
}
continue;
}
if path.extension().and_then(|s| s.to_str()) != Some("toml") {
continue;
}
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(_) => continue,
};
match toml::from_str::<IntegrationManifest>(&text) {
Ok(mut m) => {
if m.id.is_empty() || m.label.is_empty() {
continue;
}
if let Some(chip) = m.chip.as_mut()
&& !ALLOWED_COLORS.contains(&chip.color.as_str())
{
chip.color = "cyan".to_string();
}
m.source_path = path;
manifests_added.push(out.len());
out.push(m);
}
Err(_) => continue,
}
}
for (id, ov) in overrides {
let mut matched = false;
for &i in &manifests_added {
let bin_matches = out[i]
.binary
.as_deref()
.map(|b| b.rsplit('/').next().unwrap_or(b) == id)
.unwrap_or(false);
if out[i].id == id || bin_matches {
ov.clone().apply_to(&mut out[i]);
matched = true;
}
}
let _ = matched;
}
}
impl IntegrationManifest {
pub fn is_ready(&self) -> bool {
let Some(req) = &self.requires else {
return true;
};
for name in &req.env {
if std::env::var_os(name).is_none() {
return false;
}
}
if let Some(bin) = &req.binary
&& !binary_on_path(bin)
{
return false;
}
true
}
}
fn binary_on_path(name: &str) -> bool {
let Some(path) = std::env::var_os("PATH") else {
return false;
};
for dir in std::env::split_paths(&path) {
if dir.join(name).is_file() {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_minimal_manifest() {
let toml = r#"
id = "slack"
label = "Slack"
binary = "mnml-msg-slack"
"#;
let m: IntegrationManifest = toml::from_str(toml).unwrap();
assert_eq!(m.id, "slack");
assert_eq!(m.label, "Slack");
assert_eq!(m.binary.as_deref(), Some("mnml-msg-slack"));
assert!(m.chip.is_none());
assert!(m.commands.is_empty());
assert!(m.notifications.is_none());
}
#[test]
fn parses_full_manifest() {
let toml = r#"
id = "slack"
label = "Slack"
description = "Slack browse + post"
version = "0.1.0"
binary = "mnml-msg-slack"
category = "msg"
[chip]
glyph = "S"
fallback = "Sk"
color = "purple"
enabled = true
in_palette_bar = false
badge_key = "slack"
[[commands]]
id = "slack.open"
title = "Slack: open"
group = "integrations"
keys = ["<leader>iS"]
run = ":term mnml-msg-slack"
[[context_menu]]
target = "tree.file"
title = "Send via Slack"
command = "slack.send_file"
[statusline]
side = "right"
segment_id = "slack"
initial_text = "◇ slack"
initial_color = "comment"
click_command = "slack.open"
[notifications]
os_notify_on = "error_only"
os_rate_limit_sec = 5
[requires]
env = ["SLACK_TOKEN"]
binary = "mnml-msg-slack"
"#;
let m: IntegrationManifest = toml::from_str(toml).unwrap();
assert_eq!(m.id, "slack");
assert_eq!(m.chip.as_ref().unwrap().glyph, "S");
assert_eq!(m.commands.len(), 1);
assert_eq!(m.commands[0].id, "slack.open");
assert_eq!(m.context_menu.len(), 1);
assert_eq!(m.statusline.as_ref().unwrap().segment_id, "slack");
assert_eq!(
m.notifications.as_ref().unwrap().os_notify_on,
OsNotifyPolicy::ErrorOnly
);
assert_eq!(m.requires.as_ref().unwrap().env, vec!["SLACK_TOKEN"]);
}
#[test]
fn workspace_overrides_user() {
use std::io::Write;
let tmp = tempfile::tempdir().unwrap();
let ws = tmp.path().join("ws");
let ws_dir = ws.join(".mnml").join("integrations");
std::fs::create_dir_all(&ws_dir).unwrap();
let mut f = std::fs::File::create(ws_dir.join("foo.toml")).unwrap();
writeln!(
f,
r#"id = "foo"
label = "Workspace Foo"
binary = "mnml-foo"
"#
)
.unwrap();
let manifests = load_all_with_user_base(&ws, None);
assert_eq!(manifests.len(), 1);
assert_eq!(manifests[0].label, "Workspace Foo");
}
#[test]
fn unknown_chip_color_falls_back() {
use std::io::Write;
let tmp = tempfile::tempdir().unwrap();
let ws = tmp.path().join("ws");
let ws_dir = ws.join(".mnml").join("integrations");
std::fs::create_dir_all(&ws_dir).unwrap();
let mut f = std::fs::File::create(ws_dir.join("foo.toml")).unwrap();
writeln!(
f,
r#"id = "foo"
label = "Foo"
binary = "mnml-foo"
[chip]
glyph = "F"
fallback = "F"
color = "nonsense-neon"
"#
)
.unwrap();
let manifests = load_all_with_user_base(&ws, None);
assert_eq!(manifests.len(), 1);
assert_eq!(manifests[0].chip.as_ref().unwrap().color, "cyan");
}
#[test]
fn drops_manifest_missing_required_id() {
use std::io::Write;
let tmp = tempfile::tempdir().unwrap();
let ws = tmp.path().join("ws");
let ws_dir = ws.join(".mnml").join("integrations");
std::fs::create_dir_all(&ws_dir).unwrap();
let mut f = std::fs::File::create(ws_dir.join("bad.toml")).unwrap();
writeln!(f, r#"name = "No Id" # missing id"#).unwrap();
let manifests = load_all_with_user_base(&ws, None);
assert!(manifests.is_empty());
}
#[test]
fn is_ready_checks_env_and_binary() {
let m = IntegrationManifest {
id: "x".into(),
label: "X".into(),
description: None,
version: None,
binary: Some("mnml-x".into()),
category: None,
chip: None,
commands: vec![],
context_menu: vec![],
menu_bar: vec![],
statusline: None,
values_sources: vec![],
statusline_segments: vec![],
settings: vec![],
notifications: None,
requires: None,
auth: vec![],
prefetch: vec![],
source_path: PathBuf::new(),
homepage: None,
docs: None,
repository: None,
author: None,
override_env: std::collections::HashMap::new(),
override_auth_values: std::collections::HashMap::new(),
auto_update_override: None,
};
assert!(m.is_ready());
let m = IntegrationManifest {
requires: Some(Requires {
env: vec!["DEFINITELY_NOT_SET_ENV_12345".to_string()],
binary: None,
}),
..m
};
assert!(!m.is_ready());
}
#[test]
fn override_toml_layers_on_base_manifest_by_id() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("htop.toml"),
r#"id = "htop"
label = "htop"
description = "Interactive process viewer"
[chip]
glyph = "H"
fallback = "H"
color = "cyan"
enabled = true
in_palette_bar = false
"#,
)
.unwrap();
std::fs::write(
dir.path().join("htop.override.toml"),
r#"id = "htop"
label = "Top"
[chip]
color = "green"
in_palette_bar = true
"#,
)
.unwrap();
let mut out = Vec::new();
scan_dir(dir.path(), &mut out);
assert_eq!(
out.len(),
1,
"override file should not become its own manifest"
);
let m = &out[0];
assert_eq!(m.label, "Top");
assert_eq!(m.chip.as_ref().unwrap().color, "green");
assert!(m.chip.as_ref().unwrap().in_palette_bar);
assert_eq!(m.description.as_deref(), Some("Interactive process viewer"));
assert_eq!(m.chip.as_ref().unwrap().glyph, "H");
assert!(m.chip.as_ref().unwrap().enabled);
}
#[test]
fn override_toml_env_and_auth_values_land_on_manifest() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("jira.toml"),
r#"id = "jira"
label = "Jira"
[chip]
glyph = "J"
fallback = "J"
color = "cyan"
"#,
)
.unwrap();
std::fs::write(
dir.path().join("jira.override.toml"),
r#"id = "jira"
[env]
JIRA_BASE_URL = "http://localhost:7071/jira"
JIRA_PROJECT = "NTL"
[auth_values]
api_token = "demo-token"
email = "ava@bloomlabs.dev"
"#,
)
.unwrap();
let mut out = Vec::new();
scan_dir(dir.path(), &mut out);
assert_eq!(out.len(), 1);
let m = &out[0];
assert_eq!(
m.override_env.get("JIRA_BASE_URL").map(String::as_str),
Some("http://localhost:7071/jira")
);
assert_eq!(
m.override_env.get("JIRA_PROJECT").map(String::as_str),
Some("NTL")
);
assert_eq!(
m.override_auth_values.get("api_token").map(String::as_str),
Some("demo-token")
);
assert_eq!(
m.override_auth_values.get("email").map(String::as_str),
Some("ava@bloomlabs.dev")
);
}
#[test]
fn override_with_mismatched_id_is_ignored() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("htop.toml"),
r#"id = "htop"
label = "htop"
[chip]
glyph = "H"
fallback = "H"
color = "cyan"
"#,
)
.unwrap();
std::fs::write(
dir.path().join("htop.override.toml"),
r#"id = "btop"
label = "wrong"
"#,
)
.unwrap();
let mut out = Vec::new();
scan_dir(dir.path(), &mut out);
assert_eq!(out.len(), 1);
assert_eq!(out[0].label, "htop"); }
#[test]
fn override_by_binary_basename_fans_out_to_all_manifests_sharing_crate() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("jira_work.toml"),
r#"id = "jira_work"
label = "Jira Work"
binary = "mnml-tracker-jira"
"#,
)
.unwrap();
std::fs::write(
dir.path().join("jira_boards.toml"),
r#"id = "jira_boards"
label = "Jira Boards"
binary = "mnml-tracker-jira"
"#,
)
.unwrap();
std::fs::write(
dir.path().join("mnml-tracker-jira.override.toml"),
r#"id = "mnml-tracker-jira"
auto_update = true
"#,
)
.unwrap();
let mut out = Vec::new();
scan_dir(dir.path(), &mut out);
assert_eq!(out.len(), 2);
for m in &out {
assert_eq!(
m.auto_update_override,
Some(true),
"manifest {} did not receive the crate-scoped override",
m.id
);
}
}
#[test]
fn override_with_no_matching_base_is_silently_dropped() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("ghost.override.toml"),
r#"id = "ghost"
label = "orphan"
"#,
)
.unwrap();
let mut out = Vec::new();
scan_dir(dir.path(), &mut out);
assert!(out.is_empty(), "orphan override must not create a manifest");
}
#[test]
fn override_ignores_unknown_color() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("x.toml"),
r#"id = "x"
label = "X"
[chip]
glyph = "x"
fallback = "x"
color = "cyan"
"#,
)
.unwrap();
std::fs::write(
dir.path().join("x.override.toml"),
r#"id = "x"
[chip]
color = "chartreuse-fluorescent"
"#,
)
.unwrap();
let mut out = Vec::new();
scan_dir(dir.path(), &mut out);
assert_eq!(out.len(), 1);
assert_eq!(out[0].chip.as_ref().unwrap().color, "cyan");
}
}