5 Commits

Author SHA1 Message Date
f1bbd46cbd Merge pull request 'feat(rust): RunicNPC required in the Rust bundle, the banner, edge → main (runicnpc 9e step 3, merge 2 of 3)' (#40) from edge into main
All checks were successful
sync-project-tree / sync (push) Successful in -1m53s
Release installer / release (push) Successful in 13m8s
Reviewed-on: #40
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-09 07:21:41 +00:00
bd73930584 Merge pull request 'feat(rust): write the recommended PopupNotifications banner (runicnpc 9e, D327, D328)' (#39) from feat/9e-popup-banner into edge
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m19s
Reviewed-on: #39
2026-10-09 06:29:49 +00:00
0db3a8ac73 feat(rust): write the recommended PopupNotifications banner (runicnpc 9e, D327, D328)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m5s
install and update write PopupNotifications.json into the framework's config
directory, as a banner across the top of the screen, only where the file is
missing or every setting is still PopupNotifications 0.2.1's own default (the
Version aside, numbers compared as numbers). A config with any setting
changed, or one that does not parse, is kept whole. The file carries the
plugin's Version, because PopupNotifications resets a config without one to
its defaults on load. The plan prints a "popup look" row naming the case.

Run against two scratch roots (a temporary local bypass of the RunicNPC check,
since no published bundle carries RunicNPC yet): Oxide with no config got the
banner; Carbon at the rig's defaults got the walk's banner file exactly; on
update a hand-changed config and an existing banner were both kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-09 01:24:25 -05:00
6ab40a1e6a Merge pull request 'feat(rust): RunicNPC is required in the Rust bundle (runicnpc stage 9, D310)' (#38) from feat/runicnpc-required into edge
Reviewed-on: #38
2026-10-07 02:42:56 +00:00
04bab67778 feat(rust): RunicNPC is required in the Rust bundle (runicnpc stage 9, D310)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m13s
- install/update refuse a Rust bundle that carries no RunicNPC, before anything
  is written, and say how to get one that does.
- doctor fails when RunicNPC is missing or was never installed; a file edited
  by hand stays a warning, since it still loads.
- compose-bundles.sh composes no Rust bundle without a RunicNPC release that
  answers the bridge's runicnpc_api (the published one stays), and fails a
  bridge that declares none; every Rust bundle now has `npc`.
- A RunicNPC API-mismatch message lost its line continuation; restored.

The bundle type still parses a bundle without `npc`, so doctor and an old pin
can name it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-06 16:58:31 -05:00
8 changed files with 416 additions and 53 deletions

View File

@@ -377,44 +377,39 @@ compose_rust() {
[ "$link_protocol" = "$plugin_protocol" ] || fail \
"PROTOCOL MISMATCH — Rust-Link ${link_tag} speaks ${link_protocol}, Rust-Plugins ${plugin_tag} declares ${plugin_protocol}. The game link has no 409: a mismatched plugin would mis-parse. Fix: land the matching half and let its release cut, or bump Rust-Plugins/overlay.toml."
# RunicNPC (docs/runicnpc/PLAN.md D224): a third file, optional until its stage 9.
# The bridge's manifest says which RunicNPC API its npc.* commands need
# (`runicnpc_api`, from overlay.toml); the latest RunicNPC release is carried
# only when it answers at least that. A bridge that needs none carries none: a
# RunicNPC it cannot talk to would be an NPC plugin nobody manages.
local npc='null' need npc_tag npc_tarball npc_manifest npc_api npc_carried=''
# RunicNPC (docs/runicnpc/PLAN.md D224): a third file, REQUIRED since its stage 9
# (D310) — the bridge refuses every NPC an event places without it, and the
# installer refuses a Rust bundle that does not carry it. The bridge's manifest
# says which RunicNPC API it needs (`runicnpc_api`, from overlay.toml); the
# latest RunicNPC release is carried only when it answers at least that. With
# none that does, the Rust bundle is not composed: the published one stays.
local npc need npc_tag npc_tarball npc_manifest npc_api
need="$(jq -r '.runicnpc_api // 0' "$manifest")"
if [ "$need" -gt 0 ]; then
rc=0
resolve_latest "$RUNICNPC_REPO" runicnpc || rc=$?
if [ "$rc" -eq 2 ]; then
note "- **Rust**: the bridge needs RunicNPC API ${need}, and RunicNPC has not released yet — the bundle carries none."
else
npc_tag="$(release_tag runicnpc)"
verify_assets runicnpc
npc_tarball="$(single_tarball runicnpc)"
npc_manifest="$(payload_manifest runicnpc "$npc_tarball" "$RUNICNPC_PREFIX" "$npc_tag")"
[ -f "$(dirname "$npc_manifest")/RunicNPC.cs" ] \
|| fail "the RunicNPC tarball has no ${RUNICNPC_PREFIX}/RunicNPC.cs"
npc_api="$(jq -r '.api' "$npc_manifest")"
echo "==> rust: RunicNPC ${npc_tag} api=${npc_api} | the bridge needs ${need}"
if [ "$npc_api" -lt "$need" ]; then
note "- **Rust**: RunicNPC \`${npc_tag}\` answers API ${npc_api} and the bridge needs ${need} — the bundle carries none until RunicNPC releases one that does."
else
npc="$(jq -n --arg repo "$RUNICNPC_REPO" --arg tag "$npc_tag" --slurpfile m "$npc_manifest" \
--argjson asset "$(asset_json runicnpc "$npc_tarball")" \
'{ repo: $repo, tag: $tag, version: ($tag | ltrimstr("v")), commit: $m[0].commit, api: $m[0].api,
compat: { frameworks: { oxide: { min_version: $m[0].min_oxide_version },
carbon: { min_version: $m[0].min_carbon_version } },
requires_plugins: $m[0].requires_plugins },
asset: $asset }')"
jq -e '([.commit, .compat.frameworks[].min_version] | all(type == "string"))
and (.api | type == "number") and (.compat.requires_plugins | type == "array")' <<<"$npc" >/dev/null \
|| fail "the RunicNPC manifest is missing commit, api, a framework floor, or requires_plugins"
npc_carried="$npc_tag"
fi
fi
fi
[ "$need" -gt 0 ] || fail \
"Rust-Plugins ${plugin_tag} declares no runicnpc_api, and RunicNPC is required (D310) — a bridge older than RunicNPC's stage 4 cannot be bundled any more."
rc=0
resolve_latest "$RUNICNPC_REPO" runicnpc || rc=$?
[ "$rc" -ne 2 ] || fail "the bridge needs RunicNPC API ${need}, and RunicNPC has not released — RunicNPC is required (D310)."
npc_tag="$(release_tag runicnpc)"
verify_assets runicnpc
npc_tarball="$(single_tarball runicnpc)"
npc_manifest="$(payload_manifest runicnpc "$npc_tarball" "$RUNICNPC_PREFIX" "$npc_tag")"
[ -f "$(dirname "$npc_manifest")/RunicNPC.cs" ] \
|| fail "the RunicNPC tarball has no ${RUNICNPC_PREFIX}/RunicNPC.cs"
npc_api="$(jq -r '.api' "$npc_manifest")"
echo "==> rust: RunicNPC ${npc_tag} api=${npc_api} | the bridge needs ${need}"
[ "$npc_api" -ge "$need" ] || fail \
"RunicNPC ${npc_tag} answers API ${npc_api} and Rust-Plugins ${plugin_tag} needs ${need} — RunicNPC is required (D310), so no Rust bundle is composed until it releases one that does."
npc="$(jq -n --arg repo "$RUNICNPC_REPO" --arg tag "$npc_tag" --slurpfile m "$npc_manifest" \
--argjson asset "$(asset_json runicnpc "$npc_tarball")" \
'{ repo: $repo, tag: $tag, version: ($tag | ltrimstr("v")), commit: $m[0].commit, api: $m[0].api,
compat: { frameworks: { oxide: { min_version: $m[0].min_oxide_version },
carbon: { min_version: $m[0].min_carbon_version } },
requires_plugins: $m[0].requires_plugins },
asset: $asset }')"
jq -e '([.commit, .compat.frameworks[].min_version] | all(type == "string"))
and (.api | type == "number") and (.compat.requires_plugins | type == "array")' <<<"$npc" >/dev/null \
|| fail "the RunicNPC manifest is missing commit, api, a framework floor, or requires_plugins"
jq -n \
--arg link_repo "$RUST_LINK_REPO" --arg link_tag "$link_tag" \
@@ -432,8 +427,8 @@ compose_rust() {
compat: { frameworks: { oxide: { min_version: $m[0].min_oxide_version },
carbon: { min_version: $m[0].min_carbon_version } },
requires_plugins: $m[0].requires_plugins },
asset: $p_asset } }
+ (if $npc == null then {} else { npc: $npc } end)' > "$WORK/rust-s2.json"
asset: $p_asset },
npc: $npc }' > "$WORK/rust-s2.json"
# A manifest missing a key would compose a `null` the installer then trusts.
jq -e '([.payload.compat.frameworks[].min_version, .payload.commit] | all(type == "string"))
and (.payload.compat.requires_plugins | type == "array")' "$WORK/rust-s2.json" >/dev/null \
@@ -446,7 +441,7 @@ compose_rust() {
local tag
tag="$(fresh_tag "$PUBLISHED/v2/rust")"
publish_doc "$PUBLISHED/v2/rust" "$tag" "$WORK/rust-s2.json"
note "- **Rust**: published \`${tag}\` — Rust-Link \`${link_tag}\`, Rust-Plugins \`${plugin_tag}\`, protocol ${link_protocol}${npc_carried:+, RunicNPC \`${npc_tag}\`}."
note "- **Rust**: published \`${tag}\` — Rust-Link \`${link_tag}\`, Rust-Plugins \`${plugin_tag}\`, protocol ${link_protocol}, RunicNPC \`${npc_tag}\`."
echo "rust ${tag}" >> "$WORK/published-games.txt"
}

View File

@@ -19,7 +19,7 @@
# current.json, bundle-<tag>.json schema 1, ServUO: link + overlay
# v2/servuo/current.json, bundle-<tag>.json schema 2, game "servuo"
# v2/rust/current.json, bundle-<tag>.json schema 2, game "rust": Rust-Link + Rust-Plugins,
# and RunicNPC when the bridge needs it (D224)
# and RunicNPC, required since its stage 9 (D224, D310)
#
# Schema 2 (D146) names ONE game, with a `game` discriminant and a `payload`
# that is an overlay for ServUO and a plugin for Rust. Schema 1 (D147) is still

View File

@@ -362,8 +362,9 @@ pub struct RustBundle {
pub sidecar: RustSidecar,
pub payload: PluginPayload,
/// RunicNPC, Runic Gateway's NPC plugin (docs/runicnpc/PLAN.md D224): a third file placed in
/// the plugins directory beside the bridge. Optional until RunicNPC's stage 9 makes it
/// required, so a bundle without it — every one before stage 4 — still parses and installs.
/// the plugins directory beside the bridge. Required since RunicNPC's stage 9 (D310): a bundle
/// without it — every one before stage 4 — still PARSES, so `doctor` and an old pin can name
/// it, but `install` and `update` refuse it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub npc: Option<NpcComponent>,
}

View File

@@ -178,9 +178,10 @@ fn instance_rows(
}
}
// ── RunicNPC: ours when the bundle carried it (D224) ──────────────────────
// A warning, like a helper's: until RunicNPC's stage 9 the bridge runs without it, and what is
// lost is the site's NPC profiles and placements and events' profile NPCs.
// ── RunicNPC: ours, and required (D224, D310) ─────────────────────────────
// A failure when it is missing or was never placed: since RunicNPC's stage 9 the bridge
// refuses every NPC an event places without it. A file edited by hand still loads, so that
// stays a warning, as the bridge's own helpers are.
if let Some(file) = &instance.npc {
let tag = record.npc.as_ref().map(|n| n.tag.as_str()).unwrap_or("?");
match std::fs::read(&file.path) {
@@ -192,11 +193,17 @@ fn instance_rows(
.note("edited or replaced by hand; `update --game rust` puts the released one back"),
),
Err(_) => rows.push(
Row::warn(&label("RunicNPC"), format!("{} is missing", file.path))
.note("without it the site's NPC profiles and placements, and events' profile NPCs, are off")
Row::fail(&label("RunicNPC"), format!("{} is missing", file.path))
.note("RunicNPC is required: without it every NPC an event places is refused, Rust's own scientists included")
.note("`update --game rust` puts it back"),
),
}
} else {
rows.push(
Row::fail(&label("RunicNPC"), "not installed")
.note("RunicNPC is required: without it every NPC an event places is refused, Rust's own scientists included")
.note("`update --game rust` installs it from the current bundle"),
);
}
// ── The plugin's config: the website's ───────────────────────────────────

View File

@@ -20,6 +20,7 @@ use anyhow::{bail, Context, Result};
use super::npc;
use super::plugin::{self, ConfigView};
use super::popup;
use super::record::{ComponentRecord, DeployedFile, Instance, NpcRecord, RustRecord, SCHEMA};
use super::server::{self, RustServer};
use super::sidecar;
@@ -49,6 +50,8 @@ struct Planned {
retired_helpers: Vec<String>,
/// RunicNPC, when the bundle carries it (D224): absent, identical or replaced.
npc_action: Option<BinaryAction>,
/// PopupNotifications' config: the banner where it is missing or the plugin's defaults (D327).
popup: popup::State,
game_port: u16,
web_port: u16,
config_path: PathBuf,
@@ -183,7 +186,9 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
);
}
// RunicNPC (D224), fetched and checked beside the plugin, before anything is planned.
// RunicNPC (D224), fetched and checked beside the plugin, before anything is planned. It is
// required since RunicNPC's stage 9 (D310): the bridge refuses every NPC an event places
// without it, so a bundle that cannot place it is refused before anything is written.
let npc_released = match &bundle.npc {
Some(component) => {
let path = scratch.path().join(&component.asset.name);
@@ -191,7 +196,8 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
let released = npc::read_tarball(&path)?;
if released.manifest.api != component.api {
bail!(
"RunicNPC in bundle {} answers API {}, but the bundle says {} — refusing a component the bundle does not describe.",
"RunicNPC in bundle {} answers API {}, but the bundle says {} — refusing a \
component the bundle does not describe.",
bundle.bundle,
released.manifest.api,
component.api
@@ -205,7 +211,12 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
}
Some(released)
}
None => None,
None => bail!(
"bundle {} carries no RunicNPC, and Runic Gateway's Rust events need it: without it \
the bridge refuses every NPC an event places. Install the current bundle (leave \
--bundle off), or pin one published with RunicNPC v1.0.0 or later.",
bundle.bundle
),
};
// ── Plan every instance ──────────────────────────────────────────────────
@@ -387,6 +398,7 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
|| p.npc_action.is_some_and(|a| a.writes())
|| p.helper_actions.iter().any(|a| a.writes())
|| !p.retired_helpers.is_empty()
|| p.popup.writes()
})
.count();
if restored == 0 && !binary_action.writes() {
@@ -529,10 +541,12 @@ fn plan_instance(
Ok(_) => BinaryAction::Replace,
},
);
let popup = popup::inspect(&server.framework.config_dir(&server.path).join(popup::FILE));
let config_path = layout.rust_config(id);
Ok(Planned {
id: id.to_string(),
running: server::is_running(&server.path),
popup,
config_exists: config_path.exists(),
config_path,
db_path: layout.rust_db(id),
@@ -633,6 +647,18 @@ fn print_plan(
if p.config_exists { "kept" } else { "written" }
),
);
ui::row(
"popup look",
&format!(
"{} {}",
p.server
.framework
.config_dir(&p.server.path)
.join(popup::FILE)
.display(),
p.popup.label()
),
);
ui::row(
"ports",
&format!(
@@ -695,6 +721,26 @@ fn deploy_instance(
ui::ok(&format!("wrote {}", plugin_config_path.display()));
}
// PopupNotifications' look (D327): the banner, only over nothing or over the plugin's own
// defaults. Before the plugins, so a PopupNotifications that loads after them reads it; one
// already loaded keeps its old look until it is reloaded.
if let Some(banner) = popup::banner_for(plan.popup) {
let dir = plan.server.framework.config_dir(&plan.server.path);
std::fs::create_dir_all(&dir)
.with_context(|| format!("cannot create {}", dir.display()))?;
let path = dir.join(popup::FILE);
write_atomic(&path, banner.as_bytes())?;
ui::ok(&format!(
"wrote the PopupNotifications banner {}{}",
path.display(),
if plan.running {
" (takes effect when PopupNotifications next loads)"
} else {
""
}
));
}
// The sidecar's config, once; the token is the sidecar's to generate.
if !plan.config_exists {
if let Some(parent) = plan.config_path.parent() {

View File

@@ -15,6 +15,7 @@ mod doctor;
mod install;
mod npc;
mod plugin;
mod popup;
mod record;
mod server;
mod sidecar;

View File

@@ -8,8 +8,9 @@
//! it. RunicNPC makes that directory itself on first load, because one made from outside the game
//! is not writable by it (runicnpc PLAN.md §1.5).
//!
//! It is optional until RunicNPC's stage 9: a bundle without it installs as before, and a host
//! without it runs the bridge with Rust's own scientists only (D243).
//! It is required since RunicNPC's stage 9 (D310): the bridge refuses every NPC an event places
//! without it, Rust's own scientists included, so `install` and `update` refuse a bundle that does
//! not carry it and `doctor` fails a host that lacks it.
use std::collections::BTreeMap;
use std::io::Read;

312
src/rustgame/popup.rs Normal file
View File

@@ -0,0 +1,312 @@
//! The recommended PopupNotifications look: a banner across the top of the screen (docs/runicnpc/
//! PLAN.md stage 9e, D327).
//!
//! PopupNotifications is optional and the operator's to install (D141); its config is the
//! operator's too. The installer writes the banner into it only where it is **missing** or **still
//! exactly the plugin's own defaults** — every setting equal to 0.2.1's, the `Version` aside (D328).
//! A config with any one setting changed is somebody's choice and is kept whole, never merged into.
//! A config that does not parse is kept too: it is not ours to repair.
//!
//! The file carries a `Version`: PopupNotifications resets a config whose version is missing or
//! below 0.2.0 to its defaults on load, which would undo the banner at the first boot.
use std::path::Path;
use serde_json::Value;
/// The config's file name, in the framework's config directory beside the bridge's.
pub const FILE: &str = "PopupNotifications.json";
/// What is there now, and so what happens to it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum State {
/// No config: the banner is written, and PopupNotifications reads it when it is installed.
Missing,
/// The plugin's own defaults, untouched: the banner replaces them. Holds the file's version.
Defaults(Version),
/// Changed by someone, or unreadable: kept.
Theirs,
}
/// PopupNotifications' `VersionNumber`, as its config writes it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Version {
pub major: u64,
pub minor: u64,
pub patch: u64,
}
/// The version written into a config made from nothing: the release the defaults are read from.
const WRITTEN_VERSION: Version = Version {
major: 0,
minor: 2,
patch: 1,
};
impl State {
pub fn writes(self) -> bool {
!matches!(self, Self::Theirs)
}
pub fn label(self) -> &'static str {
match self {
Self::Missing => "banner written (no config yet)",
Self::Defaults(_) => "banner written (was the plugin's defaults)",
Self::Theirs => "kept (not the plugin's defaults)",
}
}
}
/// Reads the config at `path` and says what it is.
pub fn inspect(path: &Path) -> State {
match std::fs::read(path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => State::Missing,
Err(_) => State::Theirs,
Ok(bytes) => classify(&bytes),
}
}
fn classify(bytes: &[u8]) -> State {
let Ok(Value::Object(mut found)) = serde_json::from_slice::<Value>(bytes) else {
return State::Theirs;
};
let version = found.remove("Version");
let Ok(Value::Object(mut defaults)) = serde_json::from_str::<Value>(&render(DEFAULTS, None))
else {
unreachable!("the defaults are valid JSON")
};
defaults.remove("Version");
if !same(&Value::Object(found), &Value::Object(defaults)) {
return State::Theirs;
}
State::Defaults(
version
.as_ref()
.and_then(read_version)
.unwrap_or(WRITTEN_VERSION),
)
}
fn read_version(v: &Value) -> Option<Version> {
Some(Version {
major: v.get("Major")?.as_u64()?,
minor: v.get("Minor")?.as_u64()?,
patch: v.get("Patch")?.as_u64()?,
})
}
/// JSON equality with numbers compared as numbers: the plugin writes its floats through
/// Newtonsoft, and a value written `1.0` by one framework and `1` by another is the same setting.
fn same(a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) {
(Some(x), Some(y)) => (x - y).abs() < 1e-6,
_ => false,
},
(Value::Object(x), Value::Object(y)) => {
x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| same(v, w)))
}
(Value::Array(x), Value::Array(y)) => {
x.len() == y.len() && x.iter().zip(y).all(|(v, w)| same(v, w))
}
_ => a == b,
}
}
/// The banner, as the file to write for `state`. `None` for a config that is kept.
pub fn banner_for(state: State) -> Option<String> {
match state {
State::Missing => Some(render(BANNER, Some(WRITTEN_VERSION))),
State::Defaults(version) => Some(render(BANNER, Some(version))),
State::Theirs => None,
}
}
/// One look: the eleven settings PopupNotifications has.
struct Look {
left: &'static str,
bottom: &'static str,
width: &'static str,
height: &'static str,
spacing: &'static str,
close: bool,
alpha: &'static str,
font: &'static str,
font_size: u32,
}
/// PopupNotifications 0.2.1's `GetBaseConfig()`: a small grey box at the right of the screen.
const DEFAULTS: Look = Look {
left: "0.8",
bottom: "0.78",
width: "0.19",
height: "0.1",
spacing: "0.01",
close: true,
alpha: "0.5",
font: "droidsansmono.ttf",
font_size: 12,
};
/// The recommended look (9d player session, 2026-10-09): a banner across the top of the screen.
const BANNER: Look = Look {
left: "0.15",
bottom: "0.87",
width: "0.7",
height: "0.07",
spacing: "0.005",
close: false,
alpha: "0.8",
font: "robotocondensed-bold.ttf",
font_size: 18,
};
/// The config file, laid out as the plugin writes it. Duration, the most shown, both colours and
/// the close button's transparency are the plugin's own in both looks.
fn render(look: Look, version: Option<Version>) -> String {
let version = version.unwrap_or(WRITTEN_VERSION);
format!(
r##"{{
"Notification duration (in seconds)": 8,
"Maximum notifications shown at any time": 6,
"UI Positioning": {{
"Position of the left side of notification (0.0 - 1.0)": {left},
"Position of the bottom of noticiation (0.0 - 1.0)": {bottom},
"Width (0.0 - 1.0)": {width},
"Height (0.0 - 1.0)": {height},
"Space between notification (0.0 - 1.0)": {spacing}
}},
"UI Options": {{
"Show close button": {close},
"Panel color (hex)": "#2b2b2b",
"Panel transparency (0.0 - 1.0)": {alpha},
"Close button color (hex)": "#d85540",
"Close button transparency (0.0 - 1.0)": 0.5,
"Font": "{font}",
"Font size": {font_size}
}},
"Version": {{
"Major": {major},
"Minor": {minor},
"Patch": {patch}
}}
}}"##,
left = look.left,
bottom = look.bottom,
width = look.width,
height = look.height,
spacing = look.spacing,
close = look.close,
alpha = look.alpha,
font = look.font,
font_size = look.font_size,
major = version.major,
minor = version.minor,
patch = version.patch,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::TempDir;
/// The file PopupNotifications 0.2.1 wrote on both rigs at its first load (2026-10-09).
const RIG_DEFAULTS: &str = r##"{
"Notification duration (in seconds)": 8,
"Maximum notifications shown at any time": 6,
"UI Positioning": {
"Position of the left side of notification (0.0 - 1.0)": 0.8,
"Position of the bottom of noticiation (0.0 - 1.0)": 0.78,
"Width (0.0 - 1.0)": 0.19,
"Height (0.0 - 1.0)": 0.1,
"Space between notification (0.0 - 1.0)": 0.01
},
"UI Options": {
"Show close button": true,
"Panel color (hex)": "#2b2b2b",
"Panel transparency (0.0 - 1.0)": 0.5,
"Close button color (hex)": "#d85540",
"Close button transparency (0.0 - 1.0)": 0.5,
"Font": "droidsansmono.ttf",
"Font size": 12
},
"Version": {
"Major": 0,
"Minor": 2,
"Patch": 1
}
}"##;
#[test]
fn a_missing_config_gets_the_banner_with_a_version() {
let dir = TempDir::new("rg-popup-missing").unwrap();
let state = inspect(&dir.path().join(FILE));
assert_eq!(state, State::Missing);
let written: Value = serde_json::from_str(&banner_for(state).unwrap()).unwrap();
assert_eq!(written["UI Positioning"]["Width (0.0 - 1.0)"], 0.7);
assert_eq!(written["UI Options"]["Show close button"], false);
assert_eq!(written["UI Options"]["Font"], "robotocondensed-bold.ttf");
assert_eq!(written["UI Options"]["Font size"], 18);
// Without a version the plugin resets the file to its defaults at load.
assert_eq!(written["Version"]["Minor"], 2);
}
#[test]
fn the_plugins_own_defaults_are_replaced_and_their_version_kept() {
assert_eq!(
classify(RIG_DEFAULTS.as_bytes()),
State::Defaults(WRITTEN_VERSION)
);
let later = RIG_DEFAULTS.replace("\"Patch\": 1", "\"Patch\": 4");
let state = classify(later.as_bytes());
assert_eq!(
state,
State::Defaults(Version {
major: 0,
minor: 2,
patch: 4
})
);
assert!(banner_for(state).unwrap().contains("\"Patch\": 4"));
// A whole number written as a float is still the default.
let floats = RIG_DEFAULTS.replace("\"Font size\": 12", "\"Font size\": 12.0");
assert!(matches!(classify(floats.as_bytes()), State::Defaults(_)));
}
#[test]
fn the_rendered_defaults_are_the_file_the_plugin_writes() {
let ours: Value = serde_json::from_str(&render(DEFAULTS, None)).unwrap();
let rig: Value = serde_json::from_str(RIG_DEFAULTS).unwrap();
assert_eq!(ours, rig);
}
#[test]
fn any_change_keeps_the_whole_config() {
for changed in [
RIG_DEFAULTS.replace("\"Font size\": 12", "\"Font size\": 14"),
RIG_DEFAULTS.replace(
"\"Notification duration (in seconds)\": 8",
"\"Notification duration (in seconds)\": 9",
),
RIG_DEFAULTS.replace("#2b2b2b", "#000000"),
render(BANNER, None),
"{ not json".to_string(),
"[]".to_string(),
] {
let state = classify(changed.as_bytes());
assert_eq!(state, State::Theirs, "{changed}");
assert_eq!(banner_for(state), None);
}
}
#[test]
fn a_config_with_a_setting_added_or_missing_is_theirs() {
let mut v: Value = serde_json::from_str(RIG_DEFAULTS).unwrap();
v["Extra"] = Value::Bool(true);
assert_eq!(classify(v.to_string().as_bytes()), State::Theirs);
let mut v: Value = serde_json::from_str(RIG_DEFAULTS).unwrap();
v.as_object_mut().unwrap().remove("UI Options");
assert_eq!(classify(v.to_string().as_bytes()), State::Theirs);
}
}