use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Bookmark {
pub label: String,
pub url: String,
pub env: String,
}
pub const DEFAULT_ENV: &str = "other";
#[derive(Debug, Default, serde::Deserialize)]
struct RawFile {
#[serde(default)]
site: Vec<RawSite>,
#[serde(default)]
bookmark: Vec<RawBookmark>,
}
#[derive(Debug, serde::Deserialize)]
struct RawSite {
name: String,
#[serde(flatten)]
urls: std::collections::BTreeMap<String, String>,
}
#[derive(Debug, serde::Deserialize)]
struct RawBookmark {
label: String,
url: String,
env: Option<String>,
}
pub fn paths(workspace: &Path) -> Vec<PathBuf> {
vec![
crate::data_root::data_root().join("bookmarks.toml"),
workspace.join(".mnml").join("bookmarks.toml"),
]
}
pub fn load(workspace: &Path) -> Vec<Bookmark> {
let mut out = Vec::new();
for p in paths(workspace) {
let Ok(text) = std::fs::read_to_string(&p) else {
continue;
};
let Ok(raw) = toml::from_str::<RawFile>(&text) else {
eprintln!("bookmarks: {} is not valid TOML — skipped", p.display());
continue;
};
for site in raw.site {
for (env, url) in site.urls {
if url.trim().is_empty() {
continue;
}
out.push(Bookmark {
label: site.name.clone(),
url,
env,
});
}
}
for b in raw.bookmark {
if b.url.trim().is_empty() {
continue;
}
out.push(Bookmark {
label: b.label,
url: b.url,
env: b.env.unwrap_or_else(|| DEFAULT_ENV.to_string()),
});
}
}
out
}
pub fn envs(marks: &[Bookmark]) -> Vec<String> {
const ORDER: [&str; 6] = ["local", "dev", "test", "qa", "staging", "prod"];
let mut known: Vec<String> = Vec::new();
let mut rest: Vec<String> = Vec::new();
for m in marks {
let seen = known.iter().chain(rest.iter()).any(|e| e == &m.env);
if seen {
continue;
}
if ORDER.contains(&m.env.as_str()) {
known.push(m.env.clone());
} else {
rest.push(m.env.clone());
}
}
known.sort_by_key(|e| ORDER.iter().position(|o| o == e).unwrap_or(usize::MAX));
rest.sort();
known.extend(rest);
known
}
pub fn in_env<'a>(marks: &'a [Bookmark], env: &str) -> Vec<&'a Bookmark> {
marks.iter().filter(|m| m.env == env).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn write(dir: &Path, name: &str, body: &str) {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join(name), body).unwrap();
}
#[test]
fn a_site_expands_into_one_bookmark_per_env() {
let d = tempfile::tempdir().unwrap();
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _root = crate::EnvGuard::set("MNML_DATA_ROOT", d.path().join("root"));
write(
&d.path().join("root"),
"bookmarks.toml",
r#"
[[site]]
name = "ADX Admin"
dev = "https://adx.dev.example.net/admin/dashboard"
staging = "https://adx.staging.example.net/admin/dashboard"
prod = "https://adx.example.com/admin/dashboard"
"#,
);
let marks = load(d.path());
assert_eq!(marks.len(), 3, "{marks:?}");
assert!(marks.iter().all(|m| m.label == "ADX Admin"));
let envs: Vec<&str> = marks.iter().map(|m| m.env.as_str()).collect();
for e in ["dev", "staging", "prod"] {
assert!(envs.contains(&e), "missing {e}: {envs:?}");
}
}
#[test]
fn a_flat_bookmark_without_an_env_lands_in_other() {
let d = tempfile::tempdir().unwrap();
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _root = crate::EnvGuard::set("MNML_DATA_ROOT", d.path().join("root"));
write(
&d.path().join("root"),
"bookmarks.toml",
"[[bookmark]]\nlabel = \"Docs\"\nurl = \"https://example.com\"\n",
);
let marks = load(d.path());
assert_eq!(marks.len(), 1);
assert_eq!(marks[0].env, DEFAULT_ENV);
}
#[test]
fn workspace_bookmarks_are_added_not_substituted() {
let d = tempfile::tempdir().unwrap();
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _root = crate::EnvGuard::set("MNML_DATA_ROOT", d.path().join("root"));
write(
&d.path().join("root"),
"bookmarks.toml",
"[[bookmark]]\nlabel = \"Mine\"\nurl = \"https://a\"\nenv = \"dev\"\n",
);
write(
&d.path().join(".mnml"),
"bookmarks.toml",
"[[bookmark]]\nlabel = \"Repo\"\nurl = \"https://b\"\nenv = \"dev\"\n",
);
let labels: Vec<String> = load(d.path()).into_iter().map(|m| m.label).collect();
assert_eq!(labels, vec!["Mine".to_string(), "Repo".to_string()]);
}
#[test]
fn a_malformed_file_is_skipped_not_fatal() {
let d = tempfile::tempdir().unwrap();
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _root = crate::EnvGuard::set("MNML_DATA_ROOT", d.path().join("root"));
write(
&d.path().join("root"),
"bookmarks.toml",
"this is not toml [[[",
);
write(
&d.path().join(".mnml"),
"bookmarks.toml",
"[[bookmark]]\nlabel = \"Good\"\nurl = \"https://ok\"\n",
);
let marks = load(d.path());
assert_eq!(
marks.len(),
1,
"the valid file should still load: {marks:?}"
);
assert_eq!(marks[0].label, "Good");
}
#[test]
fn envs_are_ordered_by_promotion_not_alphabetically() {
let marks: Vec<Bookmark> = ["prod", "dev", "staging", "zeta", "alpha"]
.iter()
.map(|e| Bookmark {
label: "x".into(),
url: "https://x".into(),
env: (*e).to_string(),
})
.collect();
assert_eq!(
envs(&marks),
vec![
"dev".to_string(),
"staging".to_string(),
"prod".to_string(),
"alpha".to_string(),
"zeta".to_string()
],
"alphabetical order would put prod between dev and staging"
);
}
#[test]
fn an_empty_url_is_dropped() {
let d = tempfile::tempdir().unwrap();
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _root = crate::EnvGuard::set("MNML_DATA_ROOT", d.path().join("root"));
write(
&d.path().join("root"),
"bookmarks.toml",
"[[site]]\nname = \"S\"\ndev = \"https://d\"\nprod = \"\"\n",
);
let marks = load(d.path());
assert_eq!(marks.len(), 1, "an empty env URL should not become a row");
assert_eq!(marks[0].env, "dev");
}
}
#[cfg(test)]
mod menu_wiring_tests {
use crate::context_menu::MenuAction;
fn seed(root: &std::path::Path) {
std::fs::create_dir_all(root).unwrap();
std::fs::write(
root.join("bookmarks.toml"),
r#"
[[site]]
name = "ADX Admin"
dev = "https://adx.dev.example.net/admin/dashboard"
staging = "https://adx.staging.example.net/admin/dashboard"
prod = "https://adx.example.com/admin/dashboard"
"#,
)
.unwrap();
}
#[test]
fn the_browser_chip_menu_gains_one_row_per_env() {
let d = tempfile::tempdir().unwrap();
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _root = crate::EnvGuard::set("MNML_DATA_ROOT", d.path().join("root"));
seed(&d.path().join("root"));
let mut app =
crate::app::App::new(d.path().to_path_buf(), crate::config::Config::default()).unwrap();
let idx = app
.config
.ui
.integration_icons
.iter()
.position(|i| i.id == "browser")
.expect("browser chip is a shipped default");
app.open_integration_chip_context_menu(idx, (5, 5));
let menu = app.context_menu.as_ref().expect("menu did not open");
let envs: Vec<String> = menu
.items
.iter()
.filter_map(|it| match &it.action {
MenuAction::OpenBookmarks(Some(e)) => Some(e.clone()),
_ => None,
})
.collect();
assert_eq!(
envs,
vec!["dev".to_string(), "staging".to_string(), "prod".to_string()],
"env rows missing or out of promotion order: {:?}",
menu.items.iter().map(|i| &i.label).collect::<Vec<_>>()
);
assert!(
menu.items
.iter()
.any(|it| matches!(it.action, MenuAction::OpenBookmarks(None))),
"no all-envs row"
);
}
#[test]
fn no_bookmark_rows_when_no_bookmarks_are_defined() {
let d = tempfile::tempdir().unwrap();
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _root = crate::EnvGuard::set("MNML_DATA_ROOT", d.path().join("empty-root"));
let mut app =
crate::app::App::new(d.path().to_path_buf(), crate::config::Config::default()).unwrap();
let idx = app
.config
.ui
.integration_icons
.iter()
.position(|i| i.id == "browser")
.unwrap();
app.open_integration_chip_context_menu(idx, (5, 5));
let menu = app.context_menu.as_ref().expect("menu did not open");
assert!(
!menu
.items
.iter()
.any(|it| matches!(it.action, MenuAction::OpenBookmarks(_))),
"bookmark rows appeared with no bookmarks defined"
);
}
#[test]
fn the_env_scoped_picker_lists_only_that_env() {
let d = tempfile::tempdir().unwrap();
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _root = crate::EnvGuard::set("MNML_DATA_ROOT", d.path().join("root"));
seed(&d.path().join("root"));
let mut app =
crate::app::App::new(d.path().to_path_buf(), crate::config::Config::default()).unwrap();
app.open_bookmarks_picker(Some("staging"));
let pk = app.picker.as_ref().expect("picker did not open");
assert_eq!(pk.len(), 1, "expected only the staging row");
let first = pk.items_view().next().expect("no rows");
assert!(
first.id.contains("staging"),
"picker id must be the URL: {:?}",
first.id
);
}
}