Repository: nushell/nushell (language: rust)

Issue:
strange "entity not found" error
### Describe the bug

i used `ls` on an invalid path and got a strange "_entity not found_" error 🤔 

```
Error: nu::shell::io::not_found

  × I/O error
   ╭─[entry #2:1:4]
 1 │ ls does-not-exist
   ·    ───────┬──────
   ·           ╰── Entity not found
   ╰────
  help: The error occurred at '/home/amtoine/does-not-exist'
```

### How to reproduce

1. run `ls does-not-exist`
2. see the error

### Expected behavior

i expected the error to be more explicite, i think this changed recently right ?

### Configuration

| k                  | v                                        |
| ------------------ | ---------------------------------------- |
| version            | 0.102.0                                  |
| major              | 0                                        |
| minor              | 102                                      |
| patch              | 0                                        |
| branch             |                                          |
| commit_hash        | 1aa2ed1947a0b891398558fcf4e4289849cc5a1d |
| build_os           | linux-x86_64                             |
| build_target       | x86_64-unknown-linux-gnu                 |
| rust_version       | rustc 1.82.0 (f6e511eec 2024-10-15)      |
| rust_channel       | 1.82.0-x86_64-unknown-linux-gnu          |
| cargo_version      | cargo 1.82.0 (8f40fc59f 2024-08-21)      |
| build_time         | 2025-02-08 11:40:00 +01:00               |
| build_rust_channel | release                                  |
| allocator          | mimalloc                                 |
| features           | default, sqlite, trash                   |
| installed_plugins  |                                          |



Discussion / hints:
cc @cptpiepmatz 

I believe "entity" is used since we can't be sure if the non-existent entity is supposed to be a file, directory, symlink, etc. It used to always say "directory not found" which definitely wasn't right. But "entity" is a bit confusing also 😅 
i'd say `file` would be more reasonable because basically everything is a "_file_" 😋 
This is how `std` names it https://github.com/rust-lang/rust/blob/d2f335d58e6c346f94910d0f49baf185028b44be/library/std/src/io/error.rs#L476
> i'd say `file` would be more reasonable because basically everything is a "_file_" 😋

That is not true on Windows.
wait, what if we changed it to just say "Not found"
> wait, what if we changed it to just say "Not found"

I don't know if that is *better* but we have full control over the message, so that is easily possible.

Fix patch:
diff --git a/crates/nu-cli/src/eval_file.rs b/crates/nu-cli/src/eval_file.rs
index 8a761df0a9787..f302111b938fd 100644
--- a/crates/nu-cli/src/eval_file.rs
+++ b/crates/nu-cli/src/eval_file.rs
@@ -8,7 +8,7 @@ use nu_protocol::{
     debugger::WithoutDebug,
     engine::{EngineState, Stack, StateWorkingSet},
     report_parse_error, report_parse_warning,
-    shell_error::io::IoError,
+    shell_error::io::*,
     PipelineData, ShellError, Span, Value,
 };
 use std::{path::PathBuf, sync::Arc};
@@ -28,7 +28,7 @@ pub fn evaluate_file(
 
     let file_path = canonicalize_with(&path, cwd).map_err(|err| {
         IoError::new_internal_with_path(
-            err.kind(),
+            err.kind().not_found_as(NotFound::File),
             "Could not access file",
             nu_protocol::location!(),
             PathBuf::from(&path),
@@ -47,7 +47,7 @@ pub fn evaluate_file(
 
     let file = std::fs::read(&file_path).map_err(|err| {
         IoError::new_internal_with_path(
-            err.kind(),
+            err.kind().not_found_as(NotFound::File),
             "Could not read file",
             nu_protocol::location!(),
             file_path.clone(),
@@ -57,7 +57,7 @@ pub fn evaluate_file(
 
     let parent = file_path.parent().ok_or_else(|| {
         IoError::new_internal_with_path(
-            std::io::ErrorKind::NotFound,
+            ErrorKind::DirectoryNotFound,
             "The file path does not have a parent",
             nu_protocol::location!(),
             file_path.clone(),
diff --git a/crates/nu-command/src/env/config/config_reset.rs b/crates/nu-command/src/env/config/config_reset.rs
index 5215ac6a1d76d..eeed056812fe0 100644
--- a/crates/nu-command/src/env/config/config_reset.rs
+++ b/crates/nu-command/src/env/config/config_reset.rs
@@ -1,7 +1,5 @@
 use chrono::Local;
 use nu_engine::command_prelude::*;
-
-use nu_protocol::shell_error::io::IoError;
 use nu_utils::{get_scaffold_config, get_scaffold_env};
 use std::{io::Write, path::PathBuf};
 
@@ -61,7 +59,7 @@ impl Command for ConfigReset {
                 ));
                 if let Err(err) = std::fs::rename(nu_config.clone(), &backup_path) {
                     return Err(ShellError::Io(IoError::new_with_additional_context(
-                        err.kind(),
+                        err.kind().not_found_as(NotFound::Directory),
                         span,
                         PathBuf::from(backup_path),
                         "config.nu could not be backed up",
@@ -71,7 +69,7 @@ impl Command for ConfigReset {
             if let Ok(mut file) = std::fs::File::create(&nu_config) {
                 if let Err(err) = writeln!(&mut file, "{config_file}") {
                     return Err(ShellError::Io(IoError::new_with_additional_context(
-                        err.kind(),
+                        err.kind().not_found_as(NotFound::File),
                         span,
                         PathBuf::from(nu_config),
                         "config.nu could not be written to",
@@ -88,7 +86,7 @@ impl Command for ConfigReset {
                 backup_path.push(format!("oldenv-{}.nu", Local::now().format("%F-%H-%M-%S"),));
                 if let Err(err) = std::fs::rename(env_config.clone(), &backup_path) {
                     return Err(ShellError::Io(IoError::new_with_additional_context(
-                        err.kind(),
+                        err.kind().not_found_as(NotFound::Directory),
                         span,
                         PathBuf::from(backup_path),
                         "env.nu could not be backed up",
@@ -98,7 +96,7 @@ impl Command for ConfigReset {
             if let Ok(mut file) = std::fs::File::create(&env_config) {
                 if let Err(err) = writeln!(&mut file, "{config_file}") {
                     return Err(ShellError::Io(IoError::new_with_additional_context(
-                        err.kind(),
+                        err.kind().not_found_as(NotFound::File),
                         span,
                         PathBuf::from(env_config),
                         "env.nu could not be written to",
diff --git a/crates/nu-command/src/filesystem/cd.rs b/crates/nu-command/src/filesystem/cd.rs
index d8e67131995e1..5ef3e4eb5e5ad 100644
--- a/crates/nu-command/src/filesystem/cd.rs
+++ b/crates/nu-command/src/filesystem/cd.rs
@@ -88,7 +88,7 @@ impl Command for Cd {
                             path
                         } else {
                             return Err(shell_error::io::IoError::new(
-                                std::io::ErrorKind::NotFound,
+                                ErrorKind::DirectoryNotFound,
                                 v.span,
                                 PathBuf::from(path_no_whitespace),
                             )
@@ -98,7 +98,7 @@ impl Command for Cd {
                         let path = nu_path::expand_path_with(path_no_whitespace, &cwd, true);
                         if !path.exists() {
                             return Err(shell_error::io::IoError::new(
-                                std::io::ErrorKind::NotFound,
+                                ErrorKind::DirectoryNotFound,
                                 v.span,
                                 PathBuf::from(path_no_whitespace),
                             )
diff --git a/crates/nu-command/src/filesystem/open.rs b/crates/nu-command/src/filesystem/open.rs
index 2be2314835947..27dbf5723ac95 100644
--- a/crates/nu-command/src/filesystem/open.rs
+++ b/crates/nu-command/src/filesystem/open.rs
@@ -98,6 +98,7 @@ impl Command for Open {
             for path in nu_engine::glob_from(&path, &cwd, call_span, None)
                 .map_err(|err| match err {
                     ShellError::Io(mut err) => {
+                        err.kind = err.kind.not_found_as(NotFound::File);
                         err.span = arg_span;
                         err.into()
                     }
diff --git a/crates/nu-command/src/misc/source.rs b/crates/nu-command/src/misc/source.rs
index de692c557ca34..d507345f6ea97 100644
--- a/crates/nu-command/src/misc/source.rs
+++ b/crates/nu-command/src/misc/source.rs
@@ -55,8 +55,13 @@ impl Command for Source {
         let cwd = engine_state.cwd_as_string(Some(stack))?;
         let pb = std::path::PathBuf::from(block_id_name);
         let parent = pb.parent().unwrap_or(std::path::Path::new(""));
-        let file_path = canonicalize_with(pb.as_path(), cwd)
-            .map_err(|err| IoError::new(err.kind(), call.head, pb.clone()))?;
+        let file_path = canonicalize_with(pb.as_path(), cwd).map_err(|err| {
+            IoError::new(
+                err.kind().not_found_as(NotFound::File),
+                call.head,
+                pb.clone(),
+            )
+        })?;
 
         // Note: We intentionally left out PROCESS_PATH since it's supposed to
         // to work like argv[0] in C, which is the name of the program being executed.
diff --git a/crates/nu-command/src/system/nu_check.rs b/crates/nu-command/src/system/nu_check.rs
index d988974589fe1..8280232c1a89d 100644
--- a/crates/nu-command/src/system/nu_check.rs
+++ b/crates/nu-command/src/system/nu_check.rs
@@ -98,7 +98,7 @@ impl Command for NuCheck {
                         Ok(Some(path)) => path,
                         Ok(None) => {
                             return Err(ShellError::Io(IoError::new(
-                                std::io::ErrorKind::NotFound,
+                                ErrorKind::FileNotFound,
                                 path_span,
                                 PathBuf::from(path_str.item),
                             )))
@@ -255,7 +255,7 @@ fn parse_file_script(
     match std::fs::read(path) {
         Ok(contents) => parse_script(working_set, Some(&filename), &contents, is_debug, call_head),
         Err(err) => Err(ShellError::Io(IoError::new(
-            err.kind(),
+            err.kind().not_found_as(NotFound::File),
             path_span,
             PathBuf::from(path),
         ))),
diff --git a/crates/nu-engine/src/command_prelude.rs b/crates/nu-engine/src/command_prelude.rs
index 9b9ed40477294..39124af1d4439 100644
--- a/crates/nu-engine/src/command_prelude.rs
+++ b/crates/nu-engine/src/command_prelude.rs
@@ -3,7 +3,7 @@ pub use nu_protocol::{
     ast::CellPath,
     engine::{Call, Command, EngineState, Stack, StateWorkingSet},
     record,
-    shell_error::io::IoError,
+    shell_error::io::*,
     ByteStream, ByteStreamType, Category, ErrSpan, Example, IntoInterruptiblePipelineData,
     IntoPipelineData, IntoSpanned, IntoValue, PipelineData, Record, ShellError, Signature, Span,
     Spanned, SyntaxShape, Type, Value,
diff --git a/crates/nu-engine/src/env.rs b/crates/nu-engine/src/env.rs
index 2c4aec9fb362e..5a2a201911b7d 100644
--- a/crates/nu-engine/src/env.rs
+++ b/crates/nu-engine/src/env.rs
@@ -3,7 +3,7 @@ use nu_path::canonicalize_with;
 use nu_protocol::{
     ast::Expr,
     engine::{Call, EngineState, Stack, StateWorkingSet},
-    shell_error::io::IoError,
+    shell_error::io::{ErrorKindExt, IoError, NotFound},
     ShellError, Span, Type, Value, VarId,
 };
 use std::{
@@ -221,7 +221,7 @@ pub fn current_dir(engine_state: &EngineState, stack: &Stack) -> Result<PathBuf,
     // be an absolute path already.
     canonicalize_with(&cwd, ".").map_err(|err| {
         ShellError::Io(IoError::new_internal_with_path(
-            err.kind(),
+            err.kind().not_found_as(NotFound::Directory),
             "Could not canonicalize current dir",
             nu_protocol::location!(),
             PathBuf::from(cwd),
@@ -241,7 +241,7 @@ pub fn current_dir_const(working_set: &StateWorkingSet) -> Result<PathBuf, Shell
     // be an absolute path already.
     canonicalize_with(&cwd, ".").map_err(|err| {
         ShellError::Io(IoError::new_internal_with_path(
-            err.kind(),
+            err.kind().not_found_as(NotFound::Directory),
             "Could not canonicalize current dir",
             nu_protocol::location!(),
             PathBuf::from(cwd),
diff --git a/crates/nu-protocol/src/errors/shell_error/io.rs b/crates/nu-protocol/src/errors/shell_error/io.rs
index 71b9726bc0143..6ad8935685ce8 100644
--- a/crates/nu-protocol/src/errors/shell_error/io.rs
+++ b/crates/nu-protocol/src/errors/shell_error/io.rs
@@ -1,6 +1,7 @@
 use miette::{Diagnostic, LabeledSpan, SourceSpan};
 use std::{
-    fmt::Display,
+    error::Error as StdError,
+    fmt::{self, Display, Formatter},
     path::{Path, PathBuf},
 };
 use thiserror::Error;
@@ -37,11 +38,7 @@ use super::{location::Location, ShellError};
 ///
 /// # let span = Span::test_data();
 /// let path = PathBuf::from("/some/missing/file");
-/// let error = IoError::new(
-///     std::io::ErrorKind::NotFound,
-///     span,
-///     path
-/// );
+/// let error = IoError::new(std::io::ErrorKind::NotFound, span, path);
 /// println!("Error: {:?}", error);
 /// ```
 ///
@@ -87,9 +84,8 @@ use super::{location::Location, ShellError};
 /// This approach ensures clarity about where such container transfers occur.
 /// All other I/O errors should be handled using the provided constructors for `IoError`.
 /// This way, the code explicitly indicates when and where a `ShellError` transfer might happen.
-#[derive(Debug, Clone, Error, PartialEq)]
+#[derive(Debug, Clone, PartialEq)]
 #[non_exhaustive]
-#[error("I/O error")]
 pub struct IoError {
     /// The type of the underlying I/O error.
     ///
@@ -133,6 +129,9 @@ pub struct IoError {
 pub enum ErrorKind {
     Std(std::io::ErrorKind),
     NotAFile,
+    // use these variants in cases where we know precisely whether a file or directory was expected
+    FileNotFound,
+    DirectoryNotFound,
 }
 
 #[derive(Debug, Clone, PartialEq, Eq, Error, Diagnostic)]
@@ -269,8 +268,8 @@ impl IoError {
     ///
     /// # Examples
     /// ```rust
-    /// use std::path::PathBuf;
     /// use nu_protocol::shell_error::io::IoError;
+    /// use std::path::PathBuf;
     ///
     /// let error = IoError::new_internal_with_path(
     ///     std::io::ErrorKind::NotFound,
@@ -309,15 +308,30 @@ impl IoError {
     }
 }
 
+impl StdError for IoError {}
+impl Display for IoError {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        match self.kind {
+            ErrorKind::Std(std::io::ErrorKind::NotFound) => write!(f, "Not found"),
+            ErrorKind::FileNotFound => write!(f, "File not found"),
+            ErrorKind::DirectoryNotFound => write!(f, "Directory not found"),
+            _ => write!(f, "I/O error"),
+        }
+    }
+}
+
 impl Display for ErrorKind {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
         match self {
+            ErrorKind::Std(std::io::ErrorKind::NotFound) => write!(f, "Not found"),
             ErrorKind::Std(error_kind) => {
                 let msg = error_kind.to_string();
                 let (first, rest) = msg.split_at(1);
                 write!(f, "{}{}", first.to_uppercase(), rest)
             }
             ErrorKind::NotAFile => write!(f, "Not a file"),
+            ErrorKind::FileNotFound => write!(f, "File not found"),
+            ErrorKind::DirectoryNotFound => write!(f, "Directory not found"),
         }
     }
 }
@@ -352,15 +366,28 @@ impl Diagnostic for IoError {
                 kind => code.push_str(&kind.to_string().to_lowercase().replace(" ", "_")),
             },
             ErrorKind::NotAFile => code.push_str("not_a_file"),
+            ErrorKind::FileNotFound => code.push_str("file_not_found"),
+            ErrorKind::DirectoryNotFound => code.push_str("directory_not_found"),
         }
 
         Some(Box::new(code))
     }
 
     fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
+        let make_msg = |path: &Path| {
+            let path = format!("'{}'", path.display());
+            match self.kind {
+                ErrorKind::NotAFile => format!("{path} is not a file"),
+                ErrorKind::Std(std::io::ErrorKind::NotFound)
+                | ErrorKind::FileNotFound
+                | ErrorKind::DirectoryNotFound => format!("{path} does not exist"),
+                _ => format!("The error occurred at {path}"),
+            }
+        };
+
         self.path
             .as_ref()
-            .map(|path| format!("The error occurred at '{}'", path.display()))
+            .map(|path| make_msg(path))
             .map(|s| Box::new(s) as Box<dyn std::fmt::Display>)
     }
 
@@ -417,3 +444,75 @@ impl From<ErrorKind> for std::io::ErrorKind {
         }
     }
 }
+
+/// More specific variants of [`NotFound`](std::io::ErrorKind).
+///
+/// Use these to define how a `NotFound` error maps to our custom [`ErrorKind`].
+pub enum NotFound {
+    /// Map into [`FileNotFound`](ErrorKind::FileNotFound).
+    File,
+    /// Map into [`DirectoryNotFound`](ErrorKind::DirectoryNotFound).
+    Directory,
+}
+
+/// Extension trait for working with [`std::io::ErrorKind`].
+pub trait ErrorKindExt {
+    /// Map [`NotFound`](std::io::ErrorKind) variants into more precise variants.
+    ///
+    /// The OS doesn't know when an entity was not found whether it was meant to be a file or a
+    /// directory or something else.
+    /// But sometimes we, the application, know what we expect and with this method, we can further
+    /// specify it.
+    ///
+    /// # Examples
+    /// Reading a file.
+    /// If the file isn't found, return [`FileNotFound`](ErrorKind::FileNotFound).
+    /// ```rust
+    /// # use nu_protocol::{
+    /// #     shell_error::io::{ErrorKind, ErrorKindExt, IoError, NotFound},
+    /// #     ShellError, Span,
+    /// # };
+    /// # use std::{fs, path::PathBuf};
+    /// #
+    /// # fn example() -> Result<(), ShellError> {
+    /// #     let span = Span::test_data();
+    /// let a_file = PathBuf::from("scripts/ellie.nu");
+    /// let ellie = fs::read_to_string(&a_file).map_err(|err| {
+    ///     ShellError::Io(IoError::new(
+    ///         err.kind().not_found_as(NotFound::File),
+    ///         span,
+    ///         a_file,
+    ///     ))
+    /// })?;
+    /// #     Ok(())
+    /// # }
+    /// #
+    /// # assert!(matches!(
+    /// #     example(),
+    /// #     Err(ShellError::Io(IoError {
+    /// #         kind: ErrorKind::FileNotFound,
+    /// #         ..
+    /// #     }))
+    /// # ));
+    /// ```
+    fn not_found_as(self, kind: NotFound) -> ErrorKind;
+}
+
+impl ErrorKindExt for std::io::ErrorKind {
+    fn not_found_as(self, kind: NotFound) -> ErrorKind {
+        match (kind, self) {
+            (NotFound::File, Self::NotFound) => ErrorKind::FileNotFound,
+            (NotFound::Directory, Self::NotFound) => ErrorKind::DirectoryNotFound,
+            _ => ErrorKind::Std(self),
+        }
+    }
+}
+
+impl ErrorKindExt for ErrorKind {
+    fn not_found_as(self, kind: NotFound) -> ErrorKind {
+        match self {
+            Self::Std(std_kind) => std_kind.not_found_as(kind),
+            _ => self,
+        }
+    }
+}
diff --git a/src/ide.rs b/src/ide.rs
index 6ce7e69c6fd83..8d64fdd487f22 100644
--- a/src/ide.rs
+++ b/src/ide.rs
@@ -4,7 +4,7 @@ use nu_parser::{flatten_block, parse, FlatShape};
 use nu_protocol::{
     engine::{EngineState, Stack, StateWorkingSet},
     report_shell_error,
-    shell_error::io::IoError,
+    shell_error::io::{ErrorKindExt, IoError, NotFound},
     DeclId, ShellError, Span, Value, VarId,
 };
 use reedline::Completer;
@@ -58,7 +58,7 @@ fn read_in_file<'a>(
     let file = std::fs::read(file_path)
         .map_err(|err| {
             ShellError::Io(IoError::new_with_additional_context(
-                err.kind(),
+                err.kind().not_found_as(NotFound::File),
                 Span::unknown(),
                 PathBuf::from(file_path),
                 "Could not read file",


Test patch:
diff --git a/crates/nu-command/tests/commands/cd.rs b/crates/nu-command/tests/commands/cd.rs
index 7dd648d13571d..2ad25f5aa63b6 100644
--- a/crates/nu-command/tests/commands/cd.rs
+++ b/crates/nu-command/tests/commands/cd.rs
@@ -210,7 +210,7 @@ fn filesystem_directory_not_found() {
             actual.err
         );
         assert!(
-            actual.err.contains("nu::shell::io::not_found"),
+            actual.err.contains("nu::shell::io::directory_not_found"),
             "actual={:?}",
             actual.err
         );
diff --git a/crates/nu-command/tests/commands/nu_check.rs b/crates/nu-command/tests/commands/nu_check.rs
index 971349778eeb3..ed082aeedc6f9 100644
--- a/crates/nu-command/tests/commands/nu_check.rs
+++ b/crates/nu-command/tests/commands/nu_check.rs
@@ -172,7 +172,7 @@ fn file_not_exist() {
             "
         ));
 
-        assert!(actual.err.contains("nu::shell::io::not_found"));
+        assert!(actual.err.contains("nu::shell::io::file_not_found"));
     })
 }
 
diff --git a/crates/nu-command/tests/commands/open.rs b/crates/nu-command/tests/commands/open.rs
index 09df04b97a30b..529da6c28c165 100644
--- a/crates/nu-command/tests/commands/open.rs
+++ b/crates/nu-command/tests/commands/open.rs
@@ -251,7 +251,7 @@ fn errors_if_file_not_found() {
     // This seems to be not directly affected by localization compared to the OS
     // provided error message
 
-    assert!(actual.err.contains("nu::shell::io::not_found"));
+    assert!(actual.err.contains("nu::shell::io::file_not_found"));
     assert!(actual.err.contains(
         &PathBuf::from_iter(["tests", "fixtures", "formats", "i_dont_exist.txt"])
             .display()