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;