Only download necessary toolchains (#16)

Fixes #13.

We now create a repo per 'environment' (os/cpu combo) in addition to the hub repo, and then refer to the downloads of those repos from the hub.
diff --git a/multitool/private/env_repo_template/BUILD.bazel.template b/multitool/private/env_repo_template/BUILD.bazel.template
new file mode 100644
index 0000000..14f9e5b
--- /dev/null
+++ b/multitool/private/env_repo_template/BUILD.bazel.template
@@ -0,0 +1 @@
+# generated by multitool
diff --git a/multitool/private/env_repo_template/tools/BUILD.bazel.template b/multitool/private/env_repo_template/tools/BUILD.bazel.template
new file mode 100644
index 0000000..14f9e5b
--- /dev/null
+++ b/multitool/private/env_repo_template/tools/BUILD.bazel.template
@@ -0,0 +1 @@
+# generated by multitool
diff --git a/multitool/private/env_repo_tool_template/BUILD.bazel.template b/multitool/private/env_repo_tool_template/BUILD.bazel.template
new file mode 100644
index 0000000..d2f7a23
--- /dev/null
+++ b/multitool/private/env_repo_tool_template/BUILD.bazel.template
@@ -0,0 +1,3 @@
+# generated by multitool
+
+exports_files(glob(include=["*_executable"]))
diff --git a/multitool/private/tool_template/BUILD.bazel.template b/multitool/private/hub_repo_tool_template/BUILD.bazel.template
similarity index 81%
rename from multitool/private/tool_template/BUILD.bazel.template
rename to multitool/private/hub_repo_tool_template/BUILD.bazel.template
index 0165c6d..112b679 100644
--- a/multitool/private/tool_template/BUILD.bazel.template
+++ b/multitool/private/hub_repo_tool_template/BUILD.bazel.template
@@ -2,8 +2,6 @@
 
 load(":tool.bzl", "tool")
 
-exports_files(glob(include=["*_executable"]))
-
 toolchain_type(
     name = "toolchain_type",
     visibility = ["//:__subpackages__"],
diff --git a/multitool/private/tool_template/tool.bzl.template b/multitool/private/hub_repo_tool_template/tool.bzl.template
similarity index 91%
rename from multitool/private/tool_template/tool.bzl.template
rename to multitool/private/hub_repo_tool_template/tool.bzl.template
index 5ab6394..c89e8fb 100644
--- a/multitool/private/tool_template/tool.bzl.template
+++ b/multitool/private/hub_repo_tool_template/tool.bzl.template
@@ -15,7 +15,7 @@
 def _declare_toolchain(name, os, cpu):
     toolchain_info(
         name = "{name}_{os}_{cpu}_toolchain_info".format(name=name, os=os, cpu=cpu),
-        executable = "//tools/{name}:{os}_{cpu}_executable".format(name=name, os=os, cpu=cpu),
+        executable = "@@{hub_name}.{os}_{cpu}//tools/{name}:{os}_{cpu}_executable".format(name=name, os=os, cpu=cpu),
         os = os,
         cpu = cpu,
     )
diff --git a/multitool/private/multitool.bzl b/multitool/private/multitool.bzl
index 10b08f9..aeb258b 100644
--- a/multitool/private/multitool.bzl
+++ b/multitool/private/multitool.bzl
@@ -1,30 +1,64 @@
-"multitool hub implementation"
+"""
+multitool
 
-_HUB_TEMPLATE = "//multitool/private:hub_repo_template/{filename}.template"
-_TOOL_TEMPLATE = "//multitool/private:tool_template/{filename}.template"
+Multitool takes as input a JSON lockfile and emits the following repos:
 
-def _render_hub(rctx, filename, substitutions = None):
-    rctx.template(
-        filename,
-        Label(_HUB_TEMPLATE.format(filename = filename)),
-        substitutions = substitutions or {},
-    )
+ - [hub].[os]_[cpu], for each [os]/[cpu] combo in _SUPPORTED_ENVS:
+     This repository holds os/cpu specific binaries for all tools in the provided
+     lockfile(s) and constructs clean symlinks to their content for inclusion in
+     toolchains defined in the [hub] repo.
 
-def _render_tool(rctx, tool_name, filename, substitutions = None):
-    rctx.template(
-        "tools/{tool_name}/{filename}".format(tool_name = tool_name, filename = filename),
-        Label(_TOOL_TEMPLATE.format(filename = filename)),
-        substitutions = {
-            "{name}": tool_name,
-        } | (substitutions or {}),
-    )
+     The structure of this repo is, very simply:
+       tools/
+         [tool-name]/
+           BUILD.bazel            (export all *_executable files)
+           [os]_[cpu]_executable  (a downloaded file or a symlink to a file in a
+                                   downloaded and extracted archive)
+
+ - [hub]:
+     This repository holds toolchain definitions for all tools in the provided
+     lockfile(s), as well as an executable tool target that will pick the
+     appropriate toolchain.
+
+     The structure of this repo is:
+       toolchains/
+         BUILD.bazel      (a single file containing all declared toolchains for easy registration)
+       tools/
+         [tool-name]/
+           BUILD.bazel    (declares the toolchain_type and the executable tool target)
+           tool.bzl       (scaffolding for the tool target and toolchain declarations in toolchains/BUILD.bazel)
+       toolchain_info.bzl (common scaffolding for toolchain declarations)
+
+       (additional BUILD.bazel and a WORKSPACE file are included as required by Bazel)
+
+To keep things orderly, we keep all the toolchain Bazel goo in the [hub] repo and only stash
+the binaries in the [hub].[os]_[cpu] repos. It's a conscious decision not to place some fragments
+of the toolchain definitions in the latter repos to make the dependencies run exactly one way:
+[hub] -> [hub].[os]_[cpu].
+
+This implementation depends on rendering a number of templates, which are defined in sibling
+folders and managed by the templates starlark file.
+
+To maintain support both bzlmod and non-bzlmod setups, we provide two entrypoints to the rule:
+ - (bzlmod)     hub       : invoked by the hub tag in extension.bzl
+ - (non-bzlmod) multitool : invoked in WORKSPACE or related macros, and additionally registers toolchains
+"""
+
+load(":templates.bzl", "templates")
+
+_SUPPORTED_ENVS = [
+    ("linux", "arm64"),
+    ("linux", "x86_64"),
+    ("macos", "arm64"),
+    ("macos", "x86_64"),
+]
 
 def _check(condition, message):
     "fails iff condition is False and emits message"
     if not condition:
         fail(message)
 
-def _multitool_hub_impl(rctx):
+def _load_tools(rctx):
     tools = {}
     for lockfile in rctx.attr.lockfiles:
         # TODO: validate no conflicts from multiple hub declarations and/or
@@ -33,15 +67,33 @@
         #  (this is also a very naive merge at the tool level)
         tools = tools | json.decode(rctx.read(lockfile))
 
-    loads = []
-    defines = []
+    # validation
+    for tool_name, tool in tools.items():
+        for binary in tool["binaries"]:
+            _check(
+                binary["os"] in ["linux", "macos"],
+                "{tool_name}: Unknown os '{os}'".format(
+                    tool_name = tool_name,
+                    os = binary["os"],
+                ),
+            )
+            _check(
+                binary["cpu"] in ["x86_64", "arm64"],
+                "{tool_name}: Unknown cpu '{cpu}'".format(
+                    tool_name = tool_name,
+                    cpu = binary["cpu"],
+                ),
+            )
+
+    return tools
+
+def _env_specific_tools_impl(rctx):
+    tools = _load_tools(rctx)
 
     for tool_name, tool in tools.items():
-        toolchains = []
-
         for binary in tool["binaries"]:
-            _check(binary["os"] in ["linux", "macos"], "Unknown os '{os}'".format(os = binary["os"]))
-            _check(binary["cpu"] in ["x86_64", "arm64"], "Unknown cpu '{cpu}'".format(cpu = binary["cpu"]))
+            if binary["os"] != rctx.attr.os or binary["cpu"] != rctx.attr.cpu:
+                continue
 
             target_executable = "tools/{tool_name}/{os}_{cpu}_executable".format(
                 tool_name = tool_name,
@@ -104,14 +156,42 @@
             else:
                 fail("Unknown 'kind' {kind}".format(kind = binary["kind"]))
 
+            templates.env_tool(rctx, tool_name, "BUILD.bazel")
+
+    templates.env(rctx, "tools/BUILD.bazel")
+    templates.env(rctx, "BUILD.bazel")
+
+_env_specific_tools = repository_rule(
+    attrs = {
+        "lockfiles": attr.label_list(mandatory = True, allow_files = True),
+        "os": attr.string(),
+        "cpu": attr.string(),
+    },
+    implementation = _env_specific_tools_impl,
+)
+
+def _sort_fn(tup):
+    return tup[0]
+
+def _multitool_hub_impl(rctx):
+    tools = _load_tools(rctx)
+
+    loads = []
+    defines = []
+
+    for tool_name, tool in sorted(tools.items(), key = _sort_fn):
+        toolchains = []
+
+        for binary in tool["binaries"]:
             toolchains.append('\n    _declare_toolchain(name="{name}", os="{os}", cpu="{cpu}")'.format(
                 name = tool_name,
                 cpu = binary["cpu"],
                 os = binary["os"],
             ))
 
-        _render_tool(rctx, tool_name, "BUILD.bazel")
-        _render_tool(rctx, tool_name, "tool.bzl", {
+        templates.hub_tool(rctx, tool_name, "BUILD.bazel")
+        templates.hub_tool(rctx, tool_name, "tool.bzl", {
+            "{hub_name}": rctx.attr.name,
             "{toolchains}": "\n".join(toolchains),
         })
 
@@ -122,10 +202,10 @@
         ))
         defines.append("declare_{clean_name}_toolchains()".format(clean_name = clean_name))
 
-    _render_hub(rctx, "BUILD.bazel")
-    _render_hub(rctx, "toolchain_info.bzl")
-    _render_hub(rctx, "tools/BUILD.bazel")
-    _render_hub(rctx, "toolchains/BUILD.bazel", {
+    templates.hub(rctx, "BUILD.bazel")
+    templates.hub(rctx, "toolchain_info.bzl")
+    templates.hub(rctx, "tools/BUILD.bazel")
+    templates.hub(rctx, "toolchains/BUILD.bazel", {
         "{loads}": "\n".join(loads),
         "{defines}": "\n".join(defines),
     })
@@ -139,11 +219,16 @@
 
 def hub(name, lockfiles):
     "Create a multitool hub."
+    for env in _SUPPORTED_ENVS:
+        _env_specific_tools(
+            name = "{name}.{os}_{cpu}".format(name = name, os = env[0], cpu = env[1]),
+            lockfiles = lockfiles,
+            os = env[0],
+            cpu = env[1],
+        )
     _multitool_hub(name = name, lockfiles = lockfiles)
 
 def multitool(name, lockfile):
     "(non-bzlmod) Create a multitool hub and register its toolchains."
-
-    _multitool_hub(name = name, lockfiles = [lockfile])
-
-    native.register_toolchains("@multitool//toolchains:all")
+    hub(name, [lockfile])
+    native.register_toolchains("@{name}//toolchains:all".format(name = name))
diff --git a/multitool/private/templates.bzl b/multitool/private/templates.bzl
new file mode 100644
index 0000000..91a5db8
--- /dev/null
+++ b/multitool/private/templates.bzl
@@ -0,0 +1,46 @@
+"multitool templating"
+
+_ENV_TEMPLATE = "//multitool/private:env_repo_template/{filename}.template"
+_ENV_TOOL_TEMPLATE = "//multitool/private:env_repo_tool_template/{filename}.template"
+
+_HUB_TEMPLATE = "//multitool/private:hub_repo_template/{filename}.template"
+_HUB_TOOL_TEMPLATE = "//multitool/private:hub_repo_tool_template/{filename}.template"
+
+def _render_env(rctx, filename, substitutions = None):
+    rctx.template(
+        filename,
+        Label(_ENV_TEMPLATE.format(filename = filename)),
+        substitutions = substitutions or {},
+    )
+
+def _render_env_tool(rctx, tool_name, filename, substitutions = None):
+    rctx.template(
+        "tools/{tool_name}/{filename}".format(tool_name = tool_name, filename = filename),
+        Label(_ENV_TOOL_TEMPLATE.format(filename = filename)),
+        substitutions = {
+            "{name}": tool_name,
+        } | (substitutions or {}),
+    )
+
+def _render_hub(rctx, filename, substitutions = None):
+    rctx.template(
+        filename,
+        Label(_HUB_TEMPLATE.format(filename = filename)),
+        substitutions = substitutions or {},
+    )
+
+def _render_hub_tool(rctx, tool_name, filename, substitutions = None):
+    rctx.template(
+        "tools/{tool_name}/{filename}".format(tool_name = tool_name, filename = filename),
+        Label(_HUB_TOOL_TEMPLATE.format(filename = filename)),
+        substitutions = {
+            "{name}": tool_name,
+        } | (substitutions or {}),
+    )
+
+templates = struct(
+    env = _render_env,
+    env_tool = _render_env_tool,
+    hub = _render_hub,
+    hub_tool = _render_hub_tool,
+)