Repository: casey/just (rust)

Issue:
It should be possible to have groups appear in justfile order
`just` supports outputting individual commands in the order they are defined in the justfile:

> `just --list --unsorted` prints recipes in their justfile order within each group:
>
> https://github.com/casey/just?tab=readme-ov-file#recipe-groups

However, the `--unsorted` flag does not appear to apply to the groups themselves, neither can I see an option to have the groups printed in justfile order.

Pull request description:
List groups in source order with `just --groups --unsorted`
Fixes #2157.

Patch:
diff --git a/README.md b/README.md
index 5253c8e1..d3696820 100644
--- a/README.md
+++ b/README.md
@@ -1861,6 +1861,8 @@ Recipe groups:
   rust recipes
 ```
 
+Use `just --groups --unsorted` to print groups in their justfile order.
+
 ### Command Evaluation Using Backticks
 
 Backticks can be used to store the result of commands:
diff --git a/src/assignment.rs b/src/assignment.rs
index 6dab1a96..eb50f510 100644
--- a/src/assignment.rs
+++ b/src/assignment.rs
@@ -4,7 +4,7 @@ use super::*;
 pub(crate) type Assignment<'src> = Binding<'src, Expression<'src>>;
 
 impl<'src> Display for Assignment<'src> {
-  fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
+  fn fmt(&self, f: &mut Formatter) -> fmt::Result {
     if self.export {
       write!(f, "export ")?;
     }
diff --git a/src/ast.rs b/src/ast.rs
index 18c3e451..f2e01228 100644
--- a/src/ast.rs
+++ b/src/ast.rs
@@ -12,7 +12,7 @@ pub(crate) struct Ast<'src> {
 }
 
 impl<'src> Display for Ast<'src> {
-  fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
+  fn fmt(&self, f: &mut Formatter) -> fmt::Result {
     let mut iter = self.items.iter().peekable();
 
     while let Some(item) = iter.next() {
diff --git a/src/attribute.rs b/src/attribute.rs
index 5e83891f..51763646 100644
--- a/src/attribute.rs
+++ b/src/attribute.rs
@@ -110,7 +110,7 @@ impl<'src> Attribute<'src> {
 }
 
 impl<'src> Display for Attribute<'src> {
-  fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
+  fn fmt(&self, f: &mut Formatter) -> fmt::Result {
     write!(f, "{}", self.name())?;
     if let Some(argument) = self.argument() {
       write!(f, "({argument})")?;
diff --git a/src/compile_error.rs b/src/compile_error.rs
index b60835cc..6d22704a 100644
--- a/src/compile_error.rs
+++ b/src/compile_error.rs
@@ -28,7 +28,7 @@ fn capitalize(s: &str) -> String {
 }
 
 impl Display for CompileError<'_> {
-  fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
+  fn fmt(&self, f: &mut Formatter) -> fmt::Result {
     use CompileErrorKind::*;
 
     match &*self.kind {
diff --git a/src/condition.rs b/src/condition.rs
index a103a2ac..8c2a8add 100644
--- a/src/condition.rs
+++ b/src/condition.rs
@@ -8,7 +8,7 @@ pub(crate) struct Condition<'src> {
 }
 
 impl<'src> Display for Condition<'src> {
-  fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
+  fn fmt(&self, f: &mut Formatter) -> fmt::Result {
     write!(f, "{} {} {}", self.lhs, self.operator, self.rhs)
   }
 }
diff --git a/src/dependency.rs b/src/dependency.rs
index 2d1da117..5f6dce58 100644
--- a/src/dependency.rs
+++ b/src/dependency.rs
@@ -8,7 +8,7 @@ pub(crate) struct Dependency<'src> {
 }
 
 impl<'src> Display for Dependency<'src> {
-  fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
+  fn fmt(&self, f: &mut Formatter) -> fmt::Result {
     if self.arguments.is_empty() {
       write!(f, "{}", self.recipe.name())
     } else {
diff --git a/src/expression.rs b/src/expression.rs
index 202fafe0..c1e4b5e0 100644
--- a/src/expression.rs
+++ b/src/expression.rs
@@ -51,7 +51,7 @@ impl<'src> Expression<'src> {
 }
 
 impl<'src> Display for Expression<'src> {
-  fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
+  fn fmt(&self, f: &mut Formatter) -> fmt::Result {
     match self {
       Self::Assert { condition, error } => write!(f, "assert({condition}, {error})"),
       Self::Backtick { token, .. } => write!(f, "{}", token.lexeme()),
diff --git a/src/justfile.rs b/src/justfile.rs
index e108eb91..77e089c5 100644
--- a/src/justfile.rs
+++ b/src/justfile.rs
@@ -365,13 +365,13 @@ impl<'src> Justfile<'src> {
     modules
   }
 
-  pub(crate) fn public_recipes(&self, config: &Config) -> Vec<&Recipe<'src, Dependency>> {
+  pub(crate) fn public_recipes(&self, config: &Config) -> Vec<&Recipe> {
     let mut recipes = self
       .recipes
       .values()
       .map(AsRef::as_ref)
       .filter(|recipe| recipe.is_public())
-      .collect::<Vec<&Recipe<Dependency>>>();
+      .collect::<Vec<&Recipe>>();
 
     if config.unsorted {
       recipes.sort_by_key(|recipe| (&recipe.import_offsets, recipe.name.offset));
@@ -380,19 +380,33 @@ impl<'src> Justfile<'src> {
     recipes
   }
 
-  pub(crate) fn public_groups(&self) -> BTreeSet<String> {
-    self
-      .recipes
-      .values()
-      .map(AsRef::as_ref)
-      .filter(|recipe| recipe.is_public())
-      .flat_map(Recipe::groups)
-      .collect()
+  pub(crate) fn public_groups(&self, config: &Config) -> Vec<String> {
+    let mut groups = Vec::new();
+
+    for recipe in self.recipes.values() {
+      if recipe.is_public() {
+        for group in recipe.groups() {
+          groups.push((&recipe.import_offsets, recipe.name.offset, group));
+        }
+      }
+    }
+
+    if config.unsorted {
+      groups.sort();
+    } else {
+      groups.sort_by(|(_, _, a), (_, _, b)| a.cmp(b));
+    }
+
+    let mut seen = HashSet::new();
+
+    groups.retain(|(_, _, group)| seen.insert(group.clone()));
+
+    groups.into_iter().map(|(_, _, group)| group).collect()
   }
 }
 
 impl<'src> ColorDisplay for Justfile<'src> {
-  fn fmt(&self, f: &mut Formatter, color: Color) -> Result<(), fmt::Error> {
+  fn fmt(&self, f: &mut Formatter, color: Color) -> fmt::Result {
     let mut items = self.recipes.len() + self.assignments.len() + self.aliases.len();
     for (name, assignment) in &self.assignments {
       if assignment.export {
diff --git a/src/output_error.rs b/src/output_error.rs
index 797d3a11..b1a16558 100644
--- a/src/output_error.rs
+++ b/src/output_error.rs
@@ -15,7 +15,7 @@ pub(crate) enum OutputError {
 }
 
 impl Display for OutputError {
-  fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
+  fn fmt(&self, f: &mut Formatter) -> fmt::Result {
     match *self {
       Self::Code(code) => write!(f, "Process exited with status code {code}"),
       Self::Io(ref io_error) => write!(f, "Error executing process: {io_error}"),
diff --git a/src/parameter.rs b/src/parameter.rs
index e065534a..359e15d1 100644
--- a/src/parameter.rs
+++ b/src/parameter.rs
@@ -14,7 +14,7 @@ pub(crate) struct Parameter<'src> {
 }
 
 impl<'src> ColorDisplay for Parameter<'src> {
-  fn fmt(&self, f: &mut Formatter, color: Color) -> Result<(), fmt::Error> {
+  fn fmt(&self, f: &mut Formatter, color: Color) -> fmt::Result {
     if let Some(prefix) = self.kind.prefix() {
       write!(f, "{}", color.annotation().paint(prefix))?;
     }
diff --git a/src/recipe.rs b/src/recipe.rs
index e27cc2e0..a41ce935 100644
--- a/src/recipe.rs
+++ b/src/recipe.rs
@@ -476,7 +476,7 @@ impl<'src, D> Recipe<'src, D> {
 }
 
 impl<'src, D: Display> ColorDisplay for Recipe<'src, D> {
-  fn fmt(&self, f: &mut Formatter, color: Color) -> Result<(), fmt::Error> {
+  fn fmt(&self, f: &mut Formatter, color: Color) -> fmt::Result {
     if let Some(doc) = self.doc {
       writeln!(f, "# {doc}")?;
     }
diff --git a/src/set.rs b/src/set.rs
index 45e5808f..59585786 100644
--- a/src/set.rs
+++ b/src/set.rs
@@ -13,7 +13,7 @@ impl<'src> Keyed<'src> for Set<'src> {
 }
 
 impl<'src> Display for Set<'src> {
-  fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
+  fn fmt(&self, f: &mut Formatter) -> fmt::Result {
     write!(f, "set {} := {}", self.name, self.value)
   }
 }
diff --git a/src/setting.rs b/src/setting.rs
index 8bf98f25..e6cb5155 100644
--- a/src/setting.rs
+++ b/src/setting.rs
@@ -20,7 +20,7 @@ pub(crate) enum Setting<'src> {
 }
 
 impl<'src> Display for Setting<'src> {
-  fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
+  fn fmt(&self, f: &mut Formatter) -> fmt::Result {
     match self {
       Self::AllowDuplicateRecipes(value)
       | Self::AllowDuplicateVariables(value)
diff --git a/src/shell.rs b/src/shell.rs
index e6430f61..f637a7e7 100644
--- a/src/shell.rs
+++ b/src/shell.rs
@@ -7,7 +7,7 @@ pub(crate) struct Shell<'src> {
 }
 
 impl<'src> Display for Shell<'src> {
-  fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
+  fn fmt(&self, f: &mut Formatter) -> fmt::Result {
     write!(f, "[{}", self.command)?;
 
     for argument in &self.arguments {
diff --git a/src/subcommand.rs b/src/subcommand.rs
index 68b31095..a595249a 100644
--- a/src/subcommand.rs
+++ b/src/subcommand.rs
@@ -97,7 +97,7 @@ impl Subcommand {
 
   fn groups(config: &Config, justfile: &Justfile) {
     println!("Recipe groups:");
-    for group in justfile.public_groups() {
+    for group in justfile.public_groups(config) {
       println!("{}{group}", config.list_prefix);
     }
   }
@@ -215,7 +215,7 @@ impl Subcommand {
     overrides: &BTreeMap<String, String>,
     chooser: Option<&str>,
   ) -> Result<(), Error<'src>> {
-    let mut recipes = Vec::<&Recipe<Dependency>>::new();
+    let mut recipes = Vec::<&Recipe>::new();
     let mut stack = vec![justfile];
     while let Some(module) = stack.pop() {
       recipes.extend(
diff --git a/src/token_kind.rs b/src/token_kind.rs
index bb915899..0db15d2d 100644
--- a/src/token_kind.rs
+++ b/src/token_kind.rs
@@ -39,7 +39,7 @@ pub(crate) enum TokenKind {
 }
 
 impl Display for TokenKind {
-  fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
+  fn fmt(&self, f: &mut Formatter) -> fmt::Result {
     use TokenKind::*;
     write!(
       f,
diff --git a/src/unresolved_dependency.rs b/src/unresolved_dependency.rs
index 97f69d65..3ea49a7c 100644
--- a/src/unresolved_dependency.rs
+++ b/src/unresolved_dependency.rs
@@ -7,7 +7,7 @@ pub(crate) struct UnresolvedDependency<'src> {
 }
 
 impl<'src> Display for UnresolvedDependency<'src> {
-  fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
+  fn fmt(&self, f: &mut Formatter) -> fmt::Result {
     if self.arguments.is_empty() {
       write!(f, "{}", self.recipe)
     } else {


Test patch:
diff --git a/tests/groups.rs b/tests/groups.rs
index 58343331..0812852e 100644
--- a/tests/groups.rs
+++ b/tests/groups.rs
@@ -157,12 +157,89 @@ fn list_groups_with_shorthand_syntax() {
         bar:
       ",
     )
-    .args(["--groups", "--list-prefix", "..."])
+    .arg("--groups")
     .stdout(
       "
       Recipe groups:
-      ...A
-      ...B
+          A
+          B
+      ",
+    )
+    .run();
+}
+
+#[test]
+fn list_groups_unsorted() {
+  Test::new()
+    .justfile(
+      "
+        [group: 'Z']
+        baz:
+
+        [group: 'B']
+        foo:
+
+        [group: 'A', group: 'B']
+        bar:
+      ",
+    )
+    .args(["--groups", "--unsorted"])
+    .stdout(
+      "
+      Recipe groups:
+          Z
+          B
+          A
+      ",
+    )
+    .run();
+}
+
+#[test]
+fn list_groups_private_unsorted() {
+  Test::new()
+    .justfile(
+      "
+        [private]
+        [group: 'A']
+        foo:
+
+        [group: 'B']
+        bar:
+
+        [group: 'A']
+        baz:
+      ",
+    )
+    .args(["--groups", "--unsorted"])
+    .stdout(
+      "
+      Recipe groups:
+          B
+          A
+      ",
+    )
+    .run();
+}
+
+#[test]
+fn list_groups_private() {
+  Test::new()
+    .justfile(
+      "
+        [private]
+        [group: 'A']
+        foo:
+
+        [group: 'B']
+        bar:
+      ",
+    )
+    .args(["--groups", "--unsorted"])
+    .stdout(
+      "
+      Recipe groups:
+          B
       ",
     )
     .run();