ADR 001 Domain model - nekrozis/qtogo GitHub Wiki

ADR-001: Domain DTOs, versions and version rules

Status: Accepted (2026-10-06).

Context

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 6110 becomes 6.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.

Decision

1. A version keeps its raw string and its numbers

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 say 5.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_6110 gives Raw: "6110", Major: 6, Minor: 11), and the numbers are decoded from it by the repository layout, not by Version. 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 in Suffix (-preview), because the marker is in the directory's extension, not in the token. ParseVersion produces a Raw that does contain its suffix; a layout parser's Raw may not. Only Compare reads Suffix.
  • ParseVersion accepts one to three numbers, zero-filling the rest, because Qt's own directory names say things like 5.9 and 5.12. Two versions with the same numbers compare equal even when their Raw differs (5.9 and 5.9.0).
  • Compare is the only ordering API; == is not. It compares the numbers, then the suffix, with an empty suffix newest, so 6.9.0-beta1 < 6.9.0 and 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, so beta10 sorts before beta2. 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.
  • Version holds only comparable fields, so it is usable as a map key.

2. Version ranges are half-open

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 < until

Until 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.

3. What the user typed is not what exists

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); a Version is 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: latest is lower-case and exact, and a leading v is rejected. Qt writes neither form, and accepting a spelling the upstream protocol does not use invents syntax we would keep forever.
  • latest matches every version; choosing the newest match is the caller's job, not the spec's.

4. The DTO set

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.Kind rather than Target.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.Name is the archive file name from the metadata, URL is what it resolves to, and InstallPath is where its contents land. InstallPath is 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 --json document 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.

5. Layering

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.

Consequences

  • 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's Raw records 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.

Alternatives considered

  • type Version string. Rejected: every comparison would re-parse, and every rule would carry its own idea of ordering.
  • Version{Major, Minor, Patch} without Raw. Rejected: re-rendering is not free — a package version loses its build stamp and a directory that said 5.9 comes back as 5.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.
⚠️ **GitHub.com Fallback** ⚠️