use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MarketplaceEntry {
pub source_id: String,
pub kind: MarketplaceKind,
pub id: String,
pub label: String,
pub description: Option<String>,
pub install: InstallSpec,
#[serde(default)]
pub stats: EntryStats,
#[serde(default)]
pub provenance: Provenance,
#[serde(default)]
pub glyph: Option<String>,
#[serde(default)]
pub color: Option<String>,
#[serde(default, alias = "verified")]
pub ready: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MarketplaceKind {
App,
Launcher,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Provenance {
Official,
#[serde(other)]
#[default]
Community,
}
pub fn provenance_for(source_id: &str) -> Provenance {
if default_sources().iter().any(|s| s.id() == source_id) {
Provenance::Official
} else {
Provenance::Community
}
}
pub fn ready_ids() -> &'static [&'static str] {
&[
"mnml-forge-bitbucket",
"mnml-tracker-jira",
"mnml-aws-amplify",
"mnml-aws-codebuild",
"mnml-db",
]
}
pub fn is_ready(id: &str) -> bool {
ready_ids().contains(&id)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum InstallSpec {
Cargo { name: String },
LauncherToml { url: String },
CargoGit { repo: String, path: String },
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EntryStats {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub downloads: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stars: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_at: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Source {
CratesKeyword { id: String, keyword: String },
GithubLauncherFolder {
id: String,
repo: String,
path: String,
},
GithubMonorepoApps {
id: String,
repo: String,
apps_dir: String,
},
}
impl Source {
pub fn id(&self) -> &str {
match self {
Source::CratesKeyword { id, .. } => id,
Source::GithubLauncherFolder { id, .. } => id,
Source::GithubMonorepoApps { id, .. } => id,
}
}
}
pub fn default_sources() -> Vec<Source> {
vec![
Source::CratesKeyword {
id: "crates.io".to_string(),
keyword: "mnml-integration".to_string(),
},
Source::GithubLauncherFolder {
id: "chris-mclennan/mnml-integrations".to_string(),
repo: "chris-mclennan/mnml-integrations".to_string(),
path: "launchers".to_string(),
},
Source::GithubMonorepoApps {
id: "chris-mclennan/mnml-integrations-apps".to_string(),
repo: "chris-mclennan/mnml-integrations".to_string(),
apps_dir: "apps".to_string(),
},
]
}
#[derive(Debug, Deserialize)]
struct CratesResponse {
#[serde(default)]
crates: Vec<CratesCrate>,
}
#[derive(Debug, Deserialize)]
struct CratesCrate {
#[serde(rename = "name")]
name: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
downloads: Option<u64>,
#[serde(default)]
updated_at: Option<String>,
}
pub fn parse_crates_response(source_id: &str, body: &str) -> Result<Vec<MarketplaceEntry>, String> {
let resp: CratesResponse =
serde_json::from_str(body).map_err(|e| format!("crates.io json: {e}"))?;
let provenance = provenance_for(source_id);
let out = resp
.crates
.into_iter()
.map(|c| {
let label = c.name.clone();
let (glyph, color) = catalog_lookup(&c.name);
let name_for_verified = c.name.clone();
MarketplaceEntry {
source_id: source_id.to_string(),
kind: MarketplaceKind::App,
id: c.name.clone(),
label,
description: c.description,
install: InstallSpec::Cargo { name: c.name },
stats: EntryStats {
downloads: c.downloads,
stars: None,
updated_at: c.updated_at.and_then(|s| parse_iso8601_secs(&s)),
},
provenance,
glyph,
color,
ready: is_ready(&name_for_verified),
}
})
.collect();
Ok(out)
}
#[derive(Debug, Deserialize)]
struct GhFileEntry {
name: String,
#[serde(rename = "type")]
entry_type: String,
download_url: Option<String>,
}
pub fn parse_github_folder_response(body: &str) -> Result<Vec<(String, String)>, String> {
let entries: Vec<GhFileEntry> =
serde_json::from_str(body).map_err(|e| format!("github contents json: {e}"))?;
let out = entries
.into_iter()
.filter(|e| e.entry_type == "file" && e.name.ends_with(".toml"))
.filter_map(|e| e.download_url.map(|url| (e.name, url)))
.collect();
Ok(out)
}
pub fn parse_github_dir_children(body: &str) -> Result<Vec<String>, String> {
let entries: Vec<GhFileEntry> =
serde_json::from_str(body).map_err(|e| format!("github contents json: {e}"))?;
Ok(entries
.into_iter()
.filter(|e| e.entry_type == "dir")
.map(|e| e.name)
.filter(|n| !n.starts_with('.') && !n.starts_with('_'))
.filter(|n| is_safe_crate_component(n))
.collect())
}
pub fn is_safe_crate_component(s: &str) -> bool {
if s.is_empty() || s == "." || s == ".." {
return false;
}
s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
}
pub fn is_safe_repo_slug(s: &str) -> bool {
let mut parts = s.split('/');
match (parts.next(), parts.next(), parts.next()) {
(Some(owner), Some(name), None) => {
is_safe_crate_component(owner) && is_safe_crate_component(name)
}
_ => false,
}
}
pub fn is_safe_repo_subpath(s: &str) -> bool {
if s.is_empty() || s.starts_with('/') || s.ends_with('/') {
return false;
}
s.split('/')
.all(|seg| !seg.is_empty() && seg != ".." && is_safe_crate_component(seg))
}
pub fn parse_cargo_toml_metadata(body: &str) -> Result<(Option<String>, Option<String>), String> {
#[derive(Debug, Deserialize)]
struct CargoManifest {
package: Option<Package>,
}
#[derive(Debug, Deserialize)]
struct Package {
description: Option<String>,
name: Option<String>,
}
let m: CargoManifest = toml::from_str(body).map_err(|e| format!("cargo.toml: {e}"))?;
let pkg = m.package.unwrap_or(Package {
description: None,
name: None,
});
let desc = pkg
.description
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let label = pkg
.name
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
Ok((desc, label))
}
pub fn parse_launcher_toml(
source_id: &str,
download_url: &str,
body: &str,
) -> Result<MarketplaceEntry, String> {
let m: crate::integration_manifest::IntegrationManifest =
toml::from_str(body).map_err(|e| format!("launcher toml: {e}"))?;
let (chip_glyph, chip_color) = match &m.chip {
Some(c) => (Some(c.glyph.clone()), Some(c.color.clone())),
None => (None, None),
};
let (fallback_glyph, fallback_color) = catalog_lookup(&m.id);
let ready = is_ready(&m.id);
Ok(MarketplaceEntry {
source_id: source_id.to_string(),
kind: MarketplaceKind::Launcher,
id: m.id,
label: m.label,
description: m.description,
install: InstallSpec::LauncherToml {
url: download_url.to_string(),
},
stats: EntryStats::default(),
provenance: provenance_for(source_id),
glyph: chip_glyph.or(fallback_glyph),
color: chip_color.or(fallback_color),
ready,
})
}
pub fn catalog_lookup(id: &str) -> (Option<String>, Option<String>) {
let (glyph, color): (&str, &str) = match id {
"btop" => ("\u{F0AEF}", "red"), "htop" => ("\u{F0379}", "green"), "iftop" => ("\u{F06F3}", "cyan"),
"vscode" | "VSCode" => ("\u{E8DA}", "blue"),
"claude_code" | "claude-code" => ("\u{F1E00}", "orange"),
"codex" => ("\u{F1E01}", "cyan"),
"browser" => ("\u{EB01}", "blue"),
"mnml-db" => ("\u{E64D}", "blue"),
"mnml-db-driver-postgres" => ("\u{E76E}", "blue"), "mnml-db-driver-mariadb" => ("\u{F1C12}", "teal"), "mnml-db-driver-mysql" => ("\u{E704}", "orange"), "mnml-db-driver-redis" => ("\u{F1C13}", "red"), "mnml-db-driver-sqlite" => ("\u{E7C4}", "blue"), "mnml-db-driver-docdb" => ("\u{F1C11}", "blue"), "mnml-db-driver-clickhouse" => ("\u{F1C0F}", "yellow"), "mnml-db-driver-redshift" => ("\u{F1C10}", "purple"), "mnml-db-driver-dynamodb" => ("\u{F1C06}", "blue"),
"mnml-scm-bitbucket" | "mnml-forge-bitbucket" | "bitbucket" => ("\u{F00A8}", "blue"),
"mnml-scm-github" | "mnml-forge-github" | "github" => ("\u{E709}", "fg"),
"mnml-msg-slack" | "slack" => ("\u{F04B1}", "white"),
"mnml-tracker-jira" => ("\u{F0303}", "blue"),
"mnml-tattle-coverage" => ("\u{F437}", "cyan"),
"mnml-aws-amplify" => ("\u{F1C0E}", "red"), "mnml-aws-cloudwatch" => ("\u{F1C03}", "pink"), "mnml-aws-codebuild" => ("\u{F1C04}", "pink"), "mnml-aws-cognito" => ("\u{F1C05}", "red"), "mnml-aws-dynamodb" => ("\u{F1C06}", "purple"), "mnml-aws-ecr" => ("\u{F1C07}", "orange"), "mnml-aws-ecs" => ("\u{F1C08}", "orange"), "mnml-aws-eventbridge" => ("\u{F1C09}", "magenta"), "mnml-aws-lambda" => ("\u{F1C0A}", "orange"), "mnml-aws-rds" => ("\u{F1C0B}", "purple"), "mnml-aws-sns" => ("\u{F1C0C}", "magenta"), "mnml-aws-sqs" => ("\u{F1C0D}", "magenta"),
_ => return (None, None),
};
(Some(glyph.to_string()), Some(color.to_string()))
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MarketplaceCache {
pub fetched_at: u64,
pub ttl_secs: u64,
pub entries: Vec<MarketplaceEntry>,
}
impl MarketplaceCache {
pub fn path() -> Option<PathBuf> {
if crate::data_root::data_root_kind() == crate::data_root::DataRootKind::Portable {
return Some(
crate::data_root::data_root()
.join("cache")
.join("marketplace.json"),
);
}
let home = std::env::var_os("HOME").map(PathBuf::from)?;
Some(home.join(".cache").join("mnml").join("marketplace.json"))
}
pub fn load_from(path: &Path) -> Option<Self> {
let text = std::fs::read_to_string(path).ok()?;
let mut cache: Self = serde_json::from_str(&text).ok()?;
for e in &mut cache.entries {
if e.glyph.is_none() {
let (g, c) = catalog_lookup(&e.id);
e.glyph = g;
e.color = c;
}
}
Some(cache)
}
pub fn save_to(&self, path: &Path) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("mkdir cache: {e}"))?;
}
let json = serde_json::to_string_pretty(self)
.map_err(|e| format!("serialize marketplace cache: {e}"))?;
std::fs::write(path, json).map_err(|e| format!("write cache: {e}"))
}
pub fn is_expired(&self) -> bool {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
now.saturating_sub(self.fetched_at) > self.ttl_secs
}
}
fn parse_iso8601_secs(s: &str) -> Option<u64> {
let (date_str, rest) = s.split_once('T')?;
let date_parts: Vec<&str> = date_str.split('-').collect();
if date_parts.len() != 3 {
return None;
}
let year: i64 = date_parts[0].parse().ok()?;
let month: u32 = date_parts[1].parse().ok()?;
let day: u32 = date_parts[2].parse().ok()?;
let (time_str, tz_offset_secs) = if let Some(idx) = rest.find(['Z', '+', '-']) {
let (t, tz) = rest.split_at(idx);
let offset = match tz.chars().next()? {
'Z' => 0i64,
sign => {
let after = &tz[1..];
let (hh, mm) = after.split_once(':')?;
let hh: i64 = hh.parse().ok()?;
let mm: i64 = mm.parse().ok()?;
let mag = hh * 3600 + mm * 60;
if sign == '-' { -mag } else { mag }
}
};
(t, offset)
} else {
(rest, 0i64)
};
let time_str = time_str.split('.').next()?;
let time_parts: Vec<&str> = time_str.split(':').collect();
if time_parts.len() != 3 {
return None;
}
let hh: u32 = time_parts[0].parse().ok()?;
let mm: u32 = time_parts[1].parse().ok()?;
let ss: u32 = time_parts[2].parse().ok()?;
let epoch_days = days_since_epoch(year, month, day)?;
let secs =
epoch_days * 86_400 + (hh as i64) * 3600 + (mm as i64) * 60 + (ss as i64) - tz_offset_secs;
u64::try_from(secs).ok()
}
fn days_since_epoch(year: i64, month: u32, day: u32) -> Option<i64> {
if year < 1970 || month == 0 || month > 12 || day == 0 || day > 31 {
return None;
}
let y = if month <= 2 { year - 1 } else { year };
let m = if month <= 2 { month + 9 } else { month - 3 };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = (y - era * 400) as u64;
let doy = ((153 * m as u64 + 2) / 5) + day as u64 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe as i64 - 719_468;
Some(days)
}
fn user_agent() -> String {
format!("mnml-marketplace/{}", env!("CARGO_PKG_VERSION"))
}
pub fn fetch_source(source: &Source) -> Result<Vec<MarketplaceEntry>, String> {
let client = reqwest::blocking::Client::builder()
.user_agent(user_agent())
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| format!("build http client: {e}"))?;
match source {
Source::CratesKeyword { id, keyword } => {
let url = format!(
"https://crates.io/api/v1/crates?keyword={}&per_page=100",
keyword
);
let body = client
.get(&url)
.send()
.and_then(|r| r.error_for_status())
.and_then(|r| r.text())
.map_err(|e| format!("crates.io fetch: {e}"))?;
parse_crates_response(id, &body)
}
Source::GithubLauncherFolder { id, repo, path } => {
let list_url = format!("https://api.github.com/repos/{}/contents/{}", repo, path);
let mut req = client.get(&list_url);
if let Some(tok) = detect_gh_auth_token() {
req = req.bearer_auth(tok);
}
let body = req
.send()
.and_then(|r| r.error_for_status())
.and_then(|r| r.text())
.map_err(|e| format!("github contents: {e}"))?;
let files = parse_github_folder_response(&body)?;
let mut entries = Vec::with_capacity(files.len());
for (name, download_url) in files {
match client.get(&download_url).send().and_then(|r| r.text()) {
Ok(toml_body) => match parse_launcher_toml(id, &download_url, &toml_body) {
Ok(entry) => entries.push(entry),
Err(e) => eprintln!("marketplace: skip {name}: {e}"),
},
Err(e) => eprintln!("marketplace: fetch {name}: {e}"),
}
}
Ok(entries)
}
Source::GithubMonorepoApps { id, repo, apps_dir } => {
let list_url = format!(
"https://api.github.com/repos/{}/contents/{}",
repo, apps_dir
);
let mut req = client.get(&list_url);
if let Some(tok) = detect_gh_auth_token() {
req = req.bearer_auth(tok);
}
let body = req
.send()
.and_then(|r| r.error_for_status())
.and_then(|r| r.text())
.map_err(|e| format!("github contents (monorepo apps): {e}"))?;
let dirs = parse_github_dir_children(&body)?;
let gh_tok = detect_gh_auth_token();
Ok(dirs
.into_iter()
.map(|name| {
let (glyph, color) = catalog_lookup(&name);
let install_path = format!("{}/{}", apps_dir, name);
let cargo_toml_url = format!(
"https://api.github.com/repos/{}/contents/{}/Cargo.toml",
repo, install_path
);
let mut cargo_req = client.get(&cargo_toml_url);
if let Some(tok) = &gh_tok {
cargo_req = cargo_req.bearer_auth(tok);
}
let cargo_meta = cargo_req
.header("Accept", "application/vnd.github.raw")
.send()
.ok()
.and_then(|r| r.error_for_status().ok())
.and_then(|r| r.text().ok())
.and_then(|body| parse_cargo_toml_metadata(&body).ok());
let (description, label) = match cargo_meta {
Some((desc, lbl)) => (desc, lbl.unwrap_or_else(|| name.clone())),
None => (None, name.clone()),
};
MarketplaceEntry {
source_id: id.clone(),
kind: MarketplaceKind::App,
id: name.clone(),
label,
description,
install: InstallSpec::CargoGit {
repo: repo.clone(),
path: install_path,
},
stats: EntryStats::default(),
provenance: provenance_for(id),
glyph,
color,
ready: is_ready(&name),
}
})
.collect())
}
}
}
pub fn detect_gh_auth_token() -> Option<String> {
let out = std::process::Command::new("gh")
.args(["auth", "token"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let token = String::from_utf8(out.stdout).ok()?.trim().to_string();
if token.is_empty() { None } else { Some(token) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_sources_include_crates_and_github() {
let s = default_sources();
assert_eq!(s.len(), 3);
assert!(matches!(s[0], Source::CratesKeyword { .. }));
assert!(matches!(s[1], Source::GithubLauncherFolder { .. }));
assert!(matches!(s[2], Source::GithubMonorepoApps { .. }));
}
#[test]
fn parses_crates_response_with_all_fields() {
let body = r#"{
"crates": [
{
"name": "mnml-aws-amplify",
"description": "AWS Amplify viewer for mnml",
"downloads": 42,
"updated_at": "2026-08-01T18:00:00.000000+00:00"
},
{
"name": "mnml-msg-slack",
"description": null,
"downloads": null,
"updated_at": null
}
]
}"#;
let entries = parse_crates_response("crates.io", body).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].id, "mnml-aws-amplify");
assert_eq!(entries[0].label, "mnml-aws-amplify");
assert_eq!(
entries[0].description.as_deref(),
Some("AWS Amplify viewer for mnml")
);
assert_eq!(entries[0].stats.downloads, Some(42));
assert!(entries[0].stats.updated_at.is_some());
assert!(matches!(entries[0].kind, MarketplaceKind::App));
assert!(matches!(entries[0].install, InstallSpec::Cargo { .. }));
assert_eq!(entries[1].description, None);
assert_eq!(entries[1].stats.downloads, None);
}
#[test]
fn parses_crates_response_empty() {
let entries = parse_crates_response("s", r#"{"crates":[]}"#).unwrap();
assert!(entries.is_empty());
}
#[test]
fn parses_crates_response_malformed_returns_error() {
assert!(parse_crates_response("s", "not json").is_err());
}
#[test]
fn parses_github_folder_response_filters_to_toml_files() {
let body = r#"[
{
"name": "htop.toml",
"type": "file",
"download_url": "https://raw.githubusercontent.com/x/y/main/launchers/htop.toml"
},
{
"name": "README.md",
"type": "file",
"download_url": "https://raw.githubusercontent.com/x/y/main/launchers/README.md"
},
{
"name": "subfolder",
"type": "dir",
"download_url": null
},
{
"name": "iftop.toml",
"type": "file",
"download_url": "https://raw.githubusercontent.com/x/y/main/launchers/iftop.toml"
}
]"#;
let entries = parse_github_folder_response(body).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].0, "htop.toml");
assert_eq!(entries[1].0, "iftop.toml");
}
#[test]
fn parses_github_dir_children_filters_to_visible_dirs() {
let body = r#"[
{"name": "mnml-tattle-coverage", "type": "dir", "download_url": null},
{"name": "README.md", "type": "file", "download_url": "http://x"},
{"name": ".github", "type": "dir", "download_url": null},
{"name": "_scratch", "type": "dir", "download_url": null},
{"name": "mnml-tattle-launchpad", "type": "dir", "download_url": null}
]"#;
let dirs = parse_github_dir_children(body).unwrap();
assert_eq!(dirs, vec!["mnml-tattle-coverage", "mnml-tattle-launchpad"]);
}
#[test]
fn parses_cargo_toml_metadata_extracts_description_and_name() {
let body = r#"
[package]
name = "mnml-tattle-coverage"
version = "0.1.0"
description = "Feature + Istanbul coverage rollups from tattle S3 (employees only)"
edition = "2024"
[dependencies]
serde = "1"
"#;
let (desc, label) = parse_cargo_toml_metadata(body).unwrap();
assert_eq!(
desc.as_deref(),
Some("Feature + Istanbul coverage rollups from tattle S3 (employees only)")
);
assert_eq!(label.as_deref(), Some("mnml-tattle-coverage"));
}
#[test]
fn parses_cargo_toml_metadata_handles_missing_fields_gracefully() {
assert_eq!(
parse_cargo_toml_metadata("[workspace]\nmembers = []").unwrap(),
(None, None)
);
let (desc, _) =
parse_cargo_toml_metadata("[package]\nname = \"foo\"\ndescription = \" \"").unwrap();
assert_eq!(desc, None);
}
#[test]
fn parses_github_dir_children_drops_shell_injection_names() {
let body = r#"[
{"name": "good-crate", "type": "dir", "download_url": null},
{"name": "foo;rm -rf ~", "type": "dir", "download_url": null},
{"name": "back`tick`", "type": "dir", "download_url": null},
{"name": "$evil", "type": "dir", "download_url": null},
{"name": "sp ace", "type": "dir", "download_url": null},
{"name": "path/traversal", "type": "dir", "download_url": null},
{"name": "another-good", "type": "dir", "download_url": null}
]"#;
let dirs = parse_github_dir_children(body).unwrap();
assert_eq!(dirs, vec!["good-crate", "another-good"]);
}
#[test]
fn safe_charset_guards_accept_expected_shapes() {
assert!(is_safe_crate_component("mnml-tattle-coverage"));
assert!(is_safe_crate_component("foo_bar.baz"));
assert!(!is_safe_crate_component(""));
assert!(!is_safe_crate_component("."));
assert!(!is_safe_crate_component(".."));
assert!(!is_safe_crate_component("foo bar"));
assert!(!is_safe_crate_component("foo;bar"));
assert!(!is_safe_crate_component("foo/bar"));
assert!(is_safe_repo_slug("chris-mclennan/mnml-tattle-integrations"));
assert!(!is_safe_repo_slug("chris-mclennan"));
assert!(!is_safe_repo_slug("chris/mnml/extra"));
assert!(!is_safe_repo_slug("/foo/bar"));
assert!(!is_safe_repo_slug("chris; rm/-rf"));
assert!(is_safe_repo_subpath("apps"));
assert!(is_safe_repo_subpath("apps/foo"));
assert!(is_safe_repo_subpath("crates/mnml-bridge"));
assert!(!is_safe_repo_subpath(""));
assert!(!is_safe_repo_subpath("/apps"));
assert!(!is_safe_repo_subpath("apps/"));
assert!(!is_safe_repo_subpath("apps/../etc"));
assert!(!is_safe_repo_subpath("apps; rm"));
}
#[test]
fn parses_launcher_toml_end_to_end() {
let body = r#"
id = "htop"
label = "htop"
description = "Interactive process viewer"
[chip]
glyph = "5"
fallback = "H"
color = "green"
enabled = false
[[commands]]
id = "htop.open"
title = "htop: open"
group = "system"
run = ":term htop"
"#;
let entry = parse_launcher_toml("src", "https://example/htop.toml", body).unwrap();
assert_eq!(entry.id, "htop");
assert_eq!(entry.label, "htop");
assert_eq!(
entry.description.as_deref(),
Some("Interactive process viewer")
);
assert!(matches!(entry.kind, MarketplaceKind::Launcher));
match &entry.install {
InstallSpec::LauncherToml { url } => assert_eq!(url, "https://example/htop.toml"),
_ => panic!("wrong install spec"),
}
}
#[test]
fn cache_roundtrips_via_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("marketplace.json");
let original = MarketplaceCache {
fetched_at: 1_754_000_000,
ttl_secs: 3600,
entries: vec![MarketplaceEntry {
source_id: "crates.io".to_string(),
kind: MarketplaceKind::App,
id: "mnml-x".to_string(),
label: "mnml-x".to_string(),
description: Some("An x".to_string()),
install: InstallSpec::Cargo {
name: "mnml-x".to_string(),
},
stats: EntryStats {
downloads: Some(100),
stars: None,
updated_at: Some(1_753_000_000),
},
provenance: Provenance::Official,
glyph: None,
color: None,
ready: false,
}],
};
original.save_to(&path).unwrap();
let loaded = MarketplaceCache::load_from(&path).unwrap();
assert_eq!(loaded.entries.len(), 1);
assert_eq!(loaded.entries[0].id, "mnml-x");
assert_eq!(loaded.fetched_at, 1_754_000_000);
}
#[test]
fn provenance_for_default_source_ids_is_official() {
for source in default_sources() {
assert_eq!(
provenance_for(source.id()),
Provenance::Official,
"default source {:?} should be Official",
source.id()
);
}
}
#[test]
fn provenance_for_user_added_source_is_community() {
for id in ["my-catalog", "some-other-source", ""] {
assert_eq!(
provenance_for(id),
Provenance::Community,
"unknown source id {id:?} should be Community"
);
}
}
#[test]
fn unknown_provenance_string_deserializes_as_community() {
let unknown_variant = r#"{
"fetched_at": 1754000000,
"ttl_secs": 3600,
"entries": [{
"source_id": "crates.io",
"kind": "app",
"id": "foo",
"label": "Foo",
"description": null,
"install": {"kind": "cargo", "name": "foo"},
"stats": {},
"provenance": "premium"
}]
}"#;
let cache: MarketplaceCache = serde_json::from_str(unknown_variant).unwrap();
assert_eq!(cache.entries[0].provenance, Provenance::Community);
}
#[test]
fn old_cache_entries_default_to_community_provenance() {
let old_shape = r#"{
"fetched_at": 1754000000,
"ttl_secs": 3600,
"entries": [{
"source_id": "crates.io",
"kind": "app",
"id": "foo",
"label": "Foo",
"description": null,
"install": {"kind": "cargo", "name": "foo"},
"stats": {}
}]
}"#;
let cache: MarketplaceCache = serde_json::from_str(old_shape).unwrap();
assert_eq!(cache.entries.len(), 1);
assert_eq!(cache.entries[0].provenance, Provenance::Community);
}
#[test]
fn cache_missing_file_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("missing.json");
assert!(MarketplaceCache::load_from(&path).is_none());
}
#[test]
fn cache_expiry_math() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let fresh = MarketplaceCache {
fetched_at: now,
ttl_secs: 3600,
entries: vec![],
};
assert!(!fresh.is_expired());
let stale = MarketplaceCache {
fetched_at: now - 4000,
ttl_secs: 3600,
entries: vec![],
};
assert!(stale.is_expired());
}
#[test]
fn parses_iso_8601_variations() {
assert!(parse_iso8601_secs("2026-08-01T18:00:00Z").is_some());
assert!(parse_iso8601_secs("2026-08-01T18:00:00.000000+00:00").is_some());
assert!(parse_iso8601_secs("not a timestamp").is_none());
}
}