Sign in

    #Conventions

    The conventions package (@conventions) finds an application's configuration files, so the application does not list paths itself. It plugs into the core builder as a source provider. Discovery is pure: it reads the file tree and the working directory that the run gives, never the disk or the process.

    Import moonrockz/moon-lander-config/conventions as @conventions. Each mbt check block on this page runs as a test with mise run test.

    [!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.

    app_dirs("acme") gives these sources, in merge order:

    PrioritySourceRoleFiles for acme with TOML on Linux
    100SystemSystem/etc/acme/acme.toml
    200Global userGlobal~/.config/acme/acme.toml or ~/.acme/acme.toml
    300ProjectWorkspaceacme.toml, .acme/acme.toml, or .config/acme/config.toml, found by walk-up
    350MemberMemberpackages/billing/acme.toml, when the workspace part selects a member
    375Environment fileEnvironmentFileacme.Production.toml next to the project file, when environments are on
    390Local overrideLocalOverrideacme.local.toml next to the project file
    400User overrideUserOverrideacme.user.toml next to the project file

    The environment-variable source (600) of the core comes after them, so a variable wins over every file.

    #Find a declared workspace

    Select WorkspaceFirst with a parsed section path to inspect documents while walking up. An empty workspace object counts as a declaration. Discovery stops at the nearest declaration, even when that workspace excludes the project. It does not inspect an ancestor above that workspace or the explicit boundary. If no workspace is found, the nearest primary is the project fallback. An examined parse failure or ambiguity remains an error.

    ExplicitOnly suppresses automatic single-member selection. An explicit project selector still applies. The pure roots method always returns root evidence without selecting a member. It uses the same Need/Done file-tree protocol as load; the host supplies any requested entries. The result exposes the workspace, nearest project and parsed primary sources, so an application can retain its own all-member walk.

    ///|
    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")
    }

    NearestFile and Automatic remain the defaults. Existing calls retain the nearest-file and enclosing-member rules below. A workspace section must be an object; a scalar has a typed decode failure.

    #Order formats at each stage

    Opt into FormatLayers to merge formats at one location. Later groups win; extensions inside a group are alternatives, so two existing ordinary files in that group are ambiguous, including files in different layouts. Every declared extension needs a registered parser. Registration order has no precedence effect, and registered extensions outside the policy are not searched.

    An optional SupplementalFile belongs to its group and loads before that group's ordinary primary. The application owns its file name and pure transformation. Discovery reuses the transformed value for workspace classification, profile controls, and the final source. A supplemental file can declare a workspace by itself; it does not create a new override or profile stem. Its name cannot collide with an enabled ordinary primary.

    ///|
    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)
    }

    Group order applies inside each enabled system, global, workspace, member, environment, local and user stage. Stage priorities stay as listed above: even the first member format wins over the last workspace format. Workspace classification uses the merged primary documents at that location. With profiles enabled, all eligible base documents supply one control policy with each control's own origin. For each active name, workspace groups precede member groups at priority 380, before the next active name. Isolated mode retains eligible profile files and skips machine, global, local and user sources.

    Omitting format_layers retains the ambiguity rules described below.

    #Walk up to the project root

    Discovery starts at the working directory of the run and moves to each parent. The first directory that holds a primary file is the project root. Its file becomes the project source, with the role Workspace and the priority 300.

    /w/acme.toml <- found: the project primary /w/src/ /w/src/nested/ <- the working directory

    A primary file has one of three layouts in a directory:

    LayoutExample for acme with TOML
    <app>.<ext>acme.toml
    .<app>/<app>.<ext>.acme/acme.toml
    .config/<app>/config.<ext>.config/acme/config.toml

    Two primary files in one directory, in one layout or in two, are an error, Ambiguous. Discovery never merges them or picks one, and it does not look further up.

    StepWhat discovery does
    StartThe working directory that @config.working_dir sets; it must be absolute
    Search<app>.<ext> for each extension of the registered formats, by exact name
    WalkEach parent directory, up to the file-system root
    StopAt the first directory with a primary; two primaries there are Ambiguous
    None foundThe report shows NotFound with the candidates at the start directory

    The file names come from the formats that the builder registers. Register only TOML, and discovery never finds acme.yaml, a file that the run could not parse.

    ///|
    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")),
    )
    }

    #Select a file above discovered primaries

    selected_file_policy(Overlay) keeps root and member discovery when the host supplies DiscoveryOptions(config=Explicit(path)). The selected file loads once at priority 325 with role Explicit: after all workspace/project formats and before member primaries. A discovered file moves to that position, so it has no second merge or report. Paths resolve against the supplied working directory; . and .. segments and Windows separators share one normalized identity. Its parser and any discovered supplemental transform run once per discovery pass. Missing or unreadable selected files fail visibly.

    ///|
    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])
    }

    The default Primary policy keeps the existing explicit-primary behavior: Explicit(path) supplies the project primary without walk-up. With Overlay, the selection does not determine workspace identity or select a different member. It does not add sibling-file layouts for an external file.

    When profiles are enabled, selecting a discovered base preserves its control authority and each control's original source origin at priority 300. Only its ordinary values move to 325. An external or member selection cannot supply controls; rejection occurs before key filters. Explicitly disabled processing strips controls from the selected file without interpreting them.

    #Stop boundary and isolated mode

    boundary(dir) stops the walk-up at dir: discovery looks in dir and never above it. A test sets the boundary to its temporary root, so a file above the root never leaks into the test. A working directory outside the boundary is an error, OutsideBoundary.

    ///|
    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))
    }

    Isolated mode comes from DiscoveryOptions(isolated=true), for example from an --isolated flag that a host parses. It skips the files of the user and the machine: the system directory, the global user directory, and the local and user overrides. Each of them reports Skipped. The project file, its environment file and the environment variables stay. The project option selects a workspace member, as Workspaces and members shows.

    #System and global user directories

    The system source and the global user source come from the platform and the variable set of the run, which @config.platform and @config.env_vars give. Discovery never reads the process environment. The host adapter @host.load fills both from the running system when the builder holds none. With no platform, the two sources report Skipped and discovery adds a Note warning.

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

    • The file name in each directory is <app>.<ext>.
    • An empty variable counts as unset. XDG_CONFIG_HOME counts only when it is absolute, and it has no effect on Windows.
    • The user configuration directory and the application home are one global source. Two global files are Ambiguous.
    • On Windows, \ becomes / in each variable. An absolute Windows path starts with a drive and a separator (C:/) or with //server/share; C:foo is not absolute. A relative value joins the working directory.

    ///|
    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"]),
    )
    }

    #Overrides next to the project file

    A local override is for one checkout, and git ignores it. A user override is the preference of one person, so it wins over the local override. Both sit in the directory of the project file:

    Project fileLocal overrideUser override
    acme.tomlacme.local.tomlacme.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

    The extensions come from the registered formats, so acme.user.yaml can override acme.toml. Two overrides of one kind, such as acme.user.toml and acme.user.yaml, are Ambiguous. A legacy file has no overrides, and with no project file there is no override source.

    ///|
    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")
    }

    #An explicit project file

    DiscoveryOptions(config=Explicit(path)), for example from a --config flag, names the project file. Discovery does not walk up. A relative path joins the working directory, and on Windows \ becomes /. The source has the role Explicit, and a missing file is an error, FileNotFound. When the path has a standard layout, such as configs/acme.toml, its local and user overrides apply. Other files, such as configs/project.yaml, have no overrides. Skip skips the project file.

    #Environments

    An environment file holds the values of one environment, such as Production, next to the project file. Environments are off until the application turns them on with environments(default?). The name of the run comes from the first of these sources that gives one:

    OrderSourceExample
    1The environment value of DiscoveryOptionsA host fills it from --environment Staging
    2<PREFIX>_ENVIRONMENT in the variable set of the runACME_ENVIRONMENT=Production; an empty value counts as unset
    3The default of environmentsenvironments(default="Development")

    With no name, no environment file loads. With environments off, a given environment option adds a Note warning. Discovery never reads the process arguments, so a host parses --environment and gives the value as the option.

    The environment file has the priority 375: it wins over the project file, and the local and user overrides win over it. It follows the layout of the project file:

    Project fileEnvironment file for Production
    acme.tomlacme.Production.toml
    .acme/acme.toml.acme/acme.Production.toml
    .config/acme/config.toml.config/acme/config.Production.toml

    • The name matches exactly, so acme.production.toml is not the file for Production.
    • An environment file alone does not mark a project root.
    • Two environment files for one project file, such as acme.Production.toml and acme.Production.yaml, are Ambiguous.
    • The system file, the global user file, a legacy file and an explicit file outside the standard layouts have no environment file.

    A name must not be empty, must not contain /, \, . or a control character, and must not be user or local in any letter case, so an override is never an environment file. A bad name raises InvalidEnvironmentName, which names the source of the name. environment_name gives the name of the run with the same rules, and check_environment_name checks a name from another source.

    An application without a host gives the environment-variable source the reserved names <PREFIX>_ENVIRONMENT and <PREFIX>_HOME itself. These variables select files, so they must not become configuration keys:

    ///|
    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,
    )
    }

    #Choose the parts

    app_dirs uses every standard part. without(part) removes one, and base with single parts gives full control:

    ///|
    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"])
    }

    #Workspaces and members

    workspace() turns on workspaces. A project file that sets workspace.members or workspace.default_member is a workspace, and each member directory has its own primary file. The member primary loads after the workspace primary, with the role Member and the priority 350. Then the adjacent files load in pairs, the workspace file first: the environment files, the local overrides, and the user overrides.

    /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

    The walk-up finds the nearest primary first. Then discovery looks from the parent of that root up to the boundary for a workspace that has the root as a member. It passes over a directory with no file, with two files, with a file that does not parse, or with a file that is not a workspace. With no enclosing workspace, the found file is the project primary, and when it is a workspace, discovery selects a member in this order:

    OrderMember
    1The member that holds the working directory
    2The default member, when it is in the workspace and not excluded
    3The only member, when the member entries give one

    With none of them, no member source loads.

    A member entry is a path relative to the workspace root, with / between segments. * matches any run of characters in one segment, and ** matches zero or more segments. Other characters, such as ? and [, match themselves. A literal entry, such as members/alpha, names its directory whether or not it exists, so a missing member reports NotFound with its candidates. A wildcard entry gives only the directories that hold a primary or legacy file, and it never enters a hidden directory. An entry that is absolute, starts with a drive, holds \, or holds a .. segment gives a Note warning, and discovery skips it. The workspace root is never its own member.

    The members, exclude entries and default member come from the system file, the global user file and the workspace primary, merged. workspace(members?,exclude?, default_member?, project_name?) gives other key paths.

    ///|
    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")),
    ])
    }

    DiscoveryOptions(project=selector), for example from a --project flag, selects a project by name or path. . selects the root project. Then the selector matches a member path, such as packages/billing. Then it matches the project name of the root or of a member primary. No match is an error, UnknownProject. Two name matches are an error, AmbiguousProject, and the message asks for a member path. Without the workspace part, a given project option adds a Note warning.

    #Key filters

    Two filters remove keys from layers through the core exclude. A removed value has no origin.

    FilterRemoves the keys fromWarning
    member_scope_keys(paths)The member primary and its environment file, local override and user overrideKeyRemoved 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 selectedNone

    Use member_scope_keys for a key that only the workspace can set, such as an output directory. Use inherited_filter for the tables that describe the root project, so that a member does not inherit them.

    ///|
    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),
    )
    }

    #Legacy fallback

    legacy(name, format~, decode~) names an older file, for example acme.json. At a location with no primary file, the legacy file is the primary and marks the project root. Its format parses it but is not registered, so .json never becomes a searched extension, and decode turns the parsed value into a layer.

    #One directory at a time

    With an incomplete tree, discovery asks for the listings of one directory per step, nearest first: the directory itself, .<app>/, and .config/<app>/. It stops asking once it finds the root, so it never lists more parents than it needs. A host answers each request and calls @config.load again.

    ///|
    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")),
    ])
    }

    #Names

    PartValue
    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_homeThe system, user configuration, and application home directories
    layoutsThe 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
    prefixThe environment variable prefix; defaults to the name in upper case with - changed to _
    displayThe name that the ambiguity message shows; defaults to the name
    DiscoveryOptionsThe inputs that a host gives from its arguments: environment, explicit primary, isolated mode, and project

    #Compose configuration profiles

    Enable profiles explicitly. They use files such as acme.profile.metrics.toml, independently of acme.Production.toml. Includes precede the selected roots; groups expand in order, retain the group name, and keep each name's last occurrence. Profile layers use priority 380, after environment files and before local and user overrides. Missing files leave active code-only markers.

    ///|
    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)
    }

    Selection precedence is discovery options, <PREFIX>_PROFILES, base active, base default, then policy fallback. Use Selected(ProfileNames::new([])) to suppress defaults while keeping includes; use Disabled to suppress both. A present empty selector variable also disables profiles. Standalone environment sources must reserve that exact variable, for example @config.env(prefix="ACME", reserved=["ACME_PROFILES"]).

    Only the standard-layout workspace primaries, or project primaries without a workspace, supply controls. Ordered formats and their supplemental documents form one base; each winning control retains its supplying source. Selected member and overlay controls fail before key filters. Enabled controls stay out of application values and provenance; Config::profiles() gives the recorded selector, active order and reasons. The profile policy guide covers names, groups and limits. Existing applications keep their behavior until they enable profiles.

    Conventions

    pub struct Conventions {
    // private fields
    }

    An immutable convention set: the application name, the variable prefix, the display name, the standard parts to use, the walk-up rule, the stop boundary, the legacy fallback file, the environment opt-in, the workspace keys, and the key filters.

    Conventions::display

    fn Conventions::display(self : Conventions) -> String

    The display name, which the ambiguity message shows.

    Conventions::name

    fn Conventions::name(self : Conventions) -> String

    The application name.

    Conventions::prefix

    fn Conventions::prefix(self : Conventions) -> String

    The prefix of the application's environment variables.

    Conventions::profiles_enabled

    fn Conventions::profiles_enabled(self : Conventions) -> Bool

    Whether this convention set reserves profile controls and its selector.

    Conventions::roots

    Inspect roots through the same partial-tree protocol as configuration load. No member is selected. Pass the returned primary sources to public merge APIs and retain an application-owned all-member walk when needed.

    DiscoveryOptions

    pub(all) struct DiscoveryOptions {
    environment : String?
    config :
    SourceSelection
    [String]
    isolated : Bool
    project : String?
    profiles :
    ProfileSelection

    } derive(Eq,
    Debug
    )

    The discovery inputs that a host gives from its arguments. Every field defaults to "not given". Discovery never reads the arguments itself.

    DiscoveryOptions::equal

    DiscoveryOptions::new

    fn DiscoveryOptions::new(environment? : String, config? :
    SourceSelection
    [String], isolated? : Bool, project? : String, profiles? :
    ProfileSelection
    ) -> DiscoveryOptions

    Options with every field not given.

    DiscoveryOptions::not_equal

    fn DiscoveryOptions::not_equal(x : DiscoveryOptions, y : DiscoveryOptions) -> Bool

    EnvironmentName

    pub struct EnvironmentName {
    // private fields
    } derive(Eq,
    Debug
    )

    An environment name that follows the conventions rules. It is not empty, holds no /, \, . or control character, and is not user or local in any letter case. The text and its letter case stay as given.

    EnvironmentName::equal

    EnvironmentName::not_equal

    fn EnvironmentName::not_equal(x : EnvironmentName, y : EnvironmentName) -> Bool

    EnvironmentName::output

    fn EnvironmentName::output(self : EnvironmentName, logger : &Logger) -> Unit

    EnvironmentName::parse

    Parse an environment name. A broken rule raises InvalidEnvironmentName with the input and the reason. source names the input boundary and defaults to the builder default for names parsed by the caller.

    EnvironmentName::to_string

    fn EnvironmentName::to_string(self : EnvironmentName) -> String

    Extension

    pub struct Extension {
    // private fields
    } derive(Eq, Hash,
    Debug
    )

    A nonempty file extension, without a dot or path separator.

    Extension::equal

    fn Extension::equal(Extension, Extension) -> Bool

    Extension::hash

    fn Extension::hash(self : Extension) -> Int

    Extension::hash_combine

    fn Extension::hash_combine(Extension, Hasher) -> Unit

    Extension::not_equal

    fn Extension::not_equal(x : Extension, y : Extension) -> Bool

    Extension::parse

    Parse ASCII letters, digits, underscores and hyphens. Case is preserved and must match a registered format's extension.

    Extension::to_string

    fn Extension::to_string(self : Extension) -> String

    The extension without a dot.

    FormatGroup

    pub struct FormatGroup {
    // private fields
    }

    Alternatives at one precedence step. At most one ordinary candidate may exist across these extensions and layouts. A supplemental source is below it.

    FormatGroup::extensions

    fn FormatGroup::extensions(self : FormatGroup) -> Array[Extension]

    A copy of the alternatives in this group.

    FormatGroup::new

    Make a nonempty group with unique extensions. Its optional supplemental source must use one of those extensions.

    FormatLayers

    pub struct FormatLayers {
    // private fields
    }

    Explicit precedence order with each extension in one group. Later groups win shared keys. Parser registration order does not select precedence.

    FormatLayers::new

    Make a nonempty order. Repeated extensions, also across groups, are refused.

    MemberLoading

    pub(all) enum MemberLoading {
    Automatic
    ExplicitOnly
    }

    Whether discovery selects and loads one workspace member.

    Part

    pub(all) enum Part {
    SystemDir
    UserConfigDir
    AppHome
    Layouts
    LocalOverrides
    UserOverrides
    } derive(Eq,
    Debug
    )

    A standard part of a convention set, which without can remove.

    Part::equal

    fn Part::equal(Part, Part) -> Bool

    Part::not_equal

    fn Part::not_equal(x : Part, y : Part) -> Bool

    Part::to_repr

    RootEvidence

    pub struct RootEvidence {
    // private fields
    }

    Root evidence from a pure discovery pass. A nearest project is the first primary encountered, including a primary that also declares a workspace.

    RootEvidence::nearest_project

    The nearest primary's project directory, when one exists.

    RootEvidence::sources

    The selected root's primary sources, already parsed, in merge order. This does not choose a member or read any member's contents.

    RootEvidence::workspace

    The selected declared workspace directory, when one exists.

    RootPolicy

    pub(all) enum RootPolicy {
    NearestFile
    WorkspaceFirst(
    KeyPath
    )
    }

    The root selection rule. The section path is parsed once at the boundary.

    SelectedFilePolicy

    pub(all) enum SelectedFilePolicy {
    Primary
    Overlay
    } derive(Eq,
    Debug
    )

    How an explicit config selection participates in discovery.

    SelectedFilePolicy::equal

    SelectedFilePolicy::not_equal

    SupplementalFile

    pub struct SupplementalFile {
    // private fields
    }

    One application-owned source below the ordinary primary of its format group. Its transform must be pure. Discovery reuses its parsed value for classification and the final source, rather than running it twice.

    SupplementalFile::name

    fn SupplementalFile::name(self : SupplementalFile) -> String

    The validated file name.

    SupplementalFile::new

    Parse one file name with a declared extension. Paths, . and .. are refused.

    add

    Add the convention set to a core builder as a source provider. During @config.load, discovery gives its sources at its position in merge order: the system source (priority 100), the global user source (200), the project or workspace primary with the role Workspace, Legacy, or Explicit (300), the selected member primary with the role Member (350), the environment files (375), profile files (380), local overrides (390), and user overrides (400). Each kind of adjacent file loads for the workspace, then for the member, for each active name in the profile stage.

    Discovery reads isolated, config, environment, profiles and project from options. In isolated mode the system, global user, and override sources report Skipped. Explicit(path) is the project primary with no walk-up, and Skip skips it. project selects a workspace member by its path or by its project name. With environments off, a given environment adds a Note warning, and with no workspace part a given project does too.

    app_dirs

    fn app_dirs(name : String, prefix? : String, display? : String) -> Conventions

    The standard convention set: base with every standard part (the system directory, the global user directory, the application home, the three primary layouts, local overrides, and user overrides) and walk-up from the working directory.

    app_home

    fn app_home(conventions : Conventions) -> Conventions

    Add the application home: $<PREFIX>_HOME, else .<app> in the user home ($HOME, or %USERPROFILE% on Windows). The candidates are <app>.<ext>. The application home and the user configuration directory are one global source, so two global files are Ambiguous.

    base

    fn base(name : String, prefix? : String, display? : String) -> Conventions

    An empty convention set: no parts, no walk-up, no environments and no workspace. prefix defaults to the name in upper case with - changed to _, and display to the name.

    boundary

    fn boundary(conventions : Conventions, dir : String) -> Conventions

    Stop the walk-up at dir: discovery looks in dir and never above it. A relative dir joins the working directory. A working directory outside dir raises OutsideBoundary.

    check_environment_name

    fn check_environment_name(name : String, source~ : String) -> Unit raise
    ConfigError

    Check name with the environment name rules: it is not empty, it holds no /, \, . or control character, and it is not user or local in any letter case. A broken rule raises InvalidEnvironmentName with a reason that names source, for example --environment.

    environment_name

    fn environment_name(conventions : Conventions, options? : DiscoveryOptions, env_vars : Map[String, String]) -> String? raise
    ConfigError

    The environment name of the run, or None when environments are off or no source gives a name. The sources, from high to low: the environment value of options, <PREFIX>_ENVIRONMENT in env_vars (an empty value counts as unset), and the default of environments. A name that breaks the name rules raises InvalidEnvironmentName, which names the source of the name.

    environments

    fn environments(conventions : Conventions, default? : String) -> Conventions

    Turn on environments: environment files such as <app>.Production.<ext> next to each project-tier primary. The name comes from the environment option, then from <PREFIX>_ENVIRONMENT in the variable set of the run, then from default. With none of them, no environment file loads.

    format_layers

    fn format_layers(conventions : Conventions, layers : FormatLayers) -> Conventions

    Select ordered format layers. Omitting this step keeps the existing cross-format ambiguity rule. Every extension must have a registered parser.

    inherited_filter

    fn inherited_filter(conventions : Conventions, keys : Array[String]) -> Conventions

    Remove the keys keys from the layers that a selected member inherits: the system file, the global user file, the workspace primary and its environment and profile files, local override and user override. The removal gives no warning. With no selected member, every layer stays complete. Each key is the text form of a key path, usually a top-level key such as project.

    layouts

    fn layouts(conventions : Conventions) -> Conventions

    Add the three primary layouts: <app>.<ext>, .<app>/<app>.<ext>, and .config/<app>/config.<ext>.

    legacy

    Add a legacy fallback file, for example acme.json. At a location with no primary file, the legacy file is the primary and marks the project root. format parses it and is not registered, so its extensions never join the searched extensions. decode turns the parsed value into a layer.

    local_overrides

    fn local_overrides(conventions : Conventions) -> Conventions

    Add local overrides: <app>.local.<ext> next to a primary in the <app>.<ext> or .<app>/<app>.<ext> layout, and config.local.<ext> next to .config/<app>/config.<ext>, with the role LocalOverride and the priority 390.

    member_loading

    fn member_loading(conventions : Conventions, loading : MemberLoading) -> Conventions

    Select automatic single-member loading or leave the member walk to the caller.

    member_scope_keys

    fn member_scope_keys(conventions : Conventions, paths : Array[String]) -> Conventions

    Remove the key paths paths from each member layer: the member primary and its environment and profile files, local override and user override. Only the workspace can set them. A removed key gives a KeyRemoved warning in the report of that source, and it has no origin. Each path is the text form of a key path, for example workspace.out_dir.

    profiles

    Enable independent configuration profiles with an immutable policy.

    root_policy

    fn root_policy(conventions : Conventions, policy : RootPolicy) -> Conventions

    Set the root selection policy. NearestFile retains the legacy discovery behavior. WorkspaceFirst inspects documents up to the first declared section.

    selected_file_policy

    fn selected_file_policy(conventions : Conventions, policy : SelectedFilePolicy) -> Conventions

    Select explicit-primary compatibility or an overlay above base primaries and below members. Overlay paths resolve against the supplied working directory.

    system_dir

    fn system_dir(conventions : Conventions) -> Conventions

    Add the system directory: /etc/<app> on Linux and macOS, and %PROGRAMDATA%/<app> on Windows, or C:/ProgramData/<app> when PROGRAMDATA is not set. The candidates are <app>.<ext>.

    user_config_dir

    fn user_config_dir(conventions : Conventions) -> Conventions

    Add the global user configuration directory: $XDG_CONFIG_HOME when it is absolute, else $HOME/.config on Linux and $HOME/Library/Application Support on macOS, and %APPDATA% on Windows. The candidates are <app>/<app>.<ext>.

    user_overrides

    fn user_overrides(conventions : Conventions) -> Conventions

    Add user overrides: <app>.user.<ext> next to a primary in the <app>.<ext> or .<app>/<app>.<ext> layout, and config.user.<ext> next to .config/<app>/config.<ext>, with the role UserOverride and the priority 400.

    without

    fn without(conventions : Conventions, part : Part) -> Conventions

    Remove one standard part, for example without(SystemDir).

    workspace

    fn workspace(conventions : Conventions, members? : String, exclude? : String, default_member? : String, project_name? : String) -> Conventions

    Turn on workspaces. A project file whose value sets members or default_member is a workspace. Its members are the directories that the member entries select, without the entries of exclude. Each argument is a key path in text form. Discovery reads the members, the exclude entries and the default member from the merged value of the system file, the global user file and the workspace primary, and the project name from the same value or from the primary of a member.

    In an entry, * matches any run of characters in one segment, ** matches zero or more whole segments, and every other character matches itself. An absolute entry, an entry with a drive, a \ or a .. segment gives a Note warning, and discovery skips it. The workspace root is never its own member.