Repository: casey/just
Pull request #2794 (2025-06-26 10:53:39)

Add `[metadata]` attribute for adding custom metadata to recipes
This PR adds `metadata` attribute, which can appear multiple times on a recipe. The attribute takes a list of strings and its value can be inspected via JSON dump of the `justfile`.

The primary motivation is custom tooling built around `just` that can be used to, for example, automate creation of CI pipelines and other pre-processing that is outside of `just`'s scope.

Fixes: https://github.com/casey/just/issues/2703

wiktor-k: > I don't see any issue with it, and it allows metadata authors flexibility in how they'd like to structure their metadata.

Good idea.

I've left the constraint to require at least one argument in the `[metadata]` attribute as it seemed weird to have an argumentless node like that.

The code could be restructured a bit because structure-wise what `[metadata]` takes looks identical to the `Interpreter` struct (one required "command" and a set of arguments). I've kept the code simple for now, unless you see value in adjustments.
casey: LGTM!

Patch:
diff --git a/README.md b/README.md
index 423f71da..6bc1db1b 100644
--- a/README.md
+++ b/README.md
@@ -2093,12 +2093,13 @@ change their behavior.
 | Name | Type | Description |
 |------|------|-------------|
 | `[confirm]`<sup>1.17.0</sup> | recipe | Require confirmation prior to executing recipe. |
-| `[confirm('PROMPT')]`<sup>1.23.0</sup> | recipe | Require confirmation prior to executing recipe with a custom prompt. |
-| `[doc('DOC')]`<sup>1.27.0</sup> | module, recipe | Set recipe or module's [documentation comment](#documentation-comments) to `DOC`. |
-| `[extension('EXT')]`<sup>1.32.0</sup> | recipe | Set shebang recipe script's file extension to `EXT`. `EXT` should include a period if one is desired. |
-| `[group('NAME')]`<sup>1.27.0</sup> | module, recipe | Put recipe or module in in [group](#groups) `NAME`. |
+| `[confirm(PROMPT)]`<sup>1.23.0</sup> | recipe | Require confirmation prior to executing recipe with a custom prompt. |
+| `[doc(DOC)]`<sup>1.27.0</sup> | module, recipe | Set recipe or module's [documentation comment](#documentation-comments) to `DOC`. |
+| `[extension(EXT)]`<sup>1.32.0</sup> | recipe | Set shebang recipe script's file extension to `EXT`. `EXT` should include a period if one is desired. |
+| `[group(NAME)]`<sup>1.27.0</sup> | module, recipe | Put recipe or module in in [group](#groups) `NAME`. |
 | `[linux]`<sup>1.8.0</sup> | recipe | Enable recipe on Linux. |
 | `[macos]`<sup>1.8.0</sup> | recipe | Enable recipe on MacOS. |
+| `[metadata(METADATA)]`<sup>master</sup> | recipe | Attach `METADATA` to recipe. |
 | `[no-cd]`<sup>1.9.0</sup> | recipe | Don't change directory before executing recipe. |
 | `[no-exit-message]`<sup>1.7.0</sup> | recipe | Don't print an error message if recipe fails. |
 | `[no-quiet]`<sup>1.23.0</sup> | recipe | Override globally quiet recipes and always echo out the recipe. |
diff --git a/src/attribute.rs b/src/attribute.rs
index fb2fef4f..460b88fe 100644
--- a/src/attribute.rs
+++ b/src/attribute.rs
@@ -16,6 +16,7 @@ pub(crate) enum Attribute<'src> {
   Group(StringLiteral<'src>),
   Linux,
   Macos,
+  Metadata(Vec<StringLiteral<'src>>),
   NoCd,
   NoExitMessage,
   NoQuiet,
@@ -32,7 +33,6 @@ impl AttributeDiscriminant {
   fn argument_range(self) -> RangeInclusive<usize> {
     match self {
       Self::Confirm | Self::Doc => 0..=1,
-      Self::Group | Self::Extension | Self::WorkingDirectory => 1..=1,
       Self::ExitMessage
       | Self::Linux
       | Self::Macos
@@ -44,6 +44,8 @@ impl AttributeDiscriminant {
       | Self::Private
       | Self::Unix
       | Self::Windows => 0..=0,
+      Self::Extension | Self::Group | Self::WorkingDirectory => 1..=1,
+      Self::Metadata => 1..=usize::MAX,
       Self::Script => 0..=usize::MAX,
     }
   }
@@ -85,6 +87,7 @@ impl<'src> Attribute<'src> {
       AttributeDiscriminant::Group => Self::Group(arguments.into_iter().next().unwrap()),
       AttributeDiscriminant::Linux => Self::Linux,
       AttributeDiscriminant::Macos => Self::Macos,
+      AttributeDiscriminant::Metadata => Self::Metadata(arguments),
       AttributeDiscriminant::NoCd => Self::NoCd,
       AttributeDiscriminant::NoExitMessage => Self::NoExitMessage,
       AttributeDiscriminant::NoQuiet => Self::NoQuiet,
@@ -115,7 +118,7 @@ impl<'src> Attribute<'src> {
   }
 
   pub(crate) fn repeatable(&self) -> bool {
-    matches!(self, Attribute::Group(_))
+    matches!(self, Attribute::Group(_) | Attribute::Metadata(_))
   }
 }
 
@@ -124,12 +127,6 @@ impl Display for Attribute<'_> {
     write!(f, "{}", self.name())?;
 
     match self {
-      Self::Confirm(Some(argument))
-      | Self::Doc(Some(argument))
-      | Self::Extension(argument)
-      | Self::Group(argument)
-      | Self::WorkingDirectory(argument) => write!(f, "({argument})")?,
-      Self::Script(Some(shell)) => write!(f, "({shell})")?,
       Self::Confirm(None)
       | Self::Doc(None)
       | Self::ExitMessage
@@ -144,6 +141,22 @@ impl Display for Attribute<'_> {
       | Self::Script(None)
       | Self::Unix
       | Self::Windows => {}
+      Self::Confirm(Some(argument))
+      | Self::Doc(Some(argument))
+      | Self::Extension(argument)
+      | Self::Group(argument)
+      | Self::WorkingDirectory(argument) => write!(f, "({argument})")?,
+      Self::Metadata(arguments) => {
+        write!(f, "(")?;
+        for (i, argument) in arguments.iter().enumerate() {
+          if i > 0 {
+            write!(f, ", ")?;
+          }
+          write!(f, "{argument}")?;
+        }
+        write!(f, ")")?;
+      }
+      Self::Script(Some(shell)) => write!(f, "({shell})")?,
     }
 
     Ok(())


Test patch:
diff --git a/tests/attributes.rs b/tests/attributes.rs
index 80393f1a..f109bc58 100644
--- a/tests/attributes.rs
+++ b/tests/attributes.rs
@@ -131,6 +131,63 @@ fn unexpected_attribute_argument() {
     .run();
 }
 
+#[test]
+fn multiple_metadata_attributes() {
+  Test::new()
+    .justfile(
+      "
+      [metadata('example')]
+      [metadata('sample')]
+      [no-exit-message]
+      foo:
+        exit 1
+    ",
+    )
+    .stderr("exit 1\n")
+    .status(1)
+    .run();
+}
+
+#[test]
+fn multiple_metadata_attributes_with_multiple_args() {
+  Test::new()
+    .justfile(
+      "
+      [metadata('example', 'arg1')]
+      [metadata('sample', 'argument')]
+      [no-exit-message]
+      foo:
+        exit 1
+    ",
+    )
+    .stderr("exit 1\n")
+    .status(1)
+    .run();
+}
+
+#[test]
+fn expected_metadata_attribute_argument() {
+  Test::new()
+    .justfile(
+      "
+      [metadata]
+      foo:
+        exit 1
+    ",
+    )
+    .stderr(
+      "
+        error: Attribute `metadata` got 0 arguments but takes at least 1 argument
+         ——▶ justfile:1:2
+          │
+        1 │ [metadata]
+          │  ^^^^^^^^
+          ",
+    )
+    .status(1)
+    .run();
+}
+
 #[test]
 fn doc_attribute() {
   Test::new()
diff --git a/tests/json.rs b/tests/json.rs
index 879c6bb2..5850727b 100644
--- a/tests/json.rs
+++ b/tests/json.rs
@@ -783,6 +783,88 @@ fn attribute() {
   );
 }
 
+#[test]
+fn single_metadata_attribute() {
+  case(
+    "
+      [metadata('example')]
+      foo:
+    ",
+    Module {
+      first: Some("foo"),
+      recipes: [(
+        "foo",
+        Recipe {
+          attributes: [json!({"metadata": ["example"]})].into(),
+          name: "foo",
+          namepath: "foo",
+          ..default()
+        },
+      )]
+      .into(),
+      ..default()
+    },
+  );
+}
+
+#[test]
+fn multiple_metadata_attributes() {
+  case(
+    "
+      [metadata('example')]
+      [metadata('sample')]
+      foo:
+    ",
+    Module {
+      first: Some("foo"),
+      recipes: [(
+        "foo",
+        Recipe {
+          attributes: [
+            json!({"metadata": ["example"]}),
+            json!({"metadata": ["sample"]}),
+          ]
+          .into(),
+          name: "foo",
+          namepath: "foo",
+          ..default()
+        },
+      )]
+      .into(),
+      ..default()
+    },
+  );
+}
+
+#[test]
+fn multiple_metadata_attributes_with_multiple_arguments() {
+  case(
+    "
+      [metadata('example', 'arg1')]
+      [metadata('sample', 'argument')]
+      foo:
+    ",
+    Module {
+      first: Some("foo"),
+      recipes: [(
+        "foo",
+        Recipe {
+          attributes: [
+            json!({"metadata": ["example", "arg1"]}),
+            json!({"metadata": ["sample", "argument"]}),
+          ]
+          .into(),
+          name: "foo",
+          namepath: "foo",
+          ..default()
+        },
+      )]
+      .into(),
+      ..default()
+    },
+  );
+}
+
 #[test]
 fn module() {
   case_with_submodule(