From 04bab677789a4793447551f1c64b4f3614b5f276 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 6 Oct 2026 16:58:31 -0500 Subject: [PATCH 1/2] feat(rust): RunicNPC is required in the Rust bundle (runicnpc stage 9, D310) - 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .gitea/scripts/compose-bundles.sh | 75 +++++++++++++++---------------- .gitea/workflows/bundle.yml | 2 +- src/bundle.rs | 5 ++- src/rustgame/doctor.rs | 17 ++++--- src/rustgame/install.rs | 14 ++++-- src/rustgame/npc.rs | 5 ++- 6 files changed, 65 insertions(+), 53 deletions(-) diff --git a/.gitea/scripts/compose-bundles.sh b/.gitea/scripts/compose-bundles.sh index e4e2b39..ca61da8 100755 --- a/.gitea/scripts/compose-bundles.sh +++ b/.gitea/scripts/compose-bundles.sh @@ -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" } diff --git a/.gitea/workflows/bundle.yml b/.gitea/workflows/bundle.yml index 76b577a..480aac8 100644 --- a/.gitea/workflows/bundle.yml +++ b/.gitea/workflows/bundle.yml @@ -19,7 +19,7 @@ # current.json, bundle-.json schema 1, ServUO: link + overlay # v2/servuo/current.json, bundle-.json schema 2, game "servuo" # v2/rust/current.json, bundle-.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 diff --git a/src/bundle.rs b/src/bundle.rs index 439e387..3407726 100644 --- a/src/bundle.rs +++ b/src/bundle.rs @@ -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, } diff --git a/src/rustgame/doctor.rs b/src/rustgame/doctor.rs index 22ea06b..74a7179 100644 --- a/src/rustgame/doctor.rs +++ b/src/rustgame/doctor.rs @@ -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 ─────────────────────────────────── diff --git a/src/rustgame/install.rs b/src/rustgame/install.rs index 0b1cf3e..819b7cb 100644 --- a/src/rustgame/install.rs +++ b/src/rustgame/install.rs @@ -183,7 +183,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 +193,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 +208,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 ────────────────────────────────────────────────── diff --git a/src/rustgame/npc.rs b/src/rustgame/npc.rs index a35b74f..5d6e33f 100644 --- a/src/rustgame/npc.rs +++ b/src/rustgame/npc.rs @@ -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; From 0db3a8ac737bab2c932edd6fdf12e11f4be8b17d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 9 Oct 2026 01:24:25 -0500 Subject: [PATCH 2/2] feat(rust): write the recommended PopupNotifications banner (runicnpc 9e, D327, D328) 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- src/rustgame/install.rs | 38 +++++ src/rustgame/mod.rs | 1 + src/rustgame/popup.rs | 312 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 351 insertions(+) create mode 100644 src/rustgame/popup.rs diff --git a/src/rustgame/install.rs b/src/rustgame/install.rs index 819b7cb..16f69ba 100644 --- a/src/rustgame/install.rs +++ b/src/rustgame/install.rs @@ -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, /// RunicNPC, when the bundle carries it (D224): absent, identical or replaced. npc_action: Option, + /// 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, @@ -395,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() { @@ -537,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), @@ -641,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!( @@ -703,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() { diff --git a/src/rustgame/mod.rs b/src/rustgame/mod.rs index 290e27f..a682a37 100644 --- a/src/rustgame/mod.rs +++ b/src/rustgame/mod.rs @@ -15,6 +15,7 @@ mod doctor; mod install; mod npc; mod plugin; +mod popup; mod record; mod server; mod sidecar; diff --git a/src/rustgame/popup.rs b/src/rustgame/popup.rs new file mode 100644 index 0000000..8cf5181 --- /dev/null +++ b/src/rustgame/popup.rs @@ -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::(bytes) else { + return State::Theirs; + }; + let version = found.remove("Version"); + let Ok(Value::Object(mut defaults)) = serde_json::from_str::(&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 { + 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 { + 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) -> 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); + } +}