//! Running the patch tier as part of an `install`. //! //! [`crate::patch`] decides what may be written; this module decides whether it is offered at all, //! writes it, reports it, and records it. The split matters because the two halves fail //! differently: a wrong answer in `patch` corrupts a stock ServUO file, and a wrong answer here //! means an operator was never asked, or believes something was patched that was not. //! //! ## Consent (PLAN.md §2.2.2) //! //! The tier is opt-in on every tree, and on a tree that is not stock ServUO 57.4 it is opt-in //! *twice*: //! //! - The interactive prompt defaults to **no**, and on a non-57.4 tree prints an unmissable banner //! before it is even offered — that 57.4 is the only supported version, that the operator is on //! their own, and that a bad outcome may not surface until the shard is running. //! - `--patches` alone is **not** consent there. An unattended run must also pass //! `--patches-unsupported-servuo`, because a flag someone had to look up cannot be hit by //! accident in a script copied from somewhere else. //! //! Withholding that second flag **skips the tier loudly; it does not fail the run.** By the time //! this runs the overlay is deployed and the sidecar is about to be installed, and turning a //! completed base install into exit 1 over a tier that is documented as optional would cost the //! operator more than the tier is worth. Saying nothing would be the real failure, so the skip is //! reported at the point it happens and again in the closing summary. //! //! ## All-or-nothing, at two levels //! //! `patch::resolve` is all-or-nothing per patch file: if one hunk reaches rung 3, none of that //! patch's hunks are written. This module adds the second level — **per feature**. The two //! vendor-sale patches are one unit (`EventSink.cs` grows the event, `PlayerVendorGumps.cs` raises //! it, and the companion `BridgeVendorSale.cs` subscribes to it); applying either alone produces a //! tree that either does not compile or silently never emits. So every patch in a feature is //! resolved first, and nothing is written unless all of them can be. use std::path::Path; use anyhow::{Context, Result}; use crate::cli::{Cli, PatchChoice}; use crate::patch::{ self, AppliedPatch, CompanionRecord, Feature, FeatureRecord, Rebuild, Resolution, Rung, Tier, }; use crate::servuo::{self, ServUoRoot}; use crate::util::{sha256_file, write_atomic}; use crate::{paths, ui}; /// What the tier did, for the closing summary and the record. pub struct Outcome { /// One entry per feature now in place. Written to `install.json`. pub records: Vec, /// The tier ran (as opposed to being declined, skipped or unavailable). pub ran: bool, /// A feature whose patches touch a core file was applied, so the solution must be rebuilt — /// ServUO's dynamic script build is not enough and will not say so. pub core_rebuild: bool, } impl Outcome { fn skipped() -> Self { Self { records: Vec::new(), ran: false, core_rebuild: false, } } } /// Decides whether the tier runs, then runs it. /// /// `prior` is the previous run's records: on rung 0 they are preserved verbatim rather than /// re-minted, which is what keeps a second `install` byte-identical (see [`record_for`]). /// /// ## Scope under `update` /// /// An `update` re-resolves **only the features a previous run recorded as applied**, and does so /// without asking again. Two things follow from that, and both are deliberate: /// /// - It is not a fresh offer. A shard that declined the tier stays unpatched through every update, /// which is what "opt-in" has to mean if it means anything; the new release's features are named /// so the operator knows they exist, and `--patches` is how they are taken up. /// - Consent is not re-asked for what is already in the tree — including on an unsupported ServUO, /// where `install` demanded a second flag. The record is the evidence that the operator opted in, /// and re-prompting would make an unattended update of a working shard impossible on precisely /// the hosts that most need the patches re-checked after an overlay moves. /// /// Normally every one of those re-resolutions lands on rung 0 and writes nothing. When a release /// genuinely changes a patch, it is applied through the same ladder as any other — the target is /// still a file the operator may have edited, and nothing here loosens that check. #[allow(clippy::too_many_arguments)] pub fn run( cli: &Cli, mode: crate::install::Mode, root: &ServUoRoot, unpacked: &Path, declared: Option<&Tier>, layout: &paths::Layout, prior: &[FeatureRecord], backup: &mut crate::backup::Session, ) -> Result { let declared_tier = Tier::resolve(declared); if declared_tier.features.is_empty() { ui::row( "Patch tier", "not offered — this overlay declares no patches", ); return Ok(Outcome::skipped()); } let supported = root.is_supported_version(); // Under `update`, scope narrows to what is already applied unless --patches widens it back. let widen = cli.patches == PatchChoice::Yes; let tier = if mode.is_update() && !widen { scope_to_applied(&declared_tier, prior) } else { declared_tier.clone() }; if mode.is_update() && !widen { if tier.features.is_empty() { ui::row( "Patch tier", "nothing to re-check — no feature was applied by an earlier run", ); print_available(&declared_tier); return Ok(Outcome::skipped()); } ui::row( "Patch tier", &format!( "re-checking {} feature(s) an earlier run applied", tier.features.len() ), ); announce_new_features(&declared_tier, &tier); return apply_tier(cli, root, unpacked, &tier, layout, prior, supported, backup); } match consent(cli, root, supported, &tier)? { Consent::Yes => {} Consent::No(reason) => { ui::row("Patch tier", &reason); print_cost(&tier, prior); return Ok(Outcome::skipped()); } Consent::RefusedUnsupported => { println!(); ui::warn(&format!( "Patch tier REQUESTED BUT NOT RUN — this tree reports ServUO {}, and \ {} is the\n only supported version. --patches on its own is not consent here.\n \ No stock ServUO file has been touched.\n\n \ To run it anyway, unsupported and untested, add --patches-unsupported-servuo.\n \ Read INSTALL.md §4 first, and back up your ServUO tree.", root.version_display(), servuo::SUPPORTED_VERSION )); print_cost(&tier, prior); return Ok(Outcome::skipped()); } } apply_tier(cli, root, unpacked, &tier, layout, prior, supported, backup) } /// The subset of a release's tier that a previous run actually applied. fn scope_to_applied(declared: &Tier, prior: &[FeatureRecord]) -> Tier { let applied = patch::index_records(prior); Tier { features: declared .features .iter() .filter(|f| applied.contains_key(f.name.as_str())) .cloned() .collect(), } } /// Names what this release offers that the shard does not have, without offering it. /// /// An update must not quietly become the moment a shard acquires edits to stock ServUO files, but /// an operator who never learns the feature exists cannot opt in either. fn announce_new_features(declared: &Tier, in_scope: &Tier) { let scoped: Vec<&str> = in_scope.features.iter().map(|f| f.name.as_str()).collect(); let new: Vec<&Feature> = declared .features .iter() .filter(|f| !scoped.contains(&f.name.as_str())) .collect(); if new.is_empty() { return; } println!( " This release also offers {} feature(s) this shard does not have:", new.len() ); for feature in new { println!(" - {}", feature.summary); } println!(" Add them with: install --patches (they edit stock ServUO files; INSTALL.md §4)"); } /// The tier's offer, printed by an update that has nothing of its own to re-check. fn print_available(declared: &Tier) { println!(" This release offers:"); for feature in &declared.features { println!(" - {}", feature.summary); } println!(" Add them with: install --patches (they edit stock ServUO files; INSTALL.md §4)"); } enum Consent { Yes, /// Not selected, with the reason to print. No(String), /// Asked for on a tree where `--patches` alone is not enough. RefusedUnsupported, } fn consent(cli: &Cli, root: &ServUoRoot, supported: bool, tier: &Tier) -> Result { if cli.patches == PatchChoice::No { return Ok(Consent::No("skipped (--no-patches)".into())); } if cli.patches == PatchChoice::Yes { return Ok(if supported || cli.patches_unsupported_servuo { Consent::Yes } else { Consent::RefusedUnsupported }); } // PatchChoice::Ask — offer it. ui::heading("Patch tier (optional)"); println!( " {} feature(s) need edits to stock ServUO files. Without them:", tier.features.len() ); for feature in &tier.features { println!(" - {}", feature.summary); } if !supported { print_unsupported_banner(root); } else { println!( "\n Every patch is checked before anything is written, and any whose target lines are\n \ no longer stock is reported for you to apply by hand rather than forced." ); } // The default is no on every tree (INSTALL.md §2). `--yes` takes that default, which makes an // unattended run that did not ask for the tier safely skip it. match ui::confirm("Apply the patch tier?", false, cli.assume_yes) { Ok(true) => Ok(Consent::Yes), Ok(false) => Ok(Consent::No("not selected".into())), // A piped run with no --yes cannot answer, and the base install has already succeeded. // Declining for it is the documented default, so this is a note rather than a failure. Err(_) => Ok(Consent::No( "not selected (no terminal to ask; pass --patches to enable)".into(), )), } } fn print_unsupported_banner(root: &ServUoRoot) { println!(); println!(" ┌──────────────────────────────────────────────────────────────────────────────┐"); println!(" │ ⚠ UNSUPPORTED, UNTESTED, NOT GUARANTEED TO WORK │"); println!(" └──────────────────────────────────────────────────────────────────────────────┘"); println!( " This tree reports ServUO {}. Runic Gateway is designed, built and tested against\n \ stock ServUO {} — that is the only supported version.", root.version_display(), servuo::SUPPORTED_VERSION ); println!( "\n You may run the tier here. If you do, you are on your own: it is not covered by\n \ support, and a bad outcome may not show up until your shard is live, because ServUO's\n \ script build reports success even when it failed and quietly keeps running the previous\n \ Scripts.dll." ); println!( "\n A patch is still refused wherever the exact lines it edits have changed — but matching\n \ text is not matching behaviour. A hunk can land correctly and still be wrong for a tree\n \ that has diverged around it." ); println!( "\n Back up your ServUO tree first, and verify your shard boots and compiles afterwards." ); } /// What declining costs — counting only the features that are not already in place. /// /// A decline does not inspect the tree, so the previous run's record is the only evidence /// available. Ignoring it produced a run that skipped the tier and then announced the loss of two /// features `install.json` shows as applied, which is worse than saying nothing: an operator /// reading it would go looking for a problem that does not exist. fn print_cost(tier: &Tier, prior: &[FeatureRecord]) { let applied = patch::index_records(prior); let lost: Vec<&str> = tier .features .iter() .filter(|f| !applied.contains_key(f.name.as_str())) .map(|f| f.lost.as_str()) .collect(); if lost.is_empty() && !applied.is_empty() { println!(" Everything this tier provides is already applied — nothing was changed."); } else { println!(" Without it: {}.", lost.join(", ")); if !applied.is_empty() { println!( " ({} already applied by an earlier run and left in place.)", applied.keys().cloned().collect::>().join(", ") ); } } } /// Resolves and applies every feature. #[allow(clippy::too_many_arguments)] fn apply_tier( cli: &Cli, root: &ServUoRoot, unpacked: &Path, tier: &Tier, layout: &paths::Layout, prior: &[FeatureRecord], supported: bool, backup: &mut crate::backup::Session, ) -> Result { let previous = patch::index_records(prior); let mut records: Vec = Vec::new(); let mut lines: Vec = Vec::new(); let mut lost: Vec<&str> = Vec::new(); let mut applied_patches = 0usize; let mut core_rebuild = false; let mut core_targets: Vec = Vec::new(); for feature in &tier.features { let resolved = resolve_feature(root, unpacked, feature)?; let placeable = resolved.iter().all(|r| r.resolution.rung().is_some()); for r in &resolved { lines.push(render(feature, r, placeable, layout, cli.verify)); } // Every patch the tier *evaluated* is cached, applied or not. `uninstall` needs the applied // ones to print the exact hunks to revert long after the release tarball is gone (PLAN.md // §5) — and a refused one is the file this run has just told the operator to apply by hand, // so pointing them at a path that only exists on success would be the less useful half. if !cli.verify { cache_patches(&resolved, layout)?; } if !placeable { lost.push(&feature.lost); continue; } if !cli.verify { write_feature(root, unpacked, feature, &resolved, layout, backup)?; } applied_patches += resolved.len(); // The rebuild warning is about files this run *edited*, not about every file the feature // covers. A feature whose patches were all already present changed nothing, so telling the // operator to rebuild the core would be noise — and on a re-run, noise that recurs forever. // The list names only the files actually written, since a core feature can also carry // patches against Scripts files, and calling one of those a core file is simply wrong. let written: Vec = resolved .iter() .filter(|r| matches!(r.resolution, Resolution::Applicable { .. })) .map(|r| r.target.clone()) .collect(); if feature.rebuild == Rebuild::Core && !written.is_empty() { core_rebuild = true; core_targets.extend(written); } records.push(record_for( feature, &resolved, root, supported, previous.get(feature.name.as_str()).copied(), )); } // A feature an earlier run applied that this release no longer declares still has its edits // sitting in the ServUO tree. Its record is carried through rather than dropped: `uninstall` // renders the hunks to revert from these entries, and a record that quietly forgot them would // leave the operator with modified stock files and nothing saying so. let in_scope: Vec<&str> = tier.features.iter().map(|f| f.name.as_str()).collect(); let carried: Vec<&FeatureRecord> = prior .iter() .filter(|r| !in_scope.contains(&r.feature.as_str())) .collect(); for record in &carried { records.push((*record).clone()); } // ── Report ─────────────────────────────────────────────────────────────── println!(); ui::row( "Patch tier", &format!( "{applied_patches} of {} {}", tier.patch_count(), if cli.verify { "would be applied [--verify]" } else { "applied" } ), ); for line in lines { println!("{line}"); } for record in &carried { println!( " · {:<30} applied by an earlier run; this release's tier does not describe it,\n \ so it was left exactly as it is and its record kept", record.feature ); } if core_rebuild { println!(); ui::warn(&format!( "{} — a CORE ServUO file was patched. Rebuild the solution:\n \ dotnet build ServUO.sln\n \ A shard restart is not enough; ServUO's dynamic script build does not rebuild the \ core, and it will not tell you so.", core_targets.join(", ") )); } if !lost.is_empty() { println!(); println!(" Not applied, so you do not get: {}.", lost.join(", ")); println!( " Everything else works. Apply the hunks by hand if you want them, then re-run \ install to record it." ); } if cli.verify { println!("\n VERIFY only. No ServUO file was edited and no patch was cached."); } Ok(Outcome { records, ran: true, core_rebuild: core_rebuild && !cli.verify, }) } /// One patch, resolved against the tree. struct Resolved { name: String, /// Relative to the ServUO root. target: String, /// Relative to the extracted release. file: String, bytes: Vec, content: Vec, resolution: Resolution, } fn resolve_feature(root: &ServUoRoot, unpacked: &Path, feature: &Feature) -> Result> { let mut out = Vec::with_capacity(feature.patches.len()); for declared in &feature.patches { let (bytes, parsed) = patch::load(unpacked, declared)?; let target = patch::join(&root.path, &declared.target); let (content, resolution) = match std::fs::read(&target) { Ok(content) => { let resolution = patch::resolve(&parsed, &content); (content, resolution) } Err(error) if error.kind() == std::io::ErrorKind::NotFound => { (Vec::new(), Resolution::Refused(patch::Refusal::Missing)) } Err(error) => ( Vec::new(), Resolution::Refused(patch::Refusal::Unreadable(error.to_string())), ), }; out.push(Resolved { name: declared.name.clone(), target: declared.target.clone(), file: declared.file.clone(), bytes, content, resolution, }); } Ok(out) } /// Copies every evaluated patch into the state directory. /// /// Deliberately separate from [`write_feature`] and called for **refused** features too, because /// the refusal message names this path as the file to apply by hand. Caching only what applied /// would make that message point at a file the run had chosen not to write. fn cache_patches(resolved: &[Resolved], layout: &paths::Layout) -> Result<()> { for r in resolved { let cached = layout.patches_dir().join(file_name(&r.file)); write_atomic(&cached, &r.bytes).with_context(|| { format!( "cannot cache {} — the run needs somewhere to put the patch it is about to \ reference", r.name ) })?; } Ok(()) } /// Writes one feature: the patched files and its companion sources. /// /// Reached only when every patch in the feature is placeable, so a partial write is not a state /// this function can produce. The pre-image is cached **before** the file is written and never /// overwritten afterwards, so it stays the content from before the tier first touched it. fn write_feature( root: &ServUoRoot, unpacked: &Path, feature: &Feature, resolved: &[Resolved], layout: &paths::Layout, backup: &mut crate::backup::Session, ) -> Result<()> { for r in resolved { if let Resolution::Applicable { edits, .. } = &r.resolution { // `patches/originals/` holds the pre-*tier* copy and is never overwritten, which is the // right thing to revert to. It is not a copy of what this file looked like before *this* // run, though — on a second tier pass the operator's own later edits are only in the // backup (PLAN.md §5.3). backup.capture( &patch::join(&root.path, &r.target), crate::backup::Reason::PatchTarget, )?; let original = patch::join(&layout.patch_originals_dir(), &r.target); if !original.exists() { write_atomic(&original, &r.content) .with_context(|| format!("cannot save the pre-patch copy of {}", r.target))?; } let patched = patch::apply(&r.content, edits); let path = patch::join(&root.path, &r.target); write_atomic(&path, &patched) .with_context(|| format!("cannot write the patched {}", r.target))?; } } // Companions last, and only now: they reference symbols the patches introduce, so a companion // copied beside an unpatched file is a shard that does not compile — and ServUO would report a // clean boot anyway (PLAN.md §2.1). for companion in &feature.companions { let src = patch::join(unpacked, &companion.file); let dst = patch::join(&root.path, &companion.install_to); // Copied unconditionally, like every other `.cs` the overlay owns — so an operator who // edited one loses it here unless a copy is taken first. backup.capture(&dst, crate::backup::Reason::PatchCompanion)?; if let Some(parent) = dst.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("cannot create {}", parent.display()))?; } std::fs::copy(&src, &dst).with_context(|| { format!( "cannot copy {} into the ServUO tree — the patches applied, so this file is \ required for the shard to compile", companion.install_to ) })?; } Ok(()) } /// Builds the `install.json` entry for an applied feature. /// /// **A rung-0 result reuses the previous record whole.** The rung is the support-relevant fact — /// how did this land? — and re-deriving it on a later run answers `already-present` for something /// that first landed as `region-match`. That flip would rewrite `install.json` on the second run of /// an otherwise-identical install, which is the same class of bug as the `Bridge.cfg` comparison in /// `overlay::plan`: a record that describes the run instead of the state. fn record_for( feature: &Feature, resolved: &[Resolved], root: &ServUoRoot, supported: bool, prior: Option<&FeatureRecord>, ) -> FeatureRecord { let all_already_present = resolved .iter() .all(|r| r.resolution.rung() == Some(Rung::AlreadyPresent)); if all_already_present { if let Some(prior) = prior { return prior.clone(); } } FeatureRecord { feature: feature.name.clone(), rebuild: feature.rebuild, servuo_version: root.version.clone(), unsupported_servuo: !supported, patches: resolved .iter() .map(|r| AppliedPatch { name: r.name.clone(), target: r.target.clone(), rung: r .resolution .rung() .map(Rung::as_str) .unwrap_or("unknown") .to_string(), sha256: crate::util::sha256_bytes(&r.bytes), hunks: r.resolution.placements().to_vec(), }) .collect(), companions: feature .companions .iter() .map(|c| CompanionRecord { path: c.install_to.clone(), // Hashed from the tree after the copy, so the record describes what is actually // there — which is what lets `doctor` notice a companion that was later edited. sha256: sha256_file(&patch::join(&root.path, &c.install_to)).unwrap_or_default(), }) .collect(), } } /// One reported line per patch, in the layout INSTALL.md §4 illustrates. fn render( feature: &Feature, r: &Resolved, placeable: bool, layout: &paths::Layout, verify: bool, ) -> String { let mark = if placeable { "✓" } else { "✗" }; let mut out = format!(" {mark} {:<30} {}", r.name, r.target); let detail = match &r.resolution { Resolution::AlreadyPresent { .. } => Rung::AlreadyPresent.detail().to_string(), Resolution::Applicable { rung, hunks, .. } => { let at = hunks .iter() .map(|h| h.matched_line.to_string()) .collect::>() .join(", "); // "applied" is only ever claimed for something that was actually written. A dry run // wrote nothing, and a patch held back by its feature's all-or-nothing rule was // resolvable but left alone — reporting either as applied is the exact misreading this // whole tier is built to avoid. let verb = match (placeable, verify) { (true, false) => "applied at line", (true, true) => "would be applied at line", (false, _) => "could have been placed at line", }; format!("{} — {verb} {at}", rung.detail()) } Resolution::Refused(refusal) => refusal.detail(), }; out.push_str(&format!("\n {detail}")); // A feature is all-or-nothing, so a patch that could have been placed is still not written when // a sibling could not. Saying "applied" there would be a lie the operator finds out about later. if !placeable { if r.resolution.rung().is_some() { out.push_str(&format!( "\n held back — {} is applied as one unit and a sibling patch could not be \ placed", feature.name )); } if !verify { out.push_str(&format!( "\n apply this by hand, then re-run install to record it:\n {}", layout.patches_dir().join(file_name(&r.file)).display() )); } } out } fn file_name(rel: &str) -> &str { rel.rsplit('/').next().unwrap_or(rel) } #[cfg(test)] mod tests { use super::*; use crate::patch::{Companion, PatchRef}; fn feature() -> Feature { Feature { name: "vendor-sale".into(), summary: "vendor.sale events".into(), lost: "no vendor.sale events".into(), rebuild: Rebuild::Core, patches: vec![PatchRef { name: "playervendor-sale-eventsink".into(), file: "patches/playervendor-sale-eventsink.patch".into(), target: "Server/EventSink.cs".into(), }], companions: vec![Companion { file: "patches/BridgeVendorSale.cs".into(), install_to: "Scripts/Custom/Bridge/BridgeVendorSale.cs".into(), }], } } fn resolved(resolution: Resolution) -> Resolved { Resolved { name: "playervendor-sale-eventsink".into(), target: "Server/EventSink.cs".into(), file: "patches/playervendor-sale-eventsink.patch".into(), bytes: b"--- a/x\n".to_vec(), content: Vec::new(), resolution, } } fn root() -> ServUoRoot { ServUoRoot { path: std::path::PathBuf::from("/opt/ServUO"), version: Some("57.4".into()), } } fn layout() -> paths::Layout { paths::Layout { state_dir: std::path::PathBuf::from("/etc/runicgateway"), data_dir: std::path::PathBuf::from("/var/lib/runicgateway"), sidecar_bin: std::path::PathBuf::from("/usr/bin/runicgateway-link"), rust_sidecar_bin: std::path::PathBuf::from("/usr/bin/runicgateway-rust-link"), relocated: false, } } #[test] fn a_refused_patch_points_at_the_cached_file_to_apply_by_hand() { let r = resolved(Resolution::Refused(patch::Refusal::RegionModified { hunk: 2, })); let line = render(&feature(), &r, false, &layout(), false); assert!(line.contains('✗'), "{line}"); assert!(line.contains("patched region has been modified"), "{line}"); assert!( line.contains("playervendor-sale-eventsink.patch"), "the operator needs the path of the file to apply: {line}" ); } #[test] fn a_placeable_patch_held_back_by_its_sibling_says_so() { // The failure this prevents: reporting a patch as applied because it *could* have been, // when the feature's all-or-nothing rule meant nothing was written. let r = resolved(Resolution::Applicable { rung: Rung::RegionMatch, hunks: vec![patch::HunkPlacement { declared_line: 171, matched_line: 173, }], edits: Vec::new(), }); let line = render(&feature(), &r, false, &layout(), false); assert!(line.contains("held back"), "{line}"); assert!(line.contains("as one unit"), "{line}"); } #[test] fn declining_does_not_claim_a_loss_that_an_earlier_run_already_prevented() { // Caught live: `--no-patches` on a host whose install.json shows both features applied // still announced the loss of both. A decline inspects nothing, so the record is the only // evidence there is — and ignoring it sends an operator looking for a problem they do not // have. let tier = Tier::builtin(); let applied = |name: &str| FeatureRecord { feature: name.into(), rebuild: Rebuild::Core, servuo_version: Some("57.4".into()), unsupported_servuo: false, patches: Vec::new(), companions: Vec::new(), }; let all: Vec = tier.features.iter().map(|f| applied(&f.name)).collect(); let none: Vec = Vec::new(); // The three cases differ only in what the record holds, so assert on the filtering itself // rather than on captured stdout. let remaining = |prior: &[FeatureRecord]| -> Vec { let have = patch::index_records(prior); tier.features .iter() .filter(|f| !have.contains_key(f.name.as_str())) .map(|f| f.lost.clone()) .collect() }; assert_eq!(remaining(&none).len(), 2, "a fresh host loses both"); assert!( remaining(&all).is_empty(), "a fully patched host loses nothing" ); assert_eq!( remaining(&[applied("vendor-sale")]), vec!["no in-game moderation audit forwarding".to_string()], "only the feature that is genuinely absent is named" ); } #[test] fn an_update_re_checks_only_what_an_earlier_run_applied() { // The scope rule for `update`: it must not become the moment a shard acquires edits to // stock ServUO files, and it must not stop re-checking the ones it already has. let tier = Tier::builtin(); let applied = |name: &str| FeatureRecord { feature: name.into(), rebuild: Rebuild::Scripts, servuo_version: Some("57.4".into()), unsupported_servuo: false, patches: Vec::new(), companions: Vec::new(), }; let scoped = scope_to_applied(&tier, &[applied("moderation-audit")]); assert_eq!(scoped.features.len(), 1); assert_eq!(scoped.features[0].name, "moderation-audit"); // A host that declined the tier stays declined through every update. assert!(scope_to_applied(&tier, &[]).features.is_empty()); // And one that took everything keeps re-checking everything. let all: Vec = tier.features.iter().map(|f| applied(&f.name)).collect(); assert_eq!( scope_to_applied(&tier, &all).features.len(), tier.features.len() ); } #[test] fn a_feature_the_release_no_longer_declares_is_still_scoped_out_not_forgotten() { // `scope_to_applied` can only return what the release declares, so a record for a feature // that has been withdrawn falls outside it — which is exactly why `apply_tier` carries such // records through instead of rebuilding the section from what it processed. let tier = Tier { features: vec![feature()], }; let withdrawn = FeatureRecord { feature: "some-old-feature".into(), rebuild: Rebuild::Scripts, servuo_version: Some("57.4".into()), unsupported_servuo: false, patches: Vec::new(), companions: Vec::new(), }; assert!(scope_to_applied(&tier, std::slice::from_ref(&withdrawn)) .features .is_empty()); let in_scope: Vec<&str> = tier.features.iter().map(|f| f.name.as_str()).collect(); assert!(!in_scope.contains(&withdrawn.feature.as_str())); } #[test] fn nothing_that_was_not_written_is_reported_as_applied() { // Both halves caught on a live tree: a --verify run said "applied at line 75", and a patch // held back by its sibling said "applied" on the line above the one explaining it was not. let r = || { resolved(Resolution::Applicable { rung: Rung::RegionMatch, hunks: vec![patch::HunkPlacement { declared_line: 75, matched_line: 75, }], edits: Vec::new(), }) }; let dry = render(&feature(), &r(), true, &layout(), true); assert!(dry.contains("would be applied at line 75"), "{dry}"); let held = render(&feature(), &r(), false, &layout(), false); assert!(held.contains("could have been placed at line 75"), "{held}"); assert!(!held.contains("— applied at"), "{held}"); let real = render(&feature(), &r(), true, &layout(), false); assert!(real.contains("applied at line 75"), "{real}"); } #[test] fn an_applied_patch_reports_the_line_it_matched_not_the_one_it_declared() { // The declared line is advisory: an insertion above the region shifts it. Printing the // declared number would send an operator to the wrong place in their own file. let r = resolved(Resolution::Applicable { rung: Rung::RegionMatch, hunks: vec![patch::HunkPlacement { declared_line: 171, matched_line: 1180, }], edits: Vec::new(), }); let line = render(&feature(), &r, true, &layout(), false); assert!(line.contains("applied at line 1180"), "{line}"); assert!(!line.contains("171"), "{line}"); assert!( line.contains("file modified, patched region stock"), "{line}" ); } #[test] fn a_rung_zero_rerun_keeps_the_rung_that_first_applied_it() { // Idempotence: re-deriving would answer `already-present` for something that landed as // `region-match`, rewriting install.json on the second run of an identical install. let first = record_for( &feature(), &[resolved(Resolution::Applicable { rung: Rung::RegionMatch, hunks: vec![patch::HunkPlacement { declared_line: 171, matched_line: 173, }], edits: Vec::new(), })], &root(), true, None, ); assert_eq!(first.patches[0].rung, "region-match"); let second = record_for( &feature(), &[resolved(Resolution::AlreadyPresent { hunks: vec![patch::HunkPlacement { declared_line: 171, matched_line: 173, }], })], &root(), true, Some(&first), ); assert_eq!( second, first, "a second run must record exactly the same thing" ); } #[test] fn a_hand_patched_tree_with_no_prior_record_is_recorded_as_already_present() { // The other half: someone applied the hunks by hand per INSTALL.md Appendix A2, then ran // the installer. There is nothing to preserve, so the state it finds is what it records. let record = record_for( &feature(), &[resolved(Resolution::AlreadyPresent { hunks: vec![patch::HunkPlacement { declared_line: 171, matched_line: 171, }], })], &root(), true, None, ); assert_eq!(record.patches[0].rung, "already-present"); assert!(!record.unsupported_servuo); } #[test] fn an_unsupported_tree_labels_the_record_it_writes() { // The label follows the install (PLAN.md §2.2.2): whoever inherits this shard must be able // to see it from install.json without being told. let mut root = root(); root.version = Some("58.1".into()); let record = record_for( &feature(), &[resolved(Resolution::Applicable { rung: Rung::RegionMatch, hunks: Vec::new(), edits: Vec::new(), })], &root, false, None, ); assert!(record.unsupported_servuo); assert_eq!(record.servuo_version.as_deref(), Some("58.1")); } #[test] fn the_core_rebuild_warning_names_only_files_this_run_wrote() { // Caught on a live tree whose vendor-sale patches were already applied by hand: the run // wrote nothing and still demanded a core rebuild, listing a Scripts file as core. On a // re-run that warning would recur forever, which is how a real one stops being read. let already = [resolved(Resolution::AlreadyPresent { hunks: Vec::new() })]; let written: Vec = already .iter() .filter(|r| matches!(r.resolution, Resolution::Applicable { .. })) .map(|r| r.target.clone()) .collect(); assert!( written.is_empty(), "nothing was written, so nothing to rebuild" ); let fresh = [resolved(Resolution::Applicable { rung: Rung::StockHash, hunks: Vec::new(), edits: Vec::new(), })]; let written: Vec = fresh .iter() .filter(|r| matches!(r.resolution, Resolution::Applicable { .. })) .map(|r| r.target.clone()) .collect(); assert_eq!(written, vec!["Server/EventSink.cs".to_string()]); } #[test] fn patches_alone_is_not_consent_on_an_unsupported_tree() { let mut cli = Cli { patches: PatchChoice::Yes, ..Cli::default() }; assert!(matches!( consent(&cli, &root(), false, &Tier::builtin()).unwrap(), Consent::RefusedUnsupported )); // ...and the separate flag is. cli.patches_unsupported_servuo = true; assert!(matches!( consent(&cli, &root(), false, &Tier::builtin()).unwrap(), Consent::Yes )); // On a supported tree the extra flag is not needed and is simply ignored. let plain = Cli { patches: PatchChoice::Yes, ..Cli::default() }; assert!(matches!( consent(&plain, &root(), true, &Tier::builtin()).unwrap(), Consent::Yes )); } #[test] fn no_patches_declines_without_asking_anything() { let cli = Cli { patches: PatchChoice::No, ..Cli::default() }; // False for `supported` too: an explicit decline is never escalated into a prompt. assert!(matches!( consent(&cli, &root(), false, &Tier::builtin()).unwrap(), Consent::No(_) )); } #[test] fn an_unattended_run_that_did_not_ask_for_the_tier_takes_the_no_default() { // --yes means "take the default answer", and the default is no on every tree (INSTALL.md // §2). An unattended install must not start editing stock files because nobody objected. let cli = Cli { patches: PatchChoice::Ask, assume_yes: true, ..Cli::default() }; assert!(matches!( consent(&cli, &root(), true, &Tier::builtin()).unwrap(), Consent::No(_) )); } }