From 7caeca6df8440c8a5ad4431390fa695b44760113 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 25 Jul 2026 21:03:48 -0700 Subject: [PATCH 1/6] Add format argument to `[timestamp]` attribute --- README.md | 16 ++++++++++++++++ src/attribute.rs | 11 +++++------ src/recipe.rs | 20 +++++++++++++++----- src/unresolved_recipe.rs | 2 +- tests/timestamps.rs | 15 +++++++++++++++ 5 files changed, 52 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index d83fea403a..cae8045a17 100644 --- a/README.md +++ b/README.md @@ -4299,6 +4299,21 @@ $ just foo hello ``` +The attribute may be given a format stringmaster which overrides +`--timestamp-format`: + +```just +[timestamp('%H:%M:%S%.3f')] +foo: + echo hello +``` + +``` +$ just foo +[07:28:46.487] echo hello +hello +``` + ### Signal Handling [Signals](https://en.wikipedia.org/wiki/Signal_(IPC)) are messages sent to @@ -4758,6 +4773,7 @@ change their behavior. | `[script(COMMAND)]`1.32.0 | recipe | Execute recipe as a script interpreted by `COMMAND`. See [script recipes](#script-recipes) for more details. | | `[script]`1.33.0 | recipe | Execute recipe as script. See [script recipes](#script-recipes) for more details. | | `[shell]`1.52.0 | recipe | Execute recipe as a shell recipe, overriding `set default-script`. | +| `[timestamp(FORMAT)]`master | recipe | Print command timestamps with format `FORMAT`. | | `[timestamp]`master | recipe | Print command timestamps. | | `[unix]`1.8.0 | any1.56.0 | Enable item on unixes. (Includes macOS). | | `[windows]`1.8.0 | any1.56.0 | Enable item on Windows. | diff --git a/src/attribute.rs b/src/attribute.rs index a9f5cbc88c..44789958e7 100644 --- a/src/attribute.rs +++ b/src/attribute.rs @@ -65,7 +65,7 @@ pub(crate) enum Attribute<'src> { Private, Script(Option>>), Shell, - Timestamp, + Timestamp(Option>), Unix, Windows, WorkingDirectory(Expression<'src>), @@ -117,10 +117,9 @@ impl AttributeKind { | Self::PositionalArguments | Self::Private | Self::Shell - | Self::Timestamp | Self::Unix | Self::Windows => 0..=0, - Self::Confirm | Self::Doc => 0..=1, + Self::Confirm | Self::Doc | Self::Timestamp => 0..=1, Self::Continue | Self::Script => 0..=usize::MAX, Self::Arg | Self::Extension | Self::Group | Self::WorkingDirectory => 1..=1, Self::Env => 2..=2, @@ -276,7 +275,7 @@ impl<'src> Attribute<'src> { }) }), AttributeKind::Shell => Self::Shell, - AttributeKind::Timestamp => Self::Timestamp, + AttributeKind::Timestamp => Self::Timestamp(arguments.into_iter().next()), AttributeKind::Unix => Self::Unix, AttributeKind::Windows => Self::Windows, }; @@ -596,7 +595,7 @@ impl Display for Attribute<'_> { | Self::Private | Self::Script(None) | Self::Shell - | Self::Timestamp + | Self::Timestamp(None) | Self::Unix | Self::Windows => {} Self::Cache { @@ -623,7 +622,7 @@ impl Display for Attribute<'_> { | Self::WorkingDirectory(argument) => { write!(f, "({argument})")?; } - Self::Extension(argument) | Self::Group(argument) => { + Self::Extension(argument) | Self::Group(argument) | Self::Timestamp(Some(argument)) => { write!(f, "({argument})")?; } Self::Continue(signals) => { diff --git a/src/recipe.rs b/src/recipe.rs index 060b9a1b16..ca84c75f33 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -202,11 +202,21 @@ impl<'src> Recipe<'src> { } fn timestamp(&self, config: &Config) -> RunResult<'static, Option> { - (config.timestamp || self.attributes.contains(AttributeKind::Timestamp)) - .then(|| { - datetime_format(chrono::Local::now(), &config.timestamp_format) - .map_err(Error::DatetimeFormat) - }) + let format = + if let Some(Attribute::Timestamp(format)) = self.attributes.get(AttributeKind::Timestamp) { + Some( + format + .as_ref() + .map_or(config.timestamp_format.as_str(), |format| &format.cooked), + ) + } else if config.timestamp { + Some(config.timestamp_format.as_str()) + } else { + None + }; + + format + .map(|format| datetime_format(chrono::Local::now(), format).map_err(Error::DatetimeFormat)) .transpose() } diff --git a/src/unresolved_recipe.rs b/src/unresolved_recipe.rs index 0cf3a19d3f..a614dab928 100644 --- a/src/unresolved_recipe.rs +++ b/src/unresolved_recipe.rs @@ -134,7 +134,7 @@ impl<'src> UnresolvedRecipe<'src> { | Attribute::Private | Attribute::Script(_) | Attribute::Shell - | Attribute::Timestamp + | Attribute::Timestamp(_) | Attribute::Unix | Attribute::Windows => {} } diff --git a/tests/timestamps.rs b/tests/timestamps.rs index b2fb5df145..9d541b3aee 100644 --- a/tests/timestamps.rs +++ b/tests/timestamps.rs @@ -62,6 +62,21 @@ fn attribute() { .success(); } +#[test] +fn attribute_format() { + Test::new() + .justfile( + " + [timestamp('%H:%M:%S%.3f')] + recipe: + echo foo + ", + ) + .stderr_regex(concat!(r"\[\d\d:\d\d:\d\d\.\d\d\d\] echo foo", "\n")) + .stdout("foo\n") + .success(); +} + #[test] fn attribute_script() { Test::new() From e43228b67589fbfa57c21625fcb8239f333c4689 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 25 Jul 2026 21:10:06 -0700 Subject: [PATCH 2/6] Allow format to be an expression --- README.md | 2 +- src/attribute.rs | 12 +++++++---- src/recipe.rs | 46 ++++++++++++++++++++++++---------------- src/string_context.rs | 5 +++++ src/unresolved_recipe.rs | 6 ++++-- tests/timestamps.rs | 17 +++++++++++++++ 6 files changed, 63 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index cae8045a17..9706da70a6 100644 --- a/README.md +++ b/README.md @@ -4773,7 +4773,7 @@ change their behavior. | `[script(COMMAND)]`1.32.0 | recipe | Execute recipe as a script interpreted by `COMMAND`. See [script recipes](#script-recipes) for more details. | | `[script]`1.33.0 | recipe | Execute recipe as script. See [script recipes](#script-recipes) for more details. | | `[shell]`1.52.0 | recipe | Execute recipe as a shell recipe, overriding `set default-script`. | -| `[timestamp(FORMAT)]`master | recipe | Print command timestamps with format `FORMAT`. | +| `[timestamp(FORMAT)]`master | recipe | Print command timestamps with format `FORMAT`. `FORMAT` may be an expression. | | `[timestamp]`master | recipe | Print command timestamps. | | `[unix]`1.8.0 | any1.56.0 | Enable item on unixes. (Includes macOS). | | `[windows]`1.8.0 | any1.56.0 | Enable item on Windows. | diff --git a/src/attribute.rs b/src/attribute.rs index 44789958e7..c88b2a3d7b 100644 --- a/src/attribute.rs +++ b/src/attribute.rs @@ -65,7 +65,7 @@ pub(crate) enum Attribute<'src> { Private, Script(Option>>), Shell, - Timestamp(Option>), + Timestamp(Option>), Unix, Windows, WorkingDirectory(Expression<'src>), @@ -75,7 +75,7 @@ impl AttributeKind { fn accepts_expressions(self) -> bool { matches!( self, - Self::Confirm | Self::Doc | Self::Env | Self::WorkingDirectory + Self::Confirm | Self::Doc | Self::Env | Self::Timestamp | Self::WorkingDirectory ) } @@ -202,6 +202,9 @@ impl<'src> Attribute<'src> { let (_, value) = arguments.next().unwrap(); Ok(Self::Env(key, value)) } + AttributeKind::Timestamp => Ok(Self::Timestamp( + arguments.into_iter().next().map(|(_, expr)| expr), + )), AttributeKind::WorkingDirectory => Ok(Self::WorkingDirectory( arguments.into_iter().next().map(|(_, expr)| expr).unwrap(), )), @@ -247,6 +250,7 @@ impl<'src> Attribute<'src> { AttributeKind::Confirm | AttributeKind::Doc | AttributeKind::Env + | AttributeKind::Timestamp | AttributeKind::WorkingDirectory => { unreachable!() } @@ -275,7 +279,6 @@ impl<'src> Attribute<'src> { }) }), AttributeKind::Shell => Self::Shell, - AttributeKind::Timestamp => Self::Timestamp(arguments.into_iter().next()), AttributeKind::Unix => Self::Unix, AttributeKind::Windows => Self::Windows, }; @@ -619,10 +622,11 @@ impl Display for Attribute<'_> { } Self::Confirm(Some(argument)) | Self::Doc(Some(argument)) + | Self::Timestamp(Some(argument)) | Self::WorkingDirectory(argument) => { write!(f, "({argument})")?; } - Self::Extension(argument) | Self::Group(argument) | Self::Timestamp(Some(argument)) => { + Self::Extension(argument) | Self::Group(argument) => { write!(f, "({argument})")?; } Self::Continue(signals) => { diff --git a/src/recipe.rs b/src/recipe.rs index ca84c75f33..201b96ff3d 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -201,23 +201,25 @@ impl<'src> Recipe<'src> { self.attributes.contains(AttributeKind::NoQuiet) } - fn timestamp(&self, config: &Config) -> RunResult<'static, Option> { - let format = - if let Some(Attribute::Timestamp(format)) = self.attributes.get(AttributeKind::Timestamp) { - Some( - format - .as_ref() - .map_or(config.timestamp_format.as_str(), |format| &format.cooked), - ) - } else if config.timestamp { - Some(config.timestamp_format.as_str()) + fn timestamp_format( + &self, + config: &Config, + evaluator: &mut Evaluator<'src, '_>, + ) -> RunResult<'src, Option> { + if let Some(attribute) = self.attributes.get(AttributeKind::Timestamp) { + if let Attribute::Timestamp(Some(expression)) = attribute { + Ok(Some(evaluator.evaluate_string( + expression, + StringContext::TimestampAttribute(self.attributes.name(attribute)), + )?)) } else { - None - }; - - format - .map(|format| datetime_format(chrono::Local::now(), format).map_err(Error::DatetimeFormat)) - .transpose() + Ok(Some(config.timestamp_format.clone())) + } + } else if config.timestamp { + Ok(Some(config.timestamp_format.clone())) + } else { + Ok(None) + } } pub(crate) fn run<'run>( @@ -296,6 +298,8 @@ impl<'src> Recipe<'src> { let working_directory = self.working_directory(context, &mut evaluator)?; + let timestamp_format = self.timestamp_format(config, &mut evaluator)?; + loop { let Some(line) = lines.peek() else { return Ok(()); @@ -340,7 +344,10 @@ impl<'src> Recipe<'src> { let infallible = sigils.contains(&Sigil::Infallible); let quiet = sigils.contains(&Sigil::Quiet); - let timestamp = self.timestamp(config)?; + let timestamp = timestamp_format + .as_deref() + .map(|format| datetime_format(chrono::Local::now(), format).map_err(Error::DatetimeFormat)) + .transpose()?; if config.dry_run || config.verbosity.loquacious() @@ -458,7 +465,10 @@ impl<'src> Recipe<'src> { ) -> RunResult<'src> { let config = &context.config; - if let Some(timestamp) = self.timestamp(config)? { + if let Some(format) = self.timestamp_format(config, &mut evaluator)? { + let timestamp = + datetime_format(chrono::Local::now(), &format).map_err(Error::DatetimeFormat)?; + let color = if config.highlight { config.color.command(config.command_color) } else { diff --git a/src/string_context.rs b/src/string_context.rs index f144894d46..56a1b2f8b9 100644 --- a/src/string_context.rs +++ b/src/string_context.rs @@ -5,6 +5,7 @@ pub(crate) enum StringContext<'src> { EnvKey(Name<'src>), Function(Name<'src>), Setting(Name<'src>), + TimestampAttribute(Name<'src>), WorkingDirectoryAttribute(Name<'src>), } @@ -14,6 +15,7 @@ impl<'src> StringContext<'src> { Self::EnvKey(name) | Self::Function(name) | Self::Setting(name) + | Self::TimestampAttribute(name) | Self::WorkingDirectoryAttribute(name) => name.token, } } @@ -25,6 +27,9 @@ impl Display for StringContext<'_> { Self::EnvKey(_) => write!(f, "used as `env` attribute name"), Self::Function(name) => write!(f, "passed to `{name}()`"), Self::Setting(name) => write!(f, "assigned to `{name}` setting"), + Self::TimestampAttribute(_) => { + write!(f, "used as a `[timestamp]` attribute") + } Self::WorkingDirectoryAttribute(_) => { write!(f, "used as a `[working-directory]` attribute") } diff --git a/src/unresolved_recipe.rs b/src/unresolved_recipe.rs index a614dab928..38afd26ea1 100644 --- a/src/unresolved_recipe.rs +++ b/src/unresolved_recipe.rs @@ -97,7 +97,9 @@ impl<'src> UnresolvedRecipe<'src> { )?; } } - Attribute::Confirm(Some(expression)) | Attribute::WorkingDirectory(expression) => { + Attribute::Confirm(Some(expression)) + | Attribute::Timestamp(Some(expression)) + | Attribute::WorkingDirectory(expression) => { variable_resolver.resolve_expression( expression, ¶meters, @@ -134,7 +136,7 @@ impl<'src> UnresolvedRecipe<'src> { | Attribute::Private | Attribute::Script(_) | Attribute::Shell - | Attribute::Timestamp(_) + | Attribute::Timestamp(None) | Attribute::Unix | Attribute::Windows => {} } diff --git a/tests/timestamps.rs b/tests/timestamps.rs index 9d541b3aee..e51c52e9c3 100644 --- a/tests/timestamps.rs +++ b/tests/timestamps.rs @@ -77,6 +77,23 @@ fn attribute_format() { .success(); } +#[test] +fn attribute_format_expression() { + Test::new() + .justfile( + " + format := '%H:%M:%S' + '%.3f' + + [timestamp(format)] + recipe: + echo foo + ", + ) + .stderr_regex(concat!(r"\[\d\d:\d\d:\d\d\.\d\d\d\] echo foo", "\n")) + .stdout("foo\n") + .success(); +} + #[test] fn attribute_script() { Test::new() From 60f64b5dfcf692098fea886a991034ecc65c16ea Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 25 Jul 2026 21:12:24 -0700 Subject: [PATCH 3/6] Amend --- src/recipe.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/recipe.rs b/src/recipe.rs index 201b96ff3d..e50a03b4a7 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -206,19 +206,16 @@ impl<'src> Recipe<'src> { config: &Config, evaluator: &mut Evaluator<'src, '_>, ) -> RunResult<'src, Option> { - if let Some(attribute) = self.attributes.get(AttributeKind::Timestamp) { - if let Attribute::Timestamp(Some(expression)) = attribute { + match self.attributes.get(AttributeKind::Timestamp) { + Some(attribute @ Attribute::Timestamp(Some(expression))) => { Ok(Some(evaluator.evaluate_string( expression, StringContext::TimestampAttribute(self.attributes.name(attribute)), )?)) - } else { - Ok(Some(config.timestamp_format.clone())) } - } else if config.timestamp { - Ok(Some(config.timestamp_format.clone())) - } else { - Ok(None) + Some(_) => Ok(Some(config.timestamp_format.clone())), + None if config.timestamp => Ok(Some(config.timestamp_format.clone())), + None => Ok(None), } } From 59c269240803065d120732ee1f848cc9c36b1f9d Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 25 Jul 2026 21:12:41 -0700 Subject: [PATCH 4/6] Modify --- src/recipe.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/recipe.rs b/src/recipe.rs index e50a03b4a7..055fd4d637 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -213,7 +213,8 @@ impl<'src> Recipe<'src> { StringContext::TimestampAttribute(self.attributes.name(attribute)), )?)) } - Some(_) => Ok(Some(config.timestamp_format.clone())), + Some(Attribute::Timestamp(None)) => Ok(Some(config.timestamp_format.clone())), + Some(_) => unreachable!(), None if config.timestamp => Ok(Some(config.timestamp_format.clone())), None => Ok(None), } From cfc50db392a1987eb00293f38a123ae5e399acf3 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 25 Jul 2026 21:19:30 -0700 Subject: [PATCH 5/6] Modify --- src/recipe.rs | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/recipe.rs b/src/recipe.rs index 055fd4d637..b0238821a3 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -206,17 +206,26 @@ impl<'src> Recipe<'src> { config: &Config, evaluator: &mut Evaluator<'src, '_>, ) -> RunResult<'src, Option> { - match self.attributes.get(AttributeKind::Timestamp) { - Some(attribute @ Attribute::Timestamp(Some(expression))) => { - Ok(Some(evaluator.evaluate_string( - expression, - StringContext::TimestampAttribute(self.attributes.name(attribute)), - )?)) - } - Some(Attribute::Timestamp(None)) => Ok(Some(config.timestamp_format.clone())), - Some(_) => unreachable!(), - None if config.timestamp => Ok(Some(config.timestamp_format.clone())), - None => Ok(None), + if let Some(attribute) = self.attributes.get(AttributeKind::Timestamp) { + let Attribute::Timestamp(format) = attribute else { + unreachable!(); + }; + Ok(Some( + format + .as_ref() + .map(|expression| { + evaluator.evaluate_string( + expression, + StringContext::TimestampAttribute(self.attributes.name(attribute)), + ) + }) + .transpose()? + .unwrap_or_else(|| config.timestamp_format.clone()), + )) + } else if config.timestamp { + Ok(Some(config.timestamp_format.clone())) + } else { + Ok(None) } } From 465556673829fe2e3a7438cf3503a201315c334e Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 25 Jul 2026 21:21:27 -0700 Subject: [PATCH 6/6] Enhance --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 9706da70a6..5368481de6 100644 --- a/README.md +++ b/README.md @@ -4299,8 +4299,7 @@ $ just foo hello ``` -The attribute may be given a format stringmaster which overrides -`--timestamp-format`: +Which may include a format string: ```just [timestamp('%H:%M:%S%.3f')]