ADR 001 Domain model - nekrozis/qtogo GitHub Wiki
Status: Accepted (2026-10-06).
Listing, planning and installing exchange the same handful of values: which Qt (host, target, version, architecture), where it comes from, what must be downloaded, and what the outcome was. Those types are shared dependencies, so they are fixed before the code that produces them exists.
Two things make this more than a pile of structs:
-
A version is two things at once. Qt's repository paths are the authority on
what exists, so the representation a version was discovered in has to survive.
Rules, ranges and comparisons need numbers. Normalising eagerly loses that
representation: a directory token like
6110becomes6.11.0, and the token that named the directory is gone. - Rules need a frozen range semantics. Relocation and packaging rules are written as "from version X" and "up to version Y"; if the boundary means different things in different files, every rule becomes suspect.
type Version struct {
Raw string // the representation it was discovered in; never recomputed
Major, Minor, Patch int
Suffix string // a marker such as "-preview"; ordering uses it, Raw may not contain it
}
func ParseVersion(raw string) (Version, error)
func (v Version) Compare(o Version) int
func (v Version) String() string // returns Raw-
Raw is preserved even though a three-number struct would be simpler. Qt's
strings carry more than a number: a package version is
5.15.2-0-202011130601, a directory may say5.9, and a preview build carries a marker. -
Raw is the representation of the source the version was discovered in. It is
not a path segment and not necessarily a human-readable version number. A
version found in a directory name keeps that directory's own spelling
(
qt6_6110givesRaw: "6110",Major: 6,Minor: 11), and the numbers are decoded from it by the repository layout, not byVersion. Rebuilding a repository path from a version is the layout layer's job; it must go through the layout, never through a re-render of the numeric fields. -
Raw does not have to contain Suffix. A directory token whose extension marks
a preview keeps the token in
Raw(515) and the marker inSuffix(-preview), because the marker is in the directory's extension, not in the token.ParseVersionproduces aRawthat does contain its suffix; a layout parser'sRawmay not. OnlyComparereadsSuffix. -
ParseVersionaccepts one to three numbers, zero-filling the rest, because Qt's own directory names say things like5.9and5.12. Two versions with the same numbers compare equal even when theirRawdiffers (5.9and5.9.0). -
Compareis the only ordering API;==is not. It compares the numbers, then the suffix, with an empty suffix newest, so6.9.0-beta1 < 6.9.0and a preview directory sorts before its release. Non-empty suffixes are compared lexically, not semantically: the ordering is deterministic rather than a SemVer-style prerelease ordering, sobeta10sorts beforebeta2. That is intended — Qt's suffixes are build stamps and markers, not a prerelease grammar — and a future implementation must not "fix" it into one. -
Versionholds only comparable fields, so it is usable as a map key.
type VersionRange struct {
Since *Version // inclusive, or nil for "no lower bound"
Until *Version // exclusive, or nil for "no upper bound"
}
func (r VersionRange) Contains(v Version) bool // since <= v < untilUntil is exclusive. This is the decision that has to be frozen, and the
reason is composition:
- Adjacent ranges do not overlap:
[5.0, 6.0)and[6.0, 7.0)partition the space with no double coverage at the boundary. - It reads the way the rules are already written. "Qt 5.14 and newer need no
binary patch" is exactly
Until: 5.14.0, because the statement is about everything below 5.14.0. - An inclusive upper bound would force every rule to name one release before the boundary it means, which is where off-by-one mistakes live.
A missing bound means unbounded on that side.
type SpecKind uint8 // major | minor | patch | latest
type VersionSpec struct {
Raw string
Kind SpecKind
}
func ParseVersionSpec(raw string) (VersionSpec, error)
func (s VersionSpec) Matches(v Version) bool- A spec is a request (
6,6.8,6.8.0,latest); aVersionis a release the repository offers. Keeping them apart is what makes "no such version" reportable instead of silently selecting something else. - The grammar is strict:
latestis lower-case and exact, and a leadingvis rejected. Qt writes neither form, and accepting a spelling the upstream protocol does not use invents syntax we would keep forever. -
latestmatches every version; choosing the newest match is the caller's job, not the spec's.
| Type | Fields | Lands |
|---|---|---|
Host, Kind
|
closed sets: windows linux mac all_os, desktop android ios winrt wasm
|
now |
Target |
Host, Kind, Version, Arch, Extension
|
now |
RepositoryRef |
BaseURL, Path
|
now |
Package |
Name, Version, Dependencies, Downloadables
|
now |
Archive |
Name, URL, SHA256, Size, InstallPath
|
now |
InstallRequest, InstallPlan, Result
|
request, plan, outcome | with the planner |
Amended 2026-10-09 (ADR-010). Archive carries SHA1, not SHA256 — the
digest the metadata declares, with the verified SHA-256 being the transport's
(ADR-007 decision 7). InstallPath is the Extract destination or the root, never a
derived path (ADR-010 decision 3).
-
Target.Kindrather thanTarget.Target, so the field is not named after its own type. -
Architecture stays an opaque string (
win64_msvc2022_64,gcc_64,android_arm64_v8a). Its shape varies too much to decompose without inventing structure the repository does not guarantee; whoever knows the version validates it. -
Archive.Nameis the archive file name from the metadata,URLis what it resolves to, andInstallPathis where its contents land.InstallPathis either the destination the metadata's Extract operation states or the install root — there is no derived, conventional value. A package without an Extract operation extracts at the root, because its archive carries the version and architecture inside itself (ADR-010 decision 3 corrects an earlier reading that derived<version>/<arch>). -
JSON tags are frozen: the field names in a
--jsondocument are an automation contract. - These are data. Behaviour that needs a repository — resolving a spec against what exists, parsing XML, walking directories — belongs to the layers that talk to the repository, not here.
Everything above lives in internal/model, which imports nothing from the CLI,
HTTP or the filesystem. Producers depend on it, never the reverse. That is what
keeps Qt's repository layout — an external protocol that changes — from shaping
the domain types.
The repository-facing layer owns the layout rules: it decodes a directory token
into a Version (ParseVersionDirectory) and is the only place that spells a
version back into a directory segment (ADR-004). Composing a full path from
segments — deciding which nesting a repository uses — is discovery's job, not a
property of the version. Version itself never learns the qt6_ prefix, because
tomorrow a different repository spells versions differently and the domain type
must not carry that.
- Callers that need ordering use
Compare; callers that need a repository path go through the layout layer, which is the only thing that knows how a repository spells a version. A version'sRawrecords where it came from, not where it goes. - Rules are written as half-open ranges; a rule that means to include its upper bound has to say so.
- Three types describe versions (
Version,VersionSpec,VersionRange) where one would be shorter. The split is what makes "mistyped" and "missing" distinguishable and keeps boundary semantics in one place.
-
type Version string. Rejected: every comparison would re-parse, and every rule would carry its own idea of ordering. -
Version{Major, Minor, Patch}withoutRaw. Rejected: re-rendering is not free — a package version loses its build stamp and a directory that said5.9comes back as5.9.0. -
Inclusive
Until. Rejected: neighbouring rules overlap at the boundary and each rule has to be written one release short of what it means.