feat(installer): implement Phase 4 — doctor, update and uninstall
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 59s
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 59s
Completes the command surface INSTALL.md §2 published before the binary
existed. With this, `edge` cuts a binary that does everything that guide
describes.
doctor (src/doctor.rs)
Reads only. Every row is answered by asking the thing itself — the
installed binary (--version, --print-config), the service manager, and
the sidecar's /health — because the record says what `install` did,
which is a different question from what is true now. --print-config is
run ONLY when the config already exists: that flag provisions, and a
diagnosis must not create the state it reports on. It is also run under
the environment the service pins (UOLINK_DB_PATH), so the config and
database it names are the ones the service opens, not the ones the
binary would pick on its own.
Exit 1 when any row failed, so a monitoring script can read it; a ⚠
never does that. A stopped shard is therefore a ⚠, not a ✗ — "you have
not started it" and "it is running and the bridge is dead" are
different problems and only the second is broken. Offline is a ⚠ too:
a shard host with no route to Gitea is a supported way to run this.
The patch row re-resolves each recorded patch against the tree from the
cached .patch, so a core upgrade or a restored backup that silently
removed the tier's edits is caught — nothing else here would notice.
update (src/update.rs, install.rs::Mode)
The same pipeline as install, not a second one: PLAN.md describes it as
"re-resolve the bundle, then move both components to it", which is what
an install over an existing deployment already does. Writing it twice
would give the sync rules and the protocol cross-checks two places to
disagree. What differs is small and lives in Mode — a prior record is
required, the tree comes from that record rather than detection, the
patch tier's scope narrows, and the close is a diff instead of a
handoff.
The token is not reprinted: it has not changed and the website has it.
A changed protocol number IS called out, because a stale value in
Admin → Shard is answered with 409 and looks like the shard going
offline.
Tier scope: features an earlier run recorded are re-resolved without
asking again (the record is the evidence of consent, including on an
unsupported ServUO); anything new the release offers is named but not
applied without --patches. A shard that declined stays declined.
uninstall (src/uninstall.rs, service::remove)
Removes the binary, the service and install.json; prints the overlay
files and the exact hunks, rendered from the cached patches with the
rung each landed at. Files edited since deployment are flagged so
nobody deletes their own work blind. The report is also written to a
file in the working directory — it is the only thing still needed after
the command exits, and it arrives at the end of the longest output this
tool produces.
Two deviations from PLAN.md §5, both deliberate:
- The cached patch set and patches/originals/ SURVIVE. That table put
them under "removed", but the report tells the operator to diff
against those originals — advice the same command would have made
impossible to follow. --purge removes them, with the config and the
database.
- --yes means yes here, not "take the default". The prompt defaults to
no (destructive), but the operator typed the verb; reading --yes as
"no" would leave an unattended uninstall unable to express itself,
and a script that appears to succeed while removing nothing is the
worse failure.
Exit 1 if a step could not be carried out — everything else still was.
Verified on this machine against a scratch tree built from the real
ServUO 57.4 files: a healthy doctor (exit 0), one with a deleted overlay
file, an edited one and a reverted patch (all three found, exit 1), a
--verify update that wrote nothing, a real update that repaired all three
and left install.json byte-identical, uninstall with and without --purge,
a second uninstall, and doctor/update on a host with no record. Linux
fmt/clippy/tests run in Docker as well as the Windows host.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
640
src/uninstall.rs
Normal file
640
src/uninstall.rs
Normal file
@@ -0,0 +1,640 @@
|
||||
//! The `uninstall` command — remove what the installer exclusively owns, print the rest.
|
||||
//!
|
||||
//! PLAN.md §5 is unusually specific about the shape of this command, and the reason is worth
|
||||
//! keeping in front of whoever edits it: **the installer cannot know what the operator has changed
|
||||
//! in their own ServUO tree since deployment.** A clever automatic revert — deleting the overlay's
|
||||
//! files, reversing the patch hunks — would silently eat work that is not ours to judge. So this
|
||||
//! command draws a hard line:
|
||||
//!
|
||||
//! | | |
|
||||
//! |---|---|
|
||||
//! | Removed | the sidecar binary, its service entry, `install.json` |
|
||||
//! | Kept | `sidecar.toml`, `uo-link.db`, the cached patch set and the pre-patch originals (`--purge` drops them) |
|
||||
//! | Printed, not done | every overlay file in the ServUO tree, and the exact hunks each applied patch added |
|
||||
//!
|
||||
//! ## Why the patch cache outlives the uninstall
|
||||
//!
|
||||
//! PLAN.md's table put the cached patch set under "removed", but the report this command prints
|
||||
//! tells the operator to diff their stock files against the pre-patch copies under
|
||||
//! `patches/originals/` — advice that the same command would have made impossible to follow. The
|
||||
//! cache and the originals are the only offline record of what the tier changed once the release
|
||||
//! tarball is gone, so they survive by default and `--purge` is what removes them, alongside the
|
||||
//! config and the database. The report names every path it left behind.
|
||||
//!
|
||||
//! ## Why the report is a file as well as output
|
||||
//!
|
||||
//! It is the only thing the operator still needs after this command exits, and it arrives at the
|
||||
//! end of the longest output the installer ever produces. A terminal's scrollback is not a place to
|
||||
//! keep the list of files somebody has to go and delete by hand.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::cli::Cli;
|
||||
use crate::diff::HunkLine;
|
||||
use crate::record::{now_rfc3339, InstallRecord, LinkRecord};
|
||||
use crate::{patch, paths, service, ui, util};
|
||||
|
||||
pub fn run(cli: &Cli) -> Result<i32> {
|
||||
let layout = paths::layout();
|
||||
let record_path = layout.install_record();
|
||||
|
||||
println!(
|
||||
"\nRunic Gateway installer {} — uninstall",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
|
||||
let Some(record) = InstallRecord::load(&record_path)? else {
|
||||
println!();
|
||||
ui::warn(&format!(
|
||||
"Nothing to uninstall — no deployment is recorded on this host.\n \
|
||||
Looked for {}\n \
|
||||
If this host is installed, this run cannot see its record: run as \
|
||||
root/Administrator, and set {} to the same value the install used (if any).",
|
||||
record_path.display(),
|
||||
paths::STATE_DIR_ENV
|
||||
));
|
||||
return Ok(0);
|
||||
};
|
||||
|
||||
let link = record.link_record();
|
||||
print_intent(&record, link.as_ref(), &layout, cli.purge);
|
||||
|
||||
// Default **no**, because this is the one command that removes a running service and the
|
||||
// listing above is what the operator is being asked about — a defaulted-yes prompt on a
|
||||
// destructive action is answered by reflex rather than read.
|
||||
//
|
||||
// `--yes` is nevertheless a **yes** here, not "take the default". Everywhere else that flag
|
||||
// answers an offer the run made (the patch tier, a detected ServUO root), so taking the safe
|
||||
// default is right. Here the operator typed the destructive verb themselves; reading `--yes` as
|
||||
// "no" would leave an unattended uninstall with no way to express itself at all, and a script
|
||||
// that appeared to succeed while removing nothing is the worse of the two failures.
|
||||
let proceed = if cli.assume_yes {
|
||||
println!("Remove the components listed above? [y/N] (--yes)");
|
||||
true
|
||||
} else {
|
||||
ui::confirm("Remove the components listed above?", false, false)?
|
||||
};
|
||||
if !proceed {
|
||||
println!("\nNothing was removed.");
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// ── Remove what is exclusively ours ──────────────────────────────────────
|
||||
println!();
|
||||
ui::heading("Removing");
|
||||
let mut done: Vec<String> = Vec::new();
|
||||
let mut problems: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(link) = &link {
|
||||
if let Some(service_record) = &link.service {
|
||||
let removal = service::remove(service_record);
|
||||
done.extend(removal.done);
|
||||
problems.extend(removal.problems);
|
||||
}
|
||||
remove_file(Path::new(&link.binary.path), &mut done, &mut problems);
|
||||
|
||||
if cli.purge {
|
||||
remove_file(Path::new(&link.config_path), &mut done, &mut problems);
|
||||
// The database is removed with its journal and WAL siblings; SQLite writes those beside
|
||||
// it, and leaving them behind would confuse the next install rather than protect
|
||||
// anything.
|
||||
for suffix in ["", "-journal", "-wal", "-shm"] {
|
||||
let path = PathBuf::from(format!("{}{suffix}", link.db_path));
|
||||
if path.exists() {
|
||||
remove_file(&path, &mut done, &mut problems);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
done.push(format!(
|
||||
"kept {} and {} (--purge removes them)",
|
||||
link.config_path, link.db_path
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// The report is written before the record is, because it is rendered *from* the record.
|
||||
let report = render_report(&record, link.as_ref(), &layout, cli.purge);
|
||||
let report_path = write_report(&report, &layout);
|
||||
|
||||
if cli.purge {
|
||||
remove_dir(&layout.patches_dir(), &mut done, &mut problems);
|
||||
} else if layout.patches_dir().exists() {
|
||||
done.push(format!(
|
||||
"kept {} — the cached patches and the pre-patch originals you need to revert by hand",
|
||||
layout.patches_dir().display()
|
||||
));
|
||||
}
|
||||
remove_file(&record_path, &mut done, &mut problems);
|
||||
|
||||
for line in &done {
|
||||
println!(" · {line}");
|
||||
}
|
||||
for problem in &problems {
|
||||
println!();
|
||||
ui::warn(problem);
|
||||
}
|
||||
|
||||
// ── What only the operator can do ────────────────────────────────────────
|
||||
print!("{report}");
|
||||
match &report_path {
|
||||
Ok(path) => println!("This report is also saved at:\n {}\n", path.display()),
|
||||
Err(error) => ui::warn(&format!(
|
||||
"The report above could not be saved to a file ({error}) — copy it out of this \
|
||||
terminal before you lose it."
|
||||
)),
|
||||
}
|
||||
|
||||
// A step that could not be carried out is worth an exit code, for the same reason `doctor` has
|
||||
// one: the run itself succeeded, and only the shell knows whether anybody is reading the
|
||||
// output. Everything that *could* be removed still was.
|
||||
Ok(if problems.is_empty() { 0 } else { 1 })
|
||||
}
|
||||
|
||||
/// Says exactly what will happen, before asking. Nothing here touches the disk.
|
||||
fn print_intent(
|
||||
record: &InstallRecord,
|
||||
link: Option<&LinkRecord>,
|
||||
layout: &paths::Layout,
|
||||
purge: bool,
|
||||
) {
|
||||
println!();
|
||||
ui::heading("This will remove");
|
||||
match link {
|
||||
Some(link) => {
|
||||
if let Some(service) = &link.service {
|
||||
println!(" · the {} service", service.name);
|
||||
if let (Some(user), true) = (service.user.as_deref(), service.user_created) {
|
||||
println!(" · the {user} account, which the installer created");
|
||||
}
|
||||
}
|
||||
println!(" · {}", link.binary.path);
|
||||
if purge {
|
||||
println!(" · {} [--purge]", link.config_path);
|
||||
println!(" · {} [--purge]", link.db_path);
|
||||
}
|
||||
}
|
||||
None => println!(" · (no sidecar is recorded on this host)"),
|
||||
}
|
||||
println!(" · {}", layout.install_record().display());
|
||||
if purge {
|
||||
println!(" · {} [--purge]", layout.patches_dir().display());
|
||||
}
|
||||
|
||||
println!();
|
||||
ui::heading("This will NOT touch");
|
||||
println!(" · your ServUO tree — every deployed file is listed for you to delete");
|
||||
println!(
|
||||
" · any patched stock file — the hunks to revert are printed with the rung each landed at"
|
||||
);
|
||||
println!(" · your shard, which is neither stopped nor started");
|
||||
if !purge {
|
||||
if let Some(link) = link {
|
||||
println!(" · {} (the auth token)", link.config_path);
|
||||
println!(" · {} (event history)", link.db_path);
|
||||
}
|
||||
println!(
|
||||
" · {} (cached patches and pre-patch originals)",
|
||||
layout.patches_dir().display()
|
||||
);
|
||||
}
|
||||
let _ = record;
|
||||
println!();
|
||||
}
|
||||
|
||||
/// The report: everything the operator has to finish by hand.
|
||||
fn render_report(
|
||||
record: &InstallRecord,
|
||||
link: Option<&LinkRecord>,
|
||||
layout: &paths::Layout,
|
||||
purge: bool,
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"\n{:=<78}\nRunic Gateway — what is left for you to do\ngenerated {} installer {}\n{:=<78}\n",
|
||||
"",
|
||||
now_rfc3339(),
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
""
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"ServUO tree {}{}",
|
||||
record.servuo.path,
|
||||
record
|
||||
.servuo
|
||||
.version
|
||||
.as_ref()
|
||||
.map(|v| format!(" ({v})"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
if let Some(link) = link {
|
||||
// Stated flatly, with no claim about what this run managed to remove: the report is
|
||||
// rendered from the record and is about what is *left* to do. A line asserting "(removed)"
|
||||
// is a line that can be wrong — a binary locked by a still-running process is exactly the
|
||||
// case where it would be.
|
||||
let _ = writeln!(out, "uo-link {}", link.version);
|
||||
}
|
||||
|
||||
render_overlay_section(&mut out, record);
|
||||
render_patch_section(&mut out, record, layout, purge);
|
||||
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"The installer never deletes from a ServUO tree and never reverses a patch: it cannot know\n\
|
||||
what you have changed in those files since they were deployed. Both lists above are\n\
|
||||
yours to act on, or to ignore — an unused Bridge plugin is inert once the sidecar is gone.\n"
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// Every overlay file, by path, flagged where the copy on disk is no longer the one deployed.
|
||||
///
|
||||
/// The flag is the point: an operator deleting this list file by file must not lose their own
|
||||
/// `Bridge.cfg` settings, or an edit they made to a script, without being told which lines those
|
||||
/// are.
|
||||
fn render_overlay_section(out: &mut String, record: &InstallRecord) {
|
||||
let Some(overlay) = &record.overlay else {
|
||||
return;
|
||||
};
|
||||
let root = Path::new(&record.servuo.path);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"\n── Overlay files deployed into your ServUO tree ─────────────────────────────\n\n\
|
||||
{} file(s) from servuo-plugins {}. Delete them if you want the shard back to stock:\n",
|
||||
overlay.files.len(),
|
||||
overlay.version
|
||||
);
|
||||
|
||||
for (rel, file) in &overlay.files {
|
||||
let path = patch::join(root, rel);
|
||||
let note = match util::sha256_file(&path) {
|
||||
Err(_) => " (already gone)",
|
||||
Ok(actual) if actual == file.on_disk_sha256 => "",
|
||||
Ok(_) => " ← EDITED SINCE DEPLOYMENT — check before deleting",
|
||||
};
|
||||
let _ = writeln!(out, " {}{note}", path.display());
|
||||
}
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"\n The Bridge scripts are inert without a sidecar, so leaving them in place is safe.\n"
|
||||
);
|
||||
}
|
||||
|
||||
/// The exact hunks each applied patch added, rendered from the cached `.patch` files.
|
||||
///
|
||||
/// Rendered rather than referenced: the release tarball is long gone by the time somebody reads
|
||||
/// this, and "apply the reverse of the patch" is not something an operator can do from a filename.
|
||||
/// The rung each hunk landed at is printed with it, because a `region-match` apply means the
|
||||
/// surrounding file was already the operator's and deserves a closer look than a stock-hash one.
|
||||
fn render_patch_section(
|
||||
out: &mut String,
|
||||
record: &InstallRecord,
|
||||
layout: &paths::Layout,
|
||||
purge: bool,
|
||||
) {
|
||||
let features = record.patch_records();
|
||||
if features.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"\n── Stock ServUO files this installer patched ────────────────────────────────\n"
|
||||
);
|
||||
if features.iter().any(|f| f.unsupported_servuo) {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" ⚠ Some of these were applied on an UNSUPPORTED ServUO version ({}).\n",
|
||||
features
|
||||
.iter()
|
||||
.find_map(|f| f.servuo_version.clone())
|
||||
.unwrap_or_else(|| "unknown".into())
|
||||
);
|
||||
}
|
||||
|
||||
for feature in &features {
|
||||
let _ = writeln!(out, " Feature: {}", feature.feature);
|
||||
for applied in &feature.patches {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"\n {} → {}\n applied by: {}",
|
||||
applied.name,
|
||||
patch::join(Path::new(&record.servuo.path), &applied.target).display(),
|
||||
applied.rung
|
||||
);
|
||||
match render_hunks(layout, &applied.name, &applied.sha256) {
|
||||
Some(text) => out.push_str(&text),
|
||||
None => {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" (the cached copy of this patch could not be read — the hunks it added \
|
||||
start\n near line {})",
|
||||
applied
|
||||
.hunks
|
||||
.first()
|
||||
.map(|h| h.matched_line)
|
||||
.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !feature.companions.is_empty() {
|
||||
let _ = writeln!(out, "\n Companion files added by this feature:");
|
||||
for companion in &feature.companions {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" {}",
|
||||
patch::join(Path::new(&record.servuo.path), &companion.path).display()
|
||||
);
|
||||
}
|
||||
}
|
||||
let _ = writeln!(out);
|
||||
}
|
||||
|
||||
let originals = layout.patch_originals_dir();
|
||||
if purge {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" The pre-patch copies of these files were removed by --purge, so the lines above are\n\
|
||||
the only record of what changed.\n"
|
||||
);
|
||||
} else if originals.exists() {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" Each of those files as it was BEFORE the tier first touched it is kept here:\n \
|
||||
{}\n Diff against it rather than reversing the hunks by eye — after a region-match \
|
||||
apply the\n rest of the file was already yours.\n",
|
||||
originals.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders one cached patch's added and removed lines, indented for the report.
|
||||
fn render_hunks(layout: &paths::Layout, name: &str, sha256: &str) -> Option<String> {
|
||||
let path = layout.patches_dir().join(format!("{name}.patch"));
|
||||
let bytes = std::fs::read(&path).ok()?;
|
||||
if !sha256.is_empty() && util::sha256_bytes(&bytes) != sha256 {
|
||||
// Not fatal — a re-run with a newer release can legitimately have replaced the cache — but
|
||||
// the operator should know the text below is not byte-for-byte what was applied.
|
||||
let parsed = crate::diff::parse(&bytes).ok()?;
|
||||
let file = parsed.single_file().ok()?;
|
||||
let mut out = String::from(
|
||||
" (the cached patch differs from the one recorded; showing the cached copy)\n",
|
||||
);
|
||||
out.push_str(&hunk_text(file));
|
||||
return Some(out);
|
||||
}
|
||||
let parsed = crate::diff::parse(&bytes).ok()?;
|
||||
Some(hunk_text(parsed.single_file().ok()?))
|
||||
}
|
||||
|
||||
fn hunk_text(file: &crate::diff::FilePatch) -> String {
|
||||
let mut out = String::new();
|
||||
for hunk in &file.hunks {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" @@ around line {} @@",
|
||||
if hunk.old_start > 0 {
|
||||
hunk.old_start
|
||||
} else {
|
||||
1
|
||||
}
|
||||
);
|
||||
for line in &hunk.lines {
|
||||
let (sign, bytes) = match line {
|
||||
HunkLine::Context(b) => (' ', b),
|
||||
HunkLine::Added(b) => ('+', b),
|
||||
HunkLine::Removed(b) => ('-', b),
|
||||
};
|
||||
let _ = writeln!(out, " {sign}{}", String::from_utf8_lossy(bytes));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Writes the report where the operator ran the command, falling back to the state directory.
|
||||
///
|
||||
/// The working directory is the one place they are certainly looking; `/etc/runicgateway` is being
|
||||
/// emptied by this very command, and a report inside a directory the operator has just been told is
|
||||
/// gone is a report nobody finds.
|
||||
fn write_report(report: &str, layout: &paths::Layout) -> Result<PathBuf> {
|
||||
let name = format!(
|
||||
"runicgateway-uninstall-{}.txt",
|
||||
now_rfc3339().replace([':', '-'], "").replace('Z', "")
|
||||
);
|
||||
let cwd = std::env::current_dir().unwrap_or_else(|_| layout.state_dir.clone());
|
||||
let primary = cwd.join(&name);
|
||||
if util::write_atomic(&primary, report.as_bytes()).is_ok() {
|
||||
return Ok(primary);
|
||||
}
|
||||
let fallback = layout.state_dir.join(&name);
|
||||
util::write_atomic(&fallback, report.as_bytes()).with_context(|| {
|
||||
format!(
|
||||
"cannot write the uninstall report to {}",
|
||||
fallback.display()
|
||||
)
|
||||
})?;
|
||||
Ok(fallback)
|
||||
}
|
||||
|
||||
fn remove_file(path: &Path, done: &mut Vec<String>, problems: &mut Vec<String>) {
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => done.push(format!("removed {}", path.display())),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
done.push(format!("{} was already gone", path.display()))
|
||||
}
|
||||
Err(error) => {
|
||||
// A permission error on the sidecar binary is nearly always a running process holding
|
||||
// it, not an access-control problem: Windows locks a running executable, and a service
|
||||
// this command knows about was already stopped above. Saying so beats sending the
|
||||
// operator to look at ACLs.
|
||||
let hint = if error.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
"\n If something is still running it — a sidecar started by hand, or a service \
|
||||
this installer did not register — stop that first and delete the file."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
problems.push(format!("cannot remove {}: {error}{hint}", path.display()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_dir(path: &Path, done: &mut Vec<String>, problems: &mut Vec<String>) {
|
||||
if !path.exists() {
|
||||
return;
|
||||
}
|
||||
match std::fs::remove_dir_all(path) {
|
||||
Ok(()) => done.push(format!("removed {}", path.display())),
|
||||
Err(error) => problems.push(format!("cannot remove {}: {error}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::patch::{AppliedPatch, FeatureRecord, Rebuild};
|
||||
use crate::record::{BundleRef, FileRecord, InstallerInfo, OverlayRecord, ServUoRef, SCHEMA};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const PATCH: &[u8] = b"\
|
||||
--- a/Scripts/Commands/Logging.cs
|
||||
+++ b/Scripts/Commands/Logging.cs
|
||||
@@ -10,3 +10,4 @@ public static class CommandLogging
|
||||
public static void WriteLine()
|
||||
{
|
||||
+ BridgeModerationAudit.Raise();
|
||||
}
|
||||
";
|
||||
|
||||
fn record(root: &Path) -> InstallRecord {
|
||||
InstallRecord {
|
||||
schema: SCHEMA,
|
||||
installer: InstallerInfo {
|
||||
version: "0.1.0".into(),
|
||||
},
|
||||
updated: "2026-08-05T10:00:00Z".into(),
|
||||
bundle: BundleRef {
|
||||
tag: "2026.08.04".into(),
|
||||
protocol: 3,
|
||||
url: "https://example/current.json".into(),
|
||||
},
|
||||
servuo: ServUoRef {
|
||||
path: root.display().to_string(),
|
||||
version: Some("57.4".into()),
|
||||
},
|
||||
overlay: Some(OverlayRecord {
|
||||
repo: "RunicGateway/servuo-plugins".into(),
|
||||
tag: "v0.1.1".into(),
|
||||
version: "0.1.1".into(),
|
||||
commit: "3a52abb".into(),
|
||||
protocol: 3,
|
||||
files: BTreeMap::from([
|
||||
(
|
||||
"Scripts/Custom/Bridge/BridgeLink.cs".to_string(),
|
||||
FileRecord {
|
||||
overlay_sha256: util::sha256_bytes(b"deployed"),
|
||||
on_disk_sha256: util::sha256_bytes(b"deployed"),
|
||||
state: "deployed".into(),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Config/Bridge.cfg".to_string(),
|
||||
FileRecord {
|
||||
overlay_sha256: util::sha256_bytes(b"shipped"),
|
||||
on_disk_sha256: util::sha256_bytes(b"mine"),
|
||||
state: "kept-operator-modified".into(),
|
||||
},
|
||||
),
|
||||
]),
|
||||
}),
|
||||
link: None,
|
||||
patches: vec![serde_json::to_value(FeatureRecord {
|
||||
feature: "moderation-audit".into(),
|
||||
rebuild: Rebuild::Scripts,
|
||||
servuo_version: Some("57.4".into()),
|
||||
unsupported_servuo: false,
|
||||
patches: vec![AppliedPatch {
|
||||
name: "commandlogging-event".into(),
|
||||
target: "Scripts/Commands/Logging.cs".into(),
|
||||
rung: "region-match".into(),
|
||||
sha256: util::sha256_bytes(PATCH),
|
||||
hunks: Vec::new(),
|
||||
}],
|
||||
companions: Vec::new(),
|
||||
})
|
||||
.unwrap()],
|
||||
extra: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_in(dir: &Path) -> paths::Layout {
|
||||
paths::Layout {
|
||||
state_dir: dir.to_path_buf(),
|
||||
data_dir: dir.join("data"),
|
||||
sidecar_bin: dir.join("bin").join("uo-link-sidecar"),
|
||||
relocated: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_report_lists_every_overlay_file_and_flags_the_edited_ones() {
|
||||
let dir = util::TempDir::new("rg-test-uninstall").unwrap();
|
||||
let root = dir.path().join("ServUO");
|
||||
std::fs::create_dir_all(root.join("Scripts/Custom/Bridge")).unwrap();
|
||||
std::fs::create_dir_all(root.join("Config")).unwrap();
|
||||
std::fs::write(
|
||||
root.join("Scripts/Custom/Bridge/BridgeLink.cs"),
|
||||
b"deployed",
|
||||
)
|
||||
.unwrap();
|
||||
// Edited after deployment: the operator must be warned before deleting this one.
|
||||
std::fs::write(root.join("Config/Bridge.cfg"), b"changed again").unwrap();
|
||||
|
||||
let layout = layout_in(dir.path());
|
||||
let record = record(&root);
|
||||
let report = render_report(&record, None, &layout, false);
|
||||
|
||||
assert!(report.contains("BridgeLink.cs"), "{report}");
|
||||
assert!(
|
||||
report.contains("EDITED SINCE DEPLOYMENT"),
|
||||
"the edited Bridge.cfg must be flagged:\n{report}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_report_renders_the_hunks_from_the_cached_patch() {
|
||||
// The whole reason the tier caches its patches: this text has to be produceable long after
|
||||
// the release tarball is gone.
|
||||
let dir = util::TempDir::new("rg-test-uninstall-hunks").unwrap();
|
||||
let layout = layout_in(dir.path());
|
||||
std::fs::create_dir_all(layout.patches_dir()).unwrap();
|
||||
std::fs::write(
|
||||
layout.patches_dir().join("commandlogging-event.patch"),
|
||||
PATCH,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report = render_report(&record(&dir.path().join("ServUO")), None, &layout, false);
|
||||
assert!(report.contains("commandlogging-event"), "{report}");
|
||||
assert!(
|
||||
report.contains("+ BridgeModerationAudit.Raise();"),
|
||||
"the added line must appear verbatim:\n{report}"
|
||||
);
|
||||
assert!(report.contains("region-match"), "{report}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_patch_cache_degrades_to_a_line_number() {
|
||||
// --purge on an earlier run, or a hand-cleaned /etc: the report still has to say something
|
||||
// useful rather than claim there was nothing to revert.
|
||||
let dir = util::TempDir::new("rg-test-uninstall-nocache").unwrap();
|
||||
let report = render_report(
|
||||
&record(&dir.path().join("ServUO")),
|
||||
None,
|
||||
&layout_in(dir.path()),
|
||||
false,
|
||||
);
|
||||
assert!(report.contains("could not be read"), "{report}");
|
||||
assert!(report.contains("commandlogging-event"), "{report}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_report_is_written_where_the_operator_is_standing() {
|
||||
let dir = util::TempDir::new("rg-test-uninstall-report").unwrap();
|
||||
let path = write_report("hello", &layout_in(dir.path())).unwrap();
|
||||
assert!(path.is_file());
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello");
|
||||
assert!(
|
||||
path.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.starts_with("runicgateway-uninstall-"),
|
||||
"{path:?}"
|
||||
);
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user