// Links from a pack's content folders into a game directory. The records live in the pack's own // `.packwand/links.json`, and nothing that is not recorded is ever removed. A record is written // before its link exists, so a crash leaves at worst a record with nothing behind it, never a link // nobody knows about. // // A copy link is a real folder kept in step by `sync`. Only files its manifest lists are deleted, // so what the game writes into the copy survives every pass and the unlink. Ported from Packwand's // Zig engine (ledger D-ZIG-LINK-1, D-ZIG-LINK-2). AI-authored (Claude). #pragma once #include "failure.hpp" #include "fs.hpp" #include "launchers.hpp" #include "mirror.hpp" #include "records.hpp" #include "packutils.h" #include #include #include #include namespace packutils::links { inline constexpr std::string_view directory_name = ".packwand"; inline constexpr std::string_view file_name = "links.json"; /// What is in an instance's slot for one folder before linking. enum class Slot : std::uint8_t { free, /// A real directory with nothing in it: replacing it cannot lose anything. empty_directory, /// A link to this folder, or the recorded copy of it. ours, /// Something else; only a forced link moves it, to the backup folder. taken, }; [[nodiscard]] std::string_view name_of(Slot slot); [[nodiscard]] std::optional slot_named(std::string_view name); struct Check { std::string folder; Slot slot = Slot::free; /// The instance's path for the folder. std::string path; }; struct Placed { std::string folder; records::Mode mode = records::Mode::symlink; /// Already linked before this run. bool kept = false; std::optional backup; /// Copy links only: source paths the copy could not take. std::vector blocked; }; struct Removed { records::Link link; enum class Outcome : std::uint8_t { removed, /// Nothing was there any more. missing, /// A copy that still holds files the game wrote; its record is dropped all the same. left_in_place, } outcome = Outcome::removed; /// The backup a forced link made was moved back. bool restored = false; }; struct SyncReport { std::size_t copied = 0; std::size_t deleted = 0; std::vector blocked; std::vector failed; /// Copies the instance changed, left as they are. std::vector kept; /// Changed in the pack and the instance both; left as they are. std::vector conflicts; /// Version 1 entries whose copy differs from the pack's file; left as they are. std::vector unknown; }; /// A game directory, and the launcher instance it was named through, if any. struct Resolved { std::string game; std::optional launcher; }; /// What a harvest looks at (packutils SPEC.md section 6). struct HarvestRequest { /// The game directory, as `resolve_instance` gave it. std::string instance; /// The pack's linked folders: never offered as folders to take. std::vector pack_folders; /// Names never offered or taken: the game's own output and the pack's state. std::vector never; /// Folders the pack lacks, to take whole. std::vector folders; /// Top-level files to take. std::vector files; /// The pack's mods folder: a jar the instance added there is identified, not copied. std::string mods = "mods"; }; struct HarvestFinding { /// `edited`, `conflict`, `unknown`, `added`, `deleted` (copies), `jar`, or `file` (named). std::string kind; /// Relative to the game directory, with `/` separators. std::string path; /// The instance file's SHA-512 (hex) as the scan read it; empty for `deleted`. std::string sha512; }; struct Harvest { std::vector findings; /// Folders the instance has and the pack does not, which `folders` may name. std::vector candidates; }; /// How a request asks the engine to work. struct Options { /// A test seam for Wine, which has no byte-range locks. Every real run locks. bool lock_files = true; std::uint32_t lock_timeout_ms = 10'000; /// A test seam: treat directory links as unsupported, as a file system without them would. bool links_unavailable = false; /// The runtime already holds the pack's links lock (`packutils_lock`): take it again neither /// here nor anywhere. bool lock_held = false; }; /// One pack's links, as one request describes them. class PackLinks { public: PackLinks(const pu_host &host, std::string pack_root, Options options) : host_(&host), root_(std::move(pack_root)), options_(options) {} [[nodiscard]] Result> load() const; /// The game directory `instance` names, refused unless it is a directory apart from the pack. /// A launcher's instance folder names the game directory inside it, and says what it runs. [[nodiscard]] Result resolve_instance(const std::string &instance) const; [[nodiscard]] Result> check(const std::string &instance, const std::vector &folders) const; /// Links every checked folder. A taken slot is refused unless `force` says to move it to the /// backup folder first. With `copy` no link is tried. A link to this pack's folder that no /// record names is adopted, since only this pack makes one; that is how a crash between placing /// a link and recording it heals. `now` (Unix seconds) names backups. [[nodiscard]] Result> link(const std::string &instance, const std::vector &checks, bool force, bool copy, std::int64_t now) const; /// Removes every recorded link into `instance`, or into every instance, and moves back what a /// forced link set aside. A link that is no longer a link, or points elsewhere, is refused and /// kept on record; the links removed before it stay removed. [[nodiscard]] Result> unlink(const std::optional &instance) const; /// One pass of a copy link: the source's changes since the last pass reach the copy. [[nodiscard]] Result sync(const records::Link &entry) const; /// What the instance changed that the pack does not have, read under the links lock; nothing /// is written anywhere. [[nodiscard]] Result harvest(const HarvestRequest &request) const; /// The links lock, waiting up to the timeout; nothing when the lock is already held or locks /// are off. [[nodiscard]] Result> lock() const; private: const pu_host *host_; std::string root_; Options options_; [[nodiscard]] bool canceled() const; [[nodiscard]] Result state_directory() const; [[nodiscard]] Result save(const std::vector &list) const; [[nodiscard]] Result sync_locked(const records::Link &entry) const; [[nodiscard]] Result remove_copy(const records::Link &entry, const std::string &path) const; [[nodiscard]] std::string manifest_path(const records::Link &entry) const; [[nodiscard]] static Result load_manifest(const std::string &where); /// A file's SHA-512 (hex) through the host, or nothing when it cannot be read. [[nodiscard]] std::optional sha512_hex(const std::string &path) const; [[nodiscard]] mirror::Hasher hasher(std::string source_root, std::string target_root) const; [[nodiscard]] Result drop_manifest(const records::Link &entry) const; }; /// `-` for Unix seconds `now`. [[nodiscard]] std::string backup_stem(std::string_view folder, std::int64_t now); } // namespace packutils::links