Researched current industry practice on code organization/maintainability (Ousterhout's deep modules and information hiding, package-by-feature vs. package-by-layer, functional-core/imperative-shell testability, tech-debt prevention via ADR-equivalent inline rationale) and wrote it into ARCHITECTURE.md as a set of concrete, project-specific rules rather than a generic essay — each principle cites a real example already in this codebase or fixed by this commit. Cross-linked from SPEC.md, which stays about product design, not code organization. Applied it to this PR's own code: - Pulled process_package/fetch_and_verify/build_and_publish/run_review/ approve out of main.rs into a new pipeline.rs. main.rs's own main() had grown to 278 lines and zero tests by treating "it's just the entry point" as an excuse to skip separating logic from wiring; now main.rs is argv dispatch only. - Extracted decide_tier_action as a pure function (verification outcome + pending-state -> what to do), replacing dispatch logic that was previously inlined into a function that also made the real network/ build calls. Four unit tests, no I/O, covering all four outcomes. - Added a `//!` module doc comment to every file touched in this branch, each stating that module's one job in a sentence, per the "deep modules" principle the spec argues for. Coverage's reported total drops (94% -> 78%) because pipeline.rs is deliberately NOT excluded from it the way main.rs is, even though it's mostly the same kind of untestable I/O orchestration — excluding it would hide decide_tier_action's real unit-test coverage along with the untested parts. Noted inline in Makefile.toml/ci.yml so the number doesn't look like a quality regression at a glance. Also added a project reference memory pointing at ARCHITECTURE.md rather than duplicating its content there, per this session's own memory-hygiene rules (architecture/conventions are derivable from the repo and shouldn't be duplicated somewhere that can go stale). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
7.7 KiB
pkgwatch — code organization
Status: written 2026-09-17, once the build/publish pipeline PR gave this project enough real code to have actual conventions worth writing down, instead of guessing at them in advance.
This is distinct from SPEC.md, which is the product design (what
pkgwatch does and why). This file is about how the code implementing
that design is organized, so it stays readable as it grows past PoC size
instead of quietly accumulating debt. Researched against current industry
practice rather than asserted from habit — see Further reading.
Principles
-
One module, one job — and say what it is, up front. Ousterhout's "deep modules": the best modules expose a lot of functionality through a simple interface, hiding the complexity behind it. The two failure modes he names — change amplification (one conceptual change forces edits in many places) and obscurity (a reader can't tell where responsibility lives) — are both symptoms of modules that don't have one clear job. Rule: every
src/*.rsfile opens with a//!doc comment stating its one responsibility in a sentence. If it can't be one sentence, the module is doing too much. Example already here:builder.rs's job is "turn an already-downloaded, already-verified artifact into a built package." It hides PKGBUILD templating, upstream-string validation, and themakepkginvocation behind onebuild()call — none of that leaks to callers. -
Organize by pipeline stage (feature), not by technical layer. The package-by-feature vs. package-by-layer research is consistent: feature-based grouping gives high cohesion within a module and low coupling between modules; layer-based grouping (
models/,utils/,helpers/) tends toward the opposite, and a single feature change ends up touching files scattered across every layer. Rule: modules are named after what they do in the pipeline (checker,fetcher,verifier,builder,sanity,publisher,state), not generic buckets. A new pipeline stage gets a new module named after the stage, not a method bolted onto an existing one. Anti-example to keep watching for: autils.rsgrab-bag.hash.rscould look like one but isn't — it exists for exactly one piece of shared logic (sha256_hex) that two real stages (verifier,builder) both need, not as a place to dump unrelated helpers. -
Separate pure decision logic from I/O ("functional core, imperative shell"). A function that decides and does in the same body can't be tested without standing up everything the "does" half touches — often a network call, a subprocess, or the filesystem. Pulling the decision out into its own pure function makes it trivially unit-testable and makes the I/O half thin enough that it obviously matches the decision. Applied this PR:
pipeline::process_package's tier dispatch (publish now / still pending / newly pending / verification failed) was originally inline in a function that also made the real network and build calls. Pulled out intodecide_tier_action, a pure function with its own unit tests covering all four outcomes, no I/O involved. -
Every network, subprocess, filesystem-root, or environment boundary is injectable. Same testability goal as #3, applied to the specific ways this program reaches outside itself. A consistent shape beats ad hoc mocking invented per call site. Already in force:
GithubEndpoints(checker/fetcher/verifier),repo_add_binand the pacman.conf path (publisher),PKGWATCH_REPO_DIR(main, for manual dry runs against a scratch repo instead of the real one). A new external call follows the same shape: production code calls a thin wrapper with the real default; tests call the parameterized version with a fake. -
main.rsis a dispatcher, not the program. Found by looking at this project's ownmain.rs: it grew to 278 lines and zero tests over the course of one PR, because "it's just the entry point" is an easy excuse to skip separating logic from wiring — even though Rust doesn't actually stop you from unit-testing a binary crate'smain.rs. The Rust community convention of splitting entry-point parsing from application logic exists precisely so the logic ends up somewhere it's normal to test. Rule:main.rsmay parseargv, build shared clients, and print output. It must not contain a pipeline decision, a network/subprocess call, or anything with a test worth writing — that belongs inpipeline.rs. Applied this PR: movedprocess_package,fetch_and_verify,build_and_publish,run_review, andapproveout ofmain.rsinto a newpipeline.rs, leavingmain.rsas argument dispatch only. -
Validate at the boundary, once — don't scatter checks. Already stated project-wide (see the user's global instructions: don't validate scenarios that can't happen, validate at system boundaries). Example already here:
builder.rs'svalidate_pkgname/validate_pkgver/validate_shell_saferun once, at PKGBUILD-generation time, against every upstream-controlled string — not sprinkled through whatever code happens to produce those strings. -
Don't build generality the currently-tracked packages don't need. Already the load-bearing design principle in
SPEC.md("a small fixed set of PKGBUILD shapes," "extend when a third real shape shows up"). Restated here because it's also a tech-debt principle in its own right: speculative abstraction is debt too — every future reader has to understand it whether or not it's ever exercised. -
Every non-obvious structural decision gets one sentence of "why," inline. Standard tech-debt-prevention advice is to keep Architecture Decision Records; a single-crate personal tool doesn't need a
docs/adr/directory, but the same information — why this way and not the obvious alternative — needs to live somewhere a future reader will actually see it: the doc comment on the thing itself. Example already here:checker.rs's doc comment onlatest_github_releaseexplains why the newest Atom-feed entry isn't trusted outright (scaleway-cli's-dbg1tag has no real Release behind it) — the reasoning lives right next to the code it justifies, not in a commit message or a separate design doc no one will find later.
What's machine-enforced vs. what isn't
cargo make ci (format, clippy, cognitive-complexity threshold, coverage,
audit) mechanically enforces what's checkable: style, a handful of lint
categories, a complexity ceiling, and that coverage doesn't quietly
regress. It does not enforce module cohesion, naming, or "is this
logic in the right module" — those stay code-review questions. Worth
being honest about that boundary rather than implying CI catches
everything above.