A multi-platform (iOS + CLI) chat app that answers questions about countries in free text. Built with the Essential Developer method — TDD, small always-green commits, modular design, and CI. One framework-free domain core, reused verbatim by two thin composition roots (an iOS app and a CLI).
Ask it anything, however you phrase it:
> whats the capitol of belgum
Brussels is the capital of Belgium.
> countries starting with New
Countries starting with “New”: New Caledonia, New Zealand
> iso code for viet nam
The ISO alpha-2 code for Vietnam is VN.
> show me brazils flag
The flag of Brazil is 🇧🇷
Runs with zero setup — no API key, no account, no signup.
One caveat if you run the iOS app: the simulator's iOS version must match your macOS version, or the on-device model is silently unavailable and the app falls back to its rule-based interpreter. Details in Simulator and macOS versions must match. The CLI has no such constraint.
Layered Swift packages under Modules/ (one package each), consumed by two roots:
| Layer | Package | Role |
|---|---|---|
| Domain | CountryFoundation |
Country, CountryQuery, Answer value types, the pure AnswerResolver, and the CountryLoader / QuestionInterpreter seams. Foundation-only — no UI or networking framework, and depends on nothing. |
| Infrastructure | CountryAPI |
HTTPClientProtocol seam + URLSessionHTTPClient adapter, countries endpoint + mapper, RemoteCountryLoader. |
| Infrastructure | CountryInterpreter |
Free text → CountryQuery. A deterministic rule-based interpreter, plus an on-device Apple Foundation Models interpreter, behind a fallback composite. |
| Orchestration | CountryChat |
AnswerService (interpret → load → resolve) + caching and retry decorators. |
| Presentation | CountryPresentation |
MVVM ChatViewModel emitting AsyncStream<ChatState> (Foundation-only). |
| Root (iOS) | CountryChatApp |
SwiftUI ChatView + composition root. |
| Root (CLI) | country-cli |
Console chat loop + composition root. |
Every dependency points down to the framework-free domain core; only the two roots know
concrete types. The core is compiled and tested on Linux in CI to prove it stays
platform-agnostic. See docs/ for the light/dark diagrams.
Story: A user wants quick answers about countries
Narrative #1 — Happy path
As a user
I want to ask about a country in my own words
So I get an answer without learning a command syntax
Given the user has connectivity
When they ask for a capital, an ISO alpha-2 code, a flag,
or which countries start with some letters
Then the app replies with the answer
And it understands the question however it is phrased or misspelled
Narrative #2 — Unrecognised country
As a user
I want to be told when a country isn't recognised
So I can correct myself instead of trusting a wrong answer
Given the user names something that is not a country
When nothing matches closely enough
Then the app says it doesn't know that country
And it does not guess
Narrative #3 — Network failure
As a user
I want to retry when the lookup fails
So a dropped connection doesn't end the conversation
Given the country list has not loaded yet
When the request fails
Then the app shows an error with a retry action
And retrying re-runs the same question
And the error clears as soon as a request succeeds
free text
│ QuestionInterpreter (on-device model, falling back to rules)
▼
CountryQuery .capital | .countriesStartingWith | .isoCode | .flag
│ AnswerService (loads every country once, then caches)
▼
AnswerResolver (pure: normalize, fuzzy-match, prefix-filter)
│ AnswerFormatter (localized)
▼
chat reply
Interpretation is the only non-deterministic step, and it sits behind a single seam.
Everything downstream — including the misspelling tolerance that turns belgum into
Belgium — is deterministic and fully unit-tested.
mledoze/countries (MIT) served over jsDelivr, pinned
to a release tag:
https://cdn.jsdelivr.net/gh/mledoze/countries@v4.1.1/countries.json
Fetched once per session and cached. No key, no account, no signup.
Why not restcountries.com? The brief specifies it, but as of July 2026 it can no longer satisfy a zero-setup public repo:
restcountries.com/v3.1/allreturns301 → "This API version has been deprecated"- v5 requires
Authorization: Bearer <key>and a sign-up; its public demo key (rc_live_demo) returns a single canned sample object for every endpoint
Requiring a reviewer to obtain their own key — or committing a secret to a public repo —
was the worse trade. mledoze/countries is the upstream dataset restcountries is itself
built from, so the field names are the same ones the brief names: name, capital,
cca2, flag, altSpellings. Flag images aren't in the dataset, so they're derived
deterministically from the ISO code (https://flagcdn.com/w320/be.png) — the same CDN
restcountries used.
Pinning to @v4.1.1 rather than @master keeps the live end-to-end tests reproducible.
Important
Pick a simulator whose iOS version matches your macOS version. An iOS 26.3 simulator on macOS 26.3; an iOS 26.1 simulator on macOS 26.1. A mismatch silently disables the on-device model — see Simulator and macOS versions must match below. The CLI is unaffected.
# CLI — the quickest way to see it work
swift run --package-path country-cli
# Per-module tests (what CI runs)
swift test --package-path Modules/CountryFoundation
# iOS app
open Atlas.xcworkspaceThe CLI is an interactive prompt. Type a question and press return; retry re-runs the last
question after a failure, exit quits.
The iOS project is generated from CountryChatApp/project.yml with
XcodeGen. The committed .xcodeproj is the source
of truth for reviewers; regenerate with cd CountryChatApp && xcodegen generate after
editing the spec.
The on-device interpreter needs Apple's FoundationModels, which requires:
- iOS 26 / macOS 26 or later, on Apple silicon
- Apple Intelligence enabled on the host Mac (System Settings → Apple Intelligence & Siri)
- the generative model assets downloaded — while they download,
SystemLanguageModel.availabilityreports.unavailable(.modelNotReady)
The Simulator has no assets of its own; it borrows the host's. Observed on Xcode 26.3 / macOS 26.3.1:
| Host | Simulator | On-device model |
|---|---|---|
| macOS 26.3.1 | iOS 26.3 | ✅ loads com.apple.fm.language.instruct_3b |
| macOS 26.3.1 | iOS 26.1 | ❌ Model Catalog error … there are no underlying assets |
We've only verified this pairing, but the failure mode is consistent with the Simulator resolving assets against the host, so treat version parity as the safe default.
The trap is that SystemLanguageModel.isAvailable returns true on the mismatched
simulator — it reports .available rather than one of the honest reasons
(.deviceNotEligible, .appleIntelligenceNotEnabled, .modelNotReady) — so the app tries
the model and only fails at generation time. The console then fills with Prewarm failed
and ModelManagerError for every question.
The app still answers correctly — the fallback composite drops to the rule-based interpreter, which is exactly what it exists for. But you are testing the fallback, not the model. If you want to exercise the on-device path, match the versions or run the CLI, which runs natively on the host and is never affected.
Unrelated simulator noise you can ignore: IOHIDLib (HID plugin architecture) and
CHHapticPattern / hapticpatternlibrary.plist (the keyboard trying to play typing haptics
on hardware that doesn't exist).
.github/workflows/test-modules.yml—swift testfor every module in a matrix, plus the CLI, on Linux (swift:6.1container). Deterministic, no network..github/workflows/test-ios.yml—xcodebuild testfor the iOS app on a simulator with the Thread Sanitizer enabled.- Live end-to-end tests hit the real countries endpoint and live in their own package
(
CountryAPIEndToEndTests), run on demand so a third-party outage never blocks a PR.
MIT.
The country data comes from mledoze/countries,
which is also MIT licensed.