use crate::app::App;
use crate::config::IntegrationIcon;
#[derive(Debug, Clone)]
pub struct IntegrationEditState {
pub mode: IntegrationEditMode,
pub id: String,
pub command: String,
pub glyph: String,
pub fallback: String,
pub color: String,
pub label: String,
pub focused_field: IntegrationEditField,
pub id_cursor: usize,
pub command_cursor: usize,
pub glyph_cursor: usize,
pub fallback_cursor: usize,
pub label_cursor: usize,
}
#[derive(Debug, Clone)]
pub enum IntegrationEditMode {
Edit,
AddCustom,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IntegrationEditField {
Id,
Command,
Glyph,
Fallback,
Color,
Label,
}
pub const INTEGRATION_EDIT_COLORS: &[&str] = &[
"fg", "dim", "red", "orange", "yellow", "green", "cyan", "blue", "purple",
];
impl App {
pub fn open_launcher_add_local(&mut self) {
self.integration_edit = Some(IntegrationEditState {
mode: IntegrationEditMode::AddCustom,
id: String::new(),
command: ":term ".to_string(),
glyph: String::new(),
fallback: String::new(),
color: "cyan".to_string(),
label: String::new(),
focused_field: IntegrationEditField::Id,
id_cursor: 0,
command_cursor: 6, glyph_cursor: 0,
fallback_cursor: 0,
label_cursor: 0,
});
}
pub fn open_integration_edit_by_id(&mut self, id: &str) {
let icon = self
.config
.ui
.integration_icons
.iter()
.find(|ic| ic.id == id)
.cloned();
let Some(icon) = icon else {
self.toast(format!("integration: {id} not in rail"));
return;
};
let id_cursor = icon.id.len();
let command_cursor = icon.command.len();
let glyph_cursor = icon.glyph.len();
let fallback_cursor = icon.fallback.len();
let label = icon.label.unwrap_or_default();
let label_cursor = label.len();
self.integration_edit = Some(IntegrationEditState {
mode: IntegrationEditMode::Edit,
id: icon.id,
command: icon.command,
glyph: icon.glyph,
fallback: icon.fallback,
color: icon.color,
label,
focused_field: IntegrationEditField::Glyph,
id_cursor,
command_cursor,
glyph_cursor,
fallback_cursor,
label_cursor,
});
}
pub fn open_patch_nerd_font_svg_prompt(&mut self) {
self.prompt = Some(crate::prompt::Prompt::new(
crate::prompt::PromptKind::PatchNerdFontSvg,
"SVG file path to bake into Nerd Font:".to_string(),
));
}
fn next_free_pua_codepoint(&self) -> Option<u32> {
let mut taken: std::collections::HashSet<u32> = std::collections::HashSet::new();
for ic in &self.config.ui.integration_icons {
if let Some(c) = ic.glyph.chars().next() {
taken.insert(c as u32);
}
}
let mut cp = 0xF1B00u32;
while cp <= 0xF1FFF {
if !taken.contains(&cp) {
return Some(cp);
}
cp += 1;
}
None
}
pub fn run_patch_nerd_font_svg(&mut self, svg: &str) {
let svg = svg.trim();
if svg.is_empty() {
self.toast("svg path can't be empty");
return;
}
let svg_path = std::path::PathBuf::from(svg);
if !svg_path.exists() {
self.toast(format!("svg not found: {}", svg_path.display()));
return;
}
let Some(cp) = self.next_free_pua_codepoint() else {
self.toast("PUA range U+F300–F8FF exhausted — remove an integration first");
return;
};
let home = match std::env::var_os("HOME") {
Some(h) => std::path::PathBuf::from(h),
None => {
self.toast("HOME unset — can't resolve font paths");
return;
}
};
let font_in = home.join("Library/Fonts/JetBrainsMonoNerdFont-Regular.ttf");
let font_out = home.join("Library/Fonts/JetBrainsMonoNerdFont-Regular-mnml.ttf");
if !font_in.exists() {
self.toast(format!(
"font not found: {} — install JetBrainsMono Nerd Font first",
font_in.display()
));
return;
}
let script = match std::env::current_exe()
.ok()
.and_then(|p| {
let mut cur = p;
while cur.pop() {
let cand = cur.join("scripts/patch_nerd_font.py");
if cand.exists() {
return Some(cand);
}
}
None
})
.or_else(|| {
let cand = home.join("Projects/mnml/scripts/patch_nerd_font.py");
if cand.exists() { Some(cand) } else { None }
}) {
Some(p) => p,
None => {
self.toast(
"patch_nerd_font.py not found — clone mnml source tree to use this command",
);
return;
}
};
let glyph_str = char::from_u32(cp)
.map(|c| c.to_string())
.unwrap_or_else(|| format!("U+{cp:X}"));
{
let mut clip = crate::clipboard::Clipboard::new();
clip.set(glyph_str.clone(), false);
}
let glyph_name = format!("custom_{cp:04x}");
let glyph_spec = format!("{}:{cp:X}:{glyph_name}", svg_path.display());
let profile = crate::pty_pane::BinaryProfile {
label: format!("patch font: U+{cp:X}"),
exe: "fontforge".to_string(),
args: vec![
"-script".to_string(),
script.to_string_lossy().into_owned(),
"--font".to_string(),
font_in.to_string_lossy().into_owned(),
"--output".to_string(),
font_out.to_string_lossy().into_owned(),
"--glyph".to_string(),
glyph_spec,
],
cwd: None,
env: vec![],
session_id: None,
integration_id: None,
};
self.open_pty(profile);
self.toast(format!(
"patching · glyph copied · install {} after fontforge exits, then paste",
font_out.file_name().unwrap_or_default().to_string_lossy()
));
}
pub fn open_integration_remove_confirm(&mut self, id: String) {
let in_rail = self
.config
.ui
.integration_icons
.iter()
.any(|ic| ic.id == id);
let in_manifests = self.integration_manifests.iter().any(|m| m.id == id);
if !in_rail && !in_manifests {
self.toast(format!("integration: {id} not found"));
return;
}
if is_builtin_integration_id(&id) {
self.toast(format!(
"integration: `{id}` is built-in — use Disable to hide the chip instead of Uninstall",
));
return;
}
let title = format!("Uninstall `{id}`?");
self.pending_integration_remove_binary = self
.integration_manifests
.iter()
.find(|m| m.id == id)
.and_then(|m| m.binary.clone());
self.pending_integration_remove_id = Some(id);
let mut p =
crate::prompt::Prompt::new(crate::prompt::PromptKind::IntegrationRemoveConfirm, title);
p.cursor = 1;
self.prompt = Some(p);
}
pub fn remove_integration_by_id(&mut self, id: &str) {
let base_dir = Some(crate::data_root::data_root().join("integrations"));
let manifest_removed = base_dir
.as_ref()
.map(|d| d.join(format!("{id}.toml")))
.is_some_and(|p| p.exists() && std::fs::remove_file(&p).is_ok());
if let Some(override_path) = base_dir.map(|d| d.join(format!("{id}.override.toml")))
&& override_path.exists()
{
let _ = std::fs::remove_file(&override_path);
}
let (svg_gone, assignment_gone) =
crate::app::integration_glyphs::purge_integration_glyph_state(id);
if svg_gone || assignment_gone {
self.integration_glyph_codepoints.remove(id);
}
if manifest_removed {
self.integration_manifests.retain(|m| m.id != id);
}
let before = self.config.ui.integration_icons.len();
self.config.ui.integration_icons.retain(|ic| ic.id != id);
let rail_removed = self.config.ui.integration_icons.len() != before;
match (manifest_removed, rail_removed) {
(false, false) => self.toast(format!("integration: {id} not installed")),
(true, _) => self.toast(format!("uninstalled {id}")),
(false, true) => self.toast(format!("removed {id} from rail")),
}
let pin_before = self.config.ui.activity_bar_pinned_integrations.len();
self.config
.ui
.activity_bar_pinned_integrations
.retain(|s| s != id);
if self.config.ui.activity_bar_pinned_integrations.len() != pin_before
&& let Err(e) = persist_activity_bar_pinned_integrations(
&self.config.ui.activity_bar_pinned_integrations,
)
{
self.toast(format!("(pinned-list persist failed: {e})"));
}
}
pub fn integration_edit_cancel(&mut self) {
self.integration_edit = None;
}
pub fn integration_edit_save(&mut self) {
let Some(panel) = self.integration_edit.clone() else {
return;
};
let id = panel.id.trim();
let command = panel.command.trim();
let glyph = panel.glyph.trim();
if id.is_empty() {
self.toast("integration: id can't be empty");
return;
}
if command.is_empty() {
self.toast("integration: command can't be empty");
return;
}
if glyph.is_empty() {
self.toast("integration: glyph can't be empty");
return;
}
let new_icon = IntegrationIcon {
id: id.to_string(),
glyph: glyph.to_string(),
fallback: if panel.fallback.trim().is_empty() {
glyph.to_string()
} else {
panel.fallback.trim().to_string()
},
command: command.to_string(),
color: panel.color.trim().to_string(),
label: if panel.label.trim().is_empty() {
None
} else {
Some(panel.label.trim().to_string())
},
enabled: true,
in_palette_bar: false,
description: None,
homepage: None,
docs: None,
repository: None,
author: None,
version: None,
commands: Vec::new(),
};
let write_result = match panel.mode {
IntegrationEditMode::Edit => {
if let Some(slot) = self
.config
.ui
.integration_icons
.iter_mut()
.find(|ic| ic.id == new_icon.id)
{
*slot = new_icon.clone();
} else {
self.toast(format!("integration: {} no longer in rail", new_icon.id));
return;
}
write_override_toml(&new_icon)
}
IntegrationEditMode::AddCustom => {
if self
.config
.ui
.integration_icons
.iter()
.any(|ic| ic.id == new_icon.id)
{
self.toast(format!("integration: id {} already in rail", new_icon.id));
return;
}
self.config.ui.integration_icons.push(new_icon.clone());
write_authored_manifest_toml(&new_icon)
}
};
match write_result {
Ok(path) => self.toast(format!("integration saved · {}", path.display())),
Err(e) => self.toast(format!("integration saved in-memory (persist failed: {e})")),
}
self.integration_edit = None;
}
pub fn integration_edit_cycle_field(&mut self, delta: isize) {
use IntegrationEditField::*;
let order_full = [Id, Command, Glyph, Fallback, Color, Label];
let order_edit = [Glyph, Fallback, Color, Label];
let Some(panel) = self.integration_edit.as_mut() else {
return;
};
let order: &[IntegrationEditField] = match panel.mode {
IntegrationEditMode::Edit => &order_edit,
IntegrationEditMode::AddCustom => &order_full,
};
let Some(cur) = order.iter().position(|f| *f == panel.focused_field) else {
return;
};
let n = order.len() as isize;
let next = ((cur as isize + delta).rem_euclid(n)) as usize;
panel.focused_field = order[next];
match panel.focused_field {
Id => panel.id_cursor = panel.id_cursor.min(panel.id.len()),
Command => panel.command_cursor = panel.command_cursor.min(panel.command.len()),
Glyph => panel.glyph_cursor = panel.glyph_cursor.min(panel.glyph.len()),
Fallback => panel.fallback_cursor = panel.fallback_cursor.min(panel.fallback.len()),
Label => panel.label_cursor = panel.label_cursor.min(panel.label.len()),
Color => {}
}
}
pub fn integration_edit_color_cycle(&mut self, delta: isize) {
let Some(panel) = self.integration_edit.as_mut() else {
return;
};
if panel.focused_field != IntegrationEditField::Color {
return;
}
let n = INTEGRATION_EDIT_COLORS.len() as isize;
let cur = INTEGRATION_EDIT_COLORS
.iter()
.position(|c| *c == panel.color)
.unwrap_or(0) as isize;
let next = (cur + delta).rem_euclid(n) as usize;
panel.color = INTEGRATION_EDIT_COLORS[next].to_string();
}
pub fn integration_edit_type_char(&mut self, ch: char) {
let Some(panel) = self.integration_edit.as_mut() else {
return;
};
let (buf, cursor, cap): (&mut String, &mut usize, usize) = match panel.focused_field {
IntegrationEditField::Id => (&mut panel.id, &mut panel.id_cursor, 64),
IntegrationEditField::Command => (&mut panel.command, &mut panel.command_cursor, 128),
IntegrationEditField::Glyph => (&mut panel.glyph, &mut panel.glyph_cursor, 1),
IntegrationEditField::Fallback => (&mut panel.fallback, &mut panel.fallback_cursor, 8),
IntegrationEditField::Label => (&mut panel.label, &mut panel.label_cursor, 128),
IntegrationEditField::Color => return,
};
if buf.chars().count() >= cap {
return;
}
let cur = (*cursor).min(buf.len());
buf.insert(cur, ch);
*cursor = cur + ch.len_utf8();
}
pub fn integration_edit_paste(&mut self) {
let text = self.clipboard.text();
let cleaned: String = text
.trim()
.trim_matches(|c| c == '\'' || c == '"')
.chars()
.filter(|c| !c.is_control() && *c != '\r' && *c != '\n')
.collect();
if cleaned.is_empty() {
return;
}
let Some(panel) = self.integration_edit.as_mut() else {
return;
};
let (buf, cursor, cap): (&mut String, &mut usize, usize) = match panel.focused_field {
IntegrationEditField::Id => (&mut panel.id, &mut panel.id_cursor, 64),
IntegrationEditField::Command => (&mut panel.command, &mut panel.command_cursor, 128),
IntegrationEditField::Glyph => (&mut panel.glyph, &mut panel.glyph_cursor, 1),
IntegrationEditField::Fallback => (&mut panel.fallback, &mut panel.fallback_cursor, 8),
IntegrationEditField::Label => (&mut panel.label, &mut panel.label_cursor, 128),
IntegrationEditField::Color => return,
};
let existing = buf.chars().count();
let allowed = cap.saturating_sub(existing);
if allowed == 0 {
return;
}
let to_insert: String = cleaned.chars().take(allowed).collect();
let cur = (*cursor).min(buf.len());
buf.insert_str(cur, &to_insert);
*cursor = cur + to_insert.len();
}
pub fn integration_edit_backspace(&mut self) {
let Some(panel) = self.integration_edit.as_mut() else {
return;
};
let (buf, cursor): (&mut String, &mut usize) = match panel.focused_field {
IntegrationEditField::Id => (&mut panel.id, &mut panel.id_cursor),
IntegrationEditField::Command => (&mut panel.command, &mut panel.command_cursor),
IntegrationEditField::Glyph => (&mut panel.glyph, &mut panel.glyph_cursor),
IntegrationEditField::Fallback => (&mut panel.fallback, &mut panel.fallback_cursor),
IntegrationEditField::Label => (&mut panel.label, &mut panel.label_cursor),
IntegrationEditField::Color => return,
};
let cur = (*cursor).min(buf.len());
if cur == 0 {
return;
}
let prev = buf[..cur]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
buf.replace_range(prev..cur, "");
*cursor = prev;
}
pub fn integration_edit_delete_forward(&mut self) {
let Some(panel) = self.integration_edit.as_mut() else {
return;
};
let (buf, cursor): (&mut String, &mut usize) = match panel.focused_field {
IntegrationEditField::Id => (&mut panel.id, &mut panel.id_cursor),
IntegrationEditField::Command => (&mut panel.command, &mut panel.command_cursor),
IntegrationEditField::Glyph => (&mut panel.glyph, &mut panel.glyph_cursor),
IntegrationEditField::Fallback => (&mut panel.fallback, &mut panel.fallback_cursor),
IntegrationEditField::Label => (&mut panel.label, &mut panel.label_cursor),
IntegrationEditField::Color => return,
};
let cur = (*cursor).min(buf.len());
if cur >= buf.len() {
return;
}
let end = buf[cur..]
.char_indices()
.nth(1)
.map(|(i, _)| cur + i)
.unwrap_or(buf.len());
buf.replace_range(cur..end, "");
}
pub fn integration_edit_move_left(&mut self) {
let Some(panel) = self.integration_edit.as_mut() else {
return;
};
let (buf, cursor): (&String, &mut usize) = match panel.focused_field {
IntegrationEditField::Id => (&panel.id, &mut panel.id_cursor),
IntegrationEditField::Command => (&panel.command, &mut panel.command_cursor),
IntegrationEditField::Glyph => (&panel.glyph, &mut panel.glyph_cursor),
IntegrationEditField::Fallback => (&panel.fallback, &mut panel.fallback_cursor),
IntegrationEditField::Label => (&panel.label, &mut panel.label_cursor),
IntegrationEditField::Color => return,
};
let cur = (*cursor).min(buf.len());
if cur == 0 {
return;
}
let prev = buf[..cur]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
*cursor = prev;
}
pub fn integration_edit_move_right(&mut self) {
let Some(panel) = self.integration_edit.as_mut() else {
return;
};
let (buf, cursor): (&String, &mut usize) = match panel.focused_field {
IntegrationEditField::Id => (&panel.id, &mut panel.id_cursor),
IntegrationEditField::Command => (&panel.command, &mut panel.command_cursor),
IntegrationEditField::Glyph => (&panel.glyph, &mut panel.glyph_cursor),
IntegrationEditField::Fallback => (&panel.fallback, &mut panel.fallback_cursor),
IntegrationEditField::Label => (&panel.label, &mut panel.label_cursor),
IntegrationEditField::Color => return,
};
let cur = (*cursor).min(buf.len());
if cur >= buf.len() {
return;
}
let next = buf[cur..]
.char_indices()
.nth(1)
.map(|(i, _)| cur + i)
.unwrap_or(buf.len());
*cursor = next;
}
pub fn integration_edit_move_home(&mut self) {
let Some(panel) = self.integration_edit.as_mut() else {
return;
};
match panel.focused_field {
IntegrationEditField::Id => panel.id_cursor = 0,
IntegrationEditField::Command => panel.command_cursor = 0,
IntegrationEditField::Glyph => panel.glyph_cursor = 0,
IntegrationEditField::Fallback => panel.fallback_cursor = 0,
IntegrationEditField::Label => panel.label_cursor = 0,
IntegrationEditField::Color => {}
}
}
pub fn integration_edit_move_end(&mut self) {
let Some(panel) = self.integration_edit.as_mut() else {
return;
};
match panel.focused_field {
IntegrationEditField::Id => panel.id_cursor = panel.id.len(),
IntegrationEditField::Command => panel.command_cursor = panel.command.len(),
IntegrationEditField::Glyph => panel.glyph_cursor = panel.glyph.len(),
IntegrationEditField::Fallback => panel.fallback_cursor = panel.fallback.len(),
IntegrationEditField::Label => panel.label_cursor = panel.label.len(),
IntegrationEditField::Color => {}
}
}
pub fn integration_edit_delete_word_back(&mut self) {
let Some(panel) = self.integration_edit.as_mut() else {
return;
};
let (buf, cursor): (&mut String, &mut usize) = match panel.focused_field {
IntegrationEditField::Id => (&mut panel.id, &mut panel.id_cursor),
IntegrationEditField::Command => (&mut panel.command, &mut panel.command_cursor),
IntegrationEditField::Glyph => (&mut panel.glyph, &mut panel.glyph_cursor),
IntegrationEditField::Fallback => (&mut panel.fallback, &mut panel.fallback_cursor),
IntegrationEditField::Label => (&mut panel.label, &mut panel.label_cursor),
IntegrationEditField::Color => return,
};
let cur = (*cursor).min(buf.len());
let head = &buf[..cur];
let trimmed = head.trim_end_matches(char::is_whitespace);
let cut = trimmed
.char_indices()
.rev()
.find(|&(_, c)| c.is_whitespace())
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(0);
buf.replace_range(cut..cur, "");
*cursor = cut;
}
pub fn integration_edit_delete_to_start(&mut self) {
let Some(panel) = self.integration_edit.as_mut() else {
return;
};
let (buf, cursor): (&mut String, &mut usize) = match panel.focused_field {
IntegrationEditField::Id => (&mut panel.id, &mut panel.id_cursor),
IntegrationEditField::Command => (&mut panel.command, &mut panel.command_cursor),
IntegrationEditField::Glyph => (&mut panel.glyph, &mut panel.glyph_cursor),
IntegrationEditField::Fallback => (&mut panel.fallback, &mut panel.fallback_cursor),
IntegrationEditField::Label => (&mut panel.label, &mut panel.label_cursor),
IntegrationEditField::Color => return,
};
let cur = (*cursor).min(buf.len());
buf.replace_range(..cur, "");
*cursor = 0;
}
pub fn integration_edit_delete_to_end(&mut self) {
let Some(panel) = self.integration_edit.as_mut() else {
return;
};
let (buf, cursor): (&mut String, &mut usize) = match panel.focused_field {
IntegrationEditField::Id => (&mut panel.id, &mut panel.id_cursor),
IntegrationEditField::Command => (&mut panel.command, &mut panel.command_cursor),
IntegrationEditField::Glyph => (&mut panel.glyph, &mut panel.glyph_cursor),
IntegrationEditField::Fallback => (&mut panel.fallback, &mut panel.fallback_cursor),
IntegrationEditField::Label => (&mut panel.label, &mut panel.label_cursor),
IntegrationEditField::Color => return,
};
let cur = (*cursor).min(buf.len());
buf.truncate(cur);
}
}
pub fn persist_integration_icons(icons: &[IntegrationIcon]) -> Result<std::path::PathBuf, String> {
let path = crate::config::user_config_path()
.ok_or_else(|| "no $HOME or $XDG_CONFIG_HOME set".to_string())?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
}
let existing = std::fs::read_to_string(&path).unwrap_or_default();
let stripped = strip_integration_icon_blocks(&existing);
let appended = append_integration_icon_blocks(&stripped, icons);
crate::app::backup::write_toml_with_backup(&path, &appended, "config")
.map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(path)
}
pub fn write_override_toml(icon: &IntegrationIcon) -> Result<std::path::PathBuf, String> {
let dir = integrations_dir_or_err()?;
std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir {}: {e}", dir.display()))?;
let base_path = dir.join(format!("{}.toml", icon.id));
if !base_path.exists() {
if is_builtin_integration_id(&icon.id) {
let mut authoritative = builtin_default_icon(&icon.id).unwrap_or(icon.clone());
authoritative.enabled = icon.enabled;
authoritative.in_palette_bar = icon.in_palette_bar;
return write_authored_manifest_toml(&authoritative);
}
return write_authored_manifest_toml(icon);
}
let path = dir.join(format!("{}.override.toml", icon.id));
let mut body = String::new();
body.push_str(&format!("id = {}\n", toml_str(&icon.id)));
if let Some(label) = &icon.label {
body.push_str(&format!("label = {}\n", toml_str(label)));
}
body.push_str("\n[chip]\n");
body.push_str(&format!("glyph = {}\n", toml_str(&icon.glyph)));
body.push_str(&format!("fallback = {}\n", toml_str(&icon.fallback)));
body.push_str(&format!("color = {}\n", toml_str(&icon.color)));
body.push_str(&format!("enabled = {}\n", icon.enabled));
body.push_str(&format!("in_palette_bar = {}\n", icon.in_palette_bar));
crate::app::backup::write_toml_with_backup(&path, &body, "manifest")
.map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(path)
}
pub fn is_builtin_integration_id(id: &str) -> bool {
matches!(id, "browser" | "claude_code" | "codex" | "http")
}
pub fn builtin_default_icon(id: &str) -> Option<crate::config::IntegrationIcon> {
let cfg = crate::config::Config::default();
cfg.ui.integration_icons.into_iter().find(|i| i.id == id)
}
pub fn write_authored_manifest_toml(icon: &IntegrationIcon) -> Result<std::path::PathBuf, String> {
let dir = integrations_dir_or_err()?;
std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir {}: {e}", dir.display()))?;
let path = dir.join(format!("{}.toml", icon.id));
let mut body = String::new();
body.push_str(&format!("id = {}\n", toml_str(&icon.id)));
let display_label = icon.label.clone().unwrap_or_else(|| icon.id.clone());
body.push_str(&format!("label = {}\n", toml_str(&display_label)));
body.push_str("\n[chip]\n");
body.push_str(&format!("glyph = {}\n", toml_str(&icon.glyph)));
body.push_str(&format!("fallback = {}\n", toml_str(&icon.fallback)));
body.push_str(&format!("color = {}\n", toml_str(&icon.color)));
body.push_str(&format!("enabled = {}\n", icon.enabled));
body.push_str(&format!("in_palette_bar = {}\n", icon.in_palette_bar));
body.push_str("\n[[commands]]\n");
body.push_str(&format!(
"id = {}\n",
toml_str(&format!("{}.open", icon.id))
));
body.push_str(&format!(
"title = {}\n",
toml_str(&format!("{}: open", display_label))
));
body.push_str(&format!("run = {}\n", toml_str(&icon.command)));
crate::app::backup::write_toml_with_backup(&path, &body, "manifest")
.map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(path)
}
fn integrations_dir_or_err() -> Result<std::path::PathBuf, String> {
let home = std::env::var_os("HOME").ok_or("no $HOME set — can't locate integrations dir")?;
Ok(std::path::PathBuf::from(home)
.join(".config")
.join("mnml")
.join("integrations"))
}
pub fn persist_ui_string(key: &'static str, value: &str) -> Result<std::path::PathBuf, String> {
let esc = value.replace('\\', r"\\").replace('"', "\\\"");
persist_config_scalar("ui", key, format!("\"{esc}\""))
}
pub fn persist_activity_bar_pinned_integrations(
ids: &[String],
) -> Result<std::path::PathBuf, String> {
persist_ui_string_array("activity_bar_pinned_integrations", ids)
}
pub fn persist_top_bar_cluster_mode(mode: &'static str) -> Result<std::path::PathBuf, String> {
persist_ui_string("top_bar_cluster_mode", mode)
}
pub fn persist_ui_bool(key: &'static str, value: bool) -> Result<std::path::PathBuf, String> {
persist_config_scalar("ui", key, value.to_string())
}
pub fn persist_ui_int(key: &'static str, value: i64) -> Result<std::path::PathBuf, String> {
persist_config_scalar("ui", key, value.to_string())
}
pub fn persist_marketplace_bool(
key: &'static str,
value: bool,
) -> Result<std::path::PathBuf, String> {
persist_config_scalar("marketplace", key, value.to_string())
}
pub fn persist_integration_auto_update(
id: &str,
value: bool,
) -> Result<std::path::PathBuf, String> {
let dir = crate::data_root::data_root().join("integrations");
if !dir.exists() {
std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir integrations: {e}"))?;
}
let path = dir.join(format!("{id}.override.toml"));
let existing = std::fs::read_to_string(&path).ok();
let mut wrote = false;
let new_body = if let Some(body) = existing {
let mut out = String::with_capacity(body.len() + 32);
for line in body.split_inclusive('\n') {
let trimmed = line.trim_start();
if trimmed.starts_with("auto_update") && line.contains('=') && !wrote {
out.push_str(&format!("auto_update = {value}\n"));
wrote = true;
} else {
out.push_str(line);
}
}
if !wrote {
if !out.ends_with('\n') {
out.push('\n');
}
out.push_str(&format!("auto_update = {value}\n"));
}
out
} else {
format!("id = \"{id}\"\nauto_update = {value}\n")
};
std::fs::write(&path, new_body).map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(path)
}
pub fn persist_editor_bool(key: &'static str, value: bool) -> Result<std::path::PathBuf, String> {
persist_config_scalar("editor", key, value.to_string())
}
pub fn persist_editor_string(key: &'static str, value: &str) -> Result<std::path::PathBuf, String> {
let esc = value.replace('\\', r"\\").replace('"', "\\\"");
persist_config_scalar("editor", key, format!("\"{esc}\""))
}
pub fn persist_ai_bool(key: &'static str, value: bool) -> Result<std::path::PathBuf, String> {
persist_config_scalar("ai", key, value.to_string())
}
pub fn persist_ai_string(key: &'static str, value: &str) -> Result<std::path::PathBuf, String> {
let esc = value.replace('\\', r"\\").replace('"', "\\\"");
persist_config_scalar("ai", key, format!("\"{esc}\""))
}
pub fn persist_ai_routing(product: &str, value: &str) -> Result<std::path::PathBuf, String> {
let esc = value.replace('\\', r"\\").replace('"', "\\\"");
let section = format!("ai.routing.{product}");
persist_config_scalar(§ion, "backend", format!("\"{esc}\""))
}
fn persist_config_scalar(
section: &str,
key: &str,
value_encoded: String,
) -> Result<std::path::PathBuf, String> {
let path = crate::config::user_config_path()
.ok_or_else(|| "no $HOME or $XDG_CONFIG_HOME set".to_string())?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
}
let existing = std::fs::read_to_string(&path).unwrap_or_default();
let new_line = format!("{key} = {value_encoded}");
let header = format!("[{section}]");
let mut out: Vec<String> = Vec::new();
let mut in_section = false;
let mut section_header_idx: Option<usize> = None;
let mut key_replaced = false;
for line in existing.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
in_section = trimmed == header;
if in_section {
section_header_idx = Some(out.len());
}
out.push(line.to_string());
continue;
}
if in_section
&& !key_replaced
&& (trimmed.starts_with(&format!("{key} ")) || trimmed.starts_with(&format!("{key}=")))
{
let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
out.push(format!("{indent}{new_line}"));
key_replaced = true;
continue;
}
out.push(line.to_string());
}
if !key_replaced {
if let Some(idx) = section_header_idx {
out.insert(idx + 1, new_line);
} else {
if !out.is_empty() && !out.last().is_some_and(|l| l.trim().is_empty()) {
out.push(String::new());
}
out.push(header);
out.push(new_line);
}
}
let contents = out.join("\n") + "\n";
crate::app::backup::write_toml_with_backup(&path, &contents, "config")
.map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(path)
}
pub fn persist_integration_icon_order(ids: &[String]) -> Result<std::path::PathBuf, String> {
persist_ui_string_array("integration_icon_order", ids)
}
pub fn persist_ui_string_array(key: &str, ids: &[String]) -> Result<std::path::PathBuf, String> {
let path = crate::config::user_config_path()
.ok_or_else(|| "no $HOME or $XDG_CONFIG_HOME set".to_string())?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
}
let existing = std::fs::read_to_string(&path).unwrap_or_default();
let esc = |s: &str| s.replace('\\', r"\\").replace('"', "\\\"");
let arr = ids
.iter()
.map(|s| format!("\"{}\"", esc(s)))
.collect::<Vec<_>>()
.join(", ");
let new_line = format!("{key} = [{arr}]");
let mut out: Vec<String> = Vec::new();
let mut in_ui = false;
let mut ui_header_idx: Option<usize> = None;
let mut key_replaced = false;
for line in existing.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
in_ui = trimmed == "[ui]";
if in_ui {
ui_header_idx = Some(out.len());
}
out.push(line.to_string());
continue;
}
if in_ui
&& !key_replaced
&& (trimmed.starts_with(&format!("{key} ")) || trimmed.starts_with(&format!("{key}=")))
{
let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
out.push(format!("{indent}{new_line}"));
key_replaced = true;
continue;
}
out.push(line.to_string());
}
if !key_replaced {
if let Some(idx) = ui_header_idx {
out.insert(idx + 1, new_line);
} else {
if !out.is_empty() && !out.last().is_some_and(|l| l.trim().is_empty()) {
out.push(String::new());
}
out.push("[ui]".to_string());
out.push(new_line);
}
}
let contents = out.join("\n") + "\n";
crate::app::backup::write_toml_with_backup(&path, &contents, "config")
.map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(path)
}
const MANAGED_BANNER_MARKER: &str = "# ── mnml-managed integration icons";
fn strip_integration_icon_blocks(src: &str) -> String {
let mut out = String::with_capacity(src.len());
let mut skipping = false;
let mut last_was_blank = false;
for line in src.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with(MANAGED_BANNER_MARKER) {
skipping = true;
continue;
}
if trimmed == "[[ui.integration_icon]]" {
skipping = true;
continue;
}
if skipping {
if (trimmed.starts_with('[') && !trimmed.starts_with("[ "))
&& trimmed != "[[ui.integration_icon]]"
{
skipping = false;
} else {
continue;
}
}
if line.trim().is_empty() {
if last_was_blank {
continue;
}
last_was_blank = true;
} else {
last_was_blank = false;
}
out.push_str(line);
out.push('\n');
}
out
}
fn append_integration_icon_blocks(existing: &str, icons: &[IntegrationIcon]) -> String {
let mut out = existing.trim_end().to_string();
if !out.is_empty() {
out.push_str("\n\n");
}
out.push_str("# ── mnml-managed integration icons ──────────────────────────────────\n");
out.push_str("# 2026-08-01 — slim entries. Only `enabled` +\n");
out.push_str("# `in_palette_bar` (and file order) come from here now;\n");
out.push_str("# glyph / label / command / color / fallback / description\n");
out.push_str("# all read from the integration's installed manifest (or a\n");
out.push_str("# built-in default in mnml core). This section is rewritten\n");
out.push_str("# in place on every right-click toggle. Any fields you add\n");
out.push_str("# by hand will get dropped on next save — add an override\n");
out.push_str("# mechanism if you need per-user glyph/label customization.\n\n");
for ic in icons {
out.push_str("[[ui.integration_icon]]\n");
out.push_str(&format!("id = {}\n", toml_str(&ic.id)));
out.push_str(&format!("enabled = {}\n", ic.enabled));
if ic.in_palette_bar {
out.push_str("in_palette_bar = true\n");
}
out.push('\n');
}
out
}
fn toml_str(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
_ => out.push(c),
}
}
out.push('"');
out
}
pub fn cleanup_retired_id_manifests() -> usize {
let retired_ids = ["bitbucket", "linear", "gitlab", "cypress", "slack"];
let builtin_ids = ["browser", "claude_code", "codex", "http"];
let dir = crate::data_root::data_root().join("integrations");
let mut cleaned = 0usize;
for id in retired_ids {
for suffix in ["toml", "override.toml"] {
let p = dir.join(format!("{id}.{suffix}"));
if p.exists() && std::fs::remove_file(&p).is_ok() {
cleaned += 1;
}
}
}
for id in builtin_ids {
for suffix in ["toml", "override.toml"] {
let p = dir.join(format!("{id}.{suffix}"));
if p.exists() && manifest_looks_bogus(&p, id) && std::fs::remove_file(&p).is_ok() {
cleaned += 1;
}
}
}
cleaned
}
fn manifest_looks_bogus(path: &std::path::Path, id: &str) -> bool {
let Ok(text) = std::fs::read_to_string(path) else {
return false;
};
let Ok(doc) = toml::from_str::<toml::Value>(&text) else {
return false;
};
let glyph_empty = doc
.get("chip")
.and_then(|c| c.get("glyph"))
.and_then(|v| v.as_str())
.map(|s| s.is_empty())
.unwrap_or(true);
let bogus_command = doc
.get("command")
.and_then(|v| v.as_str())
.map(|s| s == format!("{id}.open"))
.unwrap_or(false);
glyph_empty || bogus_command
}
pub fn migrate_legacy_integration_icon_blocks() -> Result<(usize, Vec<String>), String> {
let cfg_path = crate::config::user_config_path()
.ok_or_else(|| "no user config path resolvable".to_string())?;
let Ok(existing) = std::fs::read_to_string(&cfg_path) else {
return Ok((0, Vec::new()));
};
if !existing.contains("[[ui.integration_icon]]") {
return Ok((0, Vec::new()));
}
let doc: toml::Value =
toml::from_str(&existing).map_err(|e| format!("parse {}: {e}", cfg_path.display()))?;
let mut migrated = 0usize;
let mut warns: Vec<String> = Vec::new();
let legacy_blocks = doc
.get("ui")
.and_then(|u| u.get("integration_icon"))
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let retired_ids = ["bitbucket", "linear", "gitlab", "cypress", "slack"];
for block in &legacy_blocks {
let Some(id) = block.get("id").and_then(|v| v.as_str()) else {
warns.push("legacy block without id — skipped".to_string());
continue;
};
if retired_ids.contains(&id) {
migrated += 1;
continue;
}
let enabled = block.get("enabled").and_then(|v| v.as_bool());
let in_palette_bar = block.get("in_palette_bar").and_then(|v| v.as_bool());
let has_delta = matches!(enabled, Some(false)) || matches!(in_palette_bar, Some(true));
if !has_delta {
migrated += 1;
continue;
}
let icon = IntegrationIcon {
id: id.to_string(),
enabled: enabled.unwrap_or(true),
in_palette_bar: in_palette_bar.unwrap_or(false),
glyph: String::new(),
fallback: String::new(),
command: String::new(),
color: String::new(),
label: None,
description: None,
homepage: None,
docs: None,
repository: None,
author: None,
version: None,
commands: Vec::new(),
};
match write_override_toml(&icon) {
Ok(_) => migrated += 1,
Err(e) => warns.push(format!("override write for {id}: {e}")),
}
}
let stripped = strip_integration_icon_blocks(&existing);
if stripped != existing {
crate::config::write_user_config(&cfg_path, &stripped)
.map_err(|e| format!("write {}: {e}", cfg_path.display()))?;
}
Ok((migrated, warns))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn integration_edit_backspace_after_glyph_width_swap_is_safe() {
let d = tempfile::tempdir().unwrap();
let cfg = crate::config::Config::default();
let mut app = crate::app::App::new(d.path().to_path_buf(), cfg).unwrap();
app.integration_edit = Some(IntegrationEditState {
mode: IntegrationEditMode::Edit,
id: "test".to_string(),
command: String::new(),
glyph: "\u{F0001}".to_string(), fallback: String::new(),
color: "cyan".to_string(),
label: String::new(),
focused_field: IntegrationEditField::Glyph,
id_cursor: 0,
command_cursor: 0,
glyph_cursor: 4, fallback_cursor: 0,
label_cursor: 0,
});
if let Some(p) = app.integration_edit.as_mut() {
p.glyph.clear();
p.glyph.push('\u{E000}'); p.glyph_cursor = p.glyph.len();
}
app.integration_edit_backspace();
assert_eq!(app.integration_edit.as_ref().unwrap().glyph, "");
assert_eq!(app.integration_edit.as_ref().unwrap().glyph_cursor, 0);
}
#[test]
fn strip_removes_block_and_leaves_other_sections() {
let src = "\
[ui]
ascii_icons = false
[[ui.integration_icon]]
id = \"lambda\"
glyph = \"x\"
fallback = \"L\"
command = \":term mnml-aws-lambda\"
color = \"orange\"
[[ui.launcher_icon]]
id = \"claude\"
glyph = \"y\"
fallback = \"C\"
command = \":ai.claude_code\"
color = \"blue\"
";
let out = strip_integration_icon_blocks(src);
assert!(!out.contains("integration_icon"));
assert!(!out.contains("mnml-aws-lambda"));
assert!(out.contains("[[ui.launcher_icon]]"));
assert!(out.contains("ascii_icons = false"));
}
#[test]
fn append_writes_full_icon_list() {
let icons = vec![
IntegrationIcon {
id: "lambda".to_string(),
glyph: "x".to_string(),
fallback: "L".to_string(),
command: ":term mnml-aws-lambda".to_string(),
color: "orange".to_string(),
label: Some("Lambda".to_string()),
enabled: false,
in_palette_bar: false,
description: None,
homepage: None,
docs: None,
repository: None,
author: None,
version: None,
commands: Vec::new(),
},
IntegrationIcon {
id: "s3".to_string(),
glyph: "y".to_string(),
fallback: "S3".to_string(),
command: ":term mnml-fs-s3".to_string(),
color: "orange".to_string(),
label: None,
enabled: false,
in_palette_bar: false,
description: None,
homepage: None,
docs: None,
repository: None,
author: None,
version: None,
commands: Vec::new(),
},
];
let out = append_integration_icon_blocks("", &icons);
let parsed: toml::Value = toml::from_str(&out).expect("roundtrips through toml::from_str");
let array = parsed
.get("ui")
.and_then(|u| u.get("integration_icon"))
.and_then(|a| a.as_array())
.expect("integration_icon array present");
assert_eq!(array.len(), 2);
}
#[test]
fn strip_then_append_is_idempotent() {
let icons = vec![IntegrationIcon {
id: "lambda".to_string(),
glyph: "x".to_string(),
fallback: "L".to_string(),
command: ":term mnml-aws-lambda".to_string(),
color: "orange".to_string(),
label: None,
enabled: false,
in_palette_bar: false,
description: None,
homepage: None,
docs: None,
repository: None,
author: None,
version: None,
commands: Vec::new(),
}];
let first = append_integration_icon_blocks("", &icons);
let stripped = strip_integration_icon_blocks(&first);
let second = append_integration_icon_blocks(&stripped, &icons);
assert_eq!(first, second);
}
#[test]
fn toml_str_escapes_quotes_and_backslashes() {
assert_eq!(toml_str("plain"), "\"plain\"");
assert_eq!(toml_str("he said \"hi\""), "\"he said \\\"hi\\\"\"");
assert_eq!(toml_str("c:\\path"), "\"c:\\\\path\"");
}
#[test]
fn append_integration_icon_blocks_preserves_enabled_true() {
let icons = vec![IntegrationIcon {
id: "myapp".to_string(),
glyph: "x".to_string(),
fallback: "M".to_string(),
command: ":term myapp".to_string(),
color: "cyan".to_string(),
label: Some("My App".to_string()),
enabled: true,
in_palette_bar: false,
description: None,
homepage: None,
docs: None,
repository: None,
author: None,
version: None,
commands: Vec::new(),
}];
let toml_out = append_integration_icon_blocks("", &icons);
assert!(
toml_out.contains("enabled = true"),
"enabled=true must appear in TOML output; got:\n{toml_out}"
);
let parsed: toml::Value = toml::from_str(&toml_out).expect("valid TOML");
let enabled = parsed
.get("ui")
.and_then(|u| u.get("integration_icon"))
.and_then(|a| a.as_array())
.and_then(|a| a.first())
.and_then(|e| e.get("enabled"))
.and_then(|v| v.as_bool())
.expect("enabled key present in parsed TOML");
assert!(enabled);
}
#[test]
fn append_integration_icon_blocks_enabled_false_is_explicit() {
let icons = vec![IntegrationIcon {
id: "disabled_one".to_string(),
glyph: "y".to_string(),
fallback: "D".to_string(),
command: ":term disabled_one".to_string(),
color: "red".to_string(),
label: None,
enabled: false,
in_palette_bar: false,
description: None,
homepage: None,
docs: None,
repository: None,
author: None,
version: None,
commands: Vec::new(),
}];
let toml_out = append_integration_icon_blocks("", &icons);
assert!(
toml_out.contains("enabled = false"),
"enabled=false must appear literally; got:\n{toml_out}"
);
}
#[test]
fn remove_integration_by_id_deletes_installed_manifest() {
let tmp = tempfile::tempdir().unwrap();
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _home = crate::EnvGuard::set("HOME", tmp.path());
let dir = tmp.path().join(".config").join("mnml").join("integrations");
std::fs::create_dir_all(&dir).unwrap();
let manifest_path = dir.join("testxyz.toml");
std::fs::write(
&manifest_path,
r#"id = "testxyz"
label = "Test XYZ"
[chip]
glyph = "T"
fallback = "T"
color = "cyan"
enabled = true
"#,
)
.unwrap();
assert!(manifest_path.exists());
let ws = tempfile::tempdir().unwrap();
let mut app =
crate::app::App::new(ws.path().to_path_buf(), crate::config::Config::default())
.unwrap();
app.config
.ui
.integration_icons
.push(crate::config::IntegrationIcon {
id: "testxyz".to_string(),
glyph: "T".to_string(),
fallback: "T".to_string(),
command: "testxyz.open".to_string(),
color: "cyan".to_string(),
label: Some("Test XYZ".to_string()),
enabled: true,
in_palette_bar: false,
description: None,
homepage: None,
docs: None,
repository: None,
author: None,
version: None,
commands: Vec::new(),
});
app.remove_integration_by_id("testxyz");
assert!(!manifest_path.exists(), "manifest file should be deleted");
assert!(
!app.config
.ui
.integration_icons
.iter()
.any(|i| i.id == "testxyz"),
"rail chip should be removed"
);
}
#[test]
fn remove_integration_by_id_also_deletes_override_sidecar() {
let tmp = tempfile::tempdir().unwrap();
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _home = crate::EnvGuard::set("HOME", tmp.path());
let dir = tmp.path().join(".config").join("mnml").join("integrations");
std::fs::create_dir_all(&dir).unwrap();
let base = dir.join("testxyz.toml");
let over = dir.join("testxyz.override.toml");
std::fs::write(&base, "id = \"testxyz\"\nlabel = \"X\"\n").unwrap();
std::fs::write(&over, "id = \"testxyz\"\n").unwrap();
let ws = tempfile::tempdir().unwrap();
let mut app =
crate::app::App::new(ws.path().to_path_buf(), crate::config::Config::default())
.unwrap();
app.remove_integration_by_id("testxyz");
assert!(!base.exists(), "base .toml should be deleted");
assert!(!over.exists(), "sidecar .override.toml should be deleted");
}
#[test]
fn write_override_toml_emits_loader_readable_shape() {
let tmp = tempfile::tempdir().unwrap();
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _home = crate::EnvGuard::set("HOME", tmp.path());
let dir = tmp.path().join(".config").join("mnml").join("integrations");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("myint.toml"),
"id = \"myint\"\nlabel = \"canonical\"\n",
)
.unwrap();
let icon = IntegrationIcon {
id: "myint".to_string(),
glyph: "M".to_string(),
fallback: "M".to_string(),
command: "myint.open".to_string(),
color: "purple".to_string(),
label: Some("My Integration".to_string()),
enabled: true,
in_palette_bar: true,
description: None,
homepage: None,
docs: None,
repository: None,
author: None,
version: None,
commands: Vec::new(),
};
let path = write_override_toml(&icon).expect("write");
let body = std::fs::read_to_string(&path).unwrap();
assert!(body.contains("id = \"myint\""));
assert!(body.contains("label = \"My Integration\""));
assert!(body.contains("[chip]"));
assert!(body.contains("glyph = \"M\""));
assert!(body.contains("color = \"purple\""));
assert!(body.contains("in_palette_bar = true"));
assert!(path.ends_with("myint.override.toml"));
}
#[test]
fn cleanup_preserves_legitimate_builtin_manifest() {
let tmp = tempfile::tempdir().unwrap();
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _home = crate::EnvGuard::set("HOME", tmp.path());
let _xdg = crate::EnvGuard::remove("XDG_CONFIG_HOME");
let icon = IntegrationIcon {
id: "codex".to_string(),
glyph: "\u{F1E01}".to_string(),
fallback: "\u{276F}_".to_string(),
command: "ai.codex".to_string(),
color: "cyan".to_string(),
label: Some("Codex".to_string()),
enabled: true,
in_palette_bar: true,
description: None,
homepage: None,
docs: None,
repository: None,
author: None,
version: None,
commands: Vec::new(),
};
let path = write_override_toml(&icon).expect("write");
assert!(path.exists(), "manifest written by toggle path");
let cleaned = cleanup_retired_id_manifests();
assert_eq!(
cleaned, 0,
"legitimate builtin manifest must not be deleted"
);
assert!(path.exists(), "codex.toml must survive cleanup");
}
#[test]
fn cleanup_still_deletes_bogus_builtin_scaffold() {
let tmp = tempfile::tempdir().unwrap();
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _home = crate::EnvGuard::set("HOME", tmp.path());
let _xdg = crate::EnvGuard::remove("XDG_CONFIG_HOME");
let dir = tmp.path().join(".config").join("mnml").join("integrations");
std::fs::create_dir_all(&dir).unwrap();
let bogus = dir.join("codex.toml");
std::fs::write(
&bogus,
"id = \"codex\"\nlabel = \"codex\"\ncommand = \"codex.open\"\n\n[chip]\nglyph = \"\"\n",
)
.unwrap();
let cleaned = cleanup_retired_id_manifests();
assert_eq!(cleaned, 1, "bogus scaffold must be deleted");
assert!(!bogus.exists(), "bogus codex.toml should be gone");
}
#[test]
fn write_override_toml_promotes_to_authored_when_no_base() {
let tmp = tempfile::tempdir().unwrap();
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _home = crate::EnvGuard::set("HOME", tmp.path());
let icon = IntegrationIcon {
id: "claude_code".to_string(),
glyph: "C".to_string(),
fallback: "C".to_string(),
command: "ai.claude_code".to_string(),
color: "orange".to_string(),
label: Some("Claude".to_string()),
enabled: true,
in_palette_bar: false,
description: None,
homepage: None,
docs: None,
repository: None,
author: None,
version: None,
commands: Vec::new(),
};
let path = write_override_toml(&icon).expect("write");
let dir = tmp.path().join(".config").join("mnml").join("integrations");
let base = dir.join("claude_code.toml");
let over = dir.join("claude_code.override.toml");
assert!(
path.ends_with("claude_code.toml"),
"expected authored .toml, got {path:?}"
);
assert!(base.exists(), "base file must exist after promotion");
assert!(!over.exists(), "no override sidecar for a promoted write");
}
#[test]
fn migrate_legacy_integration_icon_blocks_writes_override_and_strips() {
let tmp = tempfile::tempdir().unwrap();
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _home = crate::EnvGuard::set("HOME", tmp.path());
let _xdg = crate::EnvGuard::remove("XDG_CONFIG_HOME");
let idir = tmp.path().join(".config").join("mnml").join("integrations");
std::fs::create_dir_all(&idir).unwrap();
std::fs::write(
idir.join("mystery.toml"),
"id = \"mystery\"\nlabel = \"Mystery\"\n",
)
.unwrap();
let cfg_dir = tmp.path().join(".config").join("mnml");
std::fs::create_dir_all(&cfg_dir).unwrap();
let cfg_path = cfg_dir.join("config.toml");
std::fs::write(
&cfg_path,
"[ui]\ntheme = \"onedark\"\n\n\
[[ui.integration_icon]]\nid = \"mystery\"\nenabled = false\n",
)
.unwrap();
let (n, warns) = migrate_legacy_integration_icon_blocks().unwrap();
assert_eq!(n, 1, "one block migrated");
assert!(warns.is_empty(), "no warnings: {warns:?}");
let override_path = idir.join("mystery.override.toml");
assert!(override_path.exists(), "override sidecar written");
let body = std::fs::read_to_string(&override_path).unwrap();
assert!(
body.contains("enabled = false"),
"override preserves enabled=false: {body}"
);
let after = std::fs::read_to_string(&cfg_path).unwrap();
assert!(
after.contains("theme = \"onedark\""),
"non-integration keys survive: {after}"
);
assert!(
!after.contains("[[ui.integration_icon]]"),
"legacy block removed: {after}"
);
let (n2, warns2) = migrate_legacy_integration_icon_blocks().unwrap();
assert_eq!(n2, 0, "second call migrates nothing");
assert!(warns2.is_empty());
}
#[test]
fn migrate_legacy_default_only_block_strips_without_override() {
let tmp = tempfile::tempdir().unwrap();
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _home = crate::EnvGuard::set("HOME", tmp.path());
let _xdg = crate::EnvGuard::remove("XDG_CONFIG_HOME");
let idir = tmp.path().join(".config").join("mnml").join("integrations");
std::fs::create_dir_all(&idir).unwrap();
std::fs::write(idir.join("thing.toml"), "id = \"thing\"\nlabel = \"T\"\n").unwrap();
let cfg_dir = tmp.path().join(".config").join("mnml");
std::fs::create_dir_all(&cfg_dir).unwrap();
let cfg_path = cfg_dir.join("config.toml");
std::fs::write(
&cfg_path,
"[[ui.integration_icon]]\nid = \"thing\"\nenabled = true\n",
)
.unwrap();
let (n, _) = migrate_legacy_integration_icon_blocks().unwrap();
assert_eq!(n, 1, "block counted as migrated");
assert!(
!idir.join("thing.override.toml").exists(),
"no override for default-only block"
);
assert!(
!std::fs::read_to_string(&cfg_path)
.unwrap()
.contains("[[ui.integration_icon]]"),
"block still stripped"
);
}
}