diff --git a/README.md b/README.md
index d83fea403a..5368481de6 100644
--- a/README.md
+++ b/README.md
@@ -4299,6 +4299,20 @@ $ just foo
hello
```
+Which may include a format string:
+
+```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 +4772,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`. `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 a9f5cbc88c..c88b2a3d7b 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>),
@@ -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
)
}
@@ -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,
@@ -203,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(),
)),
@@ -248,6 +250,7 @@ impl<'src> Attribute<'src> {
AttributeKind::Confirm
| AttributeKind::Doc
| AttributeKind::Env
+ | AttributeKind::Timestamp
| AttributeKind::WorkingDirectory => {
unreachable!()
}
@@ -276,7 +279,6 @@ impl<'src> Attribute<'src> {
})
}),
AttributeKind::Shell => Self::Shell,
- AttributeKind::Timestamp => Self::Timestamp,
AttributeKind::Unix => Self::Unix,
AttributeKind::Windows => Self::Windows,
};
@@ -596,7 +598,7 @@ impl Display for Attribute<'_> {
| Self::Private
| Self::Script(None)
| Self::Shell
- | Self::Timestamp
+ | Self::Timestamp(None)
| Self::Unix
| Self::Windows => {}
Self::Cache {
@@ -620,6 +622,7 @@ impl Display for Attribute<'_> {
}
Self::Confirm(Some(argument))
| Self::Doc(Some(argument))
+ | Self::Timestamp(Some(argument))
| Self::WorkingDirectory(argument) => {
write!(f, "({argument})")?;
}
diff --git a/src/recipe.rs b/src/recipe.rs
index 060b9a1b16..b0238821a3 100644
--- a/src/recipe.rs
+++ b/src/recipe.rs
@@ -201,13 +201,32 @@ impl<'src> Recipe<'src> {
self.attributes.contains(AttributeKind::NoQuiet)
}
- 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)
- })
- .transpose()
+ fn timestamp_format(
+ &self,
+ config: &Config,
+ evaluator: &mut Evaluator<'src, '_>,
+ ) -> RunResult<'src, Option> {
+ 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)
+ }
}
pub(crate) fn run<'run>(
@@ -286,6 +305,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(());
@@ -330,7 +351,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()
@@ -448,7 +472,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 0cf3a19d3f..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 b2fb5df145..e51c52e9c3 100644
--- a/tests/timestamps.rs
+++ b/tests/timestamps.rs
@@ -62,6 +62,38 @@ 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_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()