[!NOTE] Discovery finds the system file, the global user file, and the project file in three layouts by walking up from the working directory, with local and user overrides next to the project file, an explicit project file, a stop boundary, a legacy fallback, isolated mode, environment files, and workspaces with members and key filters.
| Priority | Source | Role | Files for acme with TOML on Linux |
|---|---|---|---|
| 100 | System | System | /etc/acme/acme.toml |
| 200 | Global user | Global | ~/.config/acme/acme.toml or ~/.acme/acme.toml |
| 300 | Project | Workspace | acme.toml, .acme/acme.toml, or .config/acme/config.toml, found by walk-up |
| 350 | Member | Member | packages/billing/acme.toml, when the workspace part selects a member |
| 375 | Environment file | EnvironmentFile | acme.Production.toml next to the project file, when environments are on |
| 390 | Local override | LocalOverride | acme.local.toml next to the project file |
| 400 | User override | UserOverride | acme.user.toml next to the project file |
///|
test "inspect root evidence without selecting a member" {
let root = @config.FileTreePath::parse("/w")
let child = @config.FileTreePath::parse("/w/a")
let conventions = @conventions.app_dirs("acme")
|> @conventions.workspace
|> @conventions.root_policy(WorkspaceFirst(@config.KeyPath::parse("workspace")))
|> @conventions.member_loading(ExplicitOnly)
let tree = @config.FileTree::new(complete=true)
.with_listing(root, [{ name: "acme.toml", kind: File }, { name: "a", kind: Directory }])
.with_listing(child, [{ name: "acme.toml", kind: File }])
.with_file(root.child("acme.toml"), "[workspace]\nmarker='root'")
.with_file(child.child("acme.toml"), "project.name='a'")
let context : @config.LoadContext = {
tree, working_dir: Some(child), platform: Some(Linux),
env_vars: Map([]), formats: [@toml.format()],
}
guard conventions.roots(context) is Done(roots) else { fail("complete tree needs I/O") }
assert_eq(roots.workspace(), Some(root))
assert_eq(roots.nearest_project(), Some(child))
let config = @config.build(roots.sources())
assert_eq(@config.get_text(config, "workspace.marker"), "root")
}///|
test "declare ordered groups with an application-owned workspace file" {
let json = @conventions.Extension::parse("json")
let toml = @conventions.Extension::parse("toml")
let layers = @conventions.FormatLayers::new([
@conventions.FormatGroup::new([json], supplemental=
@conventions.SupplementalFile::new("acme-workspace.json", json)),
@conventions.FormatGroup::new([toml]),
])
let conventions = @conventions.base("acme") |> @conventions.layouts
|> @conventions.format_layers(layers)
|> @conventions.root_policy(WorkspaceFirst(@config.KeyPath::parse("workspace")))
let tree = @config.FileTree::new(complete=true)
.with_listing(@config.FileTreePath::parse("/w"), [
{ name: "acme-workspace.json", kind: File },
{ name: "acme.toml", kind: File },
])
.with_file(@config.FileTreePath::parse("/w/acme-workspace.json"),
"{workspace:{}, shared:'json', retained:true}")
.with_file(@config.FileTreePath::parse("/w/acme.toml"), "shared='toml'")
let builder = @config.builder() |> @config.working_dir("/w")
|> @config.format(@toml.format()) |> @config.format(@json5.format())
|> @conventions.add(conventions)
guard @config.load(builder, tree) is Done(config) else { fail("complete tree") }
assert_eq(@config.get_text(config, "shared"), "toml")
assert_eq(@config.bind(config, "retained"), true)
}/w/acme.toml <- found: the project primary
/w/src/
/w/src/nested/ <- the working directory| Layout | Example for acme with TOML |
|---|---|
| <app>.<ext> | acme.toml |
| .<app>/<app>.<ext> | .acme/acme.toml |
| .config/<app>/config.<ext> | .config/acme/config.toml |
| Step | What discovery does |
|---|---|
| Start | The working directory that @config.working_dir sets; it must be absolute |
| Search | <app>.<ext> for each extension of the registered formats, by exact name |
| Walk | Each parent directory, up to the file-system root |
| Stop | At the first directory with a primary; two primaries there are Ambiguous |
| None found | The report shows NotFound with the candidates at the start directory |
///|
test "find the project file from a nested directory" {
let builder = @config.builder()
|> @config.working_dir("/w/src/nested")
|> @config.format(@toml.format())
|> @conventions.add(@conventions.app_dirs("acme"))
let tree = @config.FileTree::new(complete=true)
.with_listing(@config.FileTreePath::parse("/w"), [
{ name: "acme.toml", kind: File },
{ name: "src", kind: Directory },
])
.with_listing(@config.FileTreePath::parse("/w/src"), [{ name: "nested", kind: Directory }])
.with_listing(@config.FileTreePath::parse("/w/src/nested"), [])
.with_file(@config.FileTreePath::parse("/w/acme.toml"), "project.name = \"acme\"")
guard @config.load(builder, tree) is Done(config) else {
fail("a complete tree never asks for files")
}
@expect.expect(@config.get_text(config, "project.name")).to_equal("acme")
let project = config
.reports()
.iter()
.find_first(report => report.kind == File(Workspace))
@expect.expect(project.map(report => report.path)).to_equal(
Some(Some("/w/acme.toml")),
)
}///|
test "select JSON over TOML without losing TOML-only settings" {
let layers = @conventions.FormatLayers::new([
@conventions.FormatGroup::new([@conventions.Extension::parse("json")]),
@conventions.FormatGroup::new([@conventions.Extension::parse("toml")]),
])
let conventions = @conventions.base("acme") |> @conventions.layouts
|> @conventions.format_layers(layers)
|> @conventions.selected_file_policy(Overlay)
let tree = @config.FileTree::new(complete=true)
.with_listing(@config.FileTreePath::parse("/w"), [
{ name: "acme.json", kind: File }, { name: "acme.toml", kind: File },
])
.with_file(@config.FileTreePath::parse("/w/acme.json"), "{shared:'json'}")
.with_file(@config.FileTreePath::parse("/w/acme.toml"), "shared='toml'\nretained=true")
let builder = @config.builder() |> @config.working_dir("/w")
|> @config.format(@json5.format()) |> @config.format(@toml.format())
|> @conventions.add(conventions, options=
@conventions.DiscoveryOptions::new(config=Explicit("./acme.json")))
guard @config.load(builder, tree) is Done(config) else { fail("complete tree") }
assert_eq(@config.get_text(config, "shared"), "json")
assert_eq(@config.bind(config, "retained"), true)
assert_eq(config.reports().filter(report => report.status is Loaded)
.map(report => report.priority), [300, 325])
}///|
test "a test root keeps files above it out" {
let builder = @config.builder()
|> @config.working_dir("/tmp/case/src")
|> @config.format(@toml.format())
|> @conventions.add(
@conventions.app_dirs("acme") |> @conventions.boundary("/tmp/case"),
)
let tree = @config.FileTree::new(complete=true)
.with_listing(@config.FileTreePath::parse("/tmp"), [
{ name: "acme.toml", kind: File },
{ name: "case", kind: Directory },
])
.with_listing(@config.FileTreePath::parse("/tmp/case"), [{ name: "src", kind: Directory }])
.with_listing(@config.FileTreePath::parse("/tmp/case/src"), [])
guard @config.load(builder, tree) is Done(config) else {
fail("a complete tree never asks for files")
}
let project = config
.reports()
.iter()
.find_first(report => report.kind == File(Workspace))
@expect.expect(project.map(report => report.status)).to_equal(Some(NotFound))
}| Directory | Linux | macOS | Windows |
|---|---|---|---|
| System | /etc/<app> | /etc/<app> | %PROGRAMDATA%/<app>, else C:/ProgramData/<app> |
| User configuration | $XDG_CONFIG_HOME/<app>, else $HOME/.config/<app> | $XDG_CONFIG_HOME/<app>, else $HOME/Library/Application Support/<app> | %APPDATA%/<app> |
| Application home | $<PREFIX>_HOME, else $HOME/.<app> | $<PREFIX>_HOME, else $HOME/.<app> | $<PREFIX>_HOME, else %USERPROFILE%/.<app> |
///|
test "the global candidates come from the variable set of the run" {
let builder = @config.builder()
|> @config.working_dir("/w")
|> @config.platform(Linux)
|> @config.env_vars({ "HOME": "/home/alice", "ACME_HOME": "/opt/acme" })
|> @config.format(@toml.format())
|> @conventions.add(@conventions.app_dirs("acme"))
guard @config.load(builder, @config.FileTree::new(complete=true))
is Done(config) else {
fail("a complete tree never asks for files")
}
let global = config
.reports()
.iter()
.find_first(report => report.kind == File(Global))
@expect.expect(global.map(report => report.candidates)).to_equal(
Some(["/home/alice/.config/acme/acme.toml", "/opt/acme/acme.toml"]),
)
}| Project file | Local override | User override |
|---|---|---|
| acme.toml | acme.local.toml | acme.user.toml |
| .acme/acme.toml | .acme/acme.local.toml | .acme/acme.user.toml |
| .config/acme/config.toml | .config/acme/config.local.toml | .config/acme/config.user.toml |
///|
test "the user override wins over the project file" {
let builder = @config.builder()
|> @config.working_dir("/w")
|> @config.format(@toml.format())
|> @conventions.add(@conventions.app_dirs("acme"))
let tree = @config.FileTree::new(complete=true)
.with_listing(@config.FileTreePath::parse("/w"), [
{ name: "acme.toml", kind: File },
{ name: "acme.user.toml", kind: File },
])
.with_file(@config.FileTreePath::parse("/w/acme.toml"), "ui.theme = \"shared\"")
.with_file(@config.FileTreePath::parse("/w/acme.user.toml"), "ui.theme = \"personal\"")
guard @config.load(builder, tree) is Done(config) else {
fail("a complete tree never asks for files")
}
@expect.expect(@config.get_text(config, "ui.theme")).to_equal("personal")
}| Order | Source | Example |
|---|---|---|
| 1 | The environment value of DiscoveryOptions | A host fills it from --environment Staging |
| 2 | <PREFIX>_ENVIRONMENT in the variable set of the run | ACME_ENVIRONMENT=Production; an empty value counts as unset |
| 3 | The default of environments | environments(default="Development") |
| Project file | Environment file for Production |
|---|---|
| acme.toml | acme.Production.toml |
| .acme/acme.toml | .acme/acme.Production.toml |
| .config/acme/config.toml | .config/acme/config.Production.toml |
///|
test "the environment file of the run wins over the project file" {
let conventions = @conventions.app_dirs("acme")
|> @conventions.environments(default="Development")
let vars = { "ACME_ENVIRONMENT": "Production", "ACME_UI__THEME": "dark" }
let builder = @config.builder()
|> @config.working_dir("/w")
|> @config.env_vars(vars)
|> @config.format(@toml.format())
|> @conventions.add(conventions)
|> @config.env(prefix="ACME", reserved=["ACME_ENVIRONMENT", "ACME_HOME"])
let tree = @config.FileTree::new(complete=true)
.with_listing(@config.FileTreePath::parse("/w"), [
{ name: "acme.toml", kind: File },
{ name: "acme.Production.toml", kind: File },
])
.with_file(@config.FileTreePath::parse("/w/acme.toml"), "log.level = \"info\"")
.with_file(@config.FileTreePath::parse("/w/acme.Production.toml"), "log.level = \"warn\"")
guard @config.load(builder, tree) is Done(config) else {
fail("a complete tree never asks for files")
}
@expect.expect(@conventions.environment_name(conventions, vars)).to_equal(
Some("Production"),
)
@expect.expect(@config.get_text(config, "log.level")).to_equal("warn")
@expect.expect(@config.get_text(config, "ui.theme")).to_equal("dark")
// The reserved names give no configuration key.
@expect.expect(config.get(@config.KeyPath::parse("environment"))).to_equal(
None,
)
}///|
test "remove the system part, or build from single parts" {
// No system file, for a tool that never installs machine-wide.
let tool = @conventions.app_dirs("acme") |> @conventions.without(SystemDir)
// Only the project file in three layouts and a user override.
let minimal = @conventions.base("acme")
|> @conventions.layouts
|> @conventions.user_overrides
let kinds = (conventions : @conventions.Conventions) => {
let builder = @config.builder()
|> @config.working_dir("/w")
|> @config.platform(Linux)
|> @config.env_vars({ "HOME": "/home/alice" })
|> @config.format(@toml.format())
|> @conventions.add(conventions)
let tree = @config.FileTree::new(complete=true)
.with_listing(@config.FileTreePath::parse("/w"), [{ name: "acme.toml", kind: File }])
.with_file(@config.FileTreePath::parse("/w/acme.toml"), "a = 1")
guard @config.load(builder, tree) is Done(config) else {
fail("a complete tree never asks for files")
}
config.reports().map(report => report.kind.report_name())
}
@expect.expect(kinds(tool)).to_equal([
"global", "project", "local-override", "user-override",
])
@expect.expect(kinds(minimal)).to_equal(["project", "user-override"])
}/w/acme.toml <- workspace primary: members = ["packages/*"]
/w/acme.user.toml <- workspace user override
/w/packages/billing/acme.toml <- member primary
/w/packages/billing/acme.user.toml <- member user override, which wins
/w/packages/billing/src/ <- the working directory| Order | Member |
|---|---|
| 1 | The member that holds the working directory |
| 2 | The default member, when it is in the workspace and not excluded |
| 3 | The only member, when the member entries give one |
///|
test "the member file wins over the workspace file" {
let builder = @config.builder()
|> @config.working_dir("/w/packages/billing/src")
|> @config.format(@toml.format())
|> @conventions.add(@conventions.app_dirs("acme") |> @conventions.workspace)
let tree = @config.FileTree::new(complete=true)
.with_listing(@config.FileTreePath::parse("/w"), [
{ name: "acme.toml", kind: File },
{ name: "packages", kind: Directory },
])
.with_listing(@config.FileTreePath::parse("/w/packages"), [{ name: "billing", kind: Directory }])
.with_listing(@config.FileTreePath::parse("/w/packages/billing"), [
{ name: "acme.toml", kind: File },
{ name: "src", kind: Directory },
])
.with_listing(@config.FileTreePath::parse("/w/packages/billing/src"), [])
.with_file(@config.FileTreePath::parse("/w/acme.toml"),
"workspace.members = [\"packages/*\"]\nui.theme = \"light\"",
)
.with_file(@config.FileTreePath::parse("/w/packages/billing/acme.toml"), "ui.theme = \"dark\"")
guard @config.load(builder, tree) is Done(config) else {
fail("a complete tree never asks for files")
}
@expect.expect(@config.get_text(config, "ui.theme")).to_equal("dark")
let roles = config
.reports()
.filter(report => report.kind is File(Workspace | Member))
.map(report => (report.kind, report.priority, report.path))
@expect.expect(roles).to_equal([
(File(Workspace), 300, Some("/w/acme.toml")),
(File(Member), 350, Some("/w/packages/billing/acme.toml")),
])
}| Filter | Removes the keys from | Warning |
|---|---|---|
| member_scope_keys(paths) | The member primary and its environment file, local override and user override | KeyRemoved in the report of the source, when the key was set |
| inherited_filter(keys) | The system file, the global user file, and the workspace primary and its adjacent files, when a member is selected | None |
///|
test "a member cannot set a workspace key and does not inherit the root project" {
let conventions = @conventions.app_dirs("acme")
|> @conventions.workspace
|> @conventions.member_scope_keys(["workspace.out_dir"])
|> @conventions.inherited_filter(["project"])
let builder = @config.builder()
|> @config.working_dir("/w/app")
|> @config.format(@toml.format())
|> @conventions.add(conventions)
let tree = @config.FileTree::new(complete=true)
.with_listing(@config.FileTreePath::parse("/w"), [
{ name: "acme.toml", kind: File },
{ name: "app", kind: Directory },
])
.with_listing(@config.FileTreePath::parse("/w/app"), [{ name: "acme.toml", kind: File }])
.with_file(@config.FileTreePath::parse("/w/acme.toml"),
"workspace.members = [\"app\"]\nworkspace.out_dir = \"out\"\nproject.name = \"root\"",
)
.with_file(@config.FileTreePath::parse("/w/app/acme.toml"), "workspace.out_dir = \"mine\"")
guard @config.load(builder, tree) is Done(config) else {
fail("a complete tree never asks for files")
}
@expect.expect(@config.get_text(config, "workspace.out_dir")).to_equal("out")
@expect.expect(config.get(@config.KeyPath::parse("project.name"))).to_equal(
None,
)
let member_report = config
.reports()
.iter()
.find_first(report => report.kind == File(Member))
@expect.expect(member_report.map(report => report.warnings.length())).to_equal(
Some(1),
)
}///|
test "discovery asks for the listing of the working directory first" {
let builder = @config.builder()
|> @config.working_dir("/w/src")
|> @config.format(@toml.format())
|> @conventions.add(@conventions.app_dirs("acme"))
guard @config.load(builder, @config.FileTree::new()) is Need(requests) else {
fail("an empty tree needs a listing")
}
@expect.expect(requests).to_equal([
ListDir(@config.FileTreePath::parse("/w/src")),
ListDir(@config.FileTreePath::parse("/w/src/.acme")),
ListDir(@config.FileTreePath::parse("/w/src/.config/acme")),
])
}| Part | Value |
|---|---|
| base(name, prefix?, display?) | An empty convention set: no parts and no walk-up |
| app_dirs(name, prefix?, display?) | base with every standard part and walk-up |
| system_dir, user_config_dir, app_home | The system, user configuration, and application home directories |
| layouts | The three primary layouts |
| local_overrides, user_overrides | *.local.* and *.user.* files next to the project file |
| without(part) | Removes one Part: SystemDir, UserConfigDir, AppHome, Layouts, LocalOverrides, or UserOverrides |
| boundary(dir) | The walk-up stop; a relative dir joins the working directory |
| legacy(name, format~, decode~) | A legacy fallback file with its own format |
| environments(default?) | Turns on environment files, with an optional default name |
| workspace(members?, exclude?, default_member?, project_name?) | Turns on workspaces and member selection, with the key paths of the workspace values |
| member_scope_keys(paths) | Keys that only the workspace can set; a member layer loses them with a KeyRemoved warning |
| inherited_filter(keys) | Keys that a selected member does not inherit from the system, global and workspace layers |
| environment_name(conventions, options?, env_vars) | The environment name of the run, or None |
| check_environment_name(name, source~) | Checks a name with the environment name rules |
| add(builder, conventions, options?) | The provider that runs discovery during @config.load |
| prefix | The environment variable prefix; defaults to the name in upper case with - changed to _ |
| display | The name that the ambiguity message shows; defaults to the name |
| DiscoveryOptions | The inputs that a host gives from its arguments: environment, explicit primary, isolated mode, and project |
///|
test "load controls and a profile with recorded metadata" {
let tree = @config.FileTree::new(complete=true)
.with_listing(@config.FileTreePath::parse("/workspace"), [
{ name: "acme.toml", kind: File },
{ name: "acme.profile.metrics.toml", kind: File },
])
.with_file(@config.FileTreePath::parse("/workspace/acme.toml"),
"[profiles]\nactive=['production']\n[profiles.groups]\nproduction=['metrics']")
.with_file(@config.FileTreePath::parse("/workspace/acme.profile.metrics.toml"),
"telemetry=true")
let builder = @config.builder()
|> @config.working_dir("/workspace") |> @config.env_vars({})
|> @config.format(@toml.format())
|> @conventions.add(@conventions.base("acme") |> @conventions.layouts
|> @conventions.profiles(@profiles.Policy::new()))
guard @config.load(builder,tree) is Done(config) else { fail("complete tree") }
assert_true(config.profiles().contains(@config.ProfileName::parse("metrics")))
assert_eq(config.get(@config.KeyPath::parse("profiles")),None)
}pub struct Conventions {
// private fields
}fn Conventions::roots(self : Conventions, context : LoadContext, options? : DiscoveryOptions) -> LoadStep[RootEvidence] raise ConfigErrorpub(all) struct DiscoveryOptions {
environment : String?
config : SourceSelection[String]
isolated : Bool
project : String?
profiles : ProfileSelection
} derive(Eq, Debug)fn DiscoveryOptions::new(environment? : String, config? : SourceSelection[String], isolated? : Bool, project? : String, profiles? : ProfileSelection) -> DiscoveryOptionsimpl Show for EnvironmentNamepub struct FormatGroup {
// private fields
}fn FormatGroup::new(extensions : Array[Extension], supplemental? : SupplementalFile) -> FormatGroup raise ConfigErrorpub struct FormatLayers {
// private fields
}pub(all) enum MemberLoading {
Automatic
ExplicitOnly
}pub struct RootEvidence {
// private fields
}pub struct SupplementalFile {
// private fields
}fn SupplementalFile::new(name : String, extension : Extension, transform? : (ConfigValue) -> ConfigValue raise ConfigError) -> SupplementalFile raise ConfigErrorfn add(builder : ConfigBuilder, conventions : Conventions, options? : DiscoveryOptions) -> ConfigBuilderfn environment_name(conventions : Conventions, options? : DiscoveryOptions, env_vars : Map[String, String]) -> String? raise ConfigErrorfn legacy(conventions : Conventions, name : String, format~ : Format, decode~ : (ConfigValue) -> ConfigValue raise ConfigError) -> Conventionsfn workspace(conventions : Conventions, members? : String, exclude? : String, default_member? : String, project_name? : String) -> Conventions