use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
const DEAD_INTEGRATION_IDS: &[&str] = &["bitbucket", "linear", "gitlab", "cypress", "slack"];
#[derive(Debug, Clone)]
pub struct Config {
pub editor: EditorConfig,
pub ui: UiConfig,
pub session: SessionConfig,
pub cloud_run: CloudRunConfig,
pub jira: JiraConfig,
pub cloud_agents: CloudAgentsConfig,
pub keys: BTreeMap<String, BTreeMap<String, String>>,
pub lsp: BTreeMap<String, toml::Value>,
pub ai: toml::Value,
pub tools: toml::Value,
pub http: HttpConfig,
pub ws: WsConfig,
pub git_graph: GitGraphConfig,
pub tasks: BTreeMap<String, TaskDef>,
pub startup_tasks: Vec<String>,
pub startup_layout: Vec<StartupLayoutEntry>,
pub default_workspace: Option<PathBuf>,
pub snippets: BTreeMap<String, BTreeMap<String, String>>,
pub abbreviations: BTreeMap<String, String>,
pub formatters: BTreeMap<String, crate::formatter::FormatterEntry>,
pub linters: BTreeMap<String, crate::linter::LinterEntry>,
pub dap: BTreeMap<String, toml::Value>,
pub browser: BrowserConfig,
pub ci: CiConfig,
pub integrations: IntegrationsConfig,
pub workspaces: Vec<WorkspaceConfig>,
pub marketplace: MarketplaceConfig,
}
#[derive(Debug, Clone)]
pub struct MarketplaceConfig {
pub enabled: bool,
pub cache_ttl_secs: u64,
pub use_defaults: bool,
pub sources: Vec<crate::marketplace::Source>,
pub show_dev_tab: bool,
}
impl Default for MarketplaceConfig {
fn default() -> Self {
Self {
enabled: true,
cache_ttl_secs: 3600,
use_defaults: true,
sources: Vec::new(),
show_dev_tab: false,
}
}
}
impl MarketplaceConfig {
pub fn effective_sources(&self) -> Vec<crate::marketplace::Source> {
let mut out = if self.use_defaults {
crate::marketplace::default_sources()
} else {
Vec::new()
};
out.extend(self.sources.iter().cloned());
out
}
}
#[derive(Debug, Clone, Default)]
pub struct CloudRunConfig {
pub defaults: CloudRunDefaults,
}
#[derive(Debug, Clone, Default)]
pub struct JiraConfig {
pub domain: String,
pub ticket_prefix: String,
}
#[derive(Debug, Clone, Default)]
pub struct CloudAgentsConfig {
pub label: String,
pub short_id: String,
pub region: String,
pub account_id: String,
pub runs_table: String,
pub cluster: String,
pub task_definition: String,
pub sg_export_name: String,
pub log_group: String,
pub aws_profile_fallback: String,
pub s3_artifacts_bucket: String,
pub default_workspace_label: String,
}
impl CloudAgentsConfig {
pub fn is_enabled(&self) -> bool {
!self.effective_region().is_empty() && !self.runs_table.is_empty()
}
pub fn effective_region(&self) -> String {
if let Ok(v) = std::env::var("MNML_CLOUD_AGENTS_REGION")
&& !v.is_empty()
{
return v;
}
self.region.clone()
}
pub fn effective_aws_profile_fallback(&self) -> Option<String> {
if let Ok(v) = std::env::var("MNML_AWS_PROFILE")
&& !v.is_empty()
{
return Some(v);
}
if self.aws_profile_fallback.is_empty() {
None
} else {
Some(self.aws_profile_fallback.clone())
}
}
pub fn effective_default_workspace_label(&self) -> &str {
if self.default_workspace_label.is_empty() {
"cloud"
} else {
&self.default_workspace_label
}
}
}
impl JiraConfig {
pub fn effective_domain(&self) -> Option<String> {
if let Ok(v) = std::env::var("MNML_JIRA_DOMAIN")
&& !v.is_empty()
{
return Some(v);
}
if self.domain.is_empty() {
None
} else {
Some(self.domain.clone())
}
}
pub fn effective_ticket_prefix(&self) -> Option<String> {
if let Ok(v) = std::env::var("MNML_JIRA_TICKET_PREFIX")
&& !v.is_empty()
{
return Some(v);
}
if self.ticket_prefix.is_empty() {
None
} else {
Some(self.ticket_prefix.clone())
}
}
}
#[derive(Debug, Clone, Default)]
pub struct CloudRunDefaults {
pub agent_id: String,
pub env_id: String,
pub sandbox: String,
pub model: String,
}
#[derive(Debug, Clone)]
pub struct WorkspaceConfig {
pub name: String,
pub path: PathBuf,
pub group: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct CiConfig {
pub provider: Option<String>,
pub project: Option<String>,
pub region: Option<String>,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct IntegrationsConfig {
pub auto_update_cargo: bool,
pub auto_update_git: bool,
}
#[derive(Debug, Clone)]
pub struct BrowserConfig {
pub headless: bool,
pub autocapture_to_log: bool,
pub profile_mode: String,
}
#[derive(Debug, Clone)]
pub struct StartupLayoutEntry {
pub kind: String,
pub path: Option<String>,
pub cmd: Option<String>,
pub split: Option<String>,
pub ratio: Option<u16>,
}
#[derive(Debug, Clone)]
pub struct TaskDef {
pub cmd: String,
pub cwd: Option<String>,
}
#[derive(Debug, Clone)]
pub struct HttpConfig {
pub default_env: Option<String>,
pub collection_root: HttpCollectionRoot,
pub auto_format_body: bool,
pub sync_normalize: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HttpCollectionRoot {
#[default]
Hidden,
Workspace,
}
impl Default for HttpConfig {
fn default() -> Self {
HttpConfig {
default_env: None,
collection_root: HttpCollectionRoot::Hidden,
auto_format_body: true,
sync_normalize: false,
}
}
}
#[derive(Debug, Clone)]
pub struct WsConfig {
pub subprotocols: Vec<String>,
pub ping_interval_secs: u32,
pub reconnect_max_attempts: u32,
}
impl Default for WsConfig {
fn default() -> Self {
Self {
subprotocols: Vec::new(),
ping_interval_secs: 30,
reconnect_max_attempts: 3,
}
}
}
#[derive(Debug, Clone)]
pub struct GitGraphConfig {
pub lane_spacing: u16,
}
impl Default for GitGraphConfig {
fn default() -> Self {
Self { lane_spacing: 1 }
}
}
#[derive(Debug, Clone)]
pub struct EditorConfig {
pub input_style: String,
pub tab_width: usize,
pub autosave_secs: u64,
pub trim_trailing_ws_on_save: bool,
pub breadcrumb: bool,
pub auto_pair: bool,
pub auto_indent: bool,
pub format_on_save: bool,
pub will_save_wait_until: bool,
pub format_on_type: bool,
pub autosave_on_focus_loss: bool,
pub inlay_hints: bool,
pub cursor_blink: bool,
pub semantic_tokens_viewport: bool,
pub code_lens: bool,
pub text_width: usize,
pub ensure_trailing_newline: bool,
pub wheel_moves_cursor: String,
}
#[derive(Debug, Clone)]
pub struct SessionConfig {
pub restore: bool,
}
#[derive(Debug, Clone)]
pub struct UiConfig {
pub theme: String,
pub cmdline_popup_border_color: String,
pub theme_toggle: Option<String>,
pub theme_auto_system: bool,
pub ascii_icons: bool,
pub tree_width: u16,
pub right_panel_visible: bool,
pub right_panel_width: u16,
pub auto_hide_narrow_width: u16,
pub auto_equalize_splits: bool,
pub relative_line_numbers: bool,
pub line_numbers: bool,
pub cursor_line: bool,
pub scrolloff: usize,
pub sidescrolloff: usize,
pub show_whitespace: bool,
pub bracket_rainbow: bool,
pub syntax: bool,
pub scrollbar: bool,
pub highlight_trailing_ws: bool,
pub clock: bool,
pub stress_meter: bool,
pub activity_bar_pinned_integrations: Vec<String>,
pub statusline_segment_order: Vec<String>,
pub highlight_word_under_cursor: bool,
pub auto_md_preview: bool,
pub color_column: usize,
pub wrap: bool,
pub highlight_todo_keywords: bool,
pub render_markdown: bool,
pub always_show_fold_arrows: bool,
pub sticky_context: bool,
pub md_image_rows: u16,
pub git_graph_branch_col: Option<usize>,
pub git_graph_author_col: Option<usize>,
pub git_graph_detail_col: Option<usize>,
pub picker_position: String,
pub integration_icons: Vec<IntegrationIcon>,
pub integration_icon_order: Vec<String>,
pub ticket_prefixes: Vec<String>,
pub now_playing_source: String,
pub preferred_music_app: String,
pub mixr_auto_play_on_open: bool,
pub projects_dir: String,
pub menu_bar: String,
pub bufferline_diag_style: String,
pub coverage_chip_mode: String,
pub expand_indicator: String,
pub hover_help_height: u16,
pub terminal_label: String,
pub terminal_glyph_svg: String,
pub top_bar_cluster_mode: String,
pub tab_bar_ai_icon: String,
pub ai_layout_mode: String,
pub ai_chip_use_mnml_glyphs: bool,
pub auto_show_sessions_on_ai_activate: bool,
pub git_section_default_expanded: bool,
pub integrations_section_default_expanded: bool,
pub hover_help: bool,
pub hover_tooltip: bool,
pub first_launch_complete: bool,
pub show_workspace_dots: bool,
pub md_preview_engine: String,
}
#[derive(Debug, Clone)]
pub struct IntegrationIcon {
pub id: String,
pub glyph: String,
pub fallback: String,
pub command: String,
pub color: String,
pub label: Option<String>,
pub enabled: bool,
pub in_palette_bar: bool,
pub description: Option<String>,
pub homepage: Option<String>,
pub docs: Option<String>,
pub repository: Option<String>,
pub author: Option<String>,
pub version: Option<String>,
pub commands: Vec<IntegrationIconCommand>,
}
#[derive(Debug, Clone)]
pub struct IntegrationIconCommand {
pub id: String,
pub title: String,
}
impl Default for Config {
fn default() -> Self {
Config {
editor: EditorConfig {
input_style: "standard".to_string(),
tab_width: 4,
autosave_secs: 0,
trim_trailing_ws_on_save: false,
breadcrumb: true,
auto_pair: true,
auto_indent: true,
format_on_save: false,
will_save_wait_until: false,
format_on_type: false,
autosave_on_focus_loss: false,
inlay_hints: true,
cursor_blink: false,
semantic_tokens_viewport: false,
code_lens: true,
text_width: 80,
ensure_trailing_newline: true,
wheel_moves_cursor: "auto".to_string(),
},
ui: UiConfig {
theme: "onedark".to_string(),
cmdline_popup_border_color: String::new(),
theme_toggle: None,
theme_auto_system: false,
ascii_icons: false,
tree_width: 30,
right_panel_visible: false,
right_panel_width: 32,
auto_hide_narrow_width: 0,
auto_equalize_splits: false,
relative_line_numbers: false,
line_numbers: true,
cursor_line: false,
scrolloff: 0,
sidescrolloff: 0,
show_whitespace: false,
syntax: true,
bracket_rainbow: false,
scrollbar: true,
highlight_trailing_ws: false,
clock: true,
stress_meter: false,
activity_bar_pinned_integrations: Vec::new(),
statusline_segment_order: Vec::new(),
highlight_word_under_cursor: false,
auto_md_preview: false,
color_column: 0,
wrap: false,
highlight_todo_keywords: false,
render_markdown: false,
always_show_fold_arrows: false,
sticky_context: false,
md_image_rows: 12,
git_graph_branch_col: None,
git_graph_author_col: None,
git_graph_detail_col: None,
picker_position: "center".to_string(),
integration_icons: vec![
IntegrationIcon {
id: "browser".to_string(),
glyph: "\u{EB01}".to_string(), fallback: "B".to_string(),
command: "browser.open".to_string(),
color: "blue".to_string(),
label: Some("Browser".to_string()),
enabled: true,
in_palette_bar: true,
description: None,
homepage: None,
docs: None,
repository: None,
author: None,
version: None,
commands: Vec::new(),
},
IntegrationIcon {
id: "claude_code".to_string(),
glyph: "\u{F1E00}".to_string(),
fallback: "\u{2733}".to_string(),
command: "ai.claude_code".to_string(),
color: "#D16D51".to_string(),
label: Some("Claude Code".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: "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: false,
in_palette_bar: false,
description: None,
homepage: None,
docs: None,
repository: None,
author: None,
version: None,
commands: Vec::new(),
},
],
integration_icon_order: Vec::new(),
ticket_prefixes: Vec::new(),
now_playing_source: "mixr".to_string(),
preferred_music_app: "mixr".to_string(),
mixr_auto_play_on_open: default_mixr_auto_play(),
projects_dir: String::new(),
menu_bar: "always".to_string(),
bufferline_diag_style: "count".to_string(),
coverage_chip_mode: "feature".to_string(),
expand_indicator: "chevron".to_string(),
hover_help_height: 8,
terminal_label: "terminal".to_string(),
terminal_glyph_svg: String::new(),
top_bar_cluster_mode: "auto".to_string(),
tab_bar_ai_icon: "claude_code".to_string(),
ai_layout_mode: "grid".to_string(),
ai_chip_use_mnml_glyphs: false,
auto_show_sessions_on_ai_activate: true,
git_section_default_expanded: false,
integrations_section_default_expanded: false,
hover_help: true,
hover_tooltip: false,
first_launch_complete: false,
show_workspace_dots: true,
md_preview_engine: "builtin".to_string(),
},
session: SessionConfig { restore: true },
keys: BTreeMap::new(),
lsp: BTreeMap::new(),
ai: toml::Value::Table(Default::default()),
tools: toml::Value::Table(Default::default()),
http: HttpConfig::default(),
ws: WsConfig::default(),
git_graph: GitGraphConfig::default(),
tasks: BTreeMap::new(),
startup_tasks: Vec::new(),
startup_layout: Vec::new(),
default_workspace: None,
snippets: BTreeMap::new(),
abbreviations: BTreeMap::new(),
formatters: BTreeMap::new(),
linters: BTreeMap::new(),
dap: BTreeMap::new(),
browser: BrowserConfig {
headless: false,
profile_mode: "workspace".to_string(),
autocapture_to_log: true,
},
ci: CiConfig::default(),
integrations: IntegrationsConfig::default(),
workspaces: Vec::new(),
marketplace: MarketplaceConfig::default(),
cloud_run: CloudRunConfig::default(),
jira: JiraConfig::default(),
cloud_agents: CloudAgentsConfig::default(),
}
}
}
#[derive(Debug, Default, Deserialize)]
struct RawConfig {
#[serde(default)]
editor: RawEditor,
#[serde(default)]
ui: RawUi,
#[serde(default)]
keys: BTreeMap<String, BTreeMap<String, String>>,
#[serde(default)]
lsp: BTreeMap<String, toml::Value>,
#[serde(default)]
ai: Option<toml::Value>,
#[serde(default)]
tools: Option<toml::Value>,
#[serde(default)]
http: RawHttp,
#[serde(default)]
ws: RawWs,
#[serde(default)]
git_graph: RawGitGraph,
#[serde(default)]
tasks: BTreeMap<String, RawTask>,
#[serde(default)]
startup: RawStartup,
#[serde(default)]
session: RawSession,
#[serde(default)]
snippets: BTreeMap<String, BTreeMap<String, String>>,
#[serde(default)]
abbr: BTreeMap<String, String>,
#[serde(default)]
formatters: BTreeMap<String, crate::formatter::FormatterEntry>,
#[serde(default)]
linters: BTreeMap<String, crate::linter::LinterEntry>,
#[serde(default)]
dap: BTreeMap<String, toml::Value>,
#[serde(default)]
browser: RawBrowser,
#[serde(default)]
ci: RawCi,
#[serde(default)]
integrations: RawIntegrations,
#[serde(default)]
workspaces: Vec<RawWorkspace>,
#[serde(default)]
cloud_run: RawCloudRun,
#[serde(default)]
jira: RawJira,
#[serde(default)]
cloud_agents: RawCloudAgents,
#[serde(default)]
marketplace: RawMarketplace,
}
#[derive(Debug, Default, Deserialize)]
struct RawMarketplace {
#[serde(default)]
enabled: Option<bool>,
#[serde(default)]
cache_ttl_secs: Option<u64>,
#[serde(default)]
use_defaults: Option<bool>,
#[serde(default, rename = "source")]
sources: Vec<RawMarketplaceSource>,
#[serde(default)]
show_dev_tab: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum RawMarketplaceSource {
CratesKeyword {
#[serde(default)]
id: Option<String>,
keyword: String,
},
GithubLauncherFolder {
#[serde(default)]
id: Option<String>,
repo: String,
path: String,
},
GithubMonorepoApps {
#[serde(default)]
id: Option<String>,
repo: String,
#[serde(default = "default_apps_dir")]
apps_dir: String,
},
}
fn default_apps_dir() -> String {
"apps".to_string()
}
fn default_mixr_auto_play() -> bool {
true
}
impl RawMarketplaceSource {
fn into_source(self) -> Option<crate::marketplace::Source> {
match self {
RawMarketplaceSource::CratesKeyword { id, keyword } => {
Some(crate::marketplace::Source::CratesKeyword {
id: id.unwrap_or_else(|| format!("crates:{keyword}")),
keyword,
})
}
RawMarketplaceSource::GithubLauncherFolder { id, repo, path } => {
Some(crate::marketplace::Source::GithubLauncherFolder {
id: id.unwrap_or_else(|| repo.clone()),
repo,
path,
})
}
RawMarketplaceSource::GithubMonorepoApps { id, repo, apps_dir } => {
if !crate::marketplace::is_safe_repo_slug(&repo)
|| !crate::marketplace::is_safe_repo_subpath(&apps_dir)
{
return None;
}
Some(crate::marketplace::Source::GithubMonorepoApps {
id: id.unwrap_or_else(|| repo.clone()),
repo,
apps_dir,
})
}
}
}
}
#[derive(Debug, Default, Deserialize)]
struct RawWorkspace {
name: Option<String>,
path: String,
#[serde(default)]
group: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct RawCi {
provider: Option<String>,
project: Option<String>,
region: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct RawIntegrations {
auto_update_cargo: Option<bool>,
auto_update_git: Option<bool>,
}
#[derive(Debug, Default, Deserialize)]
struct RawHttp {
default_env: Option<String>,
collection_root: Option<String>,
auto_format_body: Option<bool>,
sync_normalize: Option<bool>,
}
#[derive(Debug, Default, Deserialize)]
struct RawWs {
subprotocols: Option<Vec<String>>,
ping_interval_secs: Option<u32>,
reconnect_max_attempts: Option<u32>,
}
#[derive(Debug, Default, Deserialize)]
struct RawGitGraph {
lane_spacing: Option<u16>,
}
#[derive(Debug, Default, Deserialize)]
struct RawBrowser {
headless: Option<bool>,
profile_mode: Option<String>,
autocapture_to_log: Option<bool>,
}
#[derive(Debug, Default, Deserialize)]
struct RawSession {
restore: Option<bool>,
}
#[derive(Debug, Default, Deserialize)]
struct RawTask {
cmd: String,
cwd: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct RawStartup {
#[serde(default)]
tasks: Vec<String>,
#[serde(default)]
default_workspace: Option<String>,
#[serde(default)]
layout: Vec<RawStartupLayoutEntry>,
}
#[derive(Debug, Default, Deserialize)]
struct RawStartupLayoutEntry {
#[serde(default)]
kind: Option<String>,
#[serde(default)]
path: Option<String>,
#[serde(default)]
cmd: Option<String>,
#[serde(default)]
split: Option<String>,
#[serde(default)]
ratio: Option<u16>,
}
#[derive(Debug, Default, Deserialize)]
struct RawCloudRun {
#[serde(default)]
defaults: RawCloudRunDefaults,
}
#[derive(Debug, Default, Deserialize)]
struct RawJira {
#[serde(default)]
domain: Option<String>,
#[serde(default)]
ticket_prefix: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct RawCloudAgents {
#[serde(default)]
label: Option<String>,
#[serde(default)]
short_id: Option<String>,
#[serde(default)]
region: Option<String>,
#[serde(default)]
account_id: Option<String>,
#[serde(default)]
runs_table: Option<String>,
#[serde(default)]
cluster: Option<String>,
#[serde(default)]
task_definition: Option<String>,
#[serde(default)]
sg_export_name: Option<String>,
#[serde(default)]
log_group: Option<String>,
#[serde(default)]
aws_profile_fallback: Option<String>,
#[serde(default)]
s3_artifacts_bucket: Option<String>,
#[serde(default)]
default_workspace_label: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct RawCloudRunDefaults {
#[serde(default)]
agent_id: Option<String>,
#[serde(default)]
env_id: Option<String>,
#[serde(default)]
sandbox: Option<String>,
#[serde(default)]
model: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct RawEditor {
input_style: Option<String>,
tab_width: Option<usize>,
autosave_secs: Option<u64>,
trim_trailing_ws_on_save: Option<bool>,
breadcrumb: Option<bool>,
auto_pair: Option<bool>,
auto_indent: Option<bool>,
format_on_save: Option<bool>,
will_save_wait_until: Option<bool>,
format_on_type: Option<bool>,
autosave_on_focus_loss: Option<bool>,
inlay_hints: Option<bool>,
cursor_blink: Option<bool>,
semantic_tokens_viewport: Option<bool>,
code_lens: Option<bool>,
text_width: Option<usize>,
ensure_trailing_newline: Option<bool>,
wheel_moves_cursor: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct RawUi {
theme: Option<String>,
cmdline_popup_border_color: Option<String>,
theme_toggle: Option<String>,
theme_auto_system: Option<bool>,
ascii_icons: Option<bool>,
tree_width: Option<u16>,
right_panel_visible: Option<bool>,
right_panel_width: Option<u16>,
auto_hide_narrow_width: Option<u16>,
auto_equalize_splits: Option<bool>,
relative_line_numbers: Option<bool>,
line_numbers: Option<bool>,
cursor_line: Option<bool>,
scrolloff: Option<usize>,
sidescrolloff: Option<usize>,
show_whitespace: Option<bool>,
syntax: Option<bool>,
bracket_rainbow: Option<bool>,
scrollbar: Option<bool>,
highlight_trailing_ws: Option<bool>,
clock: Option<bool>,
stress_meter: Option<bool>,
activity_bar_pinned_integrations: Option<Vec<String>>,
statusline_segment_order: Option<Vec<String>>,
highlight_word_under_cursor: Option<bool>,
auto_md_preview: Option<bool>,
color_column: Option<usize>,
wrap: Option<bool>,
highlight_todo_keywords: Option<bool>,
render_markdown: Option<bool>,
always_show_fold_arrows: Option<bool>,
sticky_context: Option<bool>,
md_image_rows: Option<u16>,
git_graph_branch_col: Option<usize>,
git_graph_author_col: Option<usize>,
git_graph_detail_col: Option<usize>,
picker_position: Option<String>,
#[serde(default, rename = "integration_icon")]
integration_icons: Option<Vec<RawIntegrationIcon>>,
#[serde(default)]
integration_icon_order: Option<Vec<String>>,
#[serde(default)]
ticket_prefixes: Option<Vec<String>>,
#[serde(default)]
now_playing_source: Option<String>,
#[serde(default)]
preferred_music_app: Option<String>,
#[serde(default)]
mixr_auto_play_on_open: Option<bool>,
#[serde(default)]
projects_dir: Option<String>,
#[serde(default)]
menu_bar: Option<String>,
#[serde(default)]
bufferline_diag_style: Option<String>,
#[serde(default)]
coverage_chip_mode: Option<String>,
#[serde(default)]
expand_indicator: Option<String>,
#[serde(default)]
hover_help_height: Option<u16>,
#[serde(default)]
terminal_label: Option<String>,
#[serde(default)]
terminal_glyph_svg: Option<String>,
#[serde(default)]
top_bar_cluster_mode: Option<String>,
#[serde(default)]
tab_bar_ai_icon: Option<String>,
#[serde(default)]
ai_layout_mode: Option<String>,
#[serde(default)]
ai_chip_use_mnml_glyphs: Option<bool>,
#[serde(default)]
auto_show_sessions_on_ai_activate: Option<bool>,
#[serde(default)]
git_section_default_expanded: Option<bool>,
#[serde(default)]
integrations_section_default_expanded: Option<bool>,
#[serde(default)]
hover_help: Option<bool>,
#[serde(default)]
hover_tooltip: Option<bool>,
#[serde(default)]
first_launch_complete: Option<bool>,
#[serde(default)]
show_workspace_dots: Option<bool>,
#[serde(default)]
md_preview_engine: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct RawIntegrationIcon {
id: Option<String>,
command: Option<String>,
enabled: Option<bool>,
in_palette_bar: Option<bool>,
}
#[derive(Debug, Clone)]
pub struct ClaudeAccountConfig {
pub name: String,
pub token_path: String,
pub active: bool,
}
impl ClaudeAccountConfig {
pub fn resolved_token_path(&self) -> PathBuf {
let raw = self.token_path.trim();
if let Some(rest) = raw.strip_prefix("~/")
&& let Some(home) = std::env::var_os("HOME")
{
return PathBuf::from(home).join(rest);
}
if raw == "~"
&& let Some(home) = std::env::var_os("HOME")
{
return PathBuf::from(home);
}
let p = PathBuf::from(raw);
if p.is_absolute() {
p
} else {
crate::data_root::data_root().join(raw)
}
}
}
impl Config {
pub fn claude_accounts(&self) -> Vec<ClaudeAccountConfig> {
let mut out: Vec<ClaudeAccountConfig> = Vec::new();
let claude = self.ai.as_table().and_then(|t| t.get("claude"));
let arr = claude
.and_then(|c| c.as_table())
.and_then(|c| c.get("accounts"))
.and_then(|a| a.as_array());
if let Some(arr) = arr {
for entry in arr {
let Some(tbl) = entry.as_table() else {
continue;
};
let name = tbl
.get("name")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("default")
.to_string();
let token_path = tbl
.get("token_path")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("ai_token")
.to_string();
let active = tbl.get("active").and_then(|v| v.as_bool()).unwrap_or(false);
out.push(ClaudeAccountConfig {
name,
token_path,
active,
});
}
}
if out.is_empty() {
out.push(ClaudeAccountConfig {
name: "default".to_string(),
token_path: "ai_token".to_string(),
active: true,
});
return out;
}
let mut seen_active = false;
for acc in out.iter_mut() {
if acc.active && !seen_active {
seen_active = true;
} else if acc.active {
acc.active = false;
}
}
if !seen_active && let Some(first) = out.first_mut() {
first.active = true;
}
out
}
pub fn ai_claude_show_all(&self) -> bool {
!matches!(self.ai_claude_multi_mode(), ClaudeMultiMode::Off)
}
pub fn ai_claude_multi_mode(&self) -> ClaudeMultiMode {
let value = self
.ai
.as_table()
.and_then(|t| t.get("claude_show_all_accounts"));
match value {
Some(v) if v.as_bool() == Some(true) => ClaudeMultiMode::Compact,
Some(v) if v.as_bool() == Some(false) => ClaudeMultiMode::Off,
Some(v) => match v
.as_str()
.unwrap_or("")
.trim()
.to_ascii_lowercase()
.as_str()
{
"compact" => ClaudeMultiMode::Compact,
"ticker" => ClaudeMultiMode::Ticker,
_ => ClaudeMultiMode::Off,
},
None => ClaudeMultiMode::Off,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClaudeMultiMode {
Off,
Compact,
Ticker,
}
impl Config {
pub fn load(explicit: Option<&Path>, workspace: &Path) -> Config {
let mut cfg = Config::default();
if let Some(home) = home_config_path() {
cfg.apply_file(&home);
}
cfg.apply_file(&workspace.join(".mnml").join("config.toml"));
if let Some(p) = explicit {
cfg.apply_file(p);
}
cfg
}
pub fn apply_file_pub(&mut self, path: &Path) {
self.apply_file(path);
}
fn apply_file(&mut self, path: &Path) {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(_) => return, };
let raw: RawConfig = match toml::from_str(&text) {
Ok(r) => r,
Err(e) => {
eprintln!("mnml: ignoring bad config {}: {e}", path.display());
return;
}
};
for id in DEAD_INTEGRATION_IDS {
let _ = mnml_bridge::uninstall_integration(id);
}
if let Some(v) = raw.editor.input_style {
self.editor.input_style = v;
}
if let Some(v) = raw.editor.tab_width {
self.editor.tab_width = v.max(1);
}
if let Some(v) = raw.editor.autosave_secs {
self.editor.autosave_secs = v;
}
if let Some(v) = raw.editor.trim_trailing_ws_on_save {
self.editor.trim_trailing_ws_on_save = v;
}
if let Some(v) = raw.editor.breadcrumb {
self.editor.breadcrumb = v;
}
if let Some(v) = raw.editor.auto_pair {
self.editor.auto_pair = v;
}
if let Some(v) = raw.editor.auto_indent {
self.editor.auto_indent = v;
}
if let Some(v) = raw.editor.format_on_type {
self.editor.format_on_type = v;
}
if let Some(v) = raw.editor.format_on_save {
self.editor.format_on_save = v;
}
if let Some(v) = raw.editor.will_save_wait_until {
self.editor.will_save_wait_until = v;
}
if let Some(v) = raw.editor.autosave_on_focus_loss {
self.editor.autosave_on_focus_loss = v;
}
if let Some(v) = raw.editor.inlay_hints {
self.editor.inlay_hints = v;
}
if let Some(v) = raw.editor.cursor_blink {
self.editor.cursor_blink = v;
}
if let Some(v) = raw.editor.semantic_tokens_viewport {
self.editor.semantic_tokens_viewport = v;
}
if let Some(v) = raw.editor.code_lens {
self.editor.code_lens = v;
}
if let Some(v) = raw.editor.text_width {
self.editor.text_width = v.max(8);
}
if let Some(v) = raw.editor.ensure_trailing_newline {
self.editor.ensure_trailing_newline = v;
}
if let Some(v) = raw.editor.wheel_moves_cursor {
self.editor.wheel_moves_cursor = match v.as_str() {
"auto" | "always" | "never" => v,
_ => "auto".to_string(),
};
}
if let Some(v) = raw.ui.theme {
self.ui.theme = v;
}
if let Some(v) = raw.ui.cmdline_popup_border_color {
self.ui.cmdline_popup_border_color = v;
}
if let Some(v) = raw.ui.theme_toggle {
self.ui.theme_toggle = Some(v);
}
if let Some(v) = raw.ui.theme_auto_system {
self.ui.theme_auto_system = v;
}
if let Some(v) = raw.ui.ascii_icons {
self.ui.ascii_icons = v;
}
if let Some(v) = raw.ui.tree_width {
self.ui.tree_width = v.clamp(10, 80);
}
if let Some(v) = raw.ui.right_panel_visible {
self.ui.right_panel_visible = v;
}
if let Some(v) = raw.ui.right_panel_width {
self.ui.right_panel_width = v.clamp(10, 80);
}
if let Some(v) = raw.ui.auto_hide_narrow_width {
self.ui.auto_hide_narrow_width = if v == 0 { 0 } else { v.clamp(40, 300) };
}
if let Some(v) = raw.ui.auto_equalize_splits {
self.ui.auto_equalize_splits = v;
}
if let Some(v) = raw.ui.relative_line_numbers {
self.ui.relative_line_numbers = v;
}
if let Some(v) = raw.ui.line_numbers {
self.ui.line_numbers = v;
}
if let Some(v) = raw.ui.cursor_line {
self.ui.cursor_line = v;
}
if let Some(v) = raw.ui.scrolloff {
self.ui.scrolloff = v;
}
if let Some(v) = raw.ui.sidescrolloff {
self.ui.sidescrolloff = v;
}
if let Some(v) = raw.ui.show_whitespace {
self.ui.show_whitespace = v;
}
if let Some(v) = raw.ui.syntax {
self.ui.syntax = v;
}
if let Some(v) = raw.ui.bracket_rainbow {
self.ui.bracket_rainbow = v;
}
if let Some(v) = raw.ui.scrollbar {
self.ui.scrollbar = v;
}
if let Some(v) = raw.ui.highlight_trailing_ws {
self.ui.highlight_trailing_ws = v;
}
if let Some(v) = raw.ui.clock {
self.ui.clock = v;
}
if let Some(v) = raw.ui.stress_meter {
self.ui.stress_meter = v;
}
if let Some(v) = raw.ui.activity_bar_pinned_integrations {
self.ui.activity_bar_pinned_integrations = v;
}
if let Some(v) = raw.ui.statusline_segment_order {
self.ui.statusline_segment_order = v;
}
if let Some(v) = raw.ui.highlight_word_under_cursor {
self.ui.highlight_word_under_cursor = v;
}
if let Some(v) = raw.ui.auto_md_preview {
self.ui.auto_md_preview = v;
}
if let Some(v) = raw.ui.color_column {
self.ui.color_column = v;
}
if let Some(v) = raw.ui.wrap {
self.ui.wrap = v;
}
if let Some(v) = raw.ui.highlight_todo_keywords {
self.ui.highlight_todo_keywords = v;
}
if let Some(v) = raw.ui.render_markdown {
self.ui.render_markdown = v;
}
if let Some(v) = raw.ui.always_show_fold_arrows {
self.ui.always_show_fold_arrows = v;
}
if let Some(v) = raw.ui.sticky_context {
self.ui.sticky_context = v;
}
if let Some(v) = raw.ui.md_image_rows {
self.ui.md_image_rows = v.clamp(2, 100);
}
if raw.ui.git_graph_branch_col.is_some() {
self.ui.git_graph_branch_col = raw.ui.git_graph_branch_col;
}
if raw.ui.git_graph_author_col.is_some() {
self.ui.git_graph_author_col = raw.ui.git_graph_author_col;
}
if raw.ui.git_graph_detail_col.is_some() {
self.ui.git_graph_detail_col = raw.ui.git_graph_detail_col;
}
if let Some(v) = raw.ui.picker_position {
self.ui.picker_position = v;
}
if let Some(raws) = raw.ui.integration_icons {
let user_raws: Vec<RawIntegrationIcon> = raws;
let id_of_raw = |r: &RawIntegrationIcon| -> Option<String> {
if let Some(id) = &r.id {
return Some(id.clone());
}
r.command.as_ref().map(|c| {
c.trim_start_matches(':')
.split_whitespace()
.next()
.unwrap_or("integration")
.to_string()
})
};
let builtins_by_id: std::collections::HashMap<String, IntegrationIcon> = self
.ui
.integration_icons
.iter()
.map(|b| (b.id.clone(), b.clone()))
.collect();
let mut merged: Vec<IntegrationIcon> = Vec::new();
let mut consumed: std::collections::HashSet<String> = std::collections::HashSet::new();
for r in &user_raws {
let Some(id) = id_of_raw(r) else { continue };
if consumed.contains(&id) {
continue;
}
let Some(builtin) = builtins_by_id.get(&id) else {
consumed.insert(id);
continue;
};
let icon = IntegrationIcon {
id: builtin.id.clone(),
glyph: builtin.glyph.clone(),
fallback: builtin.fallback.clone(),
command: builtin.command.clone(),
color: builtin.color.clone(),
label: builtin.label.clone(),
enabled: r.enabled.unwrap_or(builtin.enabled),
in_palette_bar: r.in_palette_bar.unwrap_or_else(|| {
r.enabled.unwrap_or(builtin.enabled) || builtin.in_palette_bar
}),
description: builtin.description.clone(),
homepage: builtin.homepage.clone(),
docs: builtin.docs.clone(),
repository: builtin.repository.clone(),
author: builtin.author.clone(),
version: builtin.version.clone(),
commands: builtin.commands.clone(),
};
consumed.insert(id);
merged.push(icon);
}
for builtin in &self.ui.integration_icons {
if !consumed.contains(&builtin.id) {
merged.push(builtin.clone());
}
}
for icon in &mut merged {
if icon.id == "claude_code" && icon.glyph != "\u{F1E00}" {
icon.glyph = "\u{F1E00}".to_string();
}
if icon.id == "codex" && icon.glyph != "\u{F1E01}" {
icon.glyph = "\u{F1E01}".to_string();
}
if icon.id == "http" && icon.glyph != "\u{F1D8}" {
icon.glyph = "\u{F1D8}".to_string();
}
if icon.id == "amplify" && icon.glyph == "\u{F087D}" {
icon.glyph = "\u{F1B00}".to_string();
}
if icon.id == "btop" && icon.glyph == "\u{F085F}" {
icon.glyph = "\u{F2000}".to_string();
}
if icon.id == "htop" && icon.glyph == "\u{F085A}" {
icon.glyph = "\u{F2001}".to_string();
}
if icon.id == "iftop" && icon.glyph == "\u{F048D}" {
icon.glyph = "\u{F2002}".to_string();
}
}
merged.retain(|i| !DEAD_INTEGRATION_IDS.contains(&i.id.as_str()));
self.ui.integration_icons = merged;
}
if let Some(raws) = raw.ui.integration_icon_order {
self.ui.integration_icon_order = raws
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
if !self.ui.integration_icon_order.is_empty() {
let order = self.ui.integration_icon_order.clone();
let rank =
|id: &str| -> usize { order.iter().position(|o| o == id).unwrap_or(usize::MAX) };
self.ui.integration_icons.sort_by_key(|a| rank(&a.id));
}
if let Some(raws) = raw.ui.ticket_prefixes {
self.ui.ticket_prefixes = raws
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
if let Some(s) = raw.ui.now_playing_source {
let normalized = s.trim().to_ascii_lowercase();
if matches!(normalized.as_str(), "auto" | "mixr" | "macos") {
self.ui.now_playing_source = normalized;
}
}
if let Some(s) = raw.ui.preferred_music_app {
let normalized = s.trim().to_ascii_lowercase();
if matches!(normalized.as_str(), "mixr" | "music" | "spotify") {
self.ui.preferred_music_app = normalized;
}
}
if let Some(b) = raw.ui.mixr_auto_play_on_open {
self.ui.mixr_auto_play_on_open = b;
}
if let Some(s) = raw.ui.menu_bar {
let normalized = s.trim().to_ascii_lowercase();
if matches!(normalized.as_str(), "always" | "auto" | "hidden") {
self.ui.menu_bar = normalized;
}
}
if let Some(s) = raw.ui.bufferline_diag_style {
let normalized = s.trim().to_ascii_lowercase();
if matches!(normalized.as_str(), "count" | "dot" | "off") {
self.ui.bufferline_diag_style = normalized;
}
}
if let Some(s) = raw.ui.coverage_chip_mode {
let normalized = s.trim().to_ascii_lowercase();
if matches!(normalized.as_str(), "both" | "feature" | "code" | "ticker") {
self.ui.coverage_chip_mode = normalized;
}
}
if let Some(s) = raw.ui.expand_indicator {
let normalized = s.trim().to_ascii_lowercase();
if matches!(normalized.as_str(), "chevron" | "triangle") {
self.ui.expand_indicator = normalized;
}
}
if let Some(h) = raw.ui.hover_help_height {
self.ui.hover_help_height = h.clamp(3, 20);
}
if let Some(s) = raw.ui.terminal_label {
let trimmed = s.trim();
if !trimmed.is_empty() {
self.ui.terminal_label = trimmed.to_string();
}
}
if let Some(s) = raw.ui.terminal_glyph_svg {
self.ui.terminal_glyph_svg = s.trim().to_string();
}
if let Some(s) = raw.ui.top_bar_cluster_mode {
let normalized = s.trim().to_ascii_lowercase();
if matches!(normalized.as_str(), "auto" | "expanded" | "compact") {
self.ui.top_bar_cluster_mode = normalized;
}
}
if let Some(s) = raw.ui.tab_bar_ai_icon {
let normalized = s.trim().to_ascii_lowercase();
if matches!(
normalized.as_str(),
"none" | "claude_code" | "codex" | "both"
) {
self.ui.tab_bar_ai_icon = normalized;
}
}
if let Some(s) = raw.ui.ai_layout_mode {
let normalized = s.trim().to_ascii_lowercase();
if matches!(normalized.as_str(), "grid" | "tabs") {
self.ui.ai_layout_mode = normalized;
}
}
if let Some(b) = raw.ui.ai_chip_use_mnml_glyphs {
self.ui.ai_chip_use_mnml_glyphs = b;
}
if let Some(b) = raw.ui.auto_show_sessions_on_ai_activate {
self.ui.auto_show_sessions_on_ai_activate = b;
}
if let Some(b) = raw.ui.git_section_default_expanded {
self.ui.git_section_default_expanded = b;
}
if let Some(b) = raw.ui.integrations_section_default_expanded {
self.ui.integrations_section_default_expanded = b;
}
if let Some(b) = raw.ui.first_launch_complete {
self.ui.first_launch_complete = b;
}
if let Some(b) = raw.ui.show_workspace_dots {
self.ui.show_workspace_dots = b;
}
if let Some(b) = raw.ui.hover_tooltip {
self.ui.hover_tooltip = b;
}
if let Some(b) = raw.ui.hover_help {
self.ui.hover_help = b;
}
if let Some(s) = raw.ui.md_preview_engine {
let trimmed = s.trim().to_string();
if !trimmed.is_empty() {
self.ui.md_preview_engine = trimmed;
}
}
if let Some(s) = raw.ui.projects_dir {
let trimmed = s.trim();
if trimmed.is_empty() {
self.ui.projects_dir = String::new();
} else if let Some(rest) = trimmed.strip_prefix("~/")
&& let Some(home) = std::env::var_os("HOME")
{
self.ui.projects_dir = std::path::PathBuf::from(home)
.join(rest)
.to_string_lossy()
.into_owned();
} else {
self.ui.projects_dir = trimmed.to_string();
}
}
if let Some(v) = raw.session.restore {
self.session.restore = v;
}
for (k, v) in raw.keys {
self.keys.entry(k).or_default().extend(v);
}
for (k, v) in raw.lsp {
self.lsp.insert(k, v);
}
if let Some(v) = raw.ai {
self.ai = v;
}
if let Some(v) = raw.tools {
self.tools = v;
}
if let Some(name) = raw.http.default_env {
let trimmed = name.trim();
if !trimmed.is_empty() {
self.http.default_env = Some(trimmed.to_string());
}
}
if let Some(b) = raw.http.auto_format_body {
self.http.auto_format_body = b;
}
if let Some(b) = raw.http.sync_normalize {
self.http.sync_normalize = b;
}
if let Some(cr) = raw.http.collection_root {
let trimmed = cr.trim().to_ascii_lowercase();
self.http.collection_root = match trimmed.as_str() {
"workspace" | "in_tree" | "in-tree" | "bruno" => {
crate::config::HttpCollectionRoot::Workspace
}
"hidden" | ".mnml/collections" | ".mnml" | "" => {
crate::config::HttpCollectionRoot::Hidden
}
other => {
eprintln!(
"mnml: [http] collection_root = {other:?} not recognised — using \"hidden\" (\".mnml/collections\")"
);
crate::config::HttpCollectionRoot::Hidden
}
};
}
if let Some(ps) = raw.ws.subprotocols {
self.ws.subprotocols = ps
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
if let Some(v) = raw.ws.ping_interval_secs {
self.ws.ping_interval_secs = v;
}
if let Some(v) = raw.ws.reconnect_max_attempts {
self.ws.reconnect_max_attempts = v;
}
if let Some(rs) = raw.git_graph.lane_spacing {
self.git_graph.lane_spacing = rs.min(4);
}
for (k, v) in raw.tasks {
self.tasks.insert(
k,
TaskDef {
cmd: v.cmd,
cwd: v.cwd,
},
);
}
self.startup_tasks.extend(raw.startup.tasks);
if let Some(s) = raw.startup.default_workspace
&& !s.trim().is_empty()
{
self.default_workspace = Some(expand_tilde(&s));
}
for (i, entry) in raw.startup.layout.into_iter().enumerate() {
let kind = entry.kind.as_deref().unwrap_or("").trim().to_lowercase();
let is_first = i == 0;
match kind.as_str() {
"editor" => {
let path = entry
.path
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
if path.is_none() {
eprintln!(
"mnml: [[startup.layout]] entry #{i} kind=editor missing `path`; dropped"
);
continue;
}
}
"pty" => {
let cmd = entry
.cmd
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
if cmd.is_none() {
eprintln!(
"mnml: [[startup.layout]] entry #{i} kind=pty missing `cmd`; dropped"
);
continue;
}
}
other => {
eprintln!(
"mnml: [[startup.layout]] entry #{i} unknown kind={other:?} (expected \"editor\" or \"pty\"); dropped"
);
continue;
}
}
let split = entry.split.as_deref().map(str::trim).map(str::to_lowercase);
if !is_first {
match split.as_deref() {
Some("right") | Some("down") => {}
Some(other) => {
eprintln!(
"mnml: [[startup.layout]] entry #{i} unknown split={other:?} (expected \"right\" or \"down\"); dropped"
);
continue;
}
None => {
eprintln!(
"mnml: [[startup.layout]] entry #{i} missing `split` (required after the first entry); dropped"
);
continue;
}
}
}
let ratio = match entry.ratio {
Some(r) if (10..=90).contains(&r) => Some(r),
Some(other) => {
eprintln!(
"mnml: [[startup.layout]] entry #{i} ratio={other} out of range (10..=90); using layout default (50)"
);
None
}
None => None,
};
self.startup_layout.push(StartupLayoutEntry {
kind,
path: entry.path,
cmd: entry.cmd,
split,
ratio,
});
}
for (scope, map) in raw.snippets {
self.snippets.entry(scope).or_default().extend(map);
}
for (k, v) in raw.abbr {
self.abbreviations.insert(k, v);
}
for (ext, entry) in raw.formatters {
self.formatters.insert(ext, entry);
}
for (ext, entry) in raw.linters {
self.linters.insert(ext, entry);
}
for (name, v) in raw.dap {
self.dap.insert(name, v);
}
if let Some(v) = raw.browser.headless {
self.browser.headless = v;
}
if let Some(v) = raw.browser.profile_mode {
self.browser.profile_mode = match v.as_str() {
"workspace" | "shared" | "ephemeral" => v,
_ => "workspace".to_string(),
};
}
if let Some(v) = raw.browser.autocapture_to_log {
self.browser.autocapture_to_log = v;
}
if let Some(v) = raw.ci.provider {
self.ci.provider = Some(v);
}
if let Some(v) = raw.ci.project {
self.ci.project = Some(v);
}
if let Some(v) = raw.ci.region {
self.ci.region = Some(v);
}
if let Some(v) = raw.integrations.auto_update_cargo {
self.integrations.auto_update_cargo = v;
}
if let Some(v) = raw.integrations.auto_update_git {
self.integrations.auto_update_git = v;
}
for w in raw.workspaces {
let expanded = expand_tilde(&w.path);
let name = w.name.unwrap_or_else(|| {
expanded
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| w.path.clone())
});
self.workspaces.push(WorkspaceConfig {
name,
path: expanded,
group: w.group,
});
}
if let Some(v) = raw.marketplace.enabled {
self.marketplace.enabled = v;
}
if let Some(v) = raw.marketplace.cache_ttl_secs {
self.marketplace.cache_ttl_secs = v;
}
if let Some(v) = raw.marketplace.use_defaults {
self.marketplace.use_defaults = v;
}
for s in raw.marketplace.sources {
if let Some(src) = s.into_source() {
self.marketplace.sources.push(src);
}
}
if let Some(v) = raw.marketplace.show_dev_tab {
self.marketplace.show_dev_tab = v;
}
if let Some(v) = raw.cloud_run.defaults.agent_id {
self.cloud_run.defaults.agent_id = v;
}
if let Some(v) = raw.cloud_run.defaults.env_id {
self.cloud_run.defaults.env_id = v;
}
if let Some(v) = raw.cloud_run.defaults.sandbox {
self.cloud_run.defaults.sandbox = v;
}
if let Some(v) = raw.cloud_run.defaults.model {
self.cloud_run.defaults.model = v;
}
if let Some(v) = raw.jira.domain {
self.jira.domain = v;
}
if let Some(v) = raw.jira.ticket_prefix {
self.jira.ticket_prefix = v;
}
if let Some(v) = raw.cloud_agents.label {
self.cloud_agents.label = v;
}
if let Some(v) = raw.cloud_agents.short_id {
self.cloud_agents.short_id = v;
}
if let Some(v) = raw.cloud_agents.region {
self.cloud_agents.region = v;
}
if let Some(v) = raw.cloud_agents.account_id {
self.cloud_agents.account_id = v;
}
if let Some(v) = raw.cloud_agents.runs_table {
self.cloud_agents.runs_table = v;
}
if let Some(v) = raw.cloud_agents.cluster {
self.cloud_agents.cluster = v;
}
if let Some(v) = raw.cloud_agents.task_definition {
self.cloud_agents.task_definition = v;
}
if let Some(v) = raw.cloud_agents.sg_export_name {
self.cloud_agents.sg_export_name = v;
}
if let Some(v) = raw.cloud_agents.log_group {
self.cloud_agents.log_group = v;
}
if let Some(v) = raw.cloud_agents.aws_profile_fallback {
self.cloud_agents.aws_profile_fallback = v;
}
if let Some(v) = raw.cloud_agents.s3_artifacts_bucket {
self.cloud_agents.s3_artifacts_bucket = v;
}
if let Some(v) = raw.cloud_agents.default_workspace_label {
self.cloud_agents.default_workspace_label = v;
}
}
}
fn expand_tilde(s: &str) -> PathBuf {
if let Some(rest) = s.strip_prefix("~/")
&& let Some(home) = std::env::var_os("HOME")
{
return PathBuf::from(home).join(rest);
}
PathBuf::from(s)
}
pub fn user_config_path() -> Option<PathBuf> {
home_config_path()
}
pub fn config_backups_dir() -> Option<PathBuf> {
home_config_path().and_then(|p| p.parent().map(|d| d.join("backups")))
}
const MAX_CONFIG_BACKUPS: usize = 50;
pub fn write_user_config(cfg_path: &std::path::Path, contents: &str) -> std::io::Result<()> {
if cfg_path.exists()
&& let Some(backups) = config_backups_dir()
&& std::fs::create_dir_all(&backups).is_ok()
&& let Ok(existing) = std::fs::read(cfg_path)
{
let stamp = backup_timestamp();
let backup = backups.join(format!("config.{stamp}.toml"));
let backup = ensure_unique(backup);
let _ = std::fs::write(&backup, existing);
prune_old_backups(&backups, MAX_CONFIG_BACKUPS);
}
std::fs::write(cfg_path, contents)
}
fn backup_timestamp() -> String {
let now = std::time::SystemTime::now();
let dur = now
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let secs = dur.as_secs();
let (y, mo, d, h, mi, s) = utc_ymdhms(secs);
format!("{y:04}-{mo:02}-{d:02}-{h:02}{mi:02}{s:02}")
}
fn utc_ymdhms(mut secs: u64) -> (u32, u32, u32, u32, u32, u32) {
let s = (secs % 60) as u32;
secs /= 60;
let mi = (secs % 60) as u32;
secs /= 60;
let h = (secs % 24) as u32;
let mut days = (secs / 24) as i64;
let mut year: i64 = 1970;
loop {
let leap = is_leap(year);
let n = if leap { 366 } else { 365 };
if days < n {
break;
}
days -= n;
year += 1;
}
let dim = [
31,
if is_leap(year) { 29 } else { 28 },
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let mut mo: i64 = 0;
while mo < 12 && days >= dim[mo as usize] {
days -= dim[mo as usize];
mo += 1;
}
(year as u32, (mo + 1) as u32, (days + 1) as u32, h, mi, s)
}
fn is_leap(y: i64) -> bool {
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
}
fn ensure_unique(mut path: PathBuf) -> PathBuf {
if !path.exists() {
return path;
}
let stem = path.file_stem().map(|s| s.to_owned()).unwrap_or_default();
let ext = path.extension().map(|s| s.to_owned()).unwrap_or_default();
let parent = path
.parent()
.unwrap_or(std::path::Path::new("."))
.to_path_buf();
for i in 1..1000 {
let stem_s = stem.to_string_lossy();
let ext_s = ext.to_string_lossy();
let candidate = if ext_s.is_empty() {
parent.join(format!("{stem_s}-{i}"))
} else {
parent.join(format!("{stem_s}-{i}.{ext_s}"))
};
if !candidate.exists() {
return candidate;
}
path = candidate;
}
path
}
fn prune_old_backups(dir: &std::path::Path, keep: usize) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut files: Vec<(std::path::PathBuf, std::time::SystemTime)> = entries
.filter_map(|e| e.ok())
.filter_map(|e| {
let p = e.path();
let name = p.file_name()?.to_str()?.to_string();
if !name.starts_with("config.") || !name.ends_with(".toml") {
return None;
}
let mtime = e.metadata().ok().and_then(|m| m.modified().ok())?;
Some((p, mtime))
})
.collect();
if files.len() <= keep {
return;
}
files.sort_by_key(|b| std::cmp::Reverse(b.1));
for (p, _) in files.into_iter().skip(keep) {
let _ = std::fs::remove_file(p);
}
}
pub fn resolve_default_workspace() -> Option<PathBuf> {
let path = home_config_path()?;
let text = std::fs::read_to_string(&path).ok()?;
let raw: RawConfig = toml::from_str(&text).ok()?;
let s = raw.startup.default_workspace?;
let s = s.trim();
if s.is_empty() {
return None;
}
Some(expand_tilde(s))
}
pub fn persist_cloud_run_defaults(defaults: &CloudRunDefaults) -> Result<PathBuf, String> {
let cfg_path =
user_config_path().ok_or_else(|| "no $HOME or $XDG_CONFIG_HOME set".to_string())?;
if let Some(parent) = cfg_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
}
let existing = std::fs::read_to_string(&cfg_path).unwrap_or_default();
let updated = upsert_cloud_run_defaults(&existing, defaults);
write_user_config(&cfg_path, &updated)
.map_err(|e| format!("write {}: {e}", cfg_path.display()))?;
Ok(cfg_path)
}
fn upsert_cloud_run_defaults(src: &str, defaults: &CloudRunDefaults) -> String {
let mut out = String::with_capacity(src.len() + 256);
let mut in_section = false;
for line in src.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
in_section = trimmed == "[cloud_run.defaults]";
if !in_section {
out.push_str(line);
out.push('\n');
}
continue;
}
if !in_section {
out.push_str(line);
out.push('\n');
}
}
if !out.ends_with("\n\n") && !out.is_empty() {
if !out.ends_with('\n') {
out.push('\n');
}
out.push('\n');
}
out.push_str("[cloud_run.defaults]\n");
out.push_str(&format!("agent_id = {}\n", toml_str(&defaults.agent_id)));
out.push_str(&format!("env_id = {}\n", toml_str(&defaults.env_id)));
out.push_str(&format!("sandbox = {}\n", toml_str(&defaults.sandbox)));
out.push_str(&format!("model = {}\n", toml_str(&defaults.model)));
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("\\\\"),
_ => out.push(c),
}
}
out.push('"');
out
}
pub fn persist_default_workspace(path: Option<&Path>) -> Result<PathBuf, String> {
let cfg_path =
user_config_path().ok_or_else(|| "no $HOME or $XDG_CONFIG_HOME set".to_string())?;
if let Some(parent) = cfg_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
}
let existing = std::fs::read_to_string(&cfg_path).unwrap_or_default();
let updated = upsert_startup_default_workspace(&existing, path);
write_user_config(&cfg_path, &updated)
.map_err(|e| format!("write {}: {e}", cfg_path.display()))?;
Ok(cfg_path)
}
fn upsert_startup_default_workspace(src: &str, path: Option<&Path>) -> String {
let want_line = path.map(|p| {
let mut s = String::with_capacity(p.as_os_str().len() + 24);
s.push_str("default_workspace = ");
s.push('"');
for c in p.display().to_string().chars() {
match c {
'"' => s.push_str("\\\""),
'\\' => s.push_str("\\\\"),
_ => s.push(c),
}
}
s.push('"');
s
});
let mut out = String::with_capacity(src.len() + 64);
let mut in_startup = false;
let mut replaced = false;
let mut startup_seen = false;
for line in src.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with('[') {
let header = trimmed.trim_end();
if in_startup
&& !replaced
&& let Some(w) = want_line.as_ref()
{
out.push_str(w);
out.push('\n');
replaced = true;
}
in_startup = header == "[startup]";
if in_startup {
startup_seen = true;
}
out.push_str(line);
out.push('\n');
continue;
}
if in_startup && trimmed.starts_with("default_workspace") {
if let Some(w) = want_line.as_ref() {
out.push_str(w);
out.push('\n');
}
replaced = true;
continue;
}
out.push_str(line);
out.push('\n');
}
if in_startup
&& !replaced
&& let Some(w) = want_line.as_ref()
{
out.push_str(w);
out.push('\n');
}
if !startup_seen && let Some(w) = want_line.as_ref() {
if !out.ends_with('\n') {
out.push('\n');
}
if !out.is_empty() && !out.ends_with("\n\n") {
out.push('\n');
}
out.push_str("[startup]\n");
out.push_str(w);
out.push('\n');
}
out
}
pub fn persist_ui_projects_dir(value: Option<&str>) -> Result<PathBuf, String> {
let cfg_path =
user_config_path().ok_or_else(|| "no $HOME or $XDG_CONFIG_HOME set".to_string())?;
if let Some(parent) = cfg_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
}
let existing = std::fs::read_to_string(&cfg_path).unwrap_or_default();
let updated = upsert_global_string(&existing, "ui", "projects_dir", value);
write_user_config(&cfg_path, &updated)
.map_err(|e| format!("write {}: {e}", cfg_path.display()))?;
Ok(cfg_path)
}
fn upsert_global_string(src: &str, table: &str, key: &str, value: Option<&str>) -> String {
let want_line = value.filter(|v| !v.is_empty()).map(|v| {
let mut s = String::with_capacity(key.len() + v.len() + 6);
s.push_str(key);
s.push_str(" = ");
s.push_str(&toml_quote(v));
s
});
let header_line = format!("[{table}]");
let key_prefix = format!("{key} ");
let key_eq = format!("{key}=");
let mut out = String::with_capacity(src.len() + 64);
let mut in_table = false;
let mut replaced = false;
let mut table_seen = false;
for line in src.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with('[') {
let header = trimmed.trim_end();
if in_table
&& !replaced
&& let Some(w) = want_line.as_ref()
{
out.push_str(w);
out.push('\n');
replaced = true;
}
in_table = header == header_line;
if in_table {
table_seen = true;
}
out.push_str(line);
out.push('\n');
continue;
}
if in_table && (trimmed.starts_with(&key_prefix) || trimmed.starts_with(&key_eq)) {
if let Some(w) = want_line.as_ref() {
out.push_str(w);
out.push('\n');
}
replaced = true;
continue;
}
out.push_str(line);
out.push('\n');
}
if in_table
&& !replaced
&& let Some(w) = want_line.as_ref()
{
out.push_str(w);
out.push('\n');
}
if !table_seen && let Some(w) = want_line.as_ref() {
if !out.ends_with('\n') {
out.push('\n');
}
if !out.is_empty() && !out.ends_with("\n\n") {
out.push('\n');
}
out.push_str(&header_line);
out.push('\n');
out.push_str(w);
out.push('\n');
}
out
}
pub fn workspace_config_path(workspace: &Path) -> PathBuf {
workspace.join(".mnml").join("config.toml")
}
pub fn toml_quote(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 persist_workspace_setting(
workspace: &Path,
section: &str,
key: &str,
value_toml: &str,
) -> Result<PathBuf, String> {
let cfg_path = workspace_config_path(workspace);
if let Some(parent) = cfg_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
}
let existing = std::fs::read_to_string(&cfg_path).unwrap_or_default();
let updated = upsert_toml_kv(&existing, section, key, value_toml);
write_user_config(&cfg_path, &updated)
.map_err(|e| format!("write {}: {e}", cfg_path.display()))?;
Ok(cfg_path)
}
fn line_assigns_key(trimmed: &str, key: &str) -> bool {
let Some(rest) = trimmed.strip_prefix(key) else {
return false;
};
matches!(rest.trim_start().chars().next(), Some('='))
}
fn upsert_toml_kv(src: &str, section: &str, key: &str, value_toml: &str) -> String {
let want_line = format!("{key} = {value_toml}");
let want_header = format!("[{section}]");
let mut out = String::with_capacity(src.len() + want_line.len() + 8);
let mut in_section = false;
let mut replaced = false;
let mut section_seen = false;
for line in src.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with('[') {
if in_section && !replaced {
out.push_str(&want_line);
out.push('\n');
replaced = true;
}
in_section = trimmed.trim_end() == want_header;
if in_section {
section_seen = true;
}
out.push_str(line);
out.push('\n');
continue;
}
if in_section && !replaced && line_assigns_key(trimmed, key) {
out.push_str(&want_line);
out.push('\n');
replaced = true;
continue;
}
out.push_str(line);
out.push('\n');
}
if in_section && !replaced {
out.push_str(&want_line);
out.push('\n');
replaced = true;
}
if !section_seen && !replaced {
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
if !out.is_empty() && !out.ends_with("\n\n") {
out.push('\n');
}
out.push_str(&want_header);
out.push('\n');
out.push_str(&want_line);
out.push('\n');
}
out
}
pub fn scaffold_workspace(path: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(path)?;
let readme = path.join("README.md");
if !readme.exists() {
let body = "# mnml workspace\n\
\n\
This is your default workspace — the folder mnml opens when\n\
launched with no positional argument. Configured under\n\
`[startup] default_workspace` in `~/.config/mnml/config.toml`.\n\
\n\
Use it as scratch space, a test sandbox, or a quick place to\n\
drop notes / `.http` files / snippets. Open integrations (S3,\n\
Datadog, etc.) here to verify integration behavior in a\n\
known-clean state.\n";
let _ = std::fs::write(&readme, body);
}
Ok(())
}
pub fn persist_workspaces_to_global(workspaces: &[WorkspaceConfig]) -> Result<PathBuf, String> {
let cfg_path = home_config_path().ok_or("no HOME / XDG_CONFIG_HOME")?;
if let Some(parent) = cfg_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
}
let existing = std::fs::read_to_string(&cfg_path).unwrap_or_default();
let stripped = strip_workspaces_blocks(&existing);
let mut out = stripped.trim_end().to_string();
out.push_str(
"\n\n# ── Workspace picker (auto-managed by Settings → Manage workspaces…) ─────────\n",
);
for w in workspaces {
out.push_str("[[workspaces]]\n");
out.push_str(&format!("name = {}\n", toml_quote(&w.name)));
let path_str = w.path.to_string_lossy().into_owned();
let path_display = if let Some(home) = std::env::var_os("HOME") {
let home = home.to_string_lossy().into_owned();
if path_str.starts_with(&home) {
let rest = path_str.trim_start_matches(&home).trim_start_matches('/');
format!("~/{rest}")
} else {
path_str.clone()
}
} else {
path_str.clone()
};
out.push_str(&format!("path = {}\n", toml_quote(&path_display)));
if let Some(group) = w.group.as_ref() {
out.push_str(&format!("group = {}\n", toml_quote(group)));
}
out.push('\n');
}
write_user_config(&cfg_path, &out).map_err(|e| format!("write {}: {e}", cfg_path.display()))?;
Ok(cfg_path)
}
fn strip_workspaces_blocks(src: &str) -> String {
let mut out = String::with_capacity(src.len());
let mut in_ws_block = false;
for line in src.lines() {
let trimmed = line.trim_start();
if trimmed == "[[workspaces]]" {
in_ws_block = true;
continue;
}
if in_ws_block {
if trimmed.is_empty() {
in_ws_block = false;
continue;
}
if trimmed.starts_with('[') {
in_ws_block = false;
out.push_str(line);
out.push('\n');
continue;
}
continue;
}
out.push_str(line);
out.push('\n');
}
out
}
fn home_config_path() -> Option<PathBuf> {
if crate::data_root::data_root_kind() == crate::data_root::DataRootKind::Portable {
return Some(crate::data_root::data_root().join("config.toml"));
}
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME")
&& !xdg.is_empty()
{
return Some(PathBuf::from(xdg).join("mnml").join("config.toml"));
}
std::env::var_os("HOME").map(|h| {
PathBuf::from(h)
.join(".config")
.join("mnml")
.join("config.toml")
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn upsert_kv_creates_section_when_absent() {
let out = upsert_toml_kv("", "ui", "scrollbar", "true");
assert!(out.contains("[ui]"));
assert!(out.contains("scrollbar = true"));
}
#[test]
fn upsert_kv_replaces_in_existing_section() {
let src = "[ui]\nscrollbar = false\ntheme = \"onedark\"\n";
let out = upsert_toml_kv(src, "ui", "scrollbar", "true");
assert!(out.contains("scrollbar = true"));
assert!(!out.contains("scrollbar = false"));
assert!(out.contains("theme = \"onedark\""));
assert_eq!(out.matches("scrollbar = ").count(), 1);
}
#[test]
fn upsert_kv_is_idempotent() {
let once = upsert_toml_kv("", "editor", "tab_width", "2");
let twice = upsert_toml_kv(&once, "editor", "tab_width", "2");
assert_eq!(once, twice);
assert_eq!(twice.matches("tab_width = ").count(), 1);
}
#[test]
fn upsert_kv_preserves_comments_and_other_sections() {
let src = "# my workspace config\n\
[editor]\n\
tab_width = 4 # project default\n\
\n\
[browser]\n\
headless = true\n";
let out = upsert_toml_kv(src, "ui", "theme", "\"gruvbox\"");
assert!(out.contains("# my workspace config"));
assert!(out.contains("tab_width = 4 # project default"));
assert!(out.contains("[browser]"));
assert!(out.contains("headless = true"));
assert!(out.contains("[ui]"));
assert!(out.contains("theme = \"gruvbox\""));
}
#[test]
fn upsert_kv_key_boundary_does_not_clobber_prefixed_key() {
let src = "[ui]\nrelative_line_numbers = true\n";
let out = upsert_toml_kv(src, "ui", "line_numbers", "false");
assert!(out.contains("relative_line_numbers = true"));
assert!(out.contains("line_numbers = false"));
assert_eq!(out.matches("relative_line_numbers = ").count(), 1);
}
#[test]
fn claude_accounts_defaults_to_single_account_when_absent() {
let cfg = Config::default();
let accounts = cfg.claude_accounts();
assert_eq!(accounts.len(), 1);
assert_eq!(accounts[0].name, "default");
assert_eq!(accounts[0].token_path, "ai_token");
assert!(accounts[0].active);
}
#[test]
fn claude_accounts_parses_multi_account_block() {
let src = "[ai]\n\
[[ai.claude.accounts]]\n\
name = \"personal\"\n\
token_path = \"ai_token\"\n\
active = true\n\
[[ai.claude.accounts]]\n\
name = \"work\"\n\
token_path = \"ai_token.work\"\n";
let mut cfg = Config::default();
#[allow(clippy::field_reassign_with_default)]
{
cfg.ai = toml::from_str::<toml::Value>(src)
.unwrap()
.get("ai")
.cloned()
.unwrap();
}
let accounts = cfg.claude_accounts();
assert_eq!(accounts.len(), 2);
assert_eq!(accounts[0].name, "personal");
assert!(accounts[0].active);
assert_eq!(accounts[1].name, "work");
assert!(!accounts[1].active);
}
#[test]
fn claude_accounts_normalizes_no_active_to_first_wins() {
let src = "[ai]\n\
[[ai.claude.accounts]]\n\
name = \"personal\"\n\
token_path = \"ai_token\"\n\
[[ai.claude.accounts]]\n\
name = \"work\"\n\
token_path = \"ai_token.work\"\n";
let mut cfg = Config::default();
#[allow(clippy::field_reassign_with_default)]
{
cfg.ai = toml::from_str::<toml::Value>(src)
.unwrap()
.get("ai")
.cloned()
.unwrap();
}
let accounts = cfg.claude_accounts();
assert_eq!(accounts.len(), 2);
assert!(accounts[0].active);
assert!(!accounts[1].active);
}
#[test]
fn claude_accounts_show_all_flag_defaults_false() {
let cfg = Config::default();
assert!(!cfg.ai_claude_show_all());
}
#[test]
fn persist_workspace_setting_writes_file() {
let dir = tempfile::tempdir().unwrap();
let path = persist_workspace_setting(dir.path(), "editor", "tab_width", "2").unwrap();
assert_eq!(path, dir.path().join(".mnml").join("config.toml"));
let body = std::fs::read_to_string(&path).unwrap();
assert!(body.contains("[editor]"));
assert!(body.contains("tab_width = 2"));
}
#[test]
fn workspaces_config_parses_and_appends() {
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("config.toml");
let mut f = std::fs::File::create(&cfg_path).unwrap();
writeln!(
f,
r#"
[[workspaces]]
name = "work"
path = "/tmp/work-stuff"
[[workspaces]]
path = "/tmp/mnml-stuff"
"#
)
.unwrap();
let mut cfg = Config::default();
cfg.apply_file_pub(&cfg_path);
assert_eq!(cfg.workspaces.len(), 2);
assert_eq!(cfg.workspaces[0].name, "work");
assert_eq!(
cfg.workspaces[0].path,
std::path::PathBuf::from("/tmp/work-stuff")
);
assert_eq!(cfg.workspaces[1].name, "mnml-stuff");
let cfg_path2 = dir.path().join("local.toml");
let mut f2 = std::fs::File::create(&cfg_path2).unwrap();
writeln!(
f2,
r#"
[[workspaces]]
name = "extra"
path = "/tmp/extra"
"#
)
.unwrap();
cfg.apply_file_pub(&cfg_path2);
assert_eq!(cfg.workspaces.len(), 3);
assert_eq!(cfg.workspaces[2].name, "extra");
}
#[test]
fn default_workspace_parses_and_expands_tilde() {
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("config.toml");
std::fs::write(&cfg_path, "[startup]\ndefault_workspace = \"~/my-mnml\"\n").unwrap();
let mut cfg = Config::default();
cfg.apply_file_pub(&cfg_path);
let expected = std::env::var_os("HOME")
.map(|h| std::path::PathBuf::from(h).join("my-mnml"))
.unwrap_or_else(|| std::path::PathBuf::from("my-mnml"));
assert_eq!(cfg.default_workspace, Some(expected));
}
#[test]
fn default_workspace_unset_stays_none() {
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("config.toml");
std::fs::write(&cfg_path, "[startup]\ntasks = []\n").unwrap();
let mut cfg = Config::default();
cfg.apply_file_pub(&cfg_path);
assert!(cfg.default_workspace.is_none());
}
#[test]
fn default_workspace_empty_string_treated_as_unset() {
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("config.toml");
std::fs::write(&cfg_path, "[startup]\ndefault_workspace = \" \"\n").unwrap();
let mut cfg = Config::default();
cfg.apply_file_pub(&cfg_path);
assert!(cfg.default_workspace.is_none());
}
#[test]
fn scaffold_workspace_creates_dir_and_readme() {
let parent = tempfile::tempdir().unwrap();
let ws = parent.path().join("mnml-workspace");
assert!(!ws.exists());
scaffold_workspace(&ws).unwrap();
assert!(ws.is_dir());
let readme = ws.join("README.md");
assert!(readme.is_file());
let body = std::fs::read_to_string(&readme).unwrap();
assert!(body.contains("mnml workspace"));
assert!(body.contains("default_workspace"));
}
#[test]
fn scaffold_workspace_is_idempotent_and_preserves_existing_readme() {
let parent = tempfile::tempdir().unwrap();
let ws = parent.path().join("ws");
std::fs::create_dir_all(&ws).unwrap();
std::fs::write(ws.join("README.md"), "# my notes\n").unwrap();
scaffold_workspace(&ws).unwrap();
let body = std::fs::read_to_string(ws.join("README.md")).unwrap();
assert_eq!(body, "# my notes\n");
scaffold_workspace(&ws).unwrap();
let body = std::fs::read_to_string(ws.join("README.md")).unwrap();
assert_eq!(body, "# my notes\n");
}
#[test]
fn bitbucket_section_silently_ignored() {
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("config.toml");
let mut f = std::fs::File::create(&cfg_path).unwrap();
writeln!(
f,
r#"
[bitbucket]
auth_env = "BB_TOKEN"
poll_secs = 60
[[bitbucket.repos]]
workspace = "exampleorg"
slug = "example-api"
"#
)
.unwrap();
let mut cfg = Config::default();
cfg.apply_file_pub(&cfg_path);
let _ = cfg;
}
#[test]
fn azdevops_section_silently_ignored() {
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("config.toml");
let mut f = std::fs::File::create(&cfg_path).unwrap();
writeln!(
f,
r#"
[azdevops]
auth_env = "AZDO_TOKEN"
[[azdevops.projects]]
org = "exampleorg"
project = "Example"
repo = "api"
"#
)
.unwrap();
let mut cfg = Config::default();
cfg.apply_file_pub(&cfg_path);
let _ = cfg;
}
#[test]
fn github_section_silently_ignored() {
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("config.toml");
let mut f = std::fs::File::create(&cfg_path).unwrap();
writeln!(
f,
r#"
[github]
auth_env = "GH_TOKEN"
poll_secs = 45
[[github.repos]]
owner = "exampleorg"
repo = "example-knowledge"
"#
)
.unwrap();
let mut cfg = Config::default();
cfg.apply_file_pub(&cfg_path);
let _ = cfg;
}
#[test]
fn default_integration_icons_are_first_party_only() {
let cfg = Config::default();
let ids: Vec<&str> = cfg
.ui
.integration_icons
.iter()
.map(|i| i.id.as_str())
.collect();
assert!(ids.contains(&"browser"));
assert!(ids.contains(&"claude_code"));
assert!(ids.contains(&"codex"));
assert!(!ids.contains(&"bitbucket_pull_requests"));
assert!(!ids.contains(&"bitbucket_pipelines"));
assert!(!ids.contains(&"github"));
assert!(!ids.contains(&"dynamodb"));
let claude = cfg
.ui
.integration_icons
.iter()
.find(|i| i.id == "claude_code")
.unwrap();
assert_eq!(claude.command, "ai.claude_code");
assert_eq!(claude.color, "#D16D51");
}
#[test]
fn user_reorder_of_integration_icons_survives_reload() {
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("config.toml");
let mut f = std::fs::File::create(&cfg_path).unwrap();
writeln!(
f,
r#"
[[ui.integration_icon]]
id = "codex"
[[ui.integration_icon]]
id = "claude_code"
[[ui.integration_icon]]
id = "browser"
"#
)
.unwrap();
let mut cfg = Config::default();
cfg.apply_file(&cfg_path);
let ids: Vec<&str> = cfg
.ui
.integration_icons
.iter()
.map(|i| i.id.as_str())
.collect();
assert_eq!(
&ids[..3],
&["codex", "claude_code", "browser"],
"user-file order must be preserved verbatim; got {ids:?}"
);
}
#[test]
fn integration_icon_order_sorts_effective_list() {
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("config.toml");
let mut f = std::fs::File::create(&cfg_path).unwrap();
writeln!(
f,
r#"
[ui]
integration_icon_order = ["codex", "browser"]
"#
)
.unwrap();
let mut cfg = Config::default();
cfg.apply_file(&cfg_path);
let ids: Vec<&str> = cfg
.ui
.integration_icons
.iter()
.map(|i| i.id.as_str())
.collect();
assert_eq!(&ids[..2], &["codex", "browser"]);
assert!(ids[2..].contains(&"claude_code"));
}
#[test]
fn empty_integration_icon_order_leaves_order_untouched() {
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("config.toml");
std::fs::write(&cfg_path, "[ui]\nintegration_icon_order = []\n").unwrap();
let default_order: Vec<String> = Config::default()
.ui
.integration_icons
.iter()
.map(|i| i.id.clone())
.collect();
let mut cfg = Config::default();
cfg.apply_file(&cfg_path);
let after_order: Vec<String> = cfg
.ui
.integration_icons
.iter()
.map(|i| i.id.clone())
.collect();
assert_eq!(default_order, after_order);
}
#[test]
fn marketplace_config_defaults() {
let cfg = Config::default();
assert!(cfg.marketplace.enabled);
assert_eq!(cfg.marketplace.cache_ttl_secs, 3600);
assert!(cfg.marketplace.use_defaults);
assert!(cfg.marketplace.sources.is_empty());
assert_eq!(cfg.marketplace.effective_sources().len(), 3);
}
#[test]
fn marketplace_user_source_appends_to_defaults() {
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("config.toml");
let mut f = std::fs::File::create(&cfg_path).unwrap();
writeln!(
f,
r#"
[marketplace]
cache_ttl_secs = 7200
[[marketplace.source]]
type = "github_launcher_folder"
id = "acme"
repo = "acme-corp/mnml-launchers"
path = "."
"#
)
.unwrap();
let mut cfg = Config::default();
cfg.apply_file_pub(&cfg_path);
assert_eq!(cfg.marketplace.cache_ttl_secs, 7200);
assert_eq!(cfg.marketplace.sources.len(), 1);
let effective = cfg.marketplace.effective_sources();
assert_eq!(effective.len(), 4);
assert_eq!(effective[3].id(), "acme");
}
#[test]
fn marketplace_use_defaults_false_replaces_them() {
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("config.toml");
let mut f = std::fs::File::create(&cfg_path).unwrap();
writeln!(
f,
r#"
[marketplace]
use_defaults = false
[[marketplace.source]]
type = "crates_keyword"
keyword = "my-own-keyword"
"#
)
.unwrap();
let mut cfg = Config::default();
cfg.apply_file_pub(&cfg_path);
let effective = cfg.marketplace.effective_sources();
assert_eq!(effective.len(), 1);
}
#[test]
fn marketplace_disabled_still_parses_sources() {
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("config.toml");
let mut f = std::fs::File::create(&cfg_path).unwrap();
writeln!(
f,
r#"
[marketplace]
enabled = false
[[marketplace.source]]
type = "crates_keyword"
keyword = "test"
"#
)
.unwrap();
let mut cfg = Config::default();
cfg.apply_file_pub(&cfg_path);
assert!(!cfg.marketplace.enabled);
assert_eq!(cfg.marketplace.sources.len(), 1);
}
fn cfg_from_toml(s: &str) -> Config {
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
std::io::Write::write_all(&mut tmp.as_file(), s.as_bytes()).expect("write config");
let mut cfg = Config::default();
cfg.apply_file_pub(tmp.path());
cfg
}
#[test]
fn startup_layout_default_is_empty() {
assert!(Config::default().startup_layout.is_empty());
}
#[test]
fn startup_layout_parses_valid_editor_then_pty_chain() {
let cfg = cfg_from_toml(
r#"
[[startup.layout]]
kind = "editor"
path = "src/main.rs"
[[startup.layout]]
kind = "pty"
cmd = "cargo watch"
split = "down"
ratio = 30
"#,
);
assert_eq!(cfg.startup_layout.len(), 2);
assert_eq!(cfg.startup_layout[0].kind, "editor");
assert_eq!(cfg.startup_layout[0].path.as_deref(), Some("src/main.rs"));
assert_eq!(cfg.startup_layout[1].kind, "pty");
assert_eq!(cfg.startup_layout[1].cmd.as_deref(), Some("cargo watch"));
assert_eq!(cfg.startup_layout[1].split.as_deref(), Some("down"));
assert_eq!(cfg.startup_layout[1].ratio, Some(30));
}
#[test]
fn startup_layout_drops_unknown_kind() {
let cfg = cfg_from_toml(
r#"
[[startup.layout]]
kind = "editor"
path = "src/main.rs"
[[startup.layout]]
kind = "wat"
"#,
);
assert_eq!(cfg.startup_layout.len(), 1);
assert_eq!(cfg.startup_layout[0].kind, "editor");
}
#[test]
fn startup_layout_drops_editor_without_path() {
let cfg = cfg_from_toml(
r#"
[[startup.layout]]
kind = "editor"
"#,
);
assert!(cfg.startup_layout.is_empty());
}
#[test]
fn startup_layout_drops_pty_without_cmd() {
let cfg = cfg_from_toml(
r#"
[[startup.layout]]
kind = "pty"
"#,
);
assert!(cfg.startup_layout.is_empty());
}
#[test]
fn startup_layout_second_entry_requires_split() {
let cfg = cfg_from_toml(
r#"
[[startup.layout]]
kind = "editor"
path = "a"
[[startup.layout]]
kind = "editor"
path = "b"
"#,
);
assert_eq!(cfg.startup_layout.len(), 1);
assert_eq!(cfg.startup_layout[0].path.as_deref(), Some("a"));
}
#[test]
fn startup_layout_out_of_range_ratio_falls_back_to_default() {
let cfg = cfg_from_toml(
r#"
[[startup.layout]]
kind = "editor"
path = "a"
[[startup.layout]]
kind = "editor"
path = "b"
split = "right"
ratio = 99
"#,
);
assert_eq!(cfg.startup_layout.len(), 2);
assert_eq!(cfg.startup_layout[1].ratio, None);
}
}