diff --git a/README.md b/README.md
index cb87bf0a8b..c62dcb39b1 100644
--- a/README.md
+++ b/README.md
@@ -1503,6 +1503,7 @@ Recipes may be annotated with attributes that change their behavior.
| Name | Description |
|------|-------------|
| `[confirm]`1.17.0 | Require confirmation prior to executing recipe. |
+| `[cached]`Latest | See [Cached Recipes](#cached-recipes) |
| `[confirm("prompt")]`1.23.0 | Require confirmation prior to executing recipe with a custom prompt. |
| `[linux]`1.8.0 | Enable recipe on Linux. |
| `[macos]`1.8.0 | Enable recipe on MacOS. |
@@ -2487,6 +2488,26 @@ polyglot: python js perl sh ruby
Run `just --help` to see all the options.
+### Cached Recipes
+
+Cached recipes only run when the recipe body changes, where the body is compared
+*after `{{interpolations}}` are evaluated*. This gives you fine control for when
+a recipe should rerun. It is recommended you add `.justcache/` to your
+`.gitignore`. **Note: This is currently an unstable feature and requires
+`--unstable`**.
+
+```just
+[cached]
+build:
+ @# This only runs when the hash of the file changes {{sha256_file("input.c")}}
+ gcc input.c -o output
+
+[cached]
+bad-example:
+ @# This will never rerun since the body never changes
+ gcc input.c -o output
+```
+
### Private Recipes
Recipes and aliases whose name starts with a `_` are omitted from `just --list`:
diff --git a/src/attribute.rs b/src/attribute.rs
index 8516ce9403..0eb4c65d35 100644
--- a/src/attribute.rs
+++ b/src/attribute.rs
@@ -5,6 +5,7 @@ use super::*;
#[serde(rename_all = "kebab-case")]
pub(crate) enum Attribute<'src> {
Confirm(Option>),
+ Cached,
Linux,
Macos,
NoCd,
diff --git a/src/compile_error.rs b/src/compile_error.rs
index 1457dff526..570b132082 100644
--- a/src/compile_error.rs
+++ b/src/compile_error.rs
@@ -46,6 +46,12 @@ impl Display for CompileError<'_> {
recipe_line.ordinal(),
),
BacktickShebang => write!(f, "Backticks may not start with `#!`"),
+ CachedDependsOnUncached { cached, uncached } => {
+ write!(
+ f,
+ "Cached recipes cannot depend on preceding uncached ones, yet `{cached}` depends on `{uncached}`",
+ )
+ }
CircularRecipeDependency { recipe, ref circle } => {
if circle.len() == 2 {
write!(f, "Recipe `{recipe}` depends on itself")
diff --git a/src/compile_error_kind.rs b/src/compile_error_kind.rs
index fbdbf2a876..13668a7dc5 100644
--- a/src/compile_error_kind.rs
+++ b/src/compile_error_kind.rs
@@ -11,6 +11,10 @@ pub(crate) enum CompileErrorKind<'src> {
recipe_line: usize,
},
BacktickShebang,
+ CachedDependsOnUncached {
+ cached: &'src str,
+ uncached: &'src str,
+ },
CircularRecipeDependency {
recipe: &'src str,
circle: Vec<&'src str>,
diff --git a/src/error.rs b/src/error.rs
index 3411bd0426..96fb0f502e 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -17,6 +17,14 @@ pub(crate) enum Error<'src> {
token: Token<'src>,
output_error: OutputError,
},
+ CacheFileRead {
+ cache_filename: PathBuf,
+ io_error: io::Error,
+ },
+ CacheFileWrite {
+ cache_filename: PathBuf,
+ io_error: io::Error,
+ },
ChooserInvoke {
shell_binary: String,
shell_arguments: String,
@@ -267,6 +275,8 @@ impl<'src> ColorDisplay for Error<'src> {
}?,
OutputError::Utf8(utf8_error) => write!(f, "Backtick succeeded but stdout was not utf8: {utf8_error}")?,
}
+ CacheFileRead {cache_filename, io_error} => write!(f, "Failed to read cache file ({}): {}", cache_filename.display(), io_error)?,
+ CacheFileWrite{cache_filename, io_error} => write!(f, "Failed to write cache file ({}): {}", cache_filename.display(), io_error)?,
ChooserInvoke { shell_binary, shell_arguments, chooser, io_error} => {
let chooser = chooser.to_string_lossy();
write!(f, "Chooser `{shell_binary} {shell_arguments} {chooser}` invocation failed: {io_error}")?;
diff --git a/src/justfile.rs b/src/justfile.rs
index f8f24550af..c63f9b39be 100644
--- a/src/justfile.rs
+++ b/src/justfile.rs
@@ -272,12 +272,15 @@ impl<'src> Justfile<'src> {
}
let mut ran = Ran::default();
+ let mut cache = JustfileCache::new(search)?;
+
for invocation in invocations {
- let context = RecipeContext {
+ let mut context = RecipeContext {
settings: invocation.settings,
config,
scope: invocation.scope,
search,
+ cache: &mut cache,
};
Self::run_recipe(
@@ -287,7 +290,7 @@ impl<'src> Justfile<'src> {
.copied()
.map(str::to_string)
.collect::>(),
- &context,
+ &mut context,
&dotenv,
&mut ran,
invocation.recipe,
@@ -295,7 +298,7 @@ impl<'src> Justfile<'src> {
)?;
}
- Ok(())
+ return cache.save(search);
}
pub(crate) fn get_alias(&self, name: &str) -> Option<&Alias<'src>> {
@@ -403,12 +406,12 @@ impl<'src> Justfile<'src> {
fn run_recipe(
arguments: &[String],
- context: &RecipeContext<'src, '_>,
+ context: &mut RecipeContext<'src, '_>,
dotenv: &BTreeMap,
ran: &mut Ran<'src>,
recipe: &Recipe<'src>,
search: &Search,
- ) -> RunResult<'src> {
+ ) -> RunResult<'src, ()> {
if ran.has_run(&recipe.namepath, arguments) {
return Ok(());
}
@@ -445,9 +448,16 @@ impl<'src> Justfile<'src> {
}
}
- recipe.run(context, dotenv, scope.child(), search, &positional)?;
+ let updated_hash = recipe.run(context, dotenv, scope.child(), search, &positional)?;
+ let recipe_hash_changed = updated_hash.is_some();
- if !context.config.no_dependencies {
+ if let Some(body_hash) = updated_hash {
+ context
+ .cache
+ .insert_recipe(recipe.name.to_string(), body_hash);
+ }
+
+ if !context.config.no_dependencies && (!recipe.should_cache() || recipe_hash_changed) {
let mut ran = Ran::default();
for Dependency { recipe, arguments } in recipe.dependencies.iter().skip(recipe.priors) {
diff --git a/src/justfile_cache.rs b/src/justfile_cache.rs
new file mode 100644
index 0000000000..7444d0b01a
--- /dev/null
+++ b/src/justfile_cache.rs
@@ -0,0 +1,87 @@
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+use std::path::PathBuf;
+
+use super::*;
+
+#[derive(Debug, Serialize, Deserialize)]
+pub(crate) struct JustfileCache {
+ /// Only serialized for user debugging
+ pub(crate) justfile_path: PathBuf,
+ /// Only serialized for user debugging
+ pub(crate) working_directory: PathBuf,
+
+ pub(crate) recipes: HashMap,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub(crate) struct RecipeCache {
+ /// The hash of the recipe body after evaluation
+ pub(crate) body_hash: String,
+ #[serde(skip)]
+ /// Has the hash changed this run. Needed for nested cached dependencies.
+ pub(crate) hash_changed: bool,
+}
+
+impl JustfileCache {
+ fn new_empty(search: &Search) -> Self {
+ Self {
+ justfile_path: search.justfile.clone(),
+ working_directory: search.working_directory.clone(),
+ recipes: HashMap::new(),
+ }
+ }
+
+ pub(crate) fn new<'run>(search: &Search) -> RunResult<'run, Self> {
+ let cache_file = &search.cache_file;
+ let this = if cache_file.exists() {
+ let file_contents =
+ fs::read_to_string(cache_file).map_err(|io_error| Error::CacheFileRead {
+ cache_filename: cache_file.clone(),
+ io_error,
+ })?;
+ // Ignore newer versions, incompatible old versions or corrupted cache files
+ serde_json::from_str(&file_contents)
+ .or(Err(()))
+ .unwrap_or_else(|()| Self::new_empty(search))
+ } else {
+ Self::new_empty(search)
+ };
+ Ok(this)
+ }
+
+ pub(crate) fn insert_recipe(&mut self, name: String, body_hash: String) {
+ self.recipes.insert(
+ name,
+ RecipeCache {
+ body_hash,
+ hash_changed: true,
+ },
+ );
+ }
+
+ pub(crate) fn save<'run>(&self, search: &Search) -> RunResult<'run, ()> {
+ let cache = serde_json::to_string(self).map_err(|_| Error::Internal {
+ message: format!("Failed to serialize cache: {self:?}"),
+ })?;
+
+ search
+ .cache_file
+ .parent()
+ .ok_or_else(|| {
+ io::Error::new(
+ io::ErrorKind::Unsupported,
+ format!(
+ "Cannot create parent directory of {}",
+ search.cache_file.display()
+ ),
+ )
+ })
+ .and_then(fs::create_dir_all)
+ .and_then(|()| fs::write(&search.cache_file, cache))
+ .map_err(|io_error| Error::CacheFileWrite {
+ cache_filename: search.cache_file.clone(),
+ io_error,
+ })
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 445e819019..3a93ae8439 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -24,18 +24,19 @@ pub(crate) use {
enclosure::Enclosure, error::Error, evaluator::Evaluator, expression::Expression,
fragment::Fragment, function::Function, function_context::FunctionContext,
interrupt_guard::InterruptGuard, interrupt_handler::InterruptHandler, item::Item,
- justfile::Justfile, keyed::Keyed, keyword::Keyword, lexer::Lexer, line::Line, list::List,
- load_dotenv::load_dotenv, loader::Loader, name::Name, namepath::Namepath, ordinal::Ordinal,
- output::output, output_error::OutputError, parameter::Parameter, parameter_kind::ParameterKind,
- parser::Parser, platform::Platform, platform_interface::PlatformInterface, position::Position,
- positional::Positional, ran::Ran, range_ext::RangeExt, recipe::Recipe,
- recipe_context::RecipeContext, recipe_resolver::RecipeResolver, scope::Scope, search::Search,
- search_config::SearchConfig, search_error::SearchError, set::Set, setting::Setting,
- settings::Settings, shebang::Shebang, shell::Shell, show_whitespace::ShowWhitespace,
- source::Source, string_kind::StringKind, string_literal::StringLiteral, subcommand::Subcommand,
- suggestion::Suggestion, table::Table, thunk::Thunk, token::Token, token_kind::TokenKind,
- unresolved_dependency::UnresolvedDependency, unresolved_recipe::UnresolvedRecipe,
- use_color::UseColor, variables::Variables, verbosity::Verbosity, warning::Warning,
+ justfile::Justfile, justfile_cache::JustfileCache, keyed::Keyed, keyword::Keyword,
+ lexer::Lexer, line::Line, list::List, load_dotenv::load_dotenv, loader::Loader, name::Name,
+ namepath::Namepath, ordinal::Ordinal, output::output, output_error::OutputError,
+ parameter::Parameter, parameter_kind::ParameterKind, parser::Parser, platform::Platform,
+ platform_interface::PlatformInterface, position::Position, positional::Positional, ran::Ran,
+ range_ext::RangeExt, recipe::Recipe, recipe_context::RecipeContext,
+ recipe_resolver::RecipeResolver, scope::Scope, search::Search, search_config::SearchConfig,
+ search_error::SearchError, set::Set, setting::Setting, settings::Settings, shebang::Shebang,
+ shell::Shell, show_whitespace::ShowWhitespace, source::Source, string_kind::StringKind,
+ string_literal::StringLiteral, subcommand::Subcommand, suggestion::Suggestion, table::Table,
+ thunk::Thunk, token::Token, token_kind::TokenKind, unresolved_dependency::UnresolvedDependency,
+ unresolved_recipe::UnresolvedRecipe, use_color::UseColor, variables::Variables,
+ verbosity::Verbosity, warning::Warning,
},
std::{
cmp,
@@ -142,6 +143,7 @@ mod interrupt_guard;
mod interrupt_handler;
mod item;
mod justfile;
+mod justfile_cache;
mod keyed;
mod keyword;
mod lexer;
diff --git a/src/recipe.rs b/src/recipe.rs
index 3ef2843f0b..6f542f162e 100644
--- a/src/recipe.rs
+++ b/src/recipe.rs
@@ -143,6 +143,53 @@ impl<'src, D> Recipe<'src, D> {
self.attributes.contains(&Attribute::NoQuiet)
}
+ pub(crate) fn should_cache(&self) -> bool {
+ self.attributes.contains(&Attribute::Cached)
+ }
+}
+
+impl<'src> Recipe<'src, Dependency<'src>> {
+ fn get_updated_hash_if_outdated<'run>(
+ &self,
+ context: &RecipeContext<'src, 'run>,
+ mut evaluator: Evaluator<'src, 'run>,
+ ) -> RunResult<'src, Option> {
+ let mut recipe_hash = blake3::Hasher::new();
+ for line in &self.body {
+ recipe_hash.update(evaluator.evaluate_line(line, false)?.as_bytes());
+ }
+ let recipe_hash = recipe_hash.finalize().to_hex();
+ let recipes = &context.cache.recipes;
+
+ recipes.get(self.name()).map_or_else(
+ || Ok(Some(recipe_hash.to_string())),
+ |previous_run| {
+ let have_deps_changed = self
+ .dependencies
+ .iter()
+ .take(self.priors)
+ .map(|dep| {
+ recipes
+ .get(dep.recipe.name())
+ .map(|previous_run| previous_run.hash_changed)
+ .ok_or_else(|| {
+ Error::internal(format!(
+ "prior dependency `{}` did not run before current recipe `{}`",
+ dep.recipe.name, self.name
+ ))
+ })
+ })
+ .collect::, Error>>()?;
+
+ if have_deps_changed.iter().any(|x| *x) || previous_run.body_hash != recipe_hash.as_str() {
+ Ok(Some(recipe_hash.to_string()))
+ } else {
+ Ok(None)
+ }
+ },
+ )
+ }
+
pub(crate) fn run<'run>(
&self,
context: &RecipeContext<'src, 'run>,
@@ -150,9 +197,39 @@ impl<'src, D> Recipe<'src, D> {
scope: Scope<'src, 'run>,
search: &'run Search,
positional: &[String],
- ) -> RunResult<'src, ()> {
+ ) -> RunResult<'src, Option> {
let config = &context.config;
+ let updated_hash = if self.should_cache() {
+ config.require_unstable("Cached recipes are currently unstable.")?;
+
+ let evaluator = Evaluator::recipe_evaluator(config, dotenv, &scope, context.settings, search);
+
+ let hash = self.get_updated_hash_if_outdated(context, evaluator)?;
+ if hash.is_none() {
+ if config.dry_run
+ || config.verbosity.loquacious()
+ || !((context.settings.quiet && !self.no_quiet()) || config.verbosity.quiet())
+ {
+ let color = if config.highlight {
+ config.color.command(config.command_color)
+ } else {
+ config.color
+ };
+ eprintln!(
+ "{}===> Hash of recipe body of `{}` matches last run. Skipping...{}",
+ color.prefix(),
+ self.name,
+ color.suffix()
+ );
+ }
+ return Ok(None);
+ }
+ hash
+ } else {
+ None
+ };
+
if config.verbosity.loquacious() {
let color = config.color.stderr().banner();
eprintln!(
@@ -163,14 +240,15 @@ impl<'src, D> Recipe<'src, D> {
);
}
- let evaluator =
- Evaluator::recipe_evaluator(context.config, dotenv, &scope, context.settings, search);
+ let evaluator = Evaluator::recipe_evaluator(config, dotenv, &scope, context.settings, search);
if self.shebang {
- self.run_shebang(context, dotenv, &scope, positional, config, evaluator)
+ self.run_shebang(context, dotenv, &scope, positional, config, evaluator)?;
} else {
- self.run_linewise(context, dotenv, &scope, positional, config, evaluator)
+ self.run_linewise(context, dotenv, &scope, positional, config, evaluator)?;
}
+
+ Ok(updated_hash)
}
fn run_linewise<'run>(
diff --git a/src/recipe_context.rs b/src/recipe_context.rs
index 0e46f5f85d..72da594072 100644
--- a/src/recipe_context.rs
+++ b/src/recipe_context.rs
@@ -1,6 +1,7 @@
use super::*;
pub(crate) struct RecipeContext<'src: 'run, 'run> {
+ pub(crate) cache: &'run mut JustfileCache,
pub(crate) config: &'run Config,
pub(crate) scope: &'run Scope<'src, 'run>,
pub(crate) search: &'run Search,
diff --git a/src/recipe_resolver.rs b/src/recipe_resolver.rs
index 88e49783a4..b1d07a190d 100644
--- a/src/recipe_resolver.rs
+++ b/src/recipe_resolver.rs
@@ -79,13 +79,15 @@ impl<'src: 'run, 'run> RecipeResolver<'src, 'run> {
stack.push(recipe.name());
+ let should_cache = recipe.should_cache();
+
let mut dependencies: Vec> = Vec::new();
- for dependency in &recipe.dependencies {
+ for (index, dependency) in recipe.dependencies.iter().enumerate() {
let name = dependency.recipe.lexeme();
- if let Some(resolved) = self.resolved_recipes.get(name) {
+ let resolved = if let Some(resolved) = self.resolved_recipes.get(name) {
// dependency already resolved
- dependencies.push(Rc::clone(resolved));
+ Rc::clone(resolved)
} else if stack.contains(&name) {
let first = stack[0];
stack.push(first);
@@ -101,14 +103,23 @@ impl<'src: 'run, 'run> RecipeResolver<'src, 'run> {
);
} else if let Some(unresolved) = self.unresolved_recipes.remove(name) {
// resolve unresolved dependency
- dependencies.push(self.resolve_recipe(stack, unresolved)?);
+ self.resolve_recipe(stack, unresolved)?
} else {
// dependency is unknown
return Err(dependency.recipe.error(UnknownDependency {
recipe: recipe.name(),
unknown: name,
}));
+ };
+
+ if index < recipe.priors && should_cache && !resolved.should_cache() {
+ return Err(dependency.recipe.error(CachedDependsOnUncached {
+ cached: recipe.name(),
+ uncached: resolved.name(),
+ }));
}
+
+ dependencies.push(resolved);
}
stack.pop();
diff --git a/src/search.rs b/src/search.rs
index c14eb55354..fcd36c8a12 100644
--- a/src/search.rs
+++ b/src/search.rs
@@ -7,9 +7,33 @@ const PROJECT_ROOT_CHILDREN: &[&str] = &[".bzr", ".git", ".hg", ".svn", "_darcs"
pub(crate) struct Search {
pub(crate) justfile: PathBuf,
pub(crate) working_directory: PathBuf,
+ pub(crate) cache_file: PathBuf,
}
impl Search {
+ fn new(justfile: PathBuf, working_directory: PathBuf) -> Self {
+ let cache_file = {
+ let project_name = working_directory
+ .file_name()
+ .and_then(|name| name.to_str())
+ .unwrap_or("UNKNOWN_PROJECT");
+ let mut path_hash = blake3::Hasher::new();
+ path_hash.update(working_directory.as_os_str().as_encoded_bytes());
+ path_hash.update(justfile.as_os_str().as_encoded_bytes());
+ let path_hash = &path_hash.finalize().to_hex()[..16];
+
+ working_directory
+ .join(".justcache")
+ .join(format!("{project_name}-{path_hash}.json"))
+ };
+
+ Self {
+ justfile,
+ working_directory,
+ cache_file,
+ }
+ }
+
pub(crate) fn find(
search_config: &SearchConfig,
invocation_directory: &Path,
@@ -23,28 +47,22 @@ impl Search {
let working_directory = Self::working_directory_from_justfile(&justfile)?;
- Ok(Self {
- justfile,
- working_directory,
- })
+ Ok(Self::new(justfile, working_directory))
}
SearchConfig::WithJustfile { justfile } => {
let justfile = Self::clean(invocation_directory, justfile);
let working_directory = Self::working_directory_from_justfile(&justfile)?;
- Ok(Self {
- justfile,
- working_directory,
- })
+ Ok(Self::new(justfile, working_directory))
}
SearchConfig::WithJustfileAndWorkingDirectory {
justfile,
working_directory,
- } => Ok(Self {
- justfile: Self::clean(invocation_directory, justfile),
- working_directory: Self::clean(invocation_directory, working_directory),
- }),
+ } => Ok(Self::new(
+ Self::clean(invocation_directory, justfile),
+ Self::clean(invocation_directory, working_directory),
+ )),
}
}
@@ -53,10 +71,7 @@ impl Search {
let working_directory = Self::working_directory_from_justfile(&justfile)?;
- Ok(Self {
- justfile,
- working_directory,
- })
+ Ok(Self::new(justfile, working_directory))
}
pub(crate) fn init(
@@ -69,10 +84,7 @@ impl Search {
let justfile = working_directory.join(DEFAULT_JUSTFILE_NAME);
- Ok(Self {
- justfile,
- working_directory,
- })
+ Ok(Self::new(justfile, working_directory))
}
SearchConfig::FromSearchDirectory { search_directory } => {
@@ -82,10 +94,7 @@ impl Search {
let justfile = working_directory.join(DEFAULT_JUSTFILE_NAME);
- Ok(Self {
- justfile,
- working_directory,
- })
+ Ok(Self::new(justfile, working_directory))
}
SearchConfig::WithJustfile { justfile } => {
@@ -93,19 +102,16 @@ impl Search {
let working_directory = Self::working_directory_from_justfile(&justfile)?;
- Ok(Self {
- justfile,
- working_directory,
- })
+ Ok(Self::new(justfile, working_directory))
}
SearchConfig::WithJustfileAndWorkingDirectory {
justfile,
working_directory,
- } => Ok(Self {
- justfile: Self::clean(invocation_directory, justfile),
- working_directory: Self::clean(invocation_directory, working_directory),
- }),
+ } => Ok(Self::new(
+ Self::clean(invocation_directory, justfile),
+ Self::clean(invocation_directory, working_directory),
+ )),
}
}
diff --git a/src/testing.rs b/src/testing.rs
index 09ddb3e16a..19dd137d45 100644
--- a/src/testing.rs
+++ b/src/testing.rs
@@ -18,10 +18,14 @@ pub(crate) fn config(args: &[&str]) -> Config {
pub(crate) fn search(config: &Config) -> Search {
let working_directory = config.invocation_directory.clone();
let justfile = working_directory.join("justfile");
+ let cache_file = working_directory
+ .join(".justcache")
+ .join("current-project.json");
Search {
justfile,
working_directory,
+ cache_file,
}
}
diff --git a/tests/cached_recipes.rs b/tests/cached_recipes.rs
new file mode 100644
index 0000000000..3c1d72aaf8
--- /dev/null
+++ b/tests/cached_recipes.rs
@@ -0,0 +1,326 @@
+use super::*;
+
+struct ReuseableTest {
+ test: Test,
+ justfile: &'static str,
+}
+
+impl ReuseableTest {
+ pub(crate) fn new(justfile: &'static str) -> Self {
+ Self {
+ test: Test::new().justfile(justfile),
+ justfile,
+ }
+ }
+
+ fn new_with_test(justfile: &'static str, test: Test) -> Self {
+ Self { test, justfile }
+ }
+
+ pub(crate) fn map(self, map: impl FnOnce(Test) -> Test) -> Self {
+ Self::new_with_test(self.justfile, map(self.test))
+ }
+
+ pub(crate) fn run(self) -> Self {
+ let justfile = self.justfile;
+ let Output { tempdir, .. } = self.test.run();
+ Self::new_with_test(justfile, Test::with_tempdir(tempdir).justfile(justfile))
+ }
+}
+
+fn skipped_message(recipe_name: &str) -> String {
+ format!(
+ "===> Hash of recipe body of `{}` matches last run. Skipping...\n",
+ recipe_name
+ )
+}
+
+#[test]
+fn cached_recipes_are_unstable() {
+ let justfile = r#"
+ [cached]
+ echo:
+ @echo cached
+ "#;
+
+ Test::new()
+ .justfile(justfile)
+ .stderr("error: Cached recipes are currently unstable. Invoke `just` with the `--unstable` flag to enable unstable features.\n")
+ .status(EXIT_FAILURE)
+ .run();
+}
+
+#[test]
+fn cached_recipes_are_cached() {
+ let justfile = r#"
+ [cached]
+ echo:
+ @echo caching...
+ "#;
+
+ let wrapper = ReuseableTest::new(justfile);
+ let wrapper = wrapper
+ .map(|test| test.arg("--unstable").stdout("caching...\n"))
+ .run();
+ let _wrapper = wrapper
+ .map(|test| test.arg("--unstable").stderr(skipped_message("echo")))
+ .run();
+}
+
+#[test]
+fn uncached_recipes_are_uncached() {
+ let justfile = r#"
+ echo:
+ @echo uncached
+ "#;
+
+ let wrapper = ReuseableTest::new(justfile);
+ let wrapper = wrapper.map(|test| test.stdout("uncached\n")).run();
+ let _wrapper = wrapper.map(|test| test.stdout("uncached\n")).run();
+}
+
+#[test]
+fn cached_recipes_are_independent() {
+ let justfile = r#"
+ [cached]
+ echo1:
+ @echo cached1
+ [cached]
+ echo2:
+ @echo cached2
+ "#;
+
+ let wrapper = ReuseableTest::new(justfile);
+ let wrapper = wrapper
+ .map(|test| test.arg("--unstable").arg("echo1").stdout("cached1\n"))
+ .run();
+ let wrapper = wrapper
+ .map(|test| test.arg("--unstable").arg("echo2").stdout("cached2\n"))
+ .run();
+ let wrapper = wrapper
+ .map(|test| {
+ test
+ .arg("--unstable")
+ .arg("echo1")
+ .stderr(skipped_message("echo1"))
+ })
+ .run();
+ let _wrapper = wrapper
+ .map(|test| {
+ test
+ .arg("--unstable")
+ .arg("echo2")
+ .stderr(skipped_message("echo2"))
+ })
+ .run();
+}
+
+#[test]
+fn interpolated_values_are_part_of_cache_hash() {
+ let justfile = r#"
+ my-var := "1"
+ [cached]
+ echo ARG:
+ @echo {{ARG}}{{my-var}}
+ "#;
+
+ let wrapper = ReuseableTest::new(justfile);
+ let wrapper = wrapper
+ .map(|test| test.arg("--unstable").args(["echo", "a"]).stdout("a1\n"))
+ .run();
+ let wrapper = wrapper
+ .map(|test| {
+ test
+ .arg("--unstable")
+ .args(["echo", "a"])
+ .stderr(skipped_message("echo"))
+ })
+ .run();
+ let wrapper = wrapper
+ .map(|test| test.arg("--unstable").args(["echo", "b"]).stdout("b1\n"))
+ .run();
+ let wrapper = wrapper
+ .map(|test| {
+ test
+ .arg("--unstable")
+ .args(["echo", "b"])
+ .stderr(skipped_message("echo"))
+ })
+ .run();
+ let wrapper = wrapper
+ .map(|test| {
+ test
+ .arg("--unstable")
+ .args(["my-var=2", "echo", "b"])
+ .stdout("b2\n")
+ })
+ .run();
+ let _wrapper = wrapper
+ .map(|test| {
+ test
+ .arg("--unstable")
+ .args(["my-var=2", "echo", "b"])
+ .stderr(skipped_message("echo"))
+ })
+ .run();
+}
+
+#[test]
+fn invalid_cache_files_are_ignored() {
+ let justfile = r#"
+ [cached]
+ echo:
+ @echo cached
+ "#;
+
+ let wrapper = ReuseableTest::new(justfile);
+ let wrapper = wrapper
+ .map(|test| test.arg("--unstable").stdout("cached\n"))
+ .run();
+
+ let cache_dir = wrapper.test.tempdir.path().join(".justcache");
+ let mut caches = std::fs::read_dir(cache_dir).expect("could not read cache dir");
+ let cached_recipe = caches.next().expect("no recipe cache file").unwrap().path();
+ std::fs::write(cached_recipe, r#"{"invalid_cache_format": true}"#).unwrap();
+
+ let _wrapper = wrapper
+ .map(|test| test.arg("--unstable").stdout("cached\n"))
+ .run();
+}
+
+#[test]
+fn cached_deps_cannot_depend_on_preceding_uncached_ones() {
+ let justfile = r#"
+ [cached]
+ cash-money: uncached
+ uncached:
+ "#;
+
+ let wrapper = ReuseableTest::new(justfile);
+ let _wrapper = wrapper
+ .map(|test| {
+ test
+ .arg("--unstable")
+ .stderr(unindent(r#"
+ error: Cached recipes cannot depend on preceding uncached ones, yet `cash-money` depends on `uncached`
+ ——▶ justfile:2:13
+ │
+ 2 │ cash-money: uncached
+ │ ^^^^^^^^
+ "#))
+ .status(EXIT_FAILURE)
+ })
+ .run();
+}
+
+#[test]
+fn subsequent_deps_run_only_when_cached_recipe_runs() {
+ let justfile = r#"
+ [cached]
+ cash-money: && uncached
+ @echo cash money
+ uncached:
+ @echo uncached cleanup
+ "#;
+
+ let wrapper = ReuseableTest::new(justfile);
+ let wrapper = wrapper
+ .map(|test| {
+ test
+ .arg("--unstable")
+ .arg("cash-money")
+ .stdout("cash money\nuncached cleanup\n")
+ })
+ .run();
+ let _wrapper = wrapper
+ .map(|test| {
+ test
+ .arg("--unstable")
+ .arg("cash-money")
+ .stderr(skipped_message("cash-money"))
+ })
+ .run();
+}
+
+#[test]
+fn cached_recipes_rerun_when_deps_change_but_not_vice_versa() {
+ let justfile = r#"
+ top-var := "default-top"
+ mid-var := "default-middle"
+ bot-var := "default-bottom"
+
+ [cached]
+ top: mid
+ @echo {{top-var}}
+ [cached]
+ mid: bot
+ @echo {{mid-var}}
+ [cached]
+ bot:
+ @echo {{bot-var}}
+ "#;
+
+ let wrapper = ReuseableTest::new(justfile);
+ let wrapper = wrapper
+ .map(|test| test.arg("--unstable").arg("bot").stdout("default-bottom\n"))
+ .run();
+ let wrapper = wrapper
+ .map(|test| {
+ test
+ .arg("--unstable")
+ .arg("top")
+ .stderr(skipped_message("bot"))
+ .stdout("default-middle\ndefault-top\n")
+ })
+ .run();
+
+ let wrapper = wrapper
+ .map(|test| {
+ test
+ .arg("--unstable")
+ .args(["bot-var=change-bottom", "top"])
+ .stdout("change-bottom\ndefault-middle\ndefault-top\n")
+ })
+ .run();
+
+ let _wrapper = wrapper
+ .map(|test| {
+ test
+ .arg("--unstable")
+ .args(["bot-var=change-bottom", "top-var=change-top", "top"])
+ .stderr([skipped_message("bot"), skipped_message("mid")].concat())
+ .stdout("change-top\n")
+ })
+ .run();
+}
+
+#[test]
+fn failed_runs_should_not_update_cache() {
+ let justfile = r#"
+ [cached]
+ exit EXIT_CODE:
+ @exit {{EXIT_CODE}}
+ "#;
+
+ let wrapper = ReuseableTest::new(justfile);
+ let wrapper = wrapper
+ .map(|test| test.arg("--unstable").args(["exit", "0"]))
+ .run();
+ let wrapper = wrapper
+ .map(|test| {
+ test
+ .arg("--unstable")
+ .args(["exit", "1"])
+ .stderr("error: Recipe `exit` failed on line 3 with exit code 1\n")
+ .status(EXIT_FAILURE)
+ })
+ .run();
+ let _wrapper = wrapper
+ .map(|test| {
+ test
+ .arg("--unstable")
+ .args(["exit", "0"])
+ .stderr(skipped_message("exit"))
+ })
+ .run();
+}
diff --git a/tests/json.rs b/tests/json.rs
index 4c71b430ff..e662ec5fc3 100644
--- a/tests/json.rs
+++ b/tests/json.rs
@@ -775,10 +775,10 @@ fn quiet() {
#[test]
fn settings() {
case(
- "
+ r#"
set dotenv-load
- set dotenv-filename := \"filename\"
- set dotenv-path := \"path\"
+ set dotenv-filename := "dotenv-filename"
+ set dotenv-path := "path"
set export
set fallback
set positional-arguments
@@ -787,7 +787,7 @@ fn settings() {
set shell := ['a', 'b', 'c']
foo:
#!bar
- ",
+ "#,
json!({
"aliases": {},
"assignments": {},
@@ -810,7 +810,7 @@ fn settings() {
},
"settings": {
"allow_duplicate_recipes": false,
- "dotenv_filename": "filename",
+ "dotenv_filename": "dotenv-filename",
"dotenv_load": true,
"dotenv_path": "path",
"export": true,
diff --git a/tests/lib.rs b/tests/lib.rs
index b501b7e3a5..10f1f792e0 100644
--- a/tests/lib.rs
+++ b/tests/lib.rs
@@ -37,6 +37,7 @@ mod assert_stdout;
mod assert_success;
mod attributes;
mod byte_order_mark;
+mod cached_recipes;
mod changelog;
mod choose;
mod command;