diff --git a/CHANGELOG.md b/CHANGELOG.md index f28813ec3b30..e1756056d317 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,11 @@ - [Implemented in-memory and database mixed `Decimal` column comparisons.][10614] +- [Relative paths are now resolved relative to the project location, also in the + Cloud.][10660] [10614]: https://github.com/enso-org/enso/pull/10614 +[10660]: https://github.com/enso-org/enso/pull/10660 # Enso 2023.3 diff --git a/build.sbt b/build.sbt index b0c34f7e45ff..8874af612d53 100644 --- a/build.sbt +++ b/build.sbt @@ -3860,7 +3860,6 @@ pkgStdLibInternal := Def.inputTask { if (generateIndex) { val stdlibStandardRoot = root / "lib" / standardNamespace DistributionPackage.indexStdLib( - libMajor = stdlibStandardRoot, libName = stdlibStandardRoot / lib, stdLibVersion = defaultDevEnsoVersion, ensoVersion = defaultDevEnsoVersion, diff --git a/build/build/src/enso.rs b/build/build/src/enso.rs index 82b97cc43831..698beac90281 100644 --- a/build/build/src/enso.rs +++ b/build/build/src/enso.rs @@ -78,6 +78,7 @@ impl BuiltEnso { pub async fn run_benchmarks(&self, opt: BenchmarkOptions) -> Result { let filename = format!("enso{}", if TARGET_OS == OS::Windows { ".exe" } else { "" }); + let base_working_directory = self.paths.repo_root.test.benchmarks.try_parent()?; let enso = self .paths .repo_root @@ -88,6 +89,7 @@ impl BuiltEnso { .join(filename); let benchmarks = Command::new(&enso) .args(["--jvm", "--run", self.paths.repo_root.test.benchmarks.as_str()]) + .current_dir(base_working_directory) .set_env(ENSO_BENCHMARK_TEST_DRY_RUN, &Boolean::from(opt.dry_run))? .run_ok() .await; @@ -96,10 +98,12 @@ impl BuiltEnso { pub fn run_test(&self, test_path: impl AsRef, ir_caches: IrCaches) -> Result { let mut command = self.cmd()?; + let base_working_directory = test_path.try_parent()?; command .arg(ir_caches) .arg("--run") .arg(test_path.as_ref()) + .current_dir(base_working_directory) // This flag enables assertions in the JVM. Some of our stdlib tests had in the past // failed on Graal/Truffle assertions, so we want to have them triggered. .set_env(JAVA_OPTS, &ide_ci::programs::java::Option::EnableAssertions.as_ref())?; diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Enso_Cloud/Enso_File.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Enso_Cloud/Enso_File.enso index 30cac40a6409..c5f58e384716 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Enso_Cloud/Enso_File.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Enso_Cloud/Enso_File.enso @@ -88,9 +88,15 @@ type Enso_File directory. current_working_directory : Enso_File current_working_directory = + Enso_File.cloud_project_parent_directory . if_nothing Enso_File.root + + ## PRIVATE + The parent directory containing the currently open project if in the + Cloud, or `Nothing` if running locally. + cloud_project_parent_directory : Enso_File | Nothing + cloud_project_parent_directory = path = Environment.get "ENSO_CLOUD_PROJECT_DIRECTORY_PATH" - if path.is_nothing then Enso_File.root else - Enso_File.new path + path.if_not_nothing <| Enso_File.new path ## PRIVATE asset_type self -> Enso_Asset_Type = diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/System/File.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/System/File.enso index 1fb94acbb632..fab85a043fa1 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/System/File.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/System/File.enso @@ -10,6 +10,7 @@ import project.Data.Time.Date_Time.Date_Time import project.Data.Vector.Vector import project.Enso_Cloud.Data_Link.Data_Link import project.Enso_Cloud.Data_Link_Helpers +import project.Enso_Cloud.Enso_File.Enso_File import project.Error.Error import project.Errors.Common.Dry_Run_Operation import project.Errors.Common.Type_Error @@ -61,6 +62,10 @@ type File Creates a new file object, pointing to the given path. + Relative paths are resolved relative to the directory containing the + currently running workflow. Thus, if the workflow is running in the Cloud, + the relative paths will be resolved to Cloud files. + Arguments: - path: The path to the file that you want to create, or a file itself. The latter is a no-op. @@ -74,7 +79,7 @@ type File example_new = File.new Examples.csv_path new : (Text | File) -> Any ! Illegal_Argument new path = case path of - _ : Text -> if path.contains "://" . not then get_file path else + _ : Text -> if path.contains "://" . not then resolve_path path else protocol = path.split "://" . first file_system = FileSystemSPI.get_type protocol False if file_system.is_nothing then Error.throw (Illegal_Argument.Error "Unsupported protocol "+protocol) else @@ -860,6 +865,22 @@ get_cwd = @Builtin_Method "File.get_cwd" get_file : Text -> File get_file path = @Builtin_Method "File.get_file" +## PRIVATE + Resolves the given path to a corresponding file location. + + If the provided path is relative, the behaviour depends on the context: + - if the project is running in the Cloud, the path is resolved to a Cloud file, + relative to the project's location. + - if running locally, the path is resolved to a local file, relative to the + current working directory. +resolve_path (path : Text) -> File | Enso_File = + local_file = get_file path + # Absolute files always resolve to themselves. + if local_file.is_absolute then local_file else + case Enso_File.cloud_project_parent_directory of + Nothing -> local_file + base_cloud_directory -> base_cloud_directory / path + ## PRIVATE get_child_widget : File -> Widget get_child_widget file = diff --git a/engine/launcher/src/main/scala/org/enso/launcher/components/LauncherRunner.scala b/engine/launcher/src/main/scala/org/enso/launcher/components/LauncherRunner.scala index 0ba483e45efb..0aa5f63ebf30 100644 --- a/engine/launcher/src/main/scala/org/enso/launcher/components/LauncherRunner.scala +++ b/engine/launcher/src/main/scala/org/enso/launcher/components/LauncherRunner.scala @@ -55,7 +55,8 @@ class LauncherRunner( projectManager.findProject(currentWorkingDirectory).get } - val version = resolveVersion(versionOverride, inProject) + val version = resolveVersion(versionOverride, inProject) + val workingDirectory = workingDirectoryForRunner(inProject, None) val arguments = inProject match { case Some(project) => val projectPackagePath = @@ -68,6 +69,7 @@ class LauncherRunner( version, arguments ++ setLogLevelArgs(logLevel, logMasking) ++ additionalArguments, + workingDirectory = workingDirectory, connectLoggerIfAvailable = true ) } @@ -109,6 +111,10 @@ class LauncherRunner( else projectManager.findProject(actualPath).get val version = resolveVersion(versionOverride, project) + // The engine is started in the directory containing the project, or the standalone script. + val workingDirectory = + workingDirectoryForRunner(project, Some(actualPath)) + val arguments = if (projectMode) Seq("--run", actualPath.toString) else @@ -127,10 +133,24 @@ class LauncherRunner( version, arguments ++ setLogLevelArgs(logLevel, logMasking) ++ additionalArguments, + workingDirectory = workingDirectory, connectLoggerIfAvailable = true ) } + private def workingDirectoryForRunner( + inProject: Option[Project], + scriptPath: Option[Path] + ): Option[Path] = { + // The path of the project or standalone script that is being run. + val baseDirectory = inProject match { + case Some(project) => Some(project.path) + case None => scriptPath + } + + baseDirectory.map(p => p.toAbsolutePath.normalize().getParent) + } + private def setLogLevelArgs( level: Level, logMasking: Boolean @@ -190,7 +210,12 @@ class LauncherRunner( } ( - RunSettings(version, arguments, connectLoggerIfAvailable = false), + RunSettings( + version, + arguments, + workingDirectory = None, + connectLoggerIfAvailable = false + ), whichEngine ) } @@ -239,6 +264,7 @@ class LauncherRunner( version, arguments ++ setLogLevelArgs(logLevel, logMasking) ++ additionalArguments, + workingDirectory = None, connectLoggerIfAvailable = true ) } @@ -286,6 +312,7 @@ class LauncherRunner( version, arguments ++ setLogLevelArgs(logLevel, logMasking) ++ additionalArguments, + workingDirectory = None, connectLoggerIfAvailable = true ) } diff --git a/engine/launcher/src/test/scala/org/enso/launcher/components/LauncherRunnerSpec.scala b/engine/launcher/src/test/scala/org/enso/launcher/components/LauncherRunnerSpec.scala index eeeff4b1d2ea..b8658fbecafb 100644 --- a/engine/launcher/src/test/scala/org/enso/launcher/components/LauncherRunnerSpec.scala +++ b/engine/launcher/src/test/scala/org/enso/launcher/components/LauncherRunnerSpec.scala @@ -64,6 +64,7 @@ class LauncherRunnerSpec extends RuntimeVersionManagerTest with FlakySpec { val runSettings = RunSettings( SemVer.of(0, 0, 0), Seq("arg1", "--flag2"), + workingDirectory = None, connectLoggerIfAvailable = true ) val jvmOptions = Seq(("locally-added-options", "value1")) @@ -243,6 +244,7 @@ class LauncherRunnerSpec extends RuntimeVersionManagerTest with FlakySpec { .get outsideProject.engineVersion shouldEqual version + outsideProject.workingDirectory shouldEqual Some(projectPath.getParent) outsideProject.runnerArguments.mkString(" ") should (include(s"--in-project $normalizedPath") and include("--repl")) @@ -258,6 +260,7 @@ class LauncherRunnerSpec extends RuntimeVersionManagerTest with FlakySpec { .get insideProject.engineVersion shouldEqual version + insideProject.workingDirectory shouldEqual Some(projectPath.getParent) insideProject.runnerArguments.mkString(" ") should (include(s"--in-project $normalizedPath") and include("--repl")) @@ -304,6 +307,7 @@ class LauncherRunnerSpec extends RuntimeVersionManagerTest with FlakySpec { .get runSettings.engineVersion shouldEqual version + runSettings.workingDirectory shouldEqual Some(projectPath.getParent) val commandLine = runSettings.runnerArguments.mkString(" ") commandLine should include(s"--interface ${options.interface}") commandLine should include(s"--rpc-port ${options.rpcPort}") @@ -346,6 +350,7 @@ class LauncherRunnerSpec extends RuntimeVersionManagerTest with FlakySpec { .get outsideProject.engineVersion shouldEqual version + outsideProject.workingDirectory shouldEqual Some(projectPath.getParent) outsideProject.runnerArguments.mkString(" ") should include(s"--run $normalizedPath") @@ -443,6 +448,7 @@ class LauncherRunnerSpec extends RuntimeVersionManagerTest with FlakySpec { .get runSettings.engineVersion shouldEqual version + runSettings.workingDirectory shouldEqual Some(projectPath.getParent) runSettings.runnerArguments.mkString(" ") should (include(s"--run $normalizedFilePath") and include(s"--in-project $normalizedProjectPath")) diff --git a/engine/runtime/src/main/java/org/enso/interpreter/runtime/EnsoContext.java b/engine/runtime/src/main/java/org/enso/interpreter/runtime/EnsoContext.java index 5c3b13b15423..3d6081832570 100644 --- a/engine/runtime/src/main/java/org/enso/interpreter/runtime/EnsoContext.java +++ b/engine/runtime/src/main/java/org/enso/interpreter/runtime/EnsoContext.java @@ -21,10 +21,12 @@ import com.oracle.truffle.api.source.Source; import java.io.BufferedReader; import java.io.File; +import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.PrintStream; import java.net.MalformedURLException; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; @@ -56,6 +58,7 @@ import org.enso.interpreter.runtime.util.TruffleFileSystem; import org.enso.librarymanager.ProjectLoadingFailure; import org.enso.librarymanager.resolved.LibraryRoot; +import org.enso.logger.masking.MaskedPath$; import org.enso.pkg.Package; import org.enso.pkg.PackageManager; import org.enso.pkg.QualifiedName; @@ -163,6 +166,7 @@ public void initialize() { PackageManager packageManager = new PackageManager<>(fs); Optional projectRoot = OptionsHelper.getProjectRoot(environment); + checkWorkingDirectory(projectRoot); Optional> projectPackage = projectRoot.map( file -> @@ -206,6 +210,28 @@ public void initialize() { } } + /** Checks if the working directory is as expected and reports a warning if not. */ + private void checkWorkingDirectory(Optional maybeProjectRoot) { + if (maybeProjectRoot.isPresent()) { + var root = maybeProjectRoot.get(); + var parent = root.getAbsoluteFile().normalize().getParent(); + var cwd = environment.getCurrentWorkingDirectory().getAbsoluteFile().normalize(); + try { + if (!cwd.isSameFile(parent)) { + var maskedPath = MaskedPath$.MODULE$.apply(Path.of(parent.toString())); + logger.log( + Level.WARNING, + "Initializing the context in a different working directory than the one containing" + + " the project root. This may lead to relative paths not behaving as advertised" + + " by `File.new`. Please run the engine inside of `{}` directory.", + maskedPath); + } + } catch (IOException e) { + logger.severe("Error checking working directory: " + e.getMessage()); + } + } + } + /** * @param node the location of context access. Pass {@code null} if not in a node. * @return the proper context instance for the current {@link diff --git a/lib/scala/runtime-version-manager/src/main/scala/org/enso/runtimeversionmanager/runner/Command.scala b/lib/scala/runtime-version-manager/src/main/scala/org/enso/runtimeversionmanager/runner/Command.scala index b2b5ea6743d3..4c2e38e895fc 100644 --- a/lib/scala/runtime-version-manager/src/main/scala/org/enso/runtimeversionmanager/runner/Command.scala +++ b/lib/scala/runtime-version-manager/src/main/scala/org/enso/runtimeversionmanager/runner/Command.scala @@ -3,6 +3,7 @@ package org.enso.runtimeversionmanager.runner import com.typesafe.scalalogging.Logger import org.enso.process.WrappedProcess +import java.nio.file.Path import scala.sys.process.Process import scala.util.{Failure, Try} @@ -10,8 +11,13 @@ import scala.util.{Failure, Try} * * @param command the command and its arguments that should be executed * @param extraEnv environment variables that should be overridden + * @param workingDirectory the working directory in which the command should be executed (if None, the working directory is not overridden and is inherited instead) */ -case class Command(command: Seq[String], extraEnv: Seq[(String, String)]) { +case class Command( + command: Seq[String], + extraEnv: Seq[(String, String)], + workingDirectory: Option[Path] +) { private val logger = Logger[Command] /** Runs the command and returns its exit code. @@ -79,6 +85,7 @@ case class Command(command: Seq[String], extraEnv: Seq[(String, String)]) { for ((key, value) <- extraEnv) { processBuilder.environment().put(key, value) } + workingDirectory.foreach(path => processBuilder.directory(path.toFile)) processBuilder } diff --git a/lib/scala/runtime-version-manager/src/main/scala/org/enso/runtimeversionmanager/runner/RunSettings.scala b/lib/scala/runtime-version-manager/src/main/scala/org/enso/runtimeversionmanager/runner/RunSettings.scala index 9f9d0ad592d7..35615728025a 100644 --- a/lib/scala/runtime-version-manager/src/main/scala/org/enso/runtimeversionmanager/runner/RunSettings.scala +++ b/lib/scala/runtime-version-manager/src/main/scala/org/enso/runtimeversionmanager/runner/RunSettings.scala @@ -2,15 +2,19 @@ package org.enso.runtimeversionmanager.runner import org.enso.semver.SemVer +import java.nio.file.Path + /** Represents settings that are used to launch the runner JAR. * * @param engineVersion Enso engine version to use * @param runnerArguments arguments that should be passed to the runner + * @param workingDirectory the working directory override * @param connectLoggerIfAvailable specifies if the ran component should * connect to launcher's logging service */ case class RunSettings( engineVersion: SemVer, runnerArguments: Seq[String], + workingDirectory: Option[Path], connectLoggerIfAvailable: Boolean ) diff --git a/lib/scala/runtime-version-manager/src/main/scala/org/enso/runtimeversionmanager/runner/Runner.scala b/lib/scala/runtime-version-manager/src/main/scala/org/enso/runtimeversionmanager/runner/Runner.scala index 71dc607b3b6c..ea0affdb71d1 100644 --- a/lib/scala/runtime-version-manager/src/main/scala/org/enso/runtimeversionmanager/runner/Runner.scala +++ b/lib/scala/runtime-version-manager/src/main/scala/org/enso/runtimeversionmanager/runner/Runner.scala @@ -79,7 +79,12 @@ class Runner( engineVersion ) } - RunSettings(engineVersion, arguments, connectLoggerIfAvailable = false) + RunSettings( + engineVersion, + arguments, + workingDirectory = None, + connectLoggerIfAvailable = false + ) } /** Creates [[RunSettings]] for launching the Language Server. */ @@ -113,6 +118,8 @@ class Runner( additionalArguments: Seq[String] ): Try[RunSettings] = Try { + val workingDirectory = + Path.of(projectPath).toAbsolutePath.normalize.getParent val arguments = Seq( "--server", "--root-id", @@ -137,6 +144,7 @@ class Runner( RunSettings( version, arguments ++ additionalArguments, + workingDirectory = Some(workingDirectory), connectLoggerIfAvailable = true ) } @@ -238,7 +246,13 @@ class Runner( val extraEnvironmentOverrides = javaHome.map("JAVA_HOME" -> _).toSeq ++ distributionSettings.toSeq - action(Command(command, extraEnvironmentOverrides)) + action( + Command( + command, + extraEnvironmentOverrides, + runSettings.workingDirectory + ) + ) } val engineVersion = runSettings.engineVersion diff --git a/project/DistributionPackage.scala b/project/DistributionPackage.scala index 66f5bbcbbc65..507ab80f655a 100644 --- a/project/DistributionPackage.scala +++ b/project/DistributionPackage.scala @@ -210,7 +210,6 @@ object DistributionPackage { libName <- (stdLibRoot / libMajor.getName).listFiles() } yield { indexStdLib( - libMajor, libName, stdLibVersion, ensoVersion, @@ -222,7 +221,6 @@ object DistributionPackage { } def indexStdLib( - libMajor: File, libName: File, stdLibVersion: String, ensoVersion: String, @@ -239,25 +237,25 @@ object DistributionPackage { path.globRecursive("*.enso" && FileOnlyFilter).get().toSet ) { diff => if (diff.modified.nonEmpty) { - log.info(s"Generating index for ${libName} ") + log.info(s"Generating index for $libName ") val command = Seq( - Platform.executableFileName(ensoExecutable.toString), + Platform.executableFile(ensoExecutable.getAbsoluteFile), "--no-compile-dependencies", "--no-global-cache", "--compile", - path.toString + path.getAbsolutePath ) log.debug(command.mkString(" ")) val exitCode = Process( command, - None, + Some(path.getAbsoluteFile.getParentFile), "JAVA_OPTS" -> "-Dorg.jline.terminal.dumb=true" ).! if (exitCode != 0) { - throw new RuntimeException(s"Cannot compile $libMajor.$libName.") + throw new RuntimeException(s"Cannot compile $libName.") } } else { - log.debug(s"No modified files. Not generating index for ${libName}.") + log.debug(s"No modified files. Not generating index for $libName.") } } } diff --git a/project/LibraryManifestGenerator.scala b/project/LibraryManifestGenerator.scala index 7ea28b8774b9..f444229eb6ad 100644 --- a/project/LibraryManifestGenerator.scala +++ b/project/LibraryManifestGenerator.scala @@ -53,6 +53,7 @@ object LibraryManifestGenerator { javaOpts: Seq[String], log: Logger ): Unit = { + val canonicalPath = projectPath.getCanonicalFile val javaCommand = ProcessHandle.current().info().command().asScala.getOrElse("java") val command = Seq( @@ -60,7 +61,7 @@ object LibraryManifestGenerator { ) ++ javaOpts ++ Seq( "--update-manifest", "--in-project", - projectPath.getCanonicalPath + canonicalPath.toString ) val commandText = command.mkString(" ") @@ -68,7 +69,7 @@ object LibraryManifestGenerator { val exitCode = sys.process .Process( command, - None, + cwd = Some(canonicalPath.getParentFile), "ENSO_EDITION_PATH" -> file("distribution/editions").getCanonicalPath ) .! diff --git a/project/Platform.scala b/project/Platform.scala index 7dda29e257ea..8a0912c480a2 100644 --- a/project/Platform.scala +++ b/project/Platform.scala @@ -1,3 +1,7 @@ +import sbt.singleFileFinder + +import java.io.File + object Platform { /** Returns true if the build system is running on Windows. @@ -46,4 +50,17 @@ object Platform { if (isWindows) s".\\$name.bat" else name } + /** Returns the executable file on the current platform. + * + * @param file the generic executable path + * @return the file corresponding to the provided executable on the current platform + */ + def executableFile(file: File): String = + if (isWindows) { + val parent = file.getParentFile + if (parent == null) s".\\${file.getName}.bat" + else if (parent.isAbsolute) + new File(parent, s"${file.getName}.bat").getPath + else s".\\${parent.getPath}${file.getPath}.bat" + } else file.getPath } diff --git a/test/Base_Tests/src/System/File_Spec.enso b/test/Base_Tests/src/System/File_Spec.enso index 15f229fdbfc8..67c0cd9cf87a 100644 --- a/test/Base_Tests/src/System/File_Spec.enso +++ b/test/Base_Tests/src/System/File_Spec.enso @@ -12,6 +12,9 @@ import Standard.Base.System.File.Generic.Writable_File.Writable_File polyglot java import org.enso.base_test_helpers.FileSystemHelper from Standard.Test import all +import Standard.Test.Test_Environment + +import project.Network.Enso_Cloud.Cloud_Tests_Setup.Cloud_Tests_Setup ## We rely on a less strict equality for `File` as it is fine if a relative file gets resolved to absolute. File.should_equal self other frames_to_skip=0 = @@ -361,6 +364,57 @@ add_specs suite_builder = (File.new ".").name . should_equal (File.new "." . absolute . normalize . name) (File.new "..").name . should_equal (File.new "." . parent . absolute . normalize . name) + current_project_root = enso_project.root + base_directory = current_project_root.parent + is_correct_working_directory = (File.current_directory . normalize . path) == current_project_root.absolute.normalize.path + group_builder.specify "will resolve relative paths relative to the currently running project" pending=(if is_correct_working_directory.not then "The working directory is not set-up as expected, so this test cannot run. Please run the tests using `ensoup` to ensure the working directory is correct.") <| + root = File.new "." + root.should_be_a File + # The `.` path should resolve to the base path + root.absolute.normalize.path . should_equal base_directory.absolute.normalize.path + + expected_file = base_directory / "abc" / "def.txt" + f = File.new "abc/def.txt" + f.should_be_a File + f.absolute.normalize.path . should_equal expected_file.absolute.normalize.path + + (File.new "abc").create_directory . should_succeed + txt = "test-content"+Random.uuid + txt.write expected_file . should_succeed + Panic.with_finalizer expected_file.delete_if_exists <| + f.read_text . should_equal txt + Data.read "abc/def.txt" . should_equal txt + + cloud_setup = Cloud_Tests_Setup.prepare + with_temporary_cloud_root ~action = + subdir = (Enso_File.root / ("my_test_CWD-" + Random.uuid.take 5)).create_directory + subdir.should_succeed + cleanup = + Enso_User.flush_caches + subdir.delete + Panic.with_finalizer cleanup <| + Test_Environment.unsafe_with_environment_override "ENSO_CLOUD_PROJECT_DIRECTORY_PATH" subdir.path <| + # Flush caches to ensure fresh dir is used + Enso_User.flush_caches + action + + group_builder.specify "will resolve relative paths as Cloud paths if running in the Cloud" pending=cloud_setup.real_cloud_pending <| + with_temporary_cloud_root <| + root = File.new "." + root.should_be_a Enso_File + root.should_equal Enso_File.current_working_directory + + f = File.new "abc/def.txt" + f.should_be_a Enso_File + f.should_equal (Enso_File.current_working_directory / "abc" / "def.txt") + + # Data.read should be consistent too + txt = "test-content"+Random.uuid + (File.new "abc").create_directory . should_succeed + txt.write f . should_succeed + Panic.with_finalizer f.delete_if_exists <| + Data.read "abc/def.txt" . should_equal txt + suite_builder.group "read_text" group_builder-> group_builder.specify "should allow reading a UTF-8 file" <| contents = sample_file.read_text