Skip to content

Routing

It is not uncommon for the same config file to be placed in different places depending on the environment. For example, the config file for psql, a client of PostgreSQL, has the following different paths depending on the environment, according to documentation:

  • POSIX: If the PSQLRC environment variable is present, use its value as the path; if not, use ~/.psqlrc.
  • Windows: If the PSQLRC environment variable is present, use its value as the path; otherwise, use %APPDATA%\postgresql\psqlrc.conf.

Dojang utilizes a concept called routing to deal with such complex cases.

Directory Routing

Many programs store their config files in $XDG_CONFIG_HOME on Linux, ~/Library/Application Support on macOS, and %AppData% or %LocalAppData% on Windows.1 So if you want to use the same config file in multiple environments, it's convenient to route those paths together. The declaration below shows an example of this.

[dirs.app_config]
linux = "$XDG_CONFIG_HOME"
mac = "$HOME/Library/Application Support"
win = "$AppData"

[monikers.linux]
os = "linux"

[monikers.mac]
os = "macos"

[monikers.win]
os = "windows"

Here, the routing name, app_config, is also the name of a directory within the repository. Route names must be relative paths within the repository. Absolute paths and names containing a .. component are rejected, because they could select a source outside the repository. Drive-qualified names such as C:config and root-relative Windows names such as \config are also rejected on every platform. This keeps a shared manifest safe when it is checked out on Windows. Route names must also remain unique after path normalization, so a manifest cannot define both config and ./config. If we say that the files in the repository are as follows:

  • dojang.toml
  • app_config/
    • alacritty/
      • alacritty.toml
    • ghc/
      • ghci.conf
    • nvim/ init.vim

On Linux, you will have files in the following paths:

  • /home/$USER/.config/alacritty/
  • /home/$USER/.config/alacritty/alacritty.toml
  • /home/$USER/.config/ghc/
  • /home/$USER/.config/ghc/ghci.conf
  • /home/$USER/.config/nvim/
  • /home/$USER/.config/nvim/init.vim

However, what if the XDG_CONFIG_HOME environment variable is not defined? The app_config routing on Linux would be an empty path, i.e. the current working directory (CWD) at the time you run Dojang. If you want to prevent this, you can tweak the routing a bit, as shown below.

linux = "${XDG_CONFIG_HOME:-$HOME/.config}"

The syntax above means that if the XDG_CONFIG_HOME environment variable exists, take its value, but otherwise take the path ~/.config. For more advanced features like this, see the file path expressions documentation.

Ignoring irrelevant files

In some cases, like your home directory, where you might have a lot of stuff besides your config file, the results of dojang status can be overwhelming and difficult to see. In addition, too many files can make it slow.

To avoid this, you can specify which files to ignore in the ignores section. For example, suppose you added a home route like below:

[dirs.home]
linux = "$HOME"
mac = "$HOME"
win = "$UserProfile"

Now, any files inside the directory that the home routes to will appear in the results of dojang status. However, you may want to ignore the Documents directory among them. In this case, you can specify the files you want to ignore in the ignores section, as shown below:

[ignores]
home = [
  "Documents",
]

In practice, there are much more files in the home directory to ignore than not, so it's usually more convenient to just ignore everything:

[ignores]
home = ["*"]

Even if we ignore all the files like this, we can still reflect them by specifying them one by one with the dojang reflect -f command. Once added to the repository, files are managed even if they are on the ignore list.

In case of overlapping routing

Let's say you have routed app_config to XDG_CONFIG_HOME on Linux, which is ~/.config, as in the example above, and you want to add a route to your home directory:

[dirs.home]
linux = "$HOME"
mac = "$HOME"
win = "$UserProfile"

In most cases, the directory where home is routed will also contain the directory where app_config is routed. It's easy to make mistakes when dealing with overlapping directories in two or more routes like this, so you should have one side ignore the other, like below:

[ignores].
home = [
  ".config",
  "AppData/Roaming",
  "Library/Application Support"
]

Per-file routing

For some config files, not only the directory but also the filename itself can vary depending on the environment, and it can be simpler to route them on a per-file basis. For example, the psql config file, which we used as an example at the beginning of this document, could be routed on a per-file basis, as shown below:

[files.".psqlrc"]
posix = "${PSQLRC:-$HOME/.psqlrc}"
win = "${PSQLRC:-${AppData:-$UserProfile/AppData/Roaming}/postgresql/psqlrc.conf}"

[monikers.posix]
os = ["linux", "macos"]

[monikers.win]
os = "windows"

Inline route conditions

The compact syntax above is useful when a route can be expressed entirely with named monikers. A detailed route can instead place an environment predicate directly in a branch's when field:

[[files.".psqlrc"]]
moniker = "posix"
path = "${PSQLRC:-$HOME/.psqlrc}"

[[files.".psqlrc"]]
when = "os = windows && arch = aarch64"
path = "${PSQLRC:-$UserProfile/AppData/Roaming/postgresql/psqlrc.conf}"

[[files.".psqlrc"]]
when = "os = android"

Detailed routes are ordered arrays. Every branch must contain exactly one of moniker or when, and may contain a path. A moniker branch refers to a moniker defined in the same manifest. A when branch accepts the full environment predicate syntax. If path is omitted, the branch is a null route. An explicit path = "" is instead an empty path expression.

Branches are tried in their listed order, and the first matching branch determines the destination. This order is explicit: a later branch does not move ahead merely because its predicate is more specific. Compact routes use the specificity rules described below instead.

Use the detailed form when an inline predicate is clearer, when conditions would map to the same moniker, or when repeated branches must remain distinct. The compact form remains valid and is preferable for simple moniker-only routes.

Route priority

When a compact route has multiple entries that could match the current environment, Dojang determines which one to use based on specificity. The more specific a predicate is, the higher its priority. This ensures that specialized configurations take precedence over general ones. Detailed routes use their explicit branch order instead, as described above.

How specificity is calculated

Specificity is measured by how many conditions must be satisfied for a predicate to match. Here are the key rules:

  • Single conditions (like os = linux or arch = "x86_64") have a specificity of 1.

  • And (&&) combines the specificities of its parts. For example, os = linux && arch = "x86_64" has a specificity of 2 because both conditions must be satisfied.

  • Or (||) takes the maximum specificity among its parts. For example, os = linux || os = macos has a specificity of 1 because only one condition needs to match.

  • Monikers inherit the specificity of the predicate they resolve to. A moniker defined as os = linux && arch = "x86_64" has the same specificity as writing that expression directly.

  • always has a specificity of 0, meaning it matches everything but with the lowest priority.

Examples

Consider a manifest with the following routes for a config file:

[files.".bashrc"]
apple-silicon = "~/Library/bashrc"
posix = "$HOME/.bashrc"
linux = "$HOME/.bashrc.linux"

[monikers.apple-silicon]
when = "os = macos && arch = aarch64"

[monikers.posix]
when = "os in (linux, macos)"

[monikers.linux]
when = "os = linux"

In this configuration:

  • apple-silicon has specificity 2 (two conditions with &&)
  • posix has specificity 1 (single in check)
  • linux has specificity 1 (single = check)

On an Apple Silicon Mac (macOS with aarch64 architecture), both apple-silicon and posix would match. However, apple-silicon wins because it has higher specificity (2 > 1).

On an Intel Mac (macOS with x86_64 architecture), only posix matches.

On Linux, both posix and linux match with equal specificity. In this case, the route order in the manifest becomes the tiebreaker—whichever appears first in the predicate comparison takes priority.

Practical tips

  • Start general, add specific: Define broad routes first (like posix for Unix-like systems), then add more specific ones (like linux or apple-silicon) that override for particular environments.

  • Use && for specialization: When you need environment-specific behavior, combine conditions with && to create more specific predicates that take priority.

  • Avoid overlapping || routes: Routes using || don't increase specificity much. If you find yourself with ambiguous matches, consider restructuring with && conditions instead.

A route normally copies its source to the destination, and changes made at the destination can be reflected back into the repository. A route branch with kind = "symlink" instead deploys a deployment link: a symbolic link at the destination pointing at the absolute path of the route's source inside the repository checkout.

[[dirs.nvim]]
moniker = "posix"
path = "$XDG_CONFIG_HOME/nvim"
kind = "symlink"

A deployment link is intentionally one-way. Because the destination reads through to the repository source, editing the destination is editing the source, and there is nothing to reflect: dojang reflect refuses deployment links categorically, including with --force and for paths under a linked directory. dojang apply creates a missing link, repairs one whose target no longer matches the source (for example after the repository checkout moved), and treats an existing regular file or directory at the destination as a conflict unless --force replaces it.

A directory deployed as a link is also a traversal boundary: no other route may route into it, and broader routes never read or write through it.

Route ownership

Every destination entry is owned by exactly one route. When the destinations of two routes nest — for example a directory route for $XDG_CONFIG_HOME and a more specific route for $XDG_CONFIG_HOME/nvim — the route with the more specific destination owns its subtree, and the broader route neither reads nor writes entries inside it, including during removals.

Route configurations that cannot provide unambiguous ownership are rejected before anything is mutated, with exit code 38:

  • two active routes expanding to the same destination path, lexically or after resolving symbolic links;
  • a route whose destination lies inside another route's deployment link;
  • a route whose destination overlaps the repository checkout itself, or aliases its own source through symbolic links.

Null routing

Some config files may not exist or be needed in some environments. In these cases, you can choose not to route them at all, which is called null routing. For example, if you have a config file that is only needed on Windows, you can route it like below:

[files."Microsoft.WindowsTerminal.json"]
win = "$LocalAppData/Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json"

The above config file is only required on Windows, so you won't have it at all on Linux or macOS.


  1. For example, in the cross-platform app framework Electron, the app.getPath("appData") API returns different paths depending on the environment. In the same manner, GLib's get_user_config_dir() API behaves similarly.