Repository: nushell/nushell (language: rust)

Issue:
`rm` returns directory not found error for both directories and files.
### Describe the bug

When attempting to remove a directory that does not exist, nushell throws the error `nu::shell::directory_not_found`:
```nu
❯ : rm foo/
Error: nu::shell::directory_not_found

  × Directory not found
   ╭─[entry #2:1:4]
 1 │ rm foo/
   ·    ──┬─
   ·      ╰── directory not found
   ╰────
  help: /Users/mussar/workspace/github.com/0x4D5352/nushell/foo/ does not exist```

This is expected behavior. However, this same error is raised when attempting to remove a file that does not exist:

```nu
❯ : rm foo.txt
Error: nu::shell::directory_not_found

  × Directory not found
   ╭─[entry #1:1:4]
 1 │ rm foo.txt
   ·    ───┬───
   ·       ╰── directory not found
   ╰────
  help: /Users/mussar/workspace/github.com/0x4D5352/nushell/foo.txt does not exist
```

This might already be a tracked issue, as the code that returns this error in `rm.rs` is coming from a match statement on `glob_from`, and @cptpiepmatz is already working on updating this function: https://github.com/nushell/nushell/commit/dc52a6fec5be7bec59582e8e676d890aae5628a4

### How to reproduce

1. Just try to `rm` a file that doesn't exist.

### Expected behavior

Either return a generic error, or distinguish between directories and files.

### Configuration

| key                | value                                                                                          |
| ------------------ | ---------------------------------------------------------------------------------------------- |
| version            | 0.100.0                                                                                        |
| major              | 0                                                                                              |
| minor              | 100                                                                                            |
| patch              | 0                                                                                              |
| branch             |                                                                                                |
| commit_hash        |                                                                                                |
| build_os           | macos-aarch64                                                                                  |
| build_target       | aarch64-apple-darwin                                                                           |
| rust_version       | rustc 1.82.0 (f6e511eec 2024-10-15) (Homebrew)                                                 |
| cargo_version      | cargo 1.82.0                                                                                   |
| build_time         | 2024-11-13 20:37:01 -06:00                                                                     |
| build_rust_channel | release                                                                                        |
| allocator          | mimalloc                                                                                       |
| features           | default, sqlite, trash                                                                         |
| installed_plugins  | formats 0.99.1, gstat 0.99.1, highlight 1.4.1+0.100.0, inc 0.99.1, polars 0.99.1, query 0.99.1 |


Discussion / hints:
I don't think it's possible for `rm` to know that `foo.txt` was meant to be a file, since a directory could also be named that. But sure, the error message inside `glob_from` could say something like "directory or file not found" instead. Looks like `ls` and other commands also use `glob_from` and suffer from the same problem.
`glob_from` could provide more precise errors, I'm sure.

Fix patch:
diff --git a/crates/nu-cli/src/commands/history/history_.rs b/crates/nu-cli/src/commands/history/history_.rs
index 40d951966e009..00efd547d4462 100644
--- a/crates/nu-cli/src/commands/history/history_.rs
+++ b/crates/nu-cli/src/commands/history/history_.rs
@@ -1,5 +1,5 @@
 use nu_engine::command_prelude::*;
-use nu_protocol::HistoryFileFormat;
+use nu_protocol::{shell_error::io::IoError, HistoryFileFormat};
 use reedline::{
     FileBackedHistory, History as ReedlineHistory, HistoryItem, SearchDirection, SearchQuery,
     SqliteBackedHistory,
@@ -93,10 +93,11 @@ impl Command for History {
                         )
                     })
                 })
-                .ok_or(ShellError::FileNotFound {
-                    file: history_path.display().to_string(),
-                    span: head,
-                })?
+                .ok_or(IoError::new(
+                    std::io::ErrorKind::NotFound,
+                    head,
+                    history_path,
+                ))?
                 .into_pipeline_data(head, signals)),
             HistoryFileFormat::Sqlite => Ok(history_reader
                 .and_then(|h| {
@@ -109,10 +110,11 @@ impl Command for History {
                         .enumerate()
                         .map(move |(idx, entry)| create_history_record(idx, entry, long, head))
                 })
-                .ok_or(ShellError::FileNotFound {
-                    file: history_path.display().to_string(),
-                    span: head,
-                })?
+                .ok_or(IoError::new(
+                    std::io::ErrorKind::NotFound,
+                    head,
+                    history_path,
+                ))?
                 .into_pipeline_data(head, signals)),
         }
     }
diff --git a/crates/nu-cli/src/commands/history/history_import.rs b/crates/nu-cli/src/commands/history/history_import.rs
index 7666bd7eb7e76..b9764306f8f7c 100644
--- a/crates/nu-cli/src/commands/history/history_import.rs
+++ b/crates/nu-cli/src/commands/history/history_import.rs
@@ -1,7 +1,10 @@
 use std::path::{Path, PathBuf};
 
 use nu_engine::command_prelude::*;
-use nu_protocol::HistoryFileFormat;
+use nu_protocol::{
+    shell_error::{self, io::IoError},
+    HistoryFileFormat,
+};
 
 use reedline::{
     FileBackedHistory, History, HistoryItem, ReedlineError, SearchQuery, SqliteBackedHistory,
@@ -69,17 +72,16 @@ Note that history item IDs are ignored when importing from file."#
         call: &Call,
         input: PipelineData,
     ) -> Result<PipelineData, ShellError> {
+        let span = call.head;
         let ok = Ok(Value::nothing(call.head).into_pipeline_data());
 
         let Some(history) = engine_state.history_config() else {
             return ok;
         };
         let Some(current_history_path) = history.file_path() else {
-            return Err(ShellError::ConfigDirNotFound {
-                span: Some(call.head),
-            });
+            return Err(ShellError::ConfigDirNotFound { span: span.into() });
         };
-        if let Some(bak_path) = backup(&current_history_path)? {
+        if let Some(bak_path) = backup(&current_history_path, span)? {
             println!("Backed history to {}", bak_path.display());
         }
         match input {
@@ -216,7 +218,7 @@ fn item_from_record(mut rec: Record, span: Span) -> Result<HistoryItem, ShellErr
         hostname: get(rec, fields::HOSTNAME, |v| Ok(v.as_str()?.to_owned()))?,
         cwd: get(rec, fields::CWD, |v| Ok(v.as_str()?.to_owned()))?,
         exit_status: get(rec, fields::EXIT_STATUS, |v| v.as_int())?,
-        duration: get(rec, fields::DURATION, duration_from_value)?,
+        duration: get(rec, fields::DURATION, |v| duration_from_value(v, span))?,
         more_info: None,
         // TODO: Currently reedline doesn't let you create session IDs.
         session_id: None,
@@ -232,19 +234,21 @@ fn item_from_record(mut rec: Record, span: Span) -> Result<HistoryItem, ShellErr
     Ok(item)
 }
 
-fn duration_from_value(v: Value) -> Result<std::time::Duration, ShellError> {
+fn duration_from_value(v: Value, span: Span) -> Result<std::time::Duration, ShellError> {
     chrono::Duration::nanoseconds(v.as_duration()?)
         .to_std()
-        .map_err(|_| ShellError::IOError {
-            msg: "negative duration not supported".to_string(),
-        })
+        .map_err(|_| ShellError::NeedsPositiveValue { span })
 }
 
-fn find_backup_path(path: &Path) -> Result<PathBuf, ShellError> {
+fn find_backup_path(path: &Path, span: Span) -> Result<PathBuf, ShellError> {
     let Ok(mut bak_path) = path.to_path_buf().into_os_string().into_string() else {
         // This isn't fundamentally problem, but trying to work with OsString is a nightmare.
-        return Err(ShellError::IOError {
-            msg: "History path mush be representable as UTF-8".to_string(),
+        return Err(ShellError::GenericError {
+            error: "History path not UTF-8".to_string(),
+            msg: "History path must be representable as UTF-8".to_string(),
+            span: Some(span),
+            help: None,
+            inner: vec![],
         });
     };
     bak_path.push_str(".bak");
@@ -260,24 +264,45 @@ fn find_backup_path(path: &Path) -> Result<PathBuf, ShellError> {
             return Ok(PathBuf::from(bak_path));
         }
     }
-    Err(ShellError::IOError {
-        msg: "Too many existing backup files".to_string(),
+    Err(ShellError::GenericError {
+        error: "Too many backup files".to_string(),
+        msg: "Found too many existing backup files".to_string(),
+        span: Some(span),
+        help: None,
+        inner: vec![],
     })
 }
 
-fn backup(path: &Path) -> Result<Option<PathBuf>, ShellError> {
+fn backup(path: &Path, span: Span) -> Result<Option<PathBuf>, ShellError> {
     match path.metadata() {
         Ok(md) if md.is_file() => (),
         Ok(_) => {
-            return Err(ShellError::IOError {
-                msg: "history path exists but is not a file".to_string(),
-            })
+            return Err(IoError::new_with_additional_context(
+                shell_error::io::ErrorKind::NotAFile,
+                span,
+                PathBuf::from(path),
+                "history path exists but is not a file",
+            )
+            .into())
         }
         Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
-        Err(e) => return Err(e.into()),
+        Err(e) => {
+            return Err(IoError::new_internal(
+                e.kind(),
+                "Could not get metadata",
+                nu_protocol::location!(),
+            )
+            .into())
+        }
     }
-    let bak_path = find_backup_path(path)?;
-    std::fs::copy(path, &bak_path)?;
+    let bak_path = find_backup_path(path, span)?;
+    std::fs::copy(path, &bak_path).map_err(|err| {
+        IoError::new_internal(
+            err.kind(),
+            "Could not copy backup",
+            nu_protocol::location!(),
+        )
+    })?;
     Ok(Some(bak_path))
 }
 
@@ -388,7 +413,7 @@ mod tests {
         for name in existing {
             std::fs::File::create_new(dir.path().join(name)).unwrap();
         }
-        let got = find_backup_path(&dir.path().join("history.dat")).unwrap();
+        let got = find_backup_path(&dir.path().join("history.dat"), Span::test_data()).unwrap();
         assert_eq!(got, dir.path().join(want))
     }
 
@@ -400,7 +425,7 @@ mod tests {
         write!(&mut history, "123").unwrap();
         let want_bak_path = dir.path().join("history.dat.bak");
         assert_eq!(
-            backup(&dir.path().join("history.dat")),
+            backup(&dir.path().join("history.dat"), Span::test_data()),
             Ok(Some(want_bak_path.clone()))
         );
         let got_data = String::from_utf8(std::fs::read(want_bak_path).unwrap()).unwrap();
@@ -410,7 +435,7 @@ mod tests {
     #[test]
     fn test_backup_no_file() {
         let dir = tempfile::tempdir().unwrap();
-        let bak_path = backup(&dir.path().join("history.dat")).unwrap();
+        let bak_path = backup(&dir.path().join("history.dat"), Span::test_data()).unwrap();
         assert!(bak_path.is_none());
     }
 }
diff --git a/crates/nu-cli/src/commands/keybindings_listen.rs b/crates/nu-cli/src/commands/keybindings_listen.rs
index 40cda5652d5de..ec7b33f2963d3 100644
--- a/crates/nu-cli/src/commands/keybindings_listen.rs
+++ b/crates/nu-cli/src/commands/keybindings_listen.rs
@@ -2,6 +2,7 @@ use crossterm::{
     event::Event, event::KeyCode, event::KeyEvent, execute, terminal, QueueableCommand,
 };
 use nu_engine::command_prelude::*;
+use nu_protocol::shell_error::io::IoError;
 use std::io::{stdout, Write};
 
 #[derive(Clone)]
@@ -39,7 +40,13 @@ impl Command for KeybindingsListen {
         match print_events(engine_state) {
             Ok(v) => Ok(v.into_pipeline_data()),
             Err(e) => {
-                terminal::disable_raw_mode()?;
+                terminal::disable_raw_mode().map_err(|err| {
+                    IoError::new_internal(
+                        err.kind(),
+                        "Could not disable raw mode",
+                        nu_protocol::location!(),
+                    )
+                })?;
                 Err(ShellError::GenericError {
                     error: "Error with input".into(),
                     msg: "".into(),
@@ -63,8 +70,20 @@ impl Command for KeybindingsListen {
 pub fn print_events(engine_state: &EngineState) -> Result<Value, ShellError> {
     let config = engine_state.get_config();
 
-    stdout().flush()?;
-    terminal::enable_raw_mode()?;
+    stdout().flush().map_err(|err| {
+        IoError::new_internal(
+            err.kind(),
+            "Could not flush stdout",
+            nu_protocol::location!(),
+        )
+    })?;
+    terminal::enable_raw_mode().map_err(|err| {
+        IoError::new_internal(
+            err.kind(),
+            "Could not enable raw mode",
+            nu_protocol::location!(),
+        )
+    })?;
 
     if config.use_kitty_protocol {
         if let Ok(false) = crossterm::terminal::supports_keyboard_enhancement() {
@@ -94,7 +113,9 @@ pub fn print_events(engine_state: &EngineState) -> Result<Value, ShellError> {
     let mut stdout = std::io::BufWriter::new(std::io::stderr());
 
     loop {
-        let event = crossterm::event::read()?;
+        let event = crossterm::event::read().map_err(|err| {
+            IoError::new_internal(err.kind(), "Could not read event", nu_protocol::location!())
+        })?;
         if event == Event::Key(KeyCode::Esc.into()) {
             break;
         }
@@ -113,9 +134,25 @@ pub fn print_events(engine_state: &EngineState) -> Result<Value, ShellError> {
 
             _ => "".to_string(),
         };
-        stdout.queue(crossterm::style::Print(o))?;
-        stdout.queue(crossterm::style::Print("\r\n"))?;
-        stdout.flush()?;
+        stdout.queue(crossterm::style::Print(o)).map_err(|err| {
+            IoError::new_internal(
+                err.kind(),
+                "Could not print output record",
+                nu_protocol::location!(),
+            )
+        })?;
+        stdout
+            .queue(crossterm::style::Print("\r\n"))
+            .map_err(|err| {
+                IoError::new_internal(
+                    err.kind(),
+                    "Could not print linebreak",
+                    nu_protocol::location!(),
+                )
+            })?;
+        stdout.flush().map_err(|err| {
+            IoError::new_internal(err.kind(), "Could not flush", nu_protocol::location!())
+        })?;
     }
 
     if config.use_kitty_protocol {
@@ -125,7 +162,13 @@ pub fn print_events(engine_state: &EngineState) -> Result<Value, ShellError> {
         );
     }
 
-    terminal::disable_raw_mode()?;
+    terminal::disable_raw_mode().map_err(|err| {
+        IoError::new_internal(
+            err.kind(),
+            "Could not disable raw mode",
+            nu_protocol::location!(),
+        )
+    })?;
 
     Ok(Value::nothing(Span::unknown()))
 }
diff --git a/crates/nu-cli/src/config_files.rs b/crates/nu-cli/src/config_files.rs
index 28332fe998bde..bb54a860d6f54 100644
--- a/crates/nu-cli/src/config_files.rs
+++ b/crates/nu-cli/src/config_files.rs
@@ -18,7 +18,7 @@ const OLD_PLUGIN_FILE: &str = "plugin.nu";
 
 #[cfg(feature = "plugin")]
 pub fn read_plugin_file(engine_state: &mut EngineState, plugin_file: Option<Spanned<String>>) {
-    use nu_protocol::ShellError;
+    use nu_protocol::{shell_error::io::IoError, ShellError};
     use std::path::Path;
 
     let span = plugin_file.as_ref().map(|s| s.span);
@@ -78,16 +78,12 @@ pub fn read_plugin_file(engine_state: &mut EngineState, plugin_file: Option<Span
                 } else {
                     report_shell_error(
                         engine_state,
-                        &ShellError::GenericError {
-                            error: format!(
-                                "Error while opening plugin registry file: {}",
-                                plugin_path.display()
-                            ),
-                            msg: "plugin path defined here".into(),
-                            span,
-                            help: None,
-                            inner: vec![err.into()],
-                        },
+                        &ShellError::Io(IoError::new_internal_with_path(
+                            err.kind(),
+                            "Could not open plugin registry file",
+                            nu_protocol::location!(),
+                            plugin_path,
+                        )),
                     );
                     return;
                 }
@@ -234,8 +230,8 @@ pub fn eval_config_contents(
 #[cfg(feature = "plugin")]
 pub fn migrate_old_plugin_file(engine_state: &EngineState) -> bool {
     use nu_protocol::{
-        PluginExample, PluginIdentity, PluginRegistryItem, PluginRegistryItemData, PluginSignature,
-        ShellError,
+        shell_error::io::IoError, PluginExample, PluginIdentity, PluginRegistryItem,
+        PluginRegistryItemData, PluginSignature, ShellError,
     };
     use std::collections::BTreeMap;
 
@@ -324,7 +320,15 @@ pub fn migrate_old_plugin_file(engine_state: &EngineState) -> bool {
     // Write the new file
     let new_plugin_file_path = config_dir.join(PLUGIN_FILE);
     if let Err(err) = std::fs::File::create(&new_plugin_file_path)
-        .map_err(|e| e.into())
+        .map_err(|err| {
+            IoError::new_internal_with_path(
+                err.kind(),
+                "Could not create new plugin file",
+                nu_protocol::location!(),
+                new_plugin_file_path.clone(),
+            )
+        })
+        .map_err(ShellError::from)
         .and_then(|file| contents.write_to(file, None))
     {
         report_shell_error(
diff --git a/crates/nu-cli/src/eval_file.rs b/crates/nu-cli/src/eval_file.rs
index aa4dbfca78b8c..8b252bf17e347 100644
--- a/crates/nu-cli/src/eval_file.rs
+++ b/crates/nu-cli/src/eval_file.rs
@@ -7,9 +7,11 @@ use nu_protocol::{
     cli_error::report_compile_error,
     debugger::WithoutDebug,
     engine::{EngineState, Stack, StateWorkingSet},
-    report_parse_error, report_parse_warning, PipelineData, ShellError, Span, Value,
+    report_parse_error, report_parse_warning,
+    shell_error::io::IoError,
+    PipelineData, ShellError, Span, Value,
 };
-use std::sync::Arc;
+use std::{path::PathBuf, sync::Arc};
 
 /// Entry point for evaluating a file.
 ///
@@ -24,11 +26,14 @@ pub fn evaluate_file(
 ) -> Result<(), ShellError> {
     let cwd = engine_state.cwd_as_string(Some(stack))?;
 
-    let file_path =
-        canonicalize_with(&path, cwd).map_err(|err| ShellError::FileNotFoundCustom {
-            msg: format!("Could not access file '{path}': {err}"),
-            span: Span::unknown(),
-        })?;
+    let file_path = canonicalize_with(&path, cwd).map_err(|err| {
+        IoError::new_with_additional_context(
+            err.kind(),
+            Span::unknown(),
+            PathBuf::from(&path),
+            "Could not access file",
+        )
+    })?;
 
     let file_path_str = file_path
         .to_str()
@@ -40,18 +45,24 @@ pub fn evaluate_file(
             span: Span::unknown(),
         })?;
 
-    let file = std::fs::read(&file_path).map_err(|err| ShellError::FileNotFoundCustom {
-        msg: format!("Could not read file '{file_path_str}': {err}"),
-        span: Span::unknown(),
+    let file = std::fs::read(&file_path).map_err(|err| {
+        IoError::new_with_additional_context(
+            err.kind(),
+            Span::unknown(),
+            file_path.clone(),
+            "Could not read file",
+        )
     })?;
     engine_state.file = Some(file_path.clone());
 
-    let parent = file_path
-        .parent()
-        .ok_or_else(|| ShellError::FileNotFoundCustom {
-            msg: format!("The file path '{file_path_str}' does not have a parent"),
-            span: Span::unknown(),
-        })?;
+    let parent = file_path.parent().ok_or_else(|| {
+        IoError::new_with_additional_context(
+            std::io::ErrorKind::NotFound,
+            Span::unknown(),
+            file_path.clone(),
+            "The file path does not have a parent",
+        )
+    })?;
 
     stack.add_env_var(
         "FILE_PWD".to_string(),
diff --git a/crates/nu-cli/src/repl.rs b/crates/nu-cli/src/repl.rs
index b8fef7355d66b..72da95d4498b5 100644
--- a/crates/nu-cli/src/repl.rs
+++ b/crates/nu-cli/src/repl.rs
@@ -21,6 +21,7 @@ use nu_color_config::StyleComputer;
 #[allow(deprecated)]
 use nu_engine::env_to_strings;
 use nu_parser::{lex, parse, trim_quotes_str};
+use nu_protocol::shell_error::io::IoError;
 use nu_protocol::{
     config::NuCursorShape,
     engine::{EngineState, Stack, StateWorkingSet},
@@ -846,21 +847,26 @@ fn do_auto_cd(
         if !path.exists() {
             report_shell_error(
                 engine_state,
-                &ShellError::DirectoryNotFound {
-                    dir: path.to_string_lossy().to_string(),
+                &ShellError::Io(IoError::new_with_additional_context(
+                    std::io::ErrorKind::NotFound,
                     span,
-                },
+                    PathBuf::from(&path),
+                    "Cannot change directory",
+                )),
             );
         }
         path.to_string_lossy().to_string()
     };
 
-    if let PermissionResult::PermissionDenied(reason) = have_permission(path.clone()) {
+    if let PermissionResult::PermissionDenied(_) = have_permission(path.clone()) {
         report_shell_error(
             engine_state,
-            &ShellError::IOError {
-                msg: format!("Cannot change directory to {path}: {reason}"),
-            },
+            &ShellError::Io(IoError::new_with_additional_context(
+                std::io::ErrorKind::PermissionDenied,
+                span,
+                PathBuf::from(path),
+                "Cannot change directory",
+            )),
         );
         return;
     }
diff --git a/crates/nu-cmd-extra/src/extra/strings/format/bits.rs b/crates/nu-cmd-extra/src/extra/strings/format/bits.rs
index 693c6905e8a4a..b34d429c6cb2c 100644
--- a/crates/nu-cmd-extra/src/extra/strings/format/bits.rs
+++ b/crates/nu-cmd-extra/src/extra/strings/format/bits.rs
@@ -3,7 +3,7 @@ use std::io::{self, Read, Write};
 use nu_cmd_base::input_handler::{operate, CmdArgument};
 use nu_engine::command_prelude::*;
 
-use nu_protocol::Signals;
+use nu_protocol::{shell_error::io::IoError, Signals};
 use num_traits::ToPrimitive;
 
 struct Arguments {
@@ -142,7 +142,11 @@ fn byte_stream_to_bits(stream: ByteStream, head: Span) -> ByteStream {
             ByteStreamType::String,
             move |buffer| {
                 let mut byte = [0];
-                if reader.read(&mut byte[..]).err_span(head)? > 0 {
+                if reader
+                    .read(&mut byte[..])
+                    .map_err(|err| IoError::new(err.kind(), head, None))?
+                    > 0
+                {
                     // Format the byte as bits
                     if is_first {
                         is_first = false;
diff --git a/crates/nu-cmd-lang/src/core_commands/do_.rs b/crates/nu-cmd-lang/src/core_commands/do_.rs
index 62ddea5324fff..fe788983da10d 100644
--- a/crates/nu-cmd-lang/src/core_commands/do_.rs
+++ b/crates/nu-cmd-lang/src/core_commands/do_.rs
@@ -1,7 +1,9 @@
 use nu_engine::{command_prelude::*, get_eval_block_with_early_return, redirect_env};
 #[cfg(feature = "os")]
 use nu_protocol::process::{ChildPipe, ChildProcess};
-use nu_protocol::{engine::Closure, ByteStream, ByteStreamSource, OutDest};
+use nu_protocol::{
+    engine::Closure, shell_error::io::IoError, ByteStream, ByteStreamSource, OutDest,
+};
 
 use std::{
     io::{Cursor, Read},
@@ -143,10 +145,16 @@ impl Command for Do {
                                     .name("stdout consumer".to_string())
                                     .spawn(move || {
                                         let mut buf = Vec::new();
-                                        stdout.read_to_end(&mut buf)?;
+                                        stdout.read_to_end(&mut buf).map_err(|err| {
+                                            IoError::new_internal(
+                                                err.kind(),
+                                                "Could not read stdout to end",
+                                                nu_protocol::location!(),
+                                            )
+                                        })?;
                                         Ok::<_, ShellError>(buf)
                                     })
-                                    .err_span(head)
+                                    .map_err(|err| IoError::new(err.kind(), head, None))
                             })
                             .transpose()?;
 
@@ -156,7 +164,9 @@ impl Command for Do {
                             None => String::new(),
                             Some(mut stderr) => {
                                 let mut buf = String::new();
-                                stderr.read_to_string(&mut buf).err_span(span)?;
+                                stderr
+                                    .read_to_string(&mut buf)
+                                    .map_err(|err| IoError::new(err.kind(), span, None))?;
                                 buf
                             }
                         };
diff --git a/crates/nu-cmd-plugin/src/commands/plugin/add.rs b/crates/nu-cmd-plugin/src/commands/plugin/add.rs
index c603ddbb8c0a2..301b88fdffcf6 100644
--- a/crates/nu-cmd-plugin/src/commands/plugin/add.rs
+++ b/crates/nu-cmd-plugin/src/commands/plugin/add.rs
@@ -1,8 +1,10 @@
 use crate::util::{get_plugin_dirs, modify_plugin_file};
 use nu_engine::command_prelude::*;
 use nu_plugin_engine::{GetPlugin, PersistentPlugin};
-use nu_protocol::{PluginGcConfig, PluginIdentity, PluginRegistryItem, RegisteredPlugin};
-use std::sync::Arc;
+use nu_protocol::{
+    shell_error::io::IoError, PluginGcConfig, PluginIdentity, PluginRegistryItem, RegisteredPlugin,
+};
+use std::{path::PathBuf, sync::Arc};
 
 #[derive(Clone)]
 pub struct PluginAdd;
@@ -86,11 +88,14 @@ apparent the next time `nu` is next launched with that plugin registry file.
         let filename_expanded = nu_path::locate_in_dirs(&filename.item, &cwd, || {
             get_plugin_dirs(engine_state, stack)
         })
-        .err_span(filename.span)?;
+        .map_err(|err| IoError::new(err.kind(), filename.span, PathBuf::from(filename.item)))?;
 
         let shell_expanded = shell
             .as_ref()
-            .map(|s| nu_path::canonicalize_with(&s.item, &cwd).err_span(s.span))
+            .map(|s| {
+                nu_path::canonicalize_with(&s.item, &cwd)
+                    .map_err(|err| IoError::new(err.kind(), s.span, None))
+            })
             .transpose()?;
 
         // Parse the plugin filename so it can be used to spawn the plugin
diff --git a/crates/nu-cmd-plugin/src/util.rs b/crates/nu-cmd-plugin/src/util.rs
index 57de225ace01d..053900ef19f3c 100644
--- a/crates/nu-cmd-plugin/src/util.rs
+++ b/crates/nu-cmd-plugin/src/util.rs
@@ -1,6 +1,6 @@
 #[allow(deprecated)]
 use nu_engine::{command_prelude::*, current_dir};
-use nu_protocol::{engine::StateWorkingSet, PluginRegistryFile};
+use nu_protocol::{engine::StateWorkingSet, shell_error::io::IoError, PluginRegistryFile};
 use std::{
     fs::{self, File},
     path::PathBuf,
@@ -45,21 +45,16 @@ pub(crate) fn read_plugin_file(
     // Try to read the plugin file if it exists
     if fs::metadata(&plugin_registry_file_path).is_ok_and(|m| m.len() > 0) {
         PluginRegistryFile::read_from(
-            File::open(&plugin_registry_file_path).map_err(|err| ShellError::IOErrorSpanned {
-                msg: format!(
-                    "failed to read `{}`: {}",
-                    plugin_registry_file_path.display(),
-                    err
-                ),
-                span: file_span,
-            })?,
+            File::open(&plugin_registry_file_path)
+                .map_err(|err| IoError::new(err.kind(), file_span, plugin_registry_file_path))?,
             Some(file_span),
         )
     } else if let Some(path) = custom_path {
-        Err(ShellError::FileNotFound {
-            file: path.item.clone(),
-            span: path.span,
-        })
+        Err(ShellError::Io(IoError::new(
+            std::io::ErrorKind::NotFound,
+            path.span,
+            PathBuf::from(&path.item),
+        )))
     } else {
         Ok(PluginRegistryFile::default())
     }
@@ -80,13 +75,8 @@ pub(crate) fn modify_plugin_file(
     // Try to read the plugin file if it exists
     let mut contents = if fs::metadata(&plugin_registry_file_path).is_ok_and(|m| m.len() > 0) {
         PluginRegistryFile::read_from(
-            File::open(&plugin_registry_file_path).map_err(|err| ShellError::IOErrorSpanned {
-                msg: format!(
-                    "failed to read `{}`: {}",
-                    plugin_registry_file_path.display(),
-                    err
-                ),
-                span: file_span,
+            File::open(&plugin_registry_file_path).map_err(|err| {
+                IoError::new(err.kind(), file_span, plugin_registry_file_path.clone())
             })?,
             Some(file_span),
         )?
@@ -99,14 +89,8 @@ pub(crate) fn modify_plugin_file(
 
     // Save the modified file on success
     contents.write_to(
-        File::create(&plugin_registry_file_path).map_err(|err| ShellError::IOErrorSpanned {
-            msg: format!(
-                "failed to create `{}`: {}",
-                plugin_registry_file_path.display(),
-                err
-            ),
-            span: file_span,
-        })?,
+        File::create(&plugin_registry_file_path)
+            .map_err(|err| IoError::new(err.kind(), file_span, plugin_registry_file_path))?,
         Some(span),
     )?;
 
diff --git a/crates/nu-command/src/bytes/ends_with.rs b/crates/nu-command/src/bytes/ends_with.rs
index 774b45aefc534..23167a7901b9b 100644
--- a/crates/nu-command/src/bytes/ends_with.rs
+++ b/crates/nu-command/src/bytes/ends_with.rs
@@ -1,5 +1,6 @@
 use nu_cmd_base::input_handler::{operate, CmdArgument};
 use nu_engine::command_prelude::*;
+use nu_protocol::shell_error::io::IoError;
 use std::{
     collections::VecDeque,
     io::{self, BufRead},
@@ -76,7 +77,7 @@ impl Command for BytesEndsWith {
                     Ok(&[]) => break,
                     Ok(buf) => buf,
                     Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
-                    Err(e) => return Err(e.into_spanned(span).into()),
+                    Err(e) => return Err(IoError::new(e.kind(), span, None).into()),
                 };
                 let len = buf.len();
                 if len >= cap {
diff --git a/crates/nu-command/src/bytes/starts_with.rs b/crates/nu-command/src/bytes/starts_with.rs
index 39db1a80cefe2..9681b4ccbe8da 100644
--- a/crates/nu-command/src/bytes/starts_with.rs
+++ b/crates/nu-command/src/bytes/starts_with.rs
@@ -1,5 +1,6 @@
 use nu_cmd_base::input_handler::{operate, CmdArgument};
 use nu_engine::command_prelude::*;
+use nu_protocol::shell_error::io::IoError;
 use std::io::Read;
 
 struct Arguments {
@@ -71,7 +72,7 @@ impl Command for BytesStartsWith {
             reader
                 .take(pattern.len() as u64)
                 .read_to_end(&mut start)
-                .err_span(span)?;
+                .map_err(|err| IoError::new(err.kind(), span, None))?;
 
             Ok(Value::bool(start == pattern, head).into_pipeline_data())
         } else {
diff --git a/crates/nu-command/src/conversions/into/string.rs b/crates/nu-command/src/conversions/into/string.rs
index 460f6d973d041..261afe1dd7e26 100644
--- a/crates/nu-command/src/conversions/into/string.rs
+++ b/crates/nu-command/src/conversions/into/string.rs
@@ -2,7 +2,7 @@ use std::sync::Arc;
 
 use nu_cmd_base::input_handler::{operate, CmdArgument};
 use nu_engine::command_prelude::*;
-use nu_protocol::{into_code, Config};
+use nu_protocol::{shell_error::into_code, Config};
 use nu_utils::get_system_locale;
 use num_format::ToFormattedString;
 
diff --git a/crates/nu-command/src/database/values/sqlite.rs b/crates/nu-command/src/database/values/sqlite.rs
index 620ac953242c4..8959cf5998792 100644
--- a/crates/nu-command/src/database/values/sqlite.rs
+++ b/crates/nu-command/src/database/values/sqlite.rs
@@ -2,7 +2,10 @@ use super::definitions::{
     db_column::DbColumn, db_constraint::DbConstraint, db_foreignkey::DbForeignKey,
     db_index::DbIndex, db_table::DbTable,
 };
-use nu_protocol::{CustomValue, PipelineData, Record, ShellError, Signals, Span, Spanned, Value};
+use nu_protocol::{
+    shell_error::io::IoError, CustomValue, PipelineData, Record, ShellError, Signals, Span,
+    Spanned, Value,
+};
 use rusqlite::{
     types::ValueRef, Connection, DatabaseName, Error as SqliteError, OpenFlags, Row, Statement,
     ToSql,
@@ -38,24 +41,22 @@ impl SQLiteDatabase {
     }
 
     pub fn try_from_path(path: &Path, span: Span, signals: Signals) -> Result<Self, ShellError> {
-        let mut file = File::open(path).map_err(|e| ShellError::ReadingFile {
-            msg: e.to_string(),
-            span,
-        })?;
+        let mut file =
+            File::open(path).map_err(|e| IoError::new(e.kind(), span, PathBuf::from(path)))?;
 
         let mut buf: [u8; 16] = [0; 16];
         file.read_exact(&mut buf)
-            .map_err(|e| ShellError::ReadingFile {
-                msg: e.to_string(),
-                span,
-            })
+            .map_err(|e| ShellError::Io(IoError::new(e.kind(), span, PathBuf::from(path))))
             .and_then(|_| {
                 if buf == SQLITE_MAGIC_BYTES {
                     Ok(SQLiteDatabase::new(path, signals))
                 } else {
-                    Err(ShellError::ReadingFile {
-                        msg: "Not a SQLite file".into(),
-                        span,
+                    Err(ShellError::GenericError {
+                        error: "Not a SQLite file".into(),
+                        msg: format!("Could not read '{}' as SQLite file", path.display()),
+                        span: Some(span),
+                        help: None,
+                        inner: vec![],
                     })
                 }
             })
diff --git a/crates/nu-command/src/env/config/config_.rs b/crates/nu-command/src/env/config/config_.rs
index 71afd57c5abe0..f60b4be290655 100644
--- a/crates/nu-command/src/env/config/config_.rs
+++ b/crates/nu-command/src/env/config/config_.rs
@@ -60,6 +60,8 @@ pub(super) fn start_editor(
     call: &Call,
 ) -> Result<PipelineData, ShellError> {
     // Find the editor executable.
+
+    use nu_protocol::shell_error::io::IoError;
     let (editor_name, editor_args) = get_editor(engine_state, stack, call.head)?;
     let paths = nu_engine::env::path_str(engine_state, stack, call.head)?;
     let cwd = engine_state.cwd(Some(stack))?;
@@ -99,13 +101,22 @@ pub(super) fn start_editor(
     // Spawn the child process. On Unix, also put the child process to
     // foreground if we're in an interactive session.
     #[cfg(windows)]
-    let child = ForegroundChild::spawn(command)?;
+    let child = ForegroundChild::spawn(command);
     #[cfg(unix)]
     let child = ForegroundChild::spawn(
         command,
         engine_state.is_interactive,
         &engine_state.pipeline_externals_state,
-    )?;
+    );
+
+    let child = child.map_err(|err| {
+        IoError::new_with_additional_context(
+            err.kind(),
+            call.head,
+            None,
+            "Could not spawn foreground child",
+        )
+    })?;
 
     // Wrap the output into a `PipelineData::ByteStream`.
     let child = nu_protocol::process::ChildProcess::new(child, None, false, call.head)?;
diff --git a/crates/nu-command/src/env/config/config_reset.rs b/crates/nu-command/src/env/config/config_reset.rs
index 03c022c3d7683..5215ac6a1d76d 100644
--- a/crates/nu-command/src/env/config/config_reset.rs
+++ b/crates/nu-command/src/env/config/config_reset.rs
@@ -1,8 +1,9 @@
 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;
+use std::{io::Write, path::PathBuf};
 
 #[derive(Clone)]
 pub struct ConfigReset;
@@ -58,19 +59,23 @@ impl Command for ConfigReset {
                     "oldconfig-{}.nu",
                     Local::now().format("%F-%H-%M-%S"),
                 ));
-                if std::fs::rename(nu_config.clone(), backup_path).is_err() {
-                    return Err(ShellError::FileNotFoundCustom {
-                        msg: "config.nu could not be backed up".into(),
+                if let Err(err) = std::fs::rename(nu_config.clone(), &backup_path) {
+                    return Err(ShellError::Io(IoError::new_with_additional_context(
+                        err.kind(),
                         span,
-                    });
+                        PathBuf::from(backup_path),
+                        "config.nu could not be backed up",
+                    )));
                 }
             }
-            if let Ok(mut file) = std::fs::File::create(nu_config) {
-                if writeln!(&mut file, "{config_file}").is_err() {
-                    return Err(ShellError::FileNotFoundCustom {
-                        msg: "config.nu could not be written to".into(),
+            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(),
                         span,
-                    });
+                        PathBuf::from(nu_config),
+                        "config.nu could not be written to",
+                    )));
                 }
             }
         }
@@ -81,19 +86,23 @@ impl Command for ConfigReset {
             if !no_backup {
                 let mut backup_path = config_path.clone();
                 backup_path.push(format!("oldenv-{}.nu", Local::now().format("%F-%H-%M-%S"),));
-                if std::fs::rename(env_config.clone(), backup_path).is_err() {
-                    return Err(ShellError::FileNotFoundCustom {
-                        msg: "env.nu could not be backed up".into(),
+                if let Err(err) = std::fs::rename(env_config.clone(), &backup_path) {
+                    return Err(ShellError::Io(IoError::new_with_additional_context(
+                        err.kind(),
                         span,
-                    });
+                        PathBuf::from(backup_path),
+                        "env.nu could not be backed up",
+                    )));
                 }
             }
-            if let Ok(mut file) = std::fs::File::create(env_config) {
-                if writeln!(&mut file, "{config_file}").is_err() {
-                    return Err(ShellError::FileNotFoundCustom {
-                        msg: "env.nu could not be written to".into(),
+            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(),
                         span,
-                    });
+                        PathBuf::from(env_config),
+                        "env.nu could not be written to",
+                    )));
                 }
             }
         }
diff --git a/crates/nu-command/src/env/source_env.rs b/crates/nu-command/src/env/source_env.rs
index 342c31aed4c11..a16bbe557fafe 100644
--- a/crates/nu-command/src/env/source_env.rs
+++ b/crates/nu-command/src/env/source_env.rs
@@ -2,7 +2,7 @@ use nu_engine::{
     command_prelude::*, find_in_dirs_env, get_dirs_var_from_call, get_eval_block_with_early_return,
     redirect_env,
 };
-use nu_protocol::{engine::CommandType, BlockId};
+use nu_protocol::{engine::CommandType, shell_error::io::IoError, BlockId};
 use std::path::PathBuf;
 
 /// Source a file for environment variables.
@@ -65,10 +65,11 @@ impl Command for SourceEnv {
         )? {
             PathBuf::from(&path)
         } else {
-            return Err(ShellError::FileNotFound {
-                file: source_filename.item,
-                span: source_filename.span,
-            });
+            return Err(ShellError::Io(IoError::new(
+                std::io::ErrorKind::NotFound,
+                source_filename.span,
+                PathBuf::from(source_filename.item),
+            )));
         };
 
         if let Some(parent) = file_path.parent() {
diff --git a/crates/nu-command/src/filesystem/cd.rs b/crates/nu-command/src/filesystem/cd.rs
index 2af932bcb48a4..3ba438746d7fd 100644
--- a/crates/nu-command/src/filesystem/cd.rs
+++ b/crates/nu-command/src/filesystem/cd.rs
@@ -1,4 +1,7 @@
+use std::path::PathBuf;
+
 use nu_engine::command_prelude::*;
+use nu_protocol::shell_error::{self, io::IoError};
 use nu_utils::filesystem::{have_permission, PermissionResult};
 
 #[derive(Clone)]
@@ -77,25 +80,39 @@ impl Command for Cd {
                     if physical {
                         if let Ok(path) = nu_path::canonicalize_with(path_no_whitespace, &cwd) {
                             if !path.is_dir() {
-                                return Err(ShellError::NotADirectory { span: v.span });
+                                return Err(shell_error::io::IoError::new(
+                                    shell_error::io::ErrorKind::NotADirectory,
+                                    v.span,
+                                    None,
+                                )
+                                .into());
                             };
                             path
                         } else {
-                            return Err(ShellError::DirectoryNotFound {
-                                dir: path_no_whitespace.to_string(),
-                                span: v.span,
-                            });
+                            return Err(shell_error::io::IoError::new(
+                                std::io::ErrorKind::NotFound,
+                                v.span,
+                                PathBuf::from(path_no_whitespace),
+                            )
+                            .into());
                         }
                     } else {
                         let path = nu_path::expand_path_with(path_no_whitespace, &cwd, true);
                         if !path.exists() {
-                            return Err(ShellError::DirectoryNotFound {
-                                dir: path_no_whitespace.to_string(),
-                                span: v.span,
-                            });
+                            return Err(shell_error::io::IoError::new(
+                                std::io::ErrorKind::NotFound,
+                                v.span,
+                                PathBuf::from(path_no_whitespace),
+                            )
+                            .into());
                         };
                         if !path.is_dir() {
-                            return Err(ShellError::NotADirectory { span: v.span });
+                            return Err(shell_error::io::IoError::new(
+                                shell_error::io::ErrorKind::NotADirectory,
+                                v.span,
+                                path,
+                            )
+                            .into());
                         };
                         path
                     }
@@ -117,13 +134,9 @@ impl Command for Cd {
                 stack.set_cwd(path)?;
                 Ok(PipelineData::empty())
             }
-            PermissionResult::PermissionDenied(reason) => Err(ShellError::IOError {
-                msg: format!(
-                    "Cannot change directory to {}: {}",
-                    path.to_string_lossy(),
-                    reason
-                ),
-            }),
+            PermissionResult::PermissionDenied(_) => {
+                Err(IoError::new(std::io::ErrorKind::PermissionDenied, call.head, path).into())
+            }
         }
     }
 
diff --git a/crates/nu-command/src/filesystem/ls.rs b/crates/nu-command/src/filesystem/ls.rs
index 68f35c5294ecb..19418b1a70b73 100644
--- a/crates/nu-command/src/filesystem/ls.rs
+++ b/crates/nu-command/src/filesystem/ls.rs
@@ -5,7 +5,7 @@ use nu_engine::glob_from;
 use nu_engine::{command_prelude::*, env::current_dir};
 use nu_glob::MatchOptions;
 use nu_path::{expand_path_with, expand_to_real_path};
-use nu_protocol::{DataSource, NuGlob, PipelineMetadata, Signals};
+use nu_protocol::{shell_error::io::IoError, DataSource, NuGlob, PipelineMetadata, Signals};
 use pathdiff::diff_paths;
 use rayon::prelude::*;
 #[cfg(unix)]
@@ -254,10 +254,12 @@ fn ls_for_one_pattern(
         if let Some(path) = pattern_arg {
             // it makes no sense to list an empty string.
             if path.item.as_ref().is_empty() {
-                return Err(ShellError::FileNotFoundCustom {
-                    msg: "empty string('') directory or file does not exist".to_string(),
-                    span: path.span,
-                });
+                return Err(ShellError::Io(IoError::new_with_additional_context(
+                    std::io::ErrorKind::NotFound,
+                    path.span,
+                    PathBuf::from(path.item.to_string()),
+                    "empty string('') directory or file does not exist",
+                )));
             }
             match path.item {
                 NuGlob::DoNotExpand(p) => Some(Spanned {
@@ -283,10 +285,7 @@ fn ls_for_one_pattern(
                 nu_path::expand_path_with(pat.item.as_ref(), &cwd, pat.item.is_expand());
             // Avoid checking and pushing "*" to the path when directory (do not show contents) flag is true
             if !directory && tmp_expanded.is_dir() {
-                if read_dir(&tmp_expanded, p_tag, use_threads)?
-                    .next()
-                    .is_none()
-                {
+                if read_dir(tmp_expanded, p_tag, use_threads)?.next().is_none() {
                     return Ok(Value::test_nothing().into_pipeline_data());
                 }
                 just_read_dir = !(pat.item.is_expand() && nu_glob::is_glob(pat.item.as_ref()));
@@ -305,7 +304,7 @@ fn ls_for_one_pattern(
             // Avoid pushing "*" to the default path when directory (do not show contents) flag is true
             if directory {
                 (NuGlob::Expand(".".to_string()), false)
-            } else if read_dir(&cwd, p_tag, use_threads)?.next().is_none() {
+            } else if read_dir(cwd.clone(), p_tag, use_threads)?.next().is_none() {
                 return Ok(Value::test_nothing().into_pipeline_data());
             } else {
                 (NuGlob::Expand("*".to_string()), false)
@@ -318,7 +317,7 @@ fn ls_for_one_pattern(
     let path = pattern_arg.into_spanned(p_tag);
     let (prefix, paths) = if just_read_dir {
         let expanded = nu_path::expand_path_with(path.item.as_ref(), &cwd, path.item.is_expand());
-        let paths = read_dir(&expanded, p_tag, use_threads)?;
+        let paths = read_dir(expanded.clone(), p_tag, use_threads)?;
         // just need to read the directory, so prefix is path itself.
         (Some(expanded), paths)
     } else {
@@ -350,7 +349,16 @@ fn ls_for_one_pattern(
     let signals_clone = signals.clone();
 
     let pool = if use_threads {
-        let count = std::thread::available_parallelism()?.get();
+        let count = std::thread::available_parallelism()
+            .map_err(|err| {
+                IoError::new_with_additional_context(
+                    err.kind(),
+                    call_span,
+                    None,
+                    "Could not get available parallelism",
+                )
+            })?
+            .get();
         create_pool(count)?
     } else {
         create_pool(1)?
@@ -910,14 +918,12 @@ mod windows_helper {
                 &mut find_data,
             ) {
                 Ok(_) => Ok(find_data),
-                Err(e) => Err(ShellError::ReadingFile {
-                    msg: format!(
-                        "Could not read metadata for '{}':\n  '{}'",
-                        filename.to_string_lossy(),
-                        e
-                    ),
+                Err(e) => Err(ShellError::Io(IoError::new_with_additional_context(
+                    std::io::ErrorKind::Other,
                     span,
-                }),
+                    PathBuf::from(filename),
+                    format!("Could not read metadata: {e}"),
+                ))),
             }
         }
     }
@@ -950,28 +956,17 @@ mod windows_helper {
 
 #[allow(clippy::type_complexity)]
 fn read_dir(
-    f: &Path,
+    f: PathBuf,
     span: Span,
     use_threads: bool,
 ) -> Result<Box<dyn Iterator<Item = Result<PathBuf, ShellError>> + Send>, ShellError> {
     let items = f
         .read_dir()
-        .map_err(|error| {
-            if error.kind() == std::io::ErrorKind::PermissionDenied {
-                return ShellError::GenericError {
-                    error: "Permission denied".into(),
-                    msg: "The permissions may not allow access for this user".into(),
-                    span: Some(span),
-                    help: None,
-                    inner: vec![],
-                };
-            }
-
-            error.into()
-        })?
-        .map(|d| {
+        .map_err(|err| IoError::new(err.kind(), span, f.clone()))?
+        .map(move |d| {
             d.map(|r| r.path())
-                .map_err(|e| ShellError::IOError { msg: e.to_string() })
+                .map_err(|err| IoError::new(err.kind(), span, f.clone()))
+                .map_err(ShellError::from)
         });
     if !use_threads {
         let mut collected = items.collect::<Vec<_>>();
diff --git a/crates/nu-command/src/filesystem/mktemp.rs b/crates/nu-command/src/filesystem/mktemp.rs
index 9a0c041f15993..5ebd054acb9b7 100644
--- a/crates/nu-command/src/filesystem/mktemp.rs
+++ b/crates/nu-command/src/filesystem/mktemp.rs
@@ -106,14 +106,10 @@ impl Command for Mktemp {
         };
 
         let res = match uu_mktemp::mktemp(&options) {
-            Ok(res) => {
-                res.into_os_string()
-                    .into_string()
-                    .map_err(|e| ShellError::IOErrorSpanned {
-                        msg: e.to_string_lossy().to_string(),
-                        span,
-                    })?
-            }
+            Ok(res) => res
+                .into_os_string()
+                .into_string()
+                .map_err(|_| ShellError::NonUtf8 { span })?,
             Err(e) => {
                 return Err(ShellError::GenericError {
                     error: format!("{}", e),
diff --git a/crates/nu-command/src/filesystem/open.rs b/crates/nu-command/src/filesystem/open.rs
index db00f35d6dc42..a372d7e79e5ba 100644
--- a/crates/nu-command/src/filesystem/open.rs
+++ b/crates/nu-command/src/filesystem/open.rs
@@ -1,7 +1,11 @@
 #[allow(deprecated)]
 use nu_engine::{command_prelude::*, current_dir, get_eval_block};
-use nu_protocol::{ast, DataSource, NuGlob, PipelineMetadata};
-use std::path::Path;
+use nu_protocol::{
+    ast,
+    shell_error::{self, io::IoError},
+    DataSource, NuGlob, PipelineMetadata,
+};
+use std::path::{Path, PathBuf};
 
 #[cfg(feature = "sqlite")]
 use crate::database::SQLiteDatabase;
@@ -87,25 +91,10 @@ impl Command for Open {
 
             for path in nu_engine::glob_from(&path, &cwd, call_span, None)
                 .map_err(|err| match err {
-                    ShellError::DirectoryNotFound { span, .. } => ShellError::FileNotFound {
-                        file: path.item.to_string(),
-                        span,
-                    },
-                    // that particular error in `nu_engine::glob_from` doesn't have a span attached
-                    // to it, so let's add it
-                    ShellError::GenericError {
-                        error,
-                        msg,
-                        span: _,
-                        help,
-                        inner,
-                    } if error.as_str() == "Permission denied" => ShellError::GenericError {
-                        error,
-                        msg,
-                        span: Some(arg_span),
-                        help,
-                        inner,
-                    },
+                    ShellError::Io(mut err) => {
+                        err.span = arg_span;
+                        err.into()
+                    }
                     _ => err,
                 })?
                 .1
@@ -114,24 +103,26 @@ impl Command for Open {
                 let path = Path::new(&path);
 
                 if permission_denied(path) {
+                    let err = IoError::new(
+                        std::io::ErrorKind::PermissionDenied,
+                        arg_span,
+                        PathBuf::from(path),
+                    );
+
                     #[cfg(unix)]
-                    let error_msg = match path.metadata() {
-                        Ok(md) => format!(
-                            "The permissions of {:o} does not allow access for this user",
-                            md.permissions().mode() & 0o0777
-                        ),
-                        Err(e) => e.to_string(),
+                    let err = {
+                        let mut err = err;
+                        err.additional_context = Some(match path.metadata() {
+                            Ok(md) => format!(
+                                "The permissions of {:o} does not allow access for this user",
+                                md.permissions().mode() & 0o0777
+                            ),
+                            Err(e) => e.to_string(),
+                        });
+                        err
                     };
 
-                    #[cfg(not(unix))]
-                    let error_msg = String::from("Permission denied");
-                    return Err(ShellError::GenericError {
-                        error: "Permission denied".into(),
-                        msg: error_msg,
-                        span: Some(arg_span),
-                        help: None,
-                        inner: vec![],
-                    });
+                    return Err(err.into());
                 } else {
                     #[cfg(feature = "sqlite")]
                     if !raw {
@@ -147,18 +138,18 @@ impl Command for Open {
                         }
                     }
 
-                    let file = match std::fs::File::open(path) {
-                        Ok(file) => file,
-                        Err(err) => {
-                            return Err(ShellError::GenericError {
-                                error: "Permission denied".into(),
-                                msg: err.to_string(),
-                                span: Some(arg_span),
-                                help: None,
-                                inner: vec![],
-                            });
-                        }
-                    };
+                    if path.is_dir() {
+                        // At least under windows this check ensures that we don't get a
+                        // permission denied error on directories
+                        return Err(ShellError::Io(IoError::new(
+                            shell_error::io::ErrorKind::IsADirectory,
+                            arg_span,
+                            PathBuf::from(path),
+                        )));
+                    }
+
+                    let file = std::fs::File::open(path)
+                        .map_err(|err| IoError::new(err.kind(), arg_span, PathBuf::from(path)))?;
 
                     // No content_type by default - Is added later if no converter is found
                     let stream = PipelineData::ByteStream(
diff --git a/crates/nu-command/src/filesystem/rm.rs b/crates/nu-command/src/filesystem/rm.rs
index 9a13442aa38ba..91daf99d8c769 100644
--- a/crates/nu-command/src/filesystem/rm.rs
+++ b/crates/nu-command/src/filesystem/rm.rs
@@ -3,7 +3,11 @@ use super::util::try_interaction;
 use nu_engine::{command_prelude::*, env::current_dir};
 use nu_glob::MatchOptions;
 use nu_path::expand_path_with;
-use nu_protocol::{report_shell_error, NuGlob};
+use nu_protocol::{
+    report_shell_error,
+    shell_error::{self, io::IoError},
+    NuGlob,
+};
 #[cfg(unix)]
 use std::os::unix::prelude::FileTypeExt;
 use std::{
@@ -299,9 +303,17 @@ fn rm(
                 }
             }
             Err(e) => {
-                // glob_from may canonicalize path and return `DirectoryNotFound`
+                // glob_from may canonicalize path and return an error when a directory is not found
                 // nushell should suppress the error if `--force` is used.
-                if !(force && matches!(e, ShellError::DirectoryNotFound { .. })) {
+                if !(force
+                    && matches!(
+                        e,
+                        ShellError::Io(IoError {
+                            kind: shell_error::io::ErrorKind::Std(std::io::ErrorKind::NotFound),
+                            ..
+                        })
+                    ))
+                {
                     return Err(e);
                 }
             }
@@ -413,8 +425,7 @@ fn rm(
                 };
 
                 if let Err(e) = result {
-                    let msg = format!("Could not delete {:}: {e:}", f.to_string_lossy());
-                    Err(ShellError::RemoveNotPossible { msg, span })
+                    Err(ShellError::Io(IoError::new(e.kind(), span, f)))
                 } else if verbose {
                     let msg = if interactive && !confirmed {
                         "not deleted"
diff --git a/crates/nu-command/src/filesystem/save.rs b/crates/nu-command/src/filesystem/save.rs
index 67fd1a2271cb5..bd580eec8c5b2 100644
--- a/crates/nu-command/src/filesystem/save.rs
+++ b/crates/nu-command/src/filesystem/save.rs
@@ -4,8 +4,8 @@ use nu_engine::get_eval_block;
 use nu_engine::{command_prelude::*, current_dir};
 use nu_path::expand_path_with;
 use nu_protocol::{
-    ast, byte_stream::copy_with_signals, process::ChildPipe, ByteStreamSource, DataSource, OutDest,
-    PipelineMetadata, Signals,
+    ast, byte_stream::copy_with_signals, process::ChildPipe, shell_error::io::IoError,
+    ByteStreamSource, DataSource, OutDest, PipelineMetadata, Signals,
 };
 use std::{
     fs::File,
@@ -86,6 +86,7 @@ impl Command for Save {
                 span: arg.span,
             });
 
+        let from_io_error = IoError::factory(span, path.item.as_path());
         match input {
             PipelineData::ByteStream(stream, metadata) => {
                 check_saving_to_source_file(metadata.as_ref(), &path, stderr_path.as_ref())?;
@@ -129,7 +130,7 @@ impl Command for Save {
                                         io::copy(&mut tee, &mut io::stderr())
                                     }
                                 }
-                                .err_span(span)?;
+                                .map_err(|err| IoError::new(err.kind(), span, None))?;
                             }
                             Ok(())
                         }
@@ -153,7 +154,7 @@ impl Command for Save {
                                         )
                                     })
                                     .transpose()
-                                    .err_span(span)?;
+                                    .map_err(&from_io_error)?;
 
                                 let res = match stdout {
                                     ChildPipe::Pipe(pipe) => {
@@ -203,15 +204,10 @@ impl Command for Save {
                 let (mut file, _) = get_files(&path, stderr_path.as_ref(), append, force)?;
                 for val in ls {
                     file.write_all(&value_to_bytes(val)?)
-                        .map_err(|err| ShellError::IOError {
-                            msg: err.to_string(),
-                        })?;
-                    file.write_all("\n".as_bytes())
-                        .map_err(|err| ShellError::IOError {
-                            msg: err.to_string(),
-                        })?;
+                        .map_err(&from_io_error)?;
+                    file.write_all("\n".as_bytes()).map_err(&from_io_error)?;
                 }
-                file.flush()?;
+                file.flush().map_err(&from_io_error)?;
 
                 Ok(PipelineData::empty())
             }
@@ -232,11 +228,8 @@ impl Command for Save {
                 // Only open file after successful conversion
                 let (mut file, _) = get_files(&path, stderr_path.as_ref(), append, force)?;
 
-                file.write_all(&bytes).map_err(|err| ShellError::IOError {
-                    msg: err.to_string(),
-                })?;
-
-                file.flush()?;
+                file.write_all(&bytes).map_err(&from_io_error)?;
+                file.flush().map_err(&from_io_error)?;
 
                 Ok(PipelineData::empty())
             }
@@ -420,33 +413,27 @@ fn prepare_path(
 }
 
 fn open_file(path: &Path, span: Span, append: bool) -> Result<File, ShellError> {
-    let file = match (append, path.exists()) {
-        (true, true) => std::fs::OpenOptions::new().append(true).open(path),
+    let file: Result<File, nu_protocol::shell_error::io::ErrorKind> = match (append, path.exists())
+    {
+        (true, true) => std::fs::OpenOptions::new()
+            .append(true)
+            .open(path)
+            .map_err(|err| err.kind().into()),
         _ => {
             // This is a temporary solution until `std::fs::File::create` is fixed on Windows (rust-lang/rust#134893)
             // A TOCTOU problem exists here, which may cause wrong error message to be shown
             #[cfg(target_os = "windows")]
             if path.is_dir() {
-                // It should be `io::ErrorKind::IsADirectory` but it's not available in stable yet (1.83)
-                Err(io::Error::new(
-                    io::ErrorKind::Unsupported,
-                    "Is a directory (os error 21)",
-                ))
+                Err(nu_protocol::shell_error::io::ErrorKind::IsADirectory)
             } else {
-                std::fs::File::create(path)
+                std::fs::File::create(path).map_err(|err| err.kind().into())
             }
             #[cfg(not(target_os = "windows"))]
-            std::fs::File::create(path)
+            std::fs::File::create(path).map_err(|err| err.kind().into())
         }
     };
 
-    file.map_err(|e| ShellError::GenericError {
-        error: format!("Problem with [{}], Permission denied", path.display()),
-        msg: e.to_string(),
-        span: Some(span),
-        help: None,
-        inner: vec![],
-    })
+    file.map_err(|err_kind| ShellError::Io(IoError::new(err_kind, span, PathBuf::from(path))))
 }
 
 /// Get output file and optional stderr file
@@ -493,6 +480,9 @@ fn stream_to_file(
     span: Span,
     progress: bool,
 ) -> Result<(), ShellError> {
+    // TODO: maybe we can get a path in here
+    let from_io_error = IoError::factory(span, None);
+
     // https://github.com/nushell/nushell/pull/9377 contains the reason for not using `BufWriter`
     if progress {
         let mut bytes_processed = 0;
@@ -512,7 +502,7 @@ fn stream_to_file(
             match reader.fill_buf() {
                 Ok(&[]) => break Ok(()),
                 Ok(buf) => {
-                    file.write_all(buf).err_span(span)?;
+                    file.write_all(buf).map_err(&from_io_error)?;
                     let len = buf.len();
                     reader.consume(len);
                     bytes_processed += len as u64;
@@ -530,9 +520,9 @@ fn stream_to_file(
         if let Err(err) = res {
             let _ = file.flush();
             bar.abandoned_msg("# Error while saving #".to_owned());
-            Err(err.into_spanned(span).into())
+            Err(from_io_error(err).into())
         } else {
-            file.flush().err_span(span)?;
+            file.flush().map_err(&from_io_error)?;
             Ok(())
         }
     } else {
diff --git a/crates/nu-command/src/filesystem/ucp.rs b/crates/nu-command/src/filesystem/ucp.rs
index 40315d9e782c4..a65cbece664f1 100644
--- a/crates/nu-command/src/filesystem/ucp.rs
+++ b/crates/nu-command/src/filesystem/ucp.rs
@@ -1,6 +1,6 @@
 #[allow(deprecated)]
 use nu_engine::{command_prelude::*, current_dir};
-use nu_protocol::NuGlob;
+use nu_protocol::{shell_error::io::IoError, NuGlob};
 use std::path::PathBuf;
 use uu_cp::{BackupMode, CopyMode, UpdateMode};
 
@@ -197,10 +197,11 @@ impl Command for UCp {
                     .map(|f| f.1)?
                     .collect();
             if exp_files.is_empty() {
-                return Err(ShellError::FileNotFound {
-                    file: p.item.to_string(),
-                    span: p.span,
-                });
+                return Err(ShellError::Io(IoError::new(
+                    std::io::ErrorKind::NotFound,
+                    p.span,
+                    PathBuf::from(p.item.to_string()),
+                )));
             };
             let mut app_vals: Vec<PathBuf> = Vec::new();
             for v in exp_files {
diff --git a/crates/nu-command/src/filesystem/umv.rs b/crates/nu-command/src/filesystem/umv.rs
index cdf0c05b1f824..c76ab9f376ab5 100644
--- a/crates/nu-command/src/filesystem/umv.rs
+++ b/crates/nu-command/src/filesystem/umv.rs
@@ -1,7 +1,7 @@
 #[allow(deprecated)]
 use nu_engine::{command_prelude::*, current_dir};
 use nu_path::expand_path_with;
-use nu_protocol::NuGlob;
+use nu_protocol::{shell_error::io::IoError, NuGlob};
 use std::{ffi::OsString, path::PathBuf};
 use uu_mv::{BackupMode, UpdateMode};
 
@@ -138,10 +138,11 @@ impl Command for UMv {
                     .map(|f| f.1)?
                     .collect();
             if exp_files.is_empty() {
-                return Err(ShellError::FileNotFound {
-                    file: p.item.to_string(),
-                    span: p.span,
-                });
+                return Err(ShellError::Io(IoError::new(
+                    std::io::ErrorKind::NotFound,
+                    p.span,
+                    PathBuf::from(p.item.to_string()),
+                )));
             };
             let mut app_vals: Vec<PathBuf> = Vec::new();
             for v in exp_files {
diff --git a/crates/nu-command/src/filesystem/utouch.rs b/crates/nu-command/src/filesystem/utouch.rs
index 9fbf9805de586..d887a5c4118b4 100644
--- a/crates/nu-command/src/filesystem/utouch.rs
+++ b/crates/nu-command/src/filesystem/utouch.rs
@@ -3,8 +3,8 @@ use filetime::FileTime;
 use nu_engine::command_prelude::*;
 use nu_glob::{glob, is_glob};
 use nu_path::expand_path_with;
-use nu_protocol::NuGlob;
-use std::{io::ErrorKind, path::PathBuf};
+use nu_protocol::{shell_error::io::IoError, NuGlob};
+use std::path::PathBuf;
 use uu_touch::{error::TouchError, ChangeTimes, InputFile, Options, Source};
 
 #[derive(Clone)]
@@ -225,20 +225,12 @@ impl Command for UTouch {
                 },
                 TouchError::ReferenceFileInaccessible(reference_path, io_err) => {
                     let span = reference_span.expect("touch should've been given a reference file");
-                    if io_err.kind() == ErrorKind::NotFound {
-                        ShellError::FileNotFound {
-                            span,
-                            file: reference_path.display().to_string(),
-                        }
-                    } else {
-                        ShellError::GenericError {
-                            error: io_err.to_string(),
-                            msg: format!("Failed to read metadata of {}", reference_path.display()),
-                            span: Some(span),
-                            help: None,
-                            inner: Vec::new(),
-                        }
-                    }
+                    ShellError::Io(IoError::new_with_additional_context(
+                        io_err.kind(),
+                        span,
+                        reference_path,
+                        "failed to read metadata",
+                    ))
                 }
                 _ => ShellError::GenericError {
                     error: err.to_string(),
diff --git a/crates/nu-command/src/filesystem/watch.rs b/crates/nu-command/src/filesystem/watch.rs
index dd21304173179..3028181a21465 100644
--- a/crates/nu-command/src/filesystem/watch.rs
+++ b/crates/nu-command/src/filesystem/watch.rs
@@ -9,6 +9,7 @@ use nu_engine::{command_prelude::*, ClosureEval};
 use nu_protocol::{
     engine::{Closure, StateWorkingSet},
     format_shell_error,
+    shell_error::io::IoError,
 };
 use std::{
     path::PathBuf,
@@ -83,11 +84,12 @@ impl Command for Watch {
 
         let path = match nu_path::canonicalize_with(path_no_whitespace, cwd) {
             Ok(p) => p,
-            Err(_) => {
-                return Err(ShellError::DirectoryNotFound {
-                    dir: path_no_whitespace.to_string(),
-                    span: path_arg.span,
-                })
+            Err(err) => {
+                return Err(ShellError::Io(IoError::new(
+                    err.kind(),
+                    path_arg.span,
+                    PathBuf::from(path_no_whitespace),
+                )))
             }
         };
 
@@ -151,14 +153,22 @@ impl Command for Watch {
         let mut debouncer = match new_debouncer(debounce_duration, None, tx) {
             Ok(d) => d,
             Err(e) => {
-                return Err(ShellError::IOError {
-                    msg: format!("Failed to create watcher: {e}"),
-                })
+                return Err(ShellError::GenericError {
+                    error: "Failed to create watcher".to_string(),
+                    msg: e.to_string(),
+                    span: Some(call.head),
+                    help: None,
+                    inner: vec![],
+                });
             }
         };
         if let Err(e) = debouncer.watcher().watch(&path, recursive_mode) {
-            return Err(ShellError::IOError {
-                msg: format!("Failed to create watcher: {e}"),
+            return Err(ShellError::GenericError {
+                error: "Failed to create watcher".to_string(),
+                msg: e.to_string(),
+                span: Some(call.head),
+                help: None,
+                inner: vec![],
             });
         }
         // need to cache to make sure that rename event works.
@@ -249,13 +259,21 @@ impl Command for Watch {
                     }
                 }
                 Ok(Err(_)) => {
-                    return Err(ShellError::IOError {
+                    return Err(ShellError::GenericError {
+                        error: "Receiving events failed".to_string(),
                         msg: "Unexpected errors when receiving events".into(),
-                    })
+                        span: None,
+                        help: None,
+                        inner: vec![],
+                    });
                 }
                 Err(RecvTimeoutError::Disconnected) => {
-                    return Err(ShellError::IOError {
+                    return Err(ShellError::GenericError {
+                        error: "Disconnected".to_string(),
                         msg: "Unexpected disconnect from file watcher".into(),
+                        span: None,
+                        help: None,
+                        inner: vec![],
                     });
                 }
                 Err(RecvTimeoutError::Timeout) => {}
diff --git a/crates/nu-command/src/filters/chunks.rs b/crates/nu-command/src/filters/chunks.rs
index 93757c36e83e4..5149713644248 100644
--- a/crates/nu-command/src/filters/chunks.rs
+++ b/crates/nu-command/src/filters/chunks.rs
@@ -1,5 +1,5 @@
 use nu_engine::command_prelude::*;
-use nu_protocol::ListStream;
+use nu_protocol::{shell_error::io::IoError, ListStream};
 use std::{
     io::{BufRead, Cursor, ErrorKind},
     num::NonZeroUsize,
@@ -119,6 +119,7 @@ pub fn chunks(
     chunk_size: NonZeroUsize,
     span: Span,
 ) -> Result<PipelineData, ShellError> {
+    let from_io_error = IoError::factory(span, None);
     match input {
         PipelineData::Value(Value::List { vals, .. }, metadata) => {
             let chunks = ChunksIter::new(vals, chunk_size, span);
@@ -136,7 +137,7 @@ pub fn chunks(
             };
             let value_stream = chunk_read.map(move |chunk| match chunk {
                 Ok(chunk) => Value::binary(chunk, span),
-                Err(e) => Value::error(e.into(), span),
+                Err(e) => Value::error(from_io_error(e).into(), span),
             });
             let pipeline_data_with_metadata = value_stream.into_pipeline_data_with_metadata(
                 span,
@@ -155,7 +156,7 @@ pub fn chunks(
                     };
                     let value_stream = chunk_read.map(move |chunk| match chunk {
                         Ok(chunk) => Value::binary(chunk, span),
-                        Err(e) => Value::error(e.into(), span),
+                        Err(e) => Value::error(from_io_error(e).into(), span),
                     });
                     value_stream.into_pipeline_data_with_metadata(
                         span,
diff --git a/crates/nu-command/src/filters/empty.rs b/crates/nu-command/src/filters/empty.rs
index f4dd428b777d1..92a540fa4656d 100644
--- a/crates/nu-command/src/filters/empty.rs
+++ b/crates/nu-command/src/filters/empty.rs
@@ -1,4 +1,5 @@
 use nu_engine::command_prelude::*;
+use nu_protocol::shell_error::io::IoError;
 use std::io::Read;
 
 pub fn empty(
@@ -41,7 +42,12 @@ pub fn empty(
                 let span = stream.span();
                 match stream.reader() {
                     Some(reader) => {
-                        let is_empty = reader.bytes().next().transpose().err_span(span)?.is_none();
+                        let is_empty = reader
+                            .bytes()
+                            .next()
+                            .transpose()
+                            .map_err(|err| IoError::new(err.kind(), span, None))?
+                            .is_none();
                         if negate {
                             Ok(Value::bool(!is_empty, head).into_pipeline_data())
                         } else {
diff --git a/crates/nu-command/src/filters/first.rs b/crates/nu-command/src/filters/first.rs
index 5695f823c7c9a..a1f15c64ce90b 100644
--- a/crates/nu-command/src/filters/first.rs
+++ b/crates/nu-command/src/filters/first.rs
@@ -1,5 +1,5 @@
 use nu_engine::command_prelude::*;
-use nu_protocol::Signals;
+use nu_protocol::{shell_error::io::IoError, Signals};
 use std::io::Read;
 
 #[derive(Clone)]
@@ -180,7 +180,11 @@ fn first_helper(
                     if return_single_element {
                         // Take a single byte
                         let mut byte = [0u8];
-                        if reader.read(&mut byte).err_span(span)? > 0 {
+                        if reader
+                            .read(&mut byte)
+                            .map_err(|err| IoError::new(err.kind(), span, None))?
+                            > 0
+                        {
                             Ok(Value::int(byte[0] as i64, head).into_pipeline_data())
                         } else {
                             Err(ShellError::AccessEmptyContent { span: head })
diff --git a/crates/nu-command/src/filters/interleave.rs b/crates/nu-command/src/filters/interleave.rs
index f31a3411aed4d..4f2b457af3f4a 100644
--- a/crates/nu-command/src/filters/interleave.rs
+++ b/crates/nu-command/src/filters/interleave.rs
@@ -1,5 +1,5 @@
 use nu_engine::{command_prelude::*, ClosureEvalOnce};
-use nu_protocol::engine::Closure;
+use nu_protocol::{engine::Closure, shell_error::io::IoError};
 use std::{sync::mpsc, thread};
 
 #[derive(Clone)]
@@ -137,10 +137,7 @@ interleave
                             }
                         })
                         .map(|_| ())
-                        .map_err(|err| ShellError::IOErrorSpanned {
-                            msg: err.to_string(),
-                            span: head,
-                        })
+                        .map_err(|err| IoError::new(err.kind(), head, None).into())
                 })
             })?;
 
diff --git a/crates/nu-command/src/filters/last.rs b/crates/nu-command/src/filters/last.rs
index 127833dcd43c3..77b57880f62af 100644
--- a/crates/nu-command/src/filters/last.rs
+++ b/crates/nu-command/src/filters/last.rs
@@ -1,4 +1,5 @@
 use nu_engine::command_prelude::*;
+use nu_protocol::shell_error::io::IoError;
 use std::{collections::VecDeque, io::Read};
 
 #[derive(Clone)]
@@ -165,7 +166,7 @@ impl Command for Last {
                         let mut buf = VecDeque::with_capacity(rows + TAKE as usize);
                         loop {
                             let taken = std::io::copy(&mut (&mut reader).take(TAKE), &mut buf)
-                                .err_span(span)?;
+                                .map_err(|err| IoError::new(err.kind(), span, None))?;
                             if buf.len() > rows {
                                 buf.drain(..(buf.len() - rows));
                             }
diff --git a/crates/nu-command/src/filters/tee.rs b/crates/nu-command/src/filters/tee.rs
index e6439398947c8..c6b61a2fb5ef1 100644
--- a/crates/nu-command/src/filters/tee.rs
+++ b/crates/nu-command/src/filters/tee.rs
@@ -2,8 +2,8 @@ use nu_engine::{command_prelude::*, get_eval_block_with_early_return};
 #[cfg(feature = "os")]
 use nu_protocol::process::ChildPipe;
 use nu_protocol::{
-    byte_stream::copy_with_signals, engine::Closure, report_shell_error, ByteStream,
-    ByteStreamSource, OutDest, PipelineMetadata, Signals,
+    byte_stream::copy_with_signals, engine::Closure, report_shell_error, shell_error::io::IoError,
+    ByteStream, ByteStreamSource, OutDest, PipelineMetadata, Signals,
 };
 use std::{
     io::{self, Read, Write},
@@ -82,6 +82,7 @@ use it in your pipeline."#
         input: PipelineData,
     ) -> Result<PipelineData, ShellError> {
         let head = call.head;
+        let from_io_error = IoError::factory(head, None);
         let use_stderr = call.has_flag(engine_state, stack, "stderr")?;
 
         let closure: Spanned<Closure> = call.req(engine_state, stack, 0)?;
@@ -263,7 +264,7 @@ use it in your pipeline."#
                     let input = rx.into_pipeline_data_with_metadata(span, signals, metadata_clone);
                     eval_block(input)
                 })
-                .err_span(call.head)?
+                .map_err(&from_io_error)?
                 .map(move |result| result.unwrap_or_else(|err| Value::error(err, closure_span)))
                 .into_pipeline_data_with_metadata(
                     span,
@@ -278,7 +279,7 @@ use it in your pipeline."#
                 tee_once(engine_state_arc, move || {
                     eval_block(value_clone.into_pipeline_data_with_metadata(metadata_clone))
                 })
-                .err_span(call.head)?;
+                .map_err(&from_io_error)?;
                 Ok(value.into_pipeline_data_with_metadata(metadata))
             }
         }
@@ -439,7 +440,9 @@ fn spawn_tee(
             );
             eval_block(PipelineData::ByteStream(stream, info.metadata))
         })
-        .err_span(info.span)?;
+        .map_err(|err| {
+            IoError::new_with_additional_context(err.kind(), info.span, None, "Could not spawn tee")
+        })?;
 
     Ok(TeeThread { sender, thread })
 }
@@ -478,7 +481,15 @@ fn copy_on_thread(
             copy_with_signals(src, dest, span, &signals)?;
             Ok(())
         })
-        .map_err(|e| e.into_spanned(span).into())
+        .map_err(|err| {
+            IoError::new_with_additional_context(
+                err.kind(),
+                span,
+                None,
+                "Could not spawn stderr copier",
+            )
+            .into()
+        })
 }
 
 #[cfg(feature = "os")]
@@ -521,7 +532,12 @@ fn tee_forwards_errors_back_immediately() {
     use std::time::Duration;
     let slow_input = (0..100).inspect(|_| std::thread::sleep(Duration::from_millis(1)));
     let iter = tee(slow_input, |_| {
-        Err(ShellError::IOError { msg: "test".into() })
+        Err(ShellError::Io(IoError::new_with_additional_context(
+            std::io::ErrorKind::Other,
+            Span::test_data(),
+            None,
+            "test",
+        )))
     })
     .expect("io error");
     for result in iter {
@@ -548,7 +564,12 @@ fn tee_waits_for_the_other_thread() {
     let iter = tee(0..100, move |_| {
         std::thread::sleep(Duration::from_millis(10));
         waited_clone.store(true, Ordering::Relaxed);
-        Err(ShellError::IOError { msg: "test".into() })
+        Err(ShellError::Io(IoError::new_with_additional_context(
+            std::io::ErrorKind::Other,
+            Span::test_data(),
+            None,
+            "test",
+        )))
     })
     .expect("io error");
     let last = iter.last();
diff --git a/crates/nu-command/src/formats/from/json.rs b/crates/nu-command/src/formats/from/json.rs
index 36a05ea4e1bd1..17a632d949cf0 100644
--- a/crates/nu-command/src/formats/from/json.rs
+++ b/crates/nu-command/src/formats/from/json.rs
@@ -1,7 +1,7 @@
 use std::io::{BufRead, Cursor};
 
 use nu_engine::command_prelude::*;
-use nu_protocol::{ListStream, Signals};
+use nu_protocol::{shell_error::io::IoError, ListStream, Signals};
 
 #[derive(Clone)]
 pub struct FromJson;
@@ -134,7 +134,7 @@ fn read_json_lines(
         .lines()
         .filter(|line| line.as_ref().is_ok_and(|line| !line.trim().is_empty()) || line.is_err())
         .map(move |line| {
-            let line = line.err_span(span)?;
+            let line = line.map_err(|err| IoError::new(err.kind(), span, None))?;
             if strict {
                 convert_string_to_value_strict(&line, span)
             } else {
diff --git a/crates/nu-command/src/formats/to/delimited.rs b/crates/nu-command/src/formats/to/delimited.rs
index ac04739a3ddca..4054935098232 100644
--- a/crates/nu-command/src/formats/to/delimited.rs
+++ b/crates/nu-command/src/formats/to/delimited.rs
@@ -1,16 +1,14 @@
 use csv::WriterBuilder;
 use nu_cmd_base::formats::to::delimited::merge_descriptors;
 use nu_protocol::{
-    ByteStream, ByteStreamType, Config, PipelineData, ShellError, Signals, Span, Spanned, Value,
+    shell_error::io::IoError, ByteStream, ByteStreamType, Config, PipelineData, ShellError,
+    Signals, Span, Spanned, Value,
 };
 use std::{iter, sync::Arc};
 
 fn make_csv_error(error: csv::Error, format_name: &str, head: Span) -> ShellError {
     if let csv::ErrorKind::Io(error) = error.kind() {
-        ShellError::IOErrorSpanned {
-            msg: error.to_string(),
-            span: head,
-        }
+        IoError::new(error.kind(), head, None).into()
     } else {
         ShellError::GenericError {
             error: format!("Failed to generate {format_name} data"),
diff --git a/crates/nu-command/src/formats/to/msgpack.rs b/crates/nu-command/src/formats/to/msgpack.rs
index f7c4c7df3dce2..ae1d11e542952 100644
--- a/crates/nu-command/src/formats/to/msgpack.rs
+++ b/crates/nu-command/src/formats/to/msgpack.rs
@@ -5,7 +5,7 @@ use std::io;
 
 use byteorder::{BigEndian, WriteBytesExt};
 use nu_engine::command_prelude::*;
-use nu_protocol::{ast::PathMember, Signals, Spanned};
+use nu_protocol::{ast::PathMember, shell_error::io::IoError, Signals, Spanned};
 use rmp::encode as mp;
 
 /// Max recursion depth
@@ -138,7 +138,7 @@ impl From<WriteError> for ShellError {
                 help: None,
                 inner: vec![],
             },
-            WriteError::Io(err, span) => err.into_spanned(span).into(),
+            WriteError::Io(err, span) => ShellError::Io(IoError::new(err.kind(), span, None)),
             WriteError::Shell(err) => *err,
         }
     }
diff --git a/crates/nu-command/src/formats/to/msgpackz.rs b/crates/nu-command/src/formats/to/msgpackz.rs
index e4166b15d3db9..c6d667280f968 100644
--- a/crates/nu-command/src/formats/to/msgpackz.rs
+++ b/crates/nu-command/src/formats/to/msgpackz.rs
@@ -1,6 +1,7 @@
 use std::io::Write;
 
 use nu_engine::command_prelude::*;
+use nu_protocol::shell_error::io::IoError;
 
 use super::msgpack::write_value;
 
@@ -80,7 +81,8 @@ impl Command for ToMsgpackz {
         );
 
         write_value(&mut out, &value, 0)?;
-        out.flush().err_span(call.head)?;
+        out.flush()
+            .map_err(|err| IoError::new(err.kind(), call.head, None))?;
         drop(out);
 
         Ok(Value::binary(out_buf, call.head).into_pipeline_data())
diff --git a/crates/nu-command/src/formats/to/text.rs b/crates/nu-command/src/formats/to/text.rs
index 2fc3046e4e429..2736fe24e5ed1 100644
--- a/crates/nu-command/src/formats/to/text.rs
+++ b/crates/nu-command/src/formats/to/text.rs
@@ -1,6 +1,8 @@
 use chrono_humanize::HumanTime;
 use nu_engine::command_prelude::*;
-use nu_protocol::{format_duration, ByteStream, Config, PipelineMetadata};
+use nu_protocol::{
+    format_duration, shell_error::io::IoError, ByteStream, Config, PipelineMetadata,
+};
 use std::io::Write;
 
 const LINE_ENDING: &str = if cfg!(target_os = "windows") {
@@ -72,6 +74,7 @@ impl Command for ToText {
             }
             PipelineData::ListStream(stream, meta) => {
                 let span = stream.span();
+                let from_io_error = IoError::factory(head, None);
                 let stream = if no_newline {
                     let mut first = true;
                     let mut iter = stream.into_inner();
@@ -87,7 +90,7 @@ impl Command for ToText {
                             if first {
                                 first = false;
                             } else {
-                                write!(buf, "{LINE_ENDING}").err_span(head)?;
+                                write!(buf, "{LINE_ENDING}").map_err(&from_io_error)?;
                             }
                             // TODO: write directly into `buf` instead of creating an intermediate
                             // string.
@@ -98,7 +101,7 @@ impl Command for ToText {
                                 &config,
                                 serialize_types,
                             );
-                            write!(buf, "{str}").err_span(head)?;
+                            write!(buf, "{str}").map_err(&from_io_error)?;
                             Ok(true)
                         },
                     )
diff --git a/crates/nu-command/src/misc/source.rs b/crates/nu-command/src/misc/source.rs
index c1be441bb00b9..de692c557ca34 100644
--- a/crates/nu-command/src/misc/source.rs
+++ b/crates/nu-command/src/misc/source.rs
@@ -1,6 +1,6 @@
 use nu_engine::{command_prelude::*, get_eval_block_with_early_return};
 use nu_path::canonicalize_with;
-use nu_protocol::{engine::CommandType, BlockId};
+use nu_protocol::{engine::CommandType, shell_error::io::IoError, BlockId};
 
 /// Source a file for environment variables.
 #[derive(Clone)]
@@ -55,11 +55,8 @@ 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| ShellError::FileNotFoundCustom {
-                msg: format!("Could not access file '{}': {err}", pb.as_path().display()),
-                span: Span::unknown(),
-            })?;
+        let file_path = canonicalize_with(pb.as_path(), cwd)
+            .map_err(|err| IoError::new(err.kind(), 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/network/http/client.rs b/crates/nu-command/src/network/http/client.rs
index a5cad69b2ca4e..28a8bc82073a9 100644
--- a/crates/nu-command/src/network/http/client.rs
+++ b/crates/nu-command/src/network/http/client.rs
@@ -6,10 +6,11 @@ use base64::{
 };
 use multipart_rs::MultipartWriter;
 use nu_engine::command_prelude::*;
-use nu_protocol::{ByteStream, LabeledError, Signals};
+use nu_protocol::{shell_error::io::IoError, ByteStream, LabeledError, Signals};
 use serde_json::Value as JsonValue;
 use std::{
     collections::HashMap,
+    error::Error as StdError,
     io::Cursor,
     path::PathBuf,
     str::FromStr,
@@ -184,6 +185,7 @@ pub fn request_add_authorization_header(
     request
 }
 
+#[derive(Debug)]
 #[allow(clippy::large_enum_variant)]
 pub enum ShellErrorOrRequestError {
     ShellError(ShellError),
@@ -372,10 +374,10 @@ fn send_multipart_request(
         Value::Record { val, .. } => {
             let mut builder = MultipartWriter::new();
 
-            let err = |e| {
-                ShellErrorOrRequestError::ShellError(ShellError::IOError {
-                    msg: format!("failed to build multipart data: {}", e),
-                })
+            let err = |e: std::io::Error| {
+                ShellErrorOrRequestError::ShellError(
+                    IoError::new_with_additional_context(e.kind(), span, None, e).into(),
+                )
             };
 
             for (col, val) in val.into_owned() {
@@ -464,6 +466,14 @@ fn send_cancellable_request(
             let ret = request_fn();
             let _ = tx.send(ret); // may fail if the user has cancelled the operation
         })
+        .map_err(|err| {
+            IoError::new_with_additional_context(
+                err.kind(),
+                span,
+                None,
+                "Could not spawn HTTP requester",
+            )
+        })
         .map_err(ShellError::from)?;
 
     // ...and poll the channel for responses
@@ -519,6 +529,14 @@ fn send_cancellable_request_bytes(
             // may fail if the user has cancelled the operation
             let _ = tx.send(ret);
         })
+        .map_err(|err| {
+            IoError::new_with_additional_context(
+                err.kind(),
+                span,
+                None,
+                "Could not spawn HTTP requester",
+            )
+        })
         .map_err(ShellError::from)?;
 
     // ...and poll the channel for responses
@@ -618,27 +636,56 @@ pub fn request_add_custom_headers(
 
 fn handle_response_error(span: Span, requested_url: &str, response_err: Error) -> ShellError {
     match response_err {
-        Error::Status(301, _) => ShellError::NetworkFailure { msg: format!("Resource moved permanently (301): {requested_url:?}"), span },
-        Error::Status(400, _) => {
-            ShellError::NetworkFailure { msg: format!("Bad request (400) to {requested_url:?}"), span }
-        }
-        Error::Status(403, _) => {
-            ShellError::NetworkFailure { msg: format!("Access forbidden (403) to {requested_url:?}"), span }
-        }
-        Error::Status(404, _) => ShellError::NetworkFailure { msg: format!("Requested file not found (404): {requested_url:?}"), span },
-        Error::Status(408, _) => {
-            ShellError::NetworkFailure { msg: format!("Request timeout (408): {requested_url:?}"), span }
-        }
-        Error::Status(_, _) => ShellError::NetworkFailure { msg: format!(
+        Error::Status(301, _) => ShellError::NetworkFailure {
+            msg: format!("Resource moved permanently (301): {requested_url:?}"),
+            span,
+        },
+        Error::Status(400, _) => ShellError::NetworkFailure {
+            msg: format!("Bad request (400) to {requested_url:?}"),
+            span,
+        },
+        Error::Status(403, _) => ShellError::NetworkFailure {
+            msg: format!("Access forbidden (403) to {requested_url:?}"),
+            span,
+        },
+        Error::Status(404, _) => ShellError::NetworkFailure {
+            msg: format!("Requested file not found (404): {requested_url:?}"),
+            span,
+        },
+        Error::Status(408, _) => ShellError::NetworkFailure {
+            msg: format!("Request timeout (408): {requested_url:?}"),
+            span,
+        },
+        Error::Status(_, _) => ShellError::NetworkFailure {
+            msg: format!(
                 "Cannot make request to {:?}. Error is {:?}",
                 requested_url,
                 response_err.to_string()
-            ), span },
-
-        Error::Transport(t) => match t {
-            t if t.kind() == ErrorKind::ConnectionFailed => ShellError::NetworkFailure { msg: format!("Cannot make request to {requested_url}, there was an error establishing a connection.",), span },
-            t => ShellError::NetworkFailure { msg: t.to_string(), span },
+            ),
+            span,
         },
+
+        Error::Transport(t) => {
+            let generic_network_failure = || ShellError::NetworkFailure {
+                msg: t.to_string(),
+                span,
+            };
+            match t.kind() {
+                ErrorKind::ConnectionFailed => ShellError::NetworkFailure { msg: format!("Cannot make request to {requested_url}, there was an error establishing a connection.",), span },
+                ErrorKind::Io => 'io: {
+                    let Some(source) = t.source() else {
+                        break 'io generic_network_failure();
+                    };
+
+                    let Some(io_error) = source.downcast_ref::<std::io::Error>() else {
+                        break 'io generic_network_failure();
+                    };
+
+                    ShellError::Io(IoError::new(io_error.kind(), span, None))
+                }
+                _ => generic_network_failure()
+            }
+        }
     }
 }
 
diff --git a/crates/nu-command/src/network/port.rs b/crates/nu-command/src/network/port.rs
index eb77ab1d32f23..fe3c987b855de 100644
--- a/crates/nu-command/src/network/port.rs
+++ b/crates/nu-command/src/network/port.rs
@@ -1,4 +1,5 @@
 use nu_engine::command_prelude::*;
+use nu_protocol::shell_error::io::IoError;
 
 use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener};
 
@@ -61,12 +62,14 @@ fn get_free_port(
     stack: &mut Stack,
     call: &Call,
 ) -> Result<PipelineData, ShellError> {
+    let from_io_error = IoError::factory(call.head, None);
+
     let start_port: Option<Spanned<usize>> = call.opt(engine_state, stack, 0)?;
     let end_port: Option<Spanned<usize>> = call.opt(engine_state, stack, 1)?;
 
     let listener = if start_port.is_none() && end_port.is_none() {
         // get free port from system.
-        TcpListener::bind("127.0.0.1:0")?
+        TcpListener::bind("127.0.0.1:0").map_err(&from_io_error)?
     } else {
         let (start_port, start_span) = match start_port {
             Some(p) => (p.item, Some(p.span)),
@@ -118,20 +121,25 @@ fn get_free_port(
             });
         }
 
-        // try given port one by one.
-        match (start_port..=end_port)
-            .map(|port| SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, port)))
-            .find_map(|addr| TcpListener::bind(addr).ok())
-        {
-            Some(listener) => listener,
-            None => {
-                return Err(ShellError::IOError {
-                    msg: "Every port has been tried, but no valid one was found".to_string(),
-                })
+        'search: {
+            let mut last_err = None;
+            for port in start_port..=end_port {
+                let addr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, port));
+                match TcpListener::bind(addr) {
+                    Ok(listener) => break 'search Ok(listener),
+                    Err(err) => last_err = Some(err),
+                }
             }
-        }
+
+            Err(IoError::new_with_additional_context(
+                last_err.expect("range not empty, validated before").kind(),
+                range_span,
+                None,
+                "Every port has been tried, but no valid one was found",
+            ))
+        }?
     };
 
-    let free_port = listener.local_addr()?.port();
+    let free_port = listener.local_addr().map_err(&from_io_error)?.port();
     Ok(Value::int(free_port as i64, call.head).into_pipeline_data())
 }
diff --git a/crates/nu-command/src/path/exists.rs b/crates/nu-command/src/path/exists.rs
index 3e40bef28a69d..0c482a5fe5dbe 100644
--- a/crates/nu-command/src/path/exists.rs
+++ b/crates/nu-command/src/path/exists.rs
@@ -2,7 +2,7 @@ use super::PathSubcommandArguments;
 #[allow(deprecated)]
 use nu_engine::{command_prelude::*, current_dir, current_dir_const};
 use nu_path::expand_path_with;
-use nu_protocol::engine::StateWorkingSet;
+use nu_protocol::{engine::StateWorkingSet, shell_error::io::IoError};
 use std::path::{Path, PathBuf};
 
 struct Arguments {
@@ -140,7 +140,7 @@ fn exists(path: &Path, span: Span, args: &Arguments) -> Value {
         // symlink_metadata returns true if the file/folder exists
         // whether it is a symbolic link or not. Sorry, but returns Err
         // in every other scenario including the NotFound
-        std::fs::symlink_metadata(path).map_or_else(
+        std::fs::symlink_metadata(&path).map_or_else(
             |e| match e.kind() {
                 std::io::ErrorKind::NotFound => Ok(false),
                 _ => Err(e),
@@ -153,15 +153,7 @@ fn exists(path: &Path, span: Span, args: &Arguments) -> Value {
     Value::bool(
         match exists {
             Ok(exists) => exists,
-            Err(err) => {
-                return Value::error(
-                    ShellError::IOErrorSpanned {
-                        msg: err.to_string(),
-                        span,
-                    },
-                    span,
-                )
-            }
+            Err(err) => return Value::error(IoError::new(err.kind(), span, path).into(), span),
         },
         span,
     )
diff --git a/crates/nu-command/src/path/self_.rs b/crates/nu-command/src/path/self_.rs
index 242fbea7a8706..49c1de8624a51 100644
--- a/crates/nu-command/src/path/self_.rs
+++ b/crates/nu-command/src/path/self_.rs
@@ -1,6 +1,6 @@
 use nu_engine::command_prelude::*;
 use nu_path::expand_path_with;
-use nu_protocol::engine::StateWorkingSet;
+use nu_protocol::{engine::StateWorkingSet, shell_error::io::IoError};
 
 #[derive(Clone)]
 pub struct SubCommand;
@@ -54,23 +54,25 @@ impl Command for SubCommand {
     ) -> Result<PipelineData, ShellError> {
         let path: Option<String> = call.opt_const(working_set, 0)?;
         let cwd = working_set.permanent_state.cwd(None)?;
-        let current_file =
-            working_set
-                .files
-                .top()
-                .ok_or_else(|| ShellError::FileNotFoundCustom {
-                    msg: "Couldn't find current file".into(),
-                    span: call.head,
-                })?;
+        let current_file = working_set.files.top().ok_or_else(|| {
+            IoError::new_with_additional_context(
+                std::io::ErrorKind::NotFound,
+                call.head,
+                None,
+                "Couldn't find current file",
+            )
+        })?;
 
         let out = if let Some(path) = path {
             let dir = expand_path_with(
-                current_file
-                    .parent()
-                    .ok_or_else(|| ShellError::FileNotFoundCustom {
-                        msg: "Couldn't find current file's parent.".into(),
-                        span: call.head,
-                    })?,
+                current_file.parent().ok_or_else(|| {
+                    IoError::new_with_additional_context(
+                        std::io::ErrorKind::NotFound,
+                        call.head,
+                        current_file.to_owned(),
+                        "Couldn't find current file's parent.",
+                    )
+                })?,
                 &cwd,
                 true,
             );
diff --git a/crates/nu-command/src/path/type.rs b/crates/nu-command/src/path/type.rs
index 81cdaf8cfe4a6..6abed1319acc4 100644
--- a/crates/nu-command/src/path/type.rs
+++ b/crates/nu-command/src/path/type.rs
@@ -1,7 +1,7 @@
 use super::PathSubcommandArguments;
 use nu_engine::command_prelude::*;
 use nu_path::AbsolutePathBuf;
-use nu_protocol::engine::StateWorkingSet;
+use nu_protocol::{engine::StateWorkingSet, shell_error::io::IoError};
 use std::{io, path::Path};
 
 struct Arguments {
@@ -108,7 +108,7 @@ fn path_type(path: &Path, span: Span, args: &Arguments) -> Value {
     match path.symlink_metadata() {
         Ok(metadata) => Value::string(get_file_type(&metadata), span),
         Err(err) if err.kind() == io::ErrorKind::NotFound => Value::nothing(span),
-        Err(err) => Value::error(err.into_spanned(span).into(), span),
+        Err(err) => Value::error(IoError::new(err.kind(), span, None).into(), span),
     }
 }
 
diff --git a/crates/nu-command/src/platform/clear.rs b/crates/nu-command/src/platform/clear.rs
index 21e3a3054c127..38bb659df61ce 100644
--- a/crates/nu-command/src/platform/clear.rs
+++ b/crates/nu-command/src/platform/clear.rs
@@ -4,6 +4,7 @@ use crossterm::{
     QueueableCommand,
 };
 use nu_engine::command_prelude::*;
+use nu_protocol::shell_error::io::IoError;
 
 use std::io::Write;
 
@@ -41,19 +42,27 @@ impl Command for Clear {
         call: &Call,
         _input: PipelineData,
     ) -> Result<PipelineData, ShellError> {
+        let from_io_error = IoError::factory(call.head, None);
         match call.has_flag(engine_state, stack, "keep-scrollback")? {
             true => {
                 std::io::stdout()
-                    .queue(MoveTo(0, 0))?
-                    .queue(ClearCommand(ClearType::All))?
-                    .flush()?;
+                    .queue(MoveTo(0, 0))
+                    .map_err(&from_io_error)?
+                    .queue(ClearCommand(ClearType::All))
+                    .map_err(&from_io_error)?
+                    .flush()
+                    .map_err(&from_io_error)?;
             }
             _ => {
                 std::io::stdout()
-                    .queue(MoveTo(0, 0))?
-                    .queue(ClearCommand(ClearType::All))?
-                    .queue(ClearCommand(ClearType::Purge))?
-                    .flush()?;
+                    .queue(MoveTo(0, 0))
+                    .map_err(&from_io_error)?
+                    .queue(ClearCommand(ClearType::All))
+                    .map_err(&from_io_error)?
+                    .queue(ClearCommand(ClearType::Purge))
+                    .map_err(&from_io_error)?
+                    .flush()
+                    .map_err(&from_io_error)?;
             }
         };
 
diff --git a/crates/nu-command/src/platform/dir_info.rs b/crates/nu-command/src/platform/dir_info.rs
index 9b8ea1929e51e..d96e43403b37b 100644
--- a/crates/nu-command/src/platform/dir_info.rs
+++ b/crates/nu-command/src/platform/dir_info.rs
@@ -1,6 +1,6 @@
 use filesize::file_real_size_fast;
 use nu_glob::Pattern;
-use nu_protocol::{record, ShellError, Signals, Span, Value};
+use nu_protocol::{record, shell_error::io::IoError, ShellError, Signals, Span, Value};
 use std::path::PathBuf;
 
 #[derive(Debug, Clone)]
@@ -77,7 +77,7 @@ impl FileInfo {
                     long,
                 })
             }
-            Err(e) => Err(e.into()),
+            Err(e) => Err(IoError::new(e.kind(), tag, path).into()),
         }
     }
 }
@@ -91,6 +91,7 @@ impl DirInfo {
         signals: &Signals,
     ) -> Result<Self, ShellError> {
         let path = path.into();
+        let from_io_error = IoError::factory(span, path.as_path());
 
         let mut s = Self {
             dirs: Vec::new(),
@@ -99,7 +100,7 @@ impl DirInfo {
             size: 0,
             blocks: 0,
             tag: params.tag,
-            path,
+            path: path.clone(),
             long: params.long,
         };
 
@@ -108,7 +109,7 @@ impl DirInfo {
                 s.size = d.len(); // dir entry size
                 s.blocks = file_real_size_fast(&s.path, &d).ok().unwrap_or(0);
             }
-            Err(e) => s = s.add_error(e.into()),
+            Err(e) => s = s.add_error(from_io_error(e).into()),
         };
 
         match std::fs::read_dir(&s.path) {
@@ -122,13 +123,13 @@ impl DirInfo {
                                 s = s.add_dir(i.path(), depth, params, span, signals)?
                             }
                             Ok(_t) => s = s.add_file(i.path(), params),
-                            Err(e) => s = s.add_error(e.into()),
+                            Err(e) => s = s.add_error(from_io_error(e).into()),
                         },
-                        Err(e) => s = s.add_error(e.into()),
+                        Err(e) => s = s.add_error(from_io_error(e).into()),
                     }
                 }
             }
-            Err(e) => s = s.add_error(e.into()),
+            Err(e) => s = s.add_error(from_io_error(e).into()),
         }
         Ok(s)
     }
diff --git a/crates/nu-command/src/platform/input/input_.rs b/crates/nu-command/src/platform/input/input_.rs
index f792db992e025..5e088098d223e 100644
--- a/crates/nu-command/src/platform/input/input_.rs
+++ b/crates/nu-command/src/platform/input/input_.rs
@@ -7,6 +7,7 @@ use crossterm::{
 };
 use itertools::Itertools;
 use nu_engine::command_prelude::*;
+use nu_protocol::shell_error::io::IoError;
 
 use std::{io::Write, time::Duration};
 
@@ -69,6 +70,8 @@ impl Command for Input {
             span: call.head,
         });
 
+        let from_io_error = IoError::factory(call.head, None);
+
         if numchar.item < 1 {
             return Err(ShellError::UnsupportedInput {
                 msg: "Number of characters to read has to be positive".to_string(),
@@ -89,11 +92,11 @@ impl Command for Input {
 
         let mut buf = String::new();
 
-        crossterm::terminal::enable_raw_mode()?;
+        crossterm::terminal::enable_raw_mode().map_err(&from_io_error)?;
         // clear terminal events
-        while crossterm::event::poll(Duration::from_secs(0))? {
+        while crossterm::event::poll(Duration::from_secs(0)).map_err(&from_io_error)? {
             // If there's an event, read it to remove it from the queue
-            let _ = crossterm::event::read()?;
+            let _ = crossterm::event::read().map_err(&from_io_error)?;
         }
 
         loop {
@@ -110,10 +113,14 @@ impl Command for Input {
                                     || k.modifiers == KeyModifiers::CONTROL
                                 {
                                     if k.modifiers == KeyModifiers::CONTROL && c == 'c' {
-                                        crossterm::terminal::disable_raw_mode()?;
-                                        return Err(ShellError::IOError {
-                                            msg: "SIGINT".to_string(),
-                                        });
+                                        crossterm::terminal::disable_raw_mode()
+                                            .map_err(&from_io_error)?;
+                                        return Err(IoError::new(
+                                            std::io::ErrorKind::Interrupted,
+                                            call.head,
+                                            None,
+                                        )
+                                        .into());
                                     }
                                     continue;
                                 }
@@ -138,8 +145,8 @@ impl Command for Input {
                 },
                 Ok(_) => continue,
                 Err(event_error) => {
-                    crossterm::terminal::disable_raw_mode()?;
-                    return Err(event_error.into());
+                    crossterm::terminal::disable_raw_mode().map_err(&from_io_error)?;
+                    return Err(from_io_error(event_error).into());
                 }
             }
             if !suppress_output {
@@ -148,16 +155,18 @@ impl Command for Input {
                     std::io::stdout(),
                     terminal::Clear(ClearType::CurrentLine),
                     cursor::MoveToColumn(0),
-                )?;
+                )
+                .map_err(|err| IoError::new(err.kind(), call.head, None))?;
                 if let Some(prompt) = &prompt {
-                    execute!(std::io::stdout(), Print(prompt.to_string()))?;
+                    execute!(std::io::stdout(), Print(prompt.to_string()))
+                        .map_err(&from_io_error)?;
                 }
-                execute!(std::io::stdout(), Print(buf.to_string()))?;
+                execute!(std::io::stdout(), Print(buf.to_string())).map_err(&from_io_error)?;
             }
         }
-        crossterm::terminal::disable_raw_mode()?;
+        crossterm::terminal::disable_raw_mode().map_err(&from_io_error)?;
         if !suppress_output {
-            std::io::stdout().write_all(b"\n")?;
+            std::io::stdout().write_all(b"\n").map_err(&from_io_error)?;
         }
         match default_val {
             Some(val) if buf.is_empty() => Ok(Value::string(val, call.head).into_pipeline_data()),
diff --git a/crates/nu-command/src/platform/input/input_listen.rs b/crates/nu-command/src/platform/input/input_listen.rs
index 69e96e19667ca..56241d6689608 100644
--- a/crates/nu-command/src/platform/input/input_listen.rs
+++ b/crates/nu-command/src/platform/input/input_listen.rs
@@ -5,6 +5,7 @@ use crossterm::event::{
 use crossterm::{execute, terminal};
 use nu_engine::command_prelude::*;
 
+use nu_protocol::shell_error::io::IoError;
 use num_traits::AsPrimitive;
 use std::io::stdout;
 
@@ -83,7 +84,7 @@ There are 4 `key_type` variants:
         let add_raw = call.has_flag(engine_state, stack, "raw")?;
         let config = engine_state.get_config();
 
-        terminal::enable_raw_mode()?;
+        terminal::enable_raw_mode().map_err(|err| IoError::new(err.kind(), head, None))?;
 
         if config.use_kitty_protocol {
             if let Ok(false) = crossterm::terminal::supports_keyboard_enhancement() {
@@ -111,7 +112,7 @@ There are 4 `key_type` variants:
             );
         }
 
-        let console_state = event_type_filter.enable_events()?;
+        let console_state = event_type_filter.enable_events(head)?;
         loop {
             let event = crossterm::event::read().map_err(|_| ShellError::GenericError {
                 error: "Error with user input".into(),
@@ -122,7 +123,7 @@ There are 4 `key_type` variants:
             })?;
             let event = parse_event(head, &event, &event_type_filter, add_raw);
             if let Some(event) = event {
-                terminal::disable_raw_mode()?;
+                terminal::disable_raw_mode().map_err(|err| IoError::new(err.kind(), head, None))?;
                 if config.use_kitty_protocol {
                     let _ = execute!(
                         std::io::stdout(),
@@ -226,17 +227,20 @@ impl EventTypeFilter {
     /// Enable capturing of all events allowed by this filter.
     /// Call [`DeferredConsoleRestore::restore`] when done capturing events to restore
     /// console state
-    fn enable_events(&self) -> Result<DeferredConsoleRestore, ShellError> {
+    fn enable_events(&self, span: Span) -> Result<DeferredConsoleRestore, ShellError> {
         if self.listen_mouse {
-            crossterm::execute!(stdout(), EnableMouseCapture)?;
+            crossterm::execute!(stdout(), EnableMouseCapture)
+                .map_err(|err| IoError::new(err.kind(), span, None))?;
         }
 
         if self.listen_paste {
-            crossterm::execute!(stdout(), EnableBracketedPaste)?;
+            crossterm::execute!(stdout(), EnableBracketedPaste)
+                .map_err(|err| IoError::new(err.kind(), span, None))?;
         }
 
         if self.listen_focus {
-            crossterm::execute!(stdout(), crossterm::event::EnableFocusChange)?;
+            crossterm::execute!(stdout(), crossterm::event::EnableFocusChange)
+                .map_err(|err| IoError::new(err.kind(), span, None))?;
         }
 
         Ok(DeferredConsoleRestore {
diff --git a/crates/nu-command/src/platform/input/list.rs b/crates/nu-command/src/platform/input/list.rs
index 7d9761f29647c..91a31aebd8a70 100644
--- a/crates/nu-command/src/platform/input/list.rs
+++ b/crates/nu-command/src/platform/input/list.rs
@@ -1,5 +1,6 @@
 use dialoguer::{console::Term, FuzzySelect, MultiSelect, Select};
 use nu_engine::command_prelude::*;
+use nu_protocol::shell_error::io::IoError;
 
 use std::fmt::{Display, Formatter};
 
@@ -141,8 +142,13 @@ impl Command for InputList {
                 .items(&options)
                 .report(false)
                 .interact_on_opt(&Term::stderr())
-                .map_err(|err| ShellError::IOError {
-                    msg: format!("{}: {}", INTERACT_ERROR, err),
+                .map_err(|dialoguer::Error::IO(err)| {
+                    IoError::new_with_additional_context(
+                        err.kind(),
+                        call.head,
+                        None,
+                        INTERACT_ERROR,
+                    )
                 })?,
             )
         } else if fuzzy {
@@ -158,8 +164,13 @@ impl Command for InputList {
                 .default(0)
                 .report(false)
                 .interact_on_opt(&Term::stderr())
-                .map_err(|err| ShellError::IOError {
-                    msg: format!("{}: {}", INTERACT_ERROR, err),
+                .map_err(|dialoguer::Error::IO(err)| {
+                    IoError::new_with_additional_context(
+                        err.kind(),
+                        call.head,
+                        None,
+                        INTERACT_ERROR,
+                    )
                 })?,
             )
         } else {
@@ -174,8 +185,13 @@ impl Command for InputList {
                 .default(0)
                 .report(false)
                 .interact_on_opt(&Term::stderr())
-                .map_err(|err| ShellError::IOError {
-                    msg: format!("{}: {}", INTERACT_ERROR, err),
+                .map_err(|dialoguer::Error::IO(err)| {
+                    IoError::new_with_additional_context(
+                        err.kind(),
+                        call.head,
+                        None,
+                        INTERACT_ERROR,
+                    )
                 })?,
             )
         };
diff --git a/crates/nu-command/src/platform/term/term_query.rs b/crates/nu-command/src/platform/term/term_query.rs
index 2cb11f1dc3544..167f660c22704 100644
--- a/crates/nu-command/src/platform/term/term_query.rs
+++ b/crates/nu-command/src/platform/term/term_query.rs
@@ -4,6 +4,7 @@ use std::{
 };
 
 use nu_engine::command_prelude::*;
+use nu_protocol::shell_error::io::IoError;
 
 const CTRL_C: u8 = 3;
 
@@ -98,15 +99,19 @@ The `prefix` is not included in the output."
         let prefix = prefix.unwrap_or_default();
         let terminator: Option<Vec<u8>> = call.get_flag(engine_state, stack, "terminator")?;
 
-        crossterm::terminal::enable_raw_mode()?;
+        crossterm::terminal::enable_raw_mode()
+            .map_err(|err| IoError::new(err.kind(), call.head, None))?;
         scopeguard::defer! {
             let _ = crossterm::terminal::disable_raw_mode();
         }
 
         // clear terminal events
-        while crossterm::event::poll(Duration::from_secs(0))? {
+        while crossterm::event::poll(Duration::from_secs(0))
+            .map_err(|err| IoError::new(err.kind(), call.head, None))?
+        {
             // If there's an event, read it to remove it from the queue
-            let _ = crossterm::event::read()?;
+            let _ = crossterm::event::read()
+                .map_err(|err| IoError::new(err.kind(), call.head, None))?;
         }
 
         let mut b = [0u8; 1];
@@ -115,13 +120,19 @@ The `prefix` is not included in the output."
 
         {
             let mut stdout = std::io::stdout().lock();
-            stdout.write_all(&query)?;
-            stdout.flush()?;
+            stdout
+                .write_all(&query)
+                .map_err(|err| IoError::new(err.kind(), call.head, None))?;
+            stdout
+                .flush()
+                .map_err(|err| IoError::new(err.kind(), call.head, None))?;
         }
 
         // Validate and skip prefix
         for bc in prefix {
-            stdin.read_exact(&mut b)?;
+            stdin
+                .read_exact(&mut b)
+                .map_err(|err| IoError::new(err.kind(), call.head, None))?;
             if b[0] != bc {
                 return Err(ShellError::GenericError {
                     error: "Input did not begin with expected sequence".into(),
@@ -138,7 +149,9 @@ The `prefix` is not included in the output."
 
         if let Some(terminator) = terminator {
             loop {
-                stdin.read_exact(&mut b)?;
+                stdin
+                    .read_exact(&mut b)
+                    .map_err(|err| IoError::new(err.kind(), call.head, None))?;
 
                 if b[0] == CTRL_C {
                     return Err(ShellError::InterruptedByUser {
@@ -158,7 +171,9 @@ The `prefix` is not included in the output."
             }
         } else {
             loop {
-                stdin.read_exact(&mut b)?;
+                stdin
+                    .read_exact(&mut b)
+                    .map_err(|err| IoError::new(err.kind(), call.head, None))?;
 
                 if b[0] == CTRL_C {
                     break;
diff --git a/crates/nu-command/src/strings/str_/join.rs b/crates/nu-command/src/strings/str_/join.rs
index e40ffb9417e39..e500da8343efa 100644
--- a/crates/nu-command/src/strings/str_/join.rs
+++ b/crates/nu-command/src/strings/str_/join.rs
@@ -1,5 +1,5 @@
 use nu_engine::command_prelude::*;
-use nu_protocol::Signals;
+use nu_protocol::{shell_error::io::IoError, Signals};
 
 use std::io::Write;
 
@@ -94,13 +94,15 @@ fn run(
         Signals::empty(),
         ByteStreamType::String,
         move |buffer| {
+            let from_io_error = IoError::factory(span, None);
+
             // Write each input to the buffer
             if let Some(value) = iter.next() {
                 // Write the separator if this is not the first
                 if first {
                     first = false;
                 } else if let Some(separator) = &separator {
-                    write!(buffer, "{}", separator)?;
+                    write!(buffer, "{}", separator).map_err(&from_io_error)?;
                 }
 
                 match value {
@@ -109,8 +111,9 @@ fn run(
                     }
                     // Hmm, not sure what we actually want.
                     // `to_expanded_string` formats dates as human readable which feels funny.
-                    Value::Date { val, .. } => write!(buffer, "{val:?}")?,
-                    value => write!(buffer, "{}", value.to_expanded_string("\n", &config))?,
+                    Value::Date { val, .. } => write!(buffer, "{val:?}").map_err(&from_io_error)?,
+                    value => write!(buffer, "{}", value.to_expanded_string("\n", &config))
+                        .map_err(&from_io_error)?,
                 }
                 Ok(true)
             } else {
diff --git a/crates/nu-command/src/system/nu_check.rs b/crates/nu-command/src/system/nu_check.rs
index ff3047a4905ad..ca0d09965a0d2 100644
--- a/crates/nu-command/src/system/nu_check.rs
+++ b/crates/nu-command/src/system/nu_check.rs
@@ -1,7 +1,10 @@
 use nu_engine::{command_prelude::*, find_in_dirs_env, get_dirs_var_from_call};
 use nu_parser::{parse, parse_module_block, parse_module_file_or_dir, unescape_unquote_string};
-use nu_protocol::engine::{FileStack, StateWorkingSet};
-use std::path::Path;
+use nu_protocol::{
+    engine::{FileStack, StateWorkingSet},
+    shell_error::io::IoError,
+};
+use std::path::{Path, PathBuf};
 
 #[derive(Clone)]
 pub struct NuCheck;
@@ -89,17 +92,15 @@ impl Command for NuCheck {
                         stack,
                         get_dirs_var_from_call(stack, call),
                     ) {
-                        Ok(path) => {
-                            if let Some(path) = path {
-                                path
-                            } else {
-                                return Err(ShellError::FileNotFound {
-                                    file: path_str.item,
-                                    span: path_span,
-                                });
-                            }
+                        Ok(Some(path)) => path,
+                        Ok(None) => {
+                            return Err(ShellError::Io(IoError::new(
+                                std::io::ErrorKind::NotFound,
+                                path_span,
+                                PathBuf::from(path_str.item),
+                            )))
                         }
-                        Err(error) => return Err(error),
+                        Err(err) => return Err(err),
                     };
 
                     let result = if as_module || path.is_dir() {
@@ -258,13 +259,13 @@ fn parse_file_script(
 ) -> Result<PipelineData, ShellError> {
     let filename = check_path(working_set, path_span, call_head)?;
 
-    if let Ok(contents) = std::fs::read(path) {
-        parse_script(working_set, Some(&filename), &contents, is_debug, call_head)
-    } else {
-        Err(ShellError::IOErrorSpanned {
-            msg: "Could not read path".to_string(),
-            span: path_span,
-        })
+    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(),
+            path_span,
+            PathBuf::from(path),
+        ))),
     }
 }
 
diff --git a/crates/nu-command/src/system/registry_query.rs b/crates/nu-command/src/system/registry_query.rs
index 3365a7d44009f..cadb3aac29ebd 100644
--- a/crates/nu-command/src/system/registry_query.rs
+++ b/crates/nu-command/src/system/registry_query.rs
@@ -1,5 +1,6 @@
 use nu_engine::command_prelude::*;
 
+use nu_protocol::shell_error::io::IoError;
 use windows::{core::PCWSTR, Win32::System::Environment::ExpandEnvironmentStringsW};
 use winreg::{enums::*, types::FromRegValue, RegKey};
 
@@ -90,7 +91,9 @@ fn registry_query(
     let registry_value: Option<Spanned<String>> = call.opt(engine_state, stack, 1)?;
 
     let reg_hive = get_reg_hive(engine_state, stack, call)?;
-    let reg_key = reg_hive.open_subkey(registry_key.item)?;
+    let reg_key = reg_hive
+        .open_subkey(registry_key.item)
+        .map_err(|err| IoError::new(err.kind(), *registry_key_span, None))?;
 
     if registry_value.is_none() {
         let mut reg_values = vec![];
diff --git a/crates/nu-command/src/system/run_external.rs b/crates/nu-command/src/system/run_external.rs
index b917577ba9e53..70e423387a939 100644
--- a/crates/nu-command/src/system/run_external.rs
+++ b/crates/nu-command/src/system/run_external.rs
@@ -2,7 +2,8 @@ use nu_cmd_base::hook::eval_hook;
 use nu_engine::{command_prelude::*, env_to_strings};
 use nu_path::{dots::expand_ndots_safe, expand_tilde, AbsolutePath};
 use nu_protocol::{
-    did_you_mean, process::ChildProcess, ByteStream, NuGlob, OutDest, Signals, UseAnsiColoring,
+    did_you_mean, process::ChildProcess, shell_error::io::IoError, ByteStream, NuGlob, OutDest,
+    Signals, UseAnsiColoring,
 };
 use nu_system::ForegroundChild;
 use nu_utils::IgnoreCaseExt;
@@ -169,7 +170,9 @@ impl Command for External {
 
             // canonicalize the path to the script so that tests pass
             let canon_path = if let Ok(cwd) = engine_state.cwd_as_string(None) {
-                canonicalize_with(&expanded_name, cwd)?
+                canonicalize_with(&expanded_name, cwd).map_err(|err| {
+                    IoError::new(err.kind(), call.head, PathBuf::from(&expanded_name))
+                })?
             } else {
                 // If we can't get the current working directory, just provide the expanded name
                 expanded_name
@@ -191,13 +194,22 @@ impl Command for External {
         let stdout = stack.stdout();
         let stderr = stack.stderr();
         let merged_stream = if matches!(stdout, OutDest::Pipe) && matches!(stderr, OutDest::Pipe) {
-            let (reader, writer) = os_pipe::pipe()?;
-            command.stdout(writer.try_clone()?);
+            let (reader, writer) =
+                os_pipe::pipe().map_err(|err| IoError::new(err.kind(), call.head, None))?;
+            command.stdout(
+                writer
+                    .try_clone()
+                    .map_err(|err| IoError::new(err.kind(), call.head, None))?,
+            );
             command.stderr(writer);
             Some(reader)
         } else {
-            command.stdout(Stdio::try_from(stdout)?);
-            command.stderr(Stdio::try_from(stderr)?);
+            command.stdout(
+                Stdio::try_from(stdout).map_err(|err| IoError::new(err.kind(), call.head, None))?,
+            );
+            command.stderr(
+                Stdio::try_from(stderr).map_err(|err| IoError::new(err.kind(), call.head, None))?,
+            );
             None
         };
 
@@ -231,13 +243,22 @@ impl Command for External {
         // Spawn the child process. On Unix, also put the child process to
         // foreground if we're in an interactive session.
         #[cfg(windows)]
-        let mut child = ForegroundChild::spawn(command)?;
+        let child = ForegroundChild::spawn(command);
         #[cfg(unix)]
-        let mut child = ForegroundChild::spawn(
+        let child = ForegroundChild::spawn(
             command,
             engine_state.is_interactive,
             &engine_state.pipeline_externals_state,
-        )?;
+        );
+
+        let mut child = child.map_err(|err| {
+            IoError::new_with_additional_context(
+                err.kind(),
+                Span::unknown(),
+                None,
+                "Could not spawn foreground child",
+            )
+        })?;
 
         // If we need to copy data into the child process, do it now.
         if let Some(data) = data_to_copy_into_stdin {
@@ -249,7 +270,14 @@ impl Command for External {
                 .spawn(move || {
                     let _ = write_pipeline_data(engine_state, stack, data, stdin);
                 })
-                .err_span(call.head)?;
+                .map_err(|err| {
+                    IoError::new_with_additional_context(
+                        err.kind(),
+                        call.head,
+                        None,
+                        "Could not spawn external stdin worker",
+                    )
+                })?;
         }
 
         // Wrap the output into a `PipelineData::ByteStream`.
@@ -414,7 +442,14 @@ fn write_pipeline_data(
     if let PipelineData::ByteStream(stream, ..) = data {
         stream.write_to(writer)?;
     } else if let PipelineData::Value(Value::Binary { val, .. }, ..) = data {
-        writer.write_all(&val)?;
+        writer.write_all(&val).map_err(|err| {
+            IoError::new_with_additional_context(
+                err.kind(),
+                Span::unknown(),
+                None,
+                "Could not write pipeline data",
+            )
+        })?;
     } else {
         stack.start_collect_value();
 
@@ -428,7 +463,14 @@ fn write_pipeline_data(
         // Write the output.
         for value in output {
             let bytes = value.coerce_into_binary()?;
-            writer.write_all(&bytes)?;
+            writer.write_all(&bytes).map_err(|err| {
+                IoError::new_with_additional_context(
+                    err.kind(),
+                    Span::unknown(),
+                    None,
+                    "Could not write pipeline data",
+                )
+            })?;
         }
     }
     Ok(())
diff --git a/crates/nu-command/src/viewers/table.rs b/crates/nu-command/src/viewers/table.rs
index bac035f94a7c2..147555558ec77 100644
--- a/crates/nu-command/src/viewers/table.rs
+++ b/crates/nu-command/src/viewers/table.rs
@@ -13,7 +13,8 @@ use nu_engine::{command_prelude::*, env_to_string};
 use nu_path::form::Absolute;
 use nu_pretty_hex::HexConfig;
 use nu_protocol::{
-    ByteStream, Config, DataSource, ListStream, PipelineMetadata, Signals, TableMode, ValueIterator,
+    shell_error::io::IoError, ByteStream, Config, DataSource, ListStream, PipelineMetadata,
+    Signals, TableMode, ValueIterator,
 };
 use nu_table::{
     common::configure_table, CollapsedTable, ExpandedTable, JustTable, NuRecordsValue, NuTable,
@@ -518,7 +519,7 @@ fn pretty_hex_stream(stream: ByteStream, span: Span) -> ByteStream {
                 (&mut reader)
                     .take(cfg.width as u64)
                     .read_to_end(&mut read_buf)
-                    .err_span(span)?;
+                    .map_err(|err| IoError::new(err.kind(), span, None))?;
 
                 if !read_buf.is_empty() {
                     nu_pretty_hex::hex_write(&mut write_buf, &read_buf, cfg, Some(true))
diff --git a/crates/nu-engine/src/command_prelude.rs b/crates/nu-engine/src/command_prelude.rs
index 0c5c250a27182..9b9ed40477294 100644
--- a/crates/nu-engine/src/command_prelude.rs
+++ b/crates/nu-engine/src/command_prelude.rs
@@ -2,7 +2,9 @@ pub use crate::CallExt;
 pub use nu_protocol::{
     ast::CellPath,
     engine::{Call, Command, EngineState, Stack, StateWorkingSet},
-    record, ByteStream, ByteStreamType, Category, ErrSpan, Example, IntoInterruptiblePipelineData,
+    record,
+    shell_error::io::IoError,
+    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 345d81d6f78cb..0242d0b6b0683 100644
--- a/crates/nu-engine/src/env.rs
+++ b/crates/nu-engine/src/env.rs
@@ -3,6 +3,7 @@ use nu_path::canonicalize_with;
 use nu_protocol::{
     ast::Expr,
     engine::{Call, EngineState, Stack, StateWorkingSet},
+    shell_error::io::IoError,
     ShellError, Span, Type, Value, VarId,
 };
 use std::{
@@ -218,9 +219,12 @@ pub fn current_dir(engine_state: &EngineState, stack: &Stack) -> Result<PathBuf,
     // We're using `canonicalize_with` instead of `fs::canonicalize()` because
     // we still need to simplify Windows paths. "." is safe because `cwd` should
     // be an absolute path already.
-    canonicalize_with(&cwd, ".").map_err(|_| ShellError::DirectoryNotFound {
-        dir: cwd.to_string_lossy().to_string(),
-        span: Span::unknown(),
+    canonicalize_with(&cwd, ".").map_err(|err| {
+        ShellError::Io(IoError::new(
+            err.kind(),
+            Span::unknown(),
+            PathBuf::from(cwd),
+        ))
     })
 }
 
@@ -234,9 +238,12 @@ pub fn current_dir_const(working_set: &StateWorkingSet) -> Result<PathBuf, Shell
     // We're using `canonicalize_with` instead of `fs::canonicalize()` because
     // we still need to simplify Windows paths. "." is safe because `cwd` should
     // be an absolute path already.
-    canonicalize_with(&cwd, ".").map_err(|_| ShellError::DirectoryNotFound {
-        dir: cwd.to_string_lossy().to_string(),
-        span: Span::unknown(),
+    canonicalize_with(&cwd, ".").map_err(|err| {
+        ShellError::Io(IoError::new(
+            err.kind(),
+            Span::unknown(),
+            PathBuf::from(cwd),
+        ))
     })
 }
 
diff --git a/crates/nu-engine/src/eval_ir.rs b/crates/nu-engine/src/eval_ir.rs
index 20672484faad1..035f6a394d79a 100644
--- a/crates/nu-engine/src/eval_ir.rs
+++ b/crates/nu-engine/src/eval_ir.rs
@@ -8,9 +8,10 @@ use nu_protocol::{
         Argument, Closure, EngineState, ErrorHandler, Matcher, Redirection, Stack, StateWorkingSet,
     },
     ir::{Call, DataSlice, Instruction, IrAstRef, IrBlock, Literal, RedirectMode},
-    DataSource, DeclId, ErrSpan, Flag, IntoPipelineData, IntoSpanned, ListStream, OutDest,
-    PipelineData, PipelineMetadata, PositionalArg, Range, Record, RegId, ShellError, Signals,
-    Signature, Span, Spanned, Type, Value, VarId, ENV_VARIABLE_ID,
+    shell_error::io::IoError,
+    DataSource, DeclId, Flag, IntoPipelineData, IntoSpanned, ListStream, OutDest, PipelineData,
+    PipelineMetadata, PositionalArg, Range, Record, RegId, ShellError, Signals, Signature, Span,
+    Spanned, Type, Value, VarId, ENV_VARIABLE_ID,
 };
 use nu_utils::IgnoreCaseExt;
 
@@ -1489,8 +1490,8 @@ fn open_file(ctx: &EvalContext<'_>, path: &Value, append: bool) -> Result<Arc<Fi
     }
     let file = options
         .create(true)
-        .open(path_expanded)
-        .err_span(path.span())?;
+        .open(&path_expanded)
+        .map_err(|err| IoError::new(err.kind(), path.span(), path_expanded))?;
     Ok(Arc::new(file))
 }
 
diff --git a/crates/nu-engine/src/glob_from.rs b/crates/nu-engine/src/glob_from.rs
index 3765ab7c7b025..1120499b43ed8 100644
--- a/crates/nu-engine/src/glob_from.rs
+++ b/crates/nu-engine/src/glob_from.rs
@@ -1,9 +1,8 @@
 use nu_glob::MatchOptions;
 use nu_path::{canonicalize_with, expand_path_with};
-use nu_protocol::{NuGlob, ShellError, Span, Spanned};
+use nu_protocol::{shell_error::io::IoError, NuGlob, ShellError, Span, Spanned};
 use std::{
     fs,
-    io::ErrorKind,
     path::{Component, Path, PathBuf},
 };
 
@@ -28,6 +27,7 @@ pub fn glob_from(
     ShellError,
 > {
     let no_glob_for_pattern = matches!(pattern.item, NuGlob::DoNotExpand(_));
+    let pattern_span = pattern.span;
     let (prefix, pattern) = if nu_glob::is_glob(pattern.item.as_ref()) {
         // Pattern contains glob, split it
         let mut p = PathBuf::new();
@@ -80,22 +80,7 @@ pub fn glob_from(
                 }
                 Ok(p) => p,
                 Err(err) => {
-                    return match err.kind() {
-                        ErrorKind::PermissionDenied => Err(ShellError::GenericError {
-                            error: "Permission denied".into(),
-                            msg: err.to_string(),
-                            span: None,
-                            help: None,
-                            inner: vec![],
-                        }),
-                        // Previously, all these errors were treated as "directory not found."
-                        // Now, permission denied errors are handled separately.
-                        // TODO: Refine handling of I/O errors for more precise responses.
-                        _ => Err(ShellError::DirectoryNotFound {
-                            dir: path.to_string_lossy().to_string(),
-                            span: pattern.span,
-                        }),
-                    };
+                    return Err(IoError::new(err.kind(), pattern_span, path).into());
                 }
             };
             (path.parent().map(|parent| parent.to_path_buf()), path)
diff --git a/crates/nu-explore/src/nu_common/command.rs b/crates/nu-explore/src/nu_common/command.rs
index ef0157ac6756e..a41e7cf45b0a7 100644
--- a/crates/nu-explore/src/nu_common/command.rs
+++ b/crates/nu-explore/src/nu_common/command.rs
@@ -14,16 +14,24 @@ pub fn run_command_with_value(
     stack: &mut Stack,
 ) -> Result<PipelineData, ShellError> {
     if is_ignored_command(command) {
-        return Err(ShellError::IOError {
-            msg: String::from("the command is ignored"),
+        return Err(ShellError::GenericError {
+            error: "Command ignored".to_string(),
+            msg: "the command is ignored".to_string(),
+            span: None,
+            help: None,
+            inner: vec![],
         });
     }
 
     let pipeline = PipelineData::Value(input.clone(), None);
     let pipeline = run_nu_command(engine_state, stack, command, pipeline)?;
     if let PipelineData::Value(Value::Error { error, .. }, ..) = pipeline {
-        Err(ShellError::IOError {
+        Err(ShellError::GenericError {
+            error: "Error from pipeline".to_string(),
             msg: error.to_string(),
+            span: None,
+            help: None,
+            inner: vec![*error],
         })
     } else {
         Ok(pipeline)
@@ -69,8 +77,12 @@ fn eval_source2(
         );
 
         if let Some(err) = working_set.parse_errors.first() {
-            return Err(ShellError::IOError {
+            return Err(ShellError::GenericError {
+                error: "Parse error".to_string(),
                 msg: err.to_string(),
+                span: None,
+                help: None,
+                inner: vec![],
             });
         }
 
@@ -79,8 +91,12 @@ fn eval_source2(
 
     // We need to merge different info other wise things like PIPEs etc will not work.
     if let Err(err) = engine_state.merge_delta(delta) {
-        return Err(ShellError::IOError {
+        return Err(ShellError::GenericError {
+            error: "Merge error".to_string(),
             msg: err.to_string(),
+            span: None,
+            help: None,
+            inner: vec![err],
         });
     }
 
diff --git a/crates/nu-plugin-core/src/communication_mode/mod.rs b/crates/nu-plugin-core/src/communication_mode/mod.rs
index 576cfe866cce8..19d89363bd8d6 100644
--- a/crates/nu-plugin-core/src/communication_mode/mod.rs
+++ b/crates/nu-plugin-core/src/communication_mode/mod.rs
@@ -2,7 +2,8 @@ use std::ffi::OsStr;
 use std::io::{Stdin, Stdout};
 use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
 
-use nu_protocol::ShellError;
+use nu_protocol::shell_error::io::IoError;
+use nu_protocol::{ShellError, Span};
 
 #[cfg(feature = "local-socket")]
 mod local_socket;
@@ -84,8 +85,15 @@ impl CommunicationMode {
 
                 let listener = interpret_local_socket_name(name)
                     .and_then(|name| ListenerOptions::new().name(name).create_sync())
-                    .map_err(|err| ShellError::IOError {
-                        msg: format!("failed to open socket for plugin: {err}"),
+                    .map_err(|err| {
+                        IoError::new_internal(
+                            err.kind(),
+                            format!(
+                                "Could not interpret local socket name {:?}",
+                                name.to_string_lossy()
+                            ),
+                            nu_protocol::location!(),
+                        )
                     })?;
                 Ok(PreparedServerCommunication::LocalSocket { listener })
             }
@@ -107,8 +115,15 @@ impl CommunicationMode {
 
                     interpret_local_socket_name(name)
                         .and_then(|name| ls::Stream::connect(name))
-                        .map_err(|err| ShellError::IOError {
-                            msg: format!("failed to connect to socket: {err}"),
+                        .map_err(|err| {
+                            ShellError::Io(IoError::new_internal(
+                                err.kind(),
+                                format!(
+                                    "Could not interpret local socket name {:?}",
+                                    name.to_string_lossy()
+                                ),
+                                nu_protocol::location!(),
+                            ))
                         })
                 };
                 // Reverse order from the server: read in, write out
@@ -171,7 +186,16 @@ impl PreparedServerCommunication {
                 // output) and one for write (the plugin input)
                 //
                 // Be non-blocking on Accept only, so we can timeout.
-                listener.set_nonblocking(ListenerNonblockingMode::Accept)?;
+                listener
+                    .set_nonblocking(ListenerNonblockingMode::Accept)
+                    .map_err(|err| {
+                        IoError::new_with_additional_context(
+                            err.kind(),
+                            Span::unknown(),
+                            None,
+                            "Could not set non-blocking mode accept for listener",
+                        )
+                    })?;
                 let mut get_socket = || {
                     let mut result = None;
                     while let Ok(None) = child.try_wait() {
@@ -179,7 +203,14 @@ impl PreparedServerCommunication {
                             Ok(stream) => {
                                 // Success! Ensure the stream is in nonblocking mode though, for
                                 // good measure. Had an issue without this on macOS.
-                                stream.set_nonblocking(false)?;
+                                stream.set_nonblocking(false).map_err(|err| {
+                                    IoError::new_with_additional_context(
+                                        err.kind(),
+                                        Span::unknown(),
+                                        None,
+                                        "Could not disable non-blocking mode for listener",
+                                    )
+                                })?;
                                 result = Some(stream);
                                 break;
                             }
@@ -187,7 +218,11 @@ impl PreparedServerCommunication {
                                 if !is_would_block_err(&err) {
                                     // `WouldBlock` is ok, just means it's not ready yet, but some other
                                     // kind of error should be reported
-                                    return Err(err.into());
+                                    return Err(ShellError::Io(IoError::new(
+                                        err.kind(),
+                                        Span::unknown(),
+                                        None,
+                                    )));
                                 }
                             }
                         }
diff --git a/crates/nu-plugin-core/src/interface/mod.rs b/crates/nu-plugin-core/src/interface/mod.rs
index de04bd638763c..01f91b7c1c8d8 100644
--- a/crates/nu-plugin-core/src/interface/mod.rs
+++ b/crates/nu-plugin-core/src/interface/mod.rs
@@ -2,8 +2,8 @@
 
 use nu_plugin_protocol::{ByteStreamInfo, ListStreamInfo, PipelineDataHeader, StreamMessage};
 use nu_protocol::{
-    engine::Sequence, ByteStream, IntoSpanned, ListStream, PipelineData, Reader, ShellError,
-    Signals,
+    engine::Sequence, shell_error::io::IoError, ByteStream, ListStream, PipelineData, Reader,
+    ShellError, Signals, Span,
 };
 use std::{
     io::{Read, Write},
@@ -80,8 +80,12 @@ where
     }
 
     fn flush(&self) -> Result<(), ShellError> {
-        self.0.lock().flush().map_err(|err| ShellError::IOError {
-            msg: err.to_string(),
+        self.0.lock().flush().map_err(|err| {
+            ShellError::Io(IoError::new_internal(
+                err.kind(),
+                "PluginWrite could not flush",
+                nu_protocol::location!(),
+            ))
         })
     }
 
@@ -106,8 +110,12 @@ where
         let mut lock = self.0.lock().map_err(|_| ShellError::NushellFailed {
             msg: "writer mutex poisoned".into(),
         })?;
-        lock.flush().map_err(|err| ShellError::IOError {
-            msg: err.to_string(),
+        lock.flush().map_err(|err| {
+            ShellError::Io(IoError::new_internal(
+                err.kind(),
+                "PluginWrite could not flush",
+                nu_protocol::location!(),
+            ))
         })
     }
 }
@@ -332,7 +340,7 @@ where
                 writer.write_all(std::iter::from_fn(move || match reader.read(buf) {
                     Ok(0) => None,
                     Ok(len) => Some(Ok(buf[..len].to_vec())),
-                    Err(err) => Some(Err(ShellError::from(err.into_spanned(span)))),
+                    Err(err) => Some(Err(ShellError::from(IoError::new(err.kind(), span, None)))),
                 }))?;
                 Ok(())
             }
@@ -357,6 +365,14 @@ where
                             log::warn!("Error while writing pipeline in background: {err}");
                         }
                         result
+                    })
+                    .map_err(|err| {
+                        IoError::new_with_additional_context(
+                            err.kind(),
+                            Span::unknown(),
+                            None,
+                            "Could not spawn plugin stream background writer",
+                        )
                     })?,
             )),
         }
diff --git a/crates/nu-plugin-core/src/serializers/json.rs b/crates/nu-plugin-core/src/serializers/json.rs
index 7dc868b806e9d..8dfadac2d023a 100644
--- a/crates/nu-plugin-core/src/serializers/json.rs
+++ b/crates/nu-plugin-core/src/serializers/json.rs
@@ -1,5 +1,5 @@
 use nu_plugin_protocol::{PluginInput, PluginOutput};
-use nu_protocol::ShellError;
+use nu_protocol::{location, shell_error::io::IoError, ShellError};
 use serde::Deserialize;
 
 use crate::{Encoder, PluginEncoder};
@@ -26,8 +26,12 @@ impl Encoder<PluginInput> for JsonSerializer {
         writer: &mut impl std::io::Write,
     ) -> Result<(), nu_protocol::ShellError> {
         serde_json::to_writer(&mut *writer, plugin_input).map_err(json_encode_err)?;
-        writer.write_all(b"\n").map_err(|err| ShellError::IOError {
-            msg: err.to_string(),
+        writer.write_all(b"\n").map_err(|err| {
+            ShellError::Io(IoError::new_internal(
+                err.kind(),
+                "Failed to write final line break",
+                location!(),
+            ))
         })
     }
 
@@ -49,8 +53,12 @@ impl Encoder<PluginOutput> for JsonSerializer {
         writer: &mut impl std::io::Write,
     ) -> Result<(), ShellError> {
         serde_json::to_writer(&mut *writer, plugin_output).map_err(json_encode_err)?;
-        writer.write_all(b"\n").map_err(|err| ShellError::IOError {
-            msg: err.to_string(),
+        writer.write_all(b"\n").map_err(|err| {
+            ShellError::Io(IoError::new_internal(
+                err.kind(),
+                "JsonSerializer could not encode linebreak",
+                nu_protocol::location!(),
+            ))
         })
     }
 
@@ -68,9 +76,11 @@ impl Encoder<PluginOutput> for JsonSerializer {
 /// Handle a `serde_json` encode error.
 fn json_encode_err(err: serde_json::Error) -> ShellError {
     if err.is_io() {
-        ShellError::IOError {
-            msg: err.to_string(),
-        }
+        ShellError::Io(IoError::new_internal(
+            err.io_error_kind().expect("is io"),
+            "Could not encode with json",
+            nu_protocol::location!(),
+        ))
     } else {
         ShellError::PluginFailedToEncode {
             msg: err.to_string(),
@@ -83,9 +93,11 @@ fn json_decode_err<T>(err: serde_json::Error) -> Result<Option<T>, ShellError> {
     if err.is_eof() {
         Ok(None)
     } else if err.is_io() {
-        Err(ShellError::IOError {
-            msg: err.to_string(),
-        })
+        Err(ShellError::Io(IoError::new_internal(
+            err.io_error_kind().expect("is io"),
+            "Could not decode with json",
+            nu_protocol::location!(),
+        )))
     } else {
         Err(ShellError::PluginFailedToDecode {
             msg: err.to_string(),
diff --git a/crates/nu-plugin-core/src/serializers/msgpack.rs b/crates/nu-plugin-core/src/serializers/msgpack.rs
index bf136fd79069c..372511631c915 100644
--- a/crates/nu-plugin-core/src/serializers/msgpack.rs
+++ b/crates/nu-plugin-core/src/serializers/msgpack.rs
@@ -1,7 +1,7 @@
 use std::io::ErrorKind;
 
 use nu_plugin_protocol::{PluginInput, PluginOutput};
-use nu_protocol::ShellError;
+use nu_protocol::{shell_error::io::IoError, ShellError};
 use serde::Deserialize;
 
 use crate::{Encoder, PluginEncoder};
@@ -64,9 +64,12 @@ fn rmp_encode_err(err: rmp_serde::encode::Error) -> ShellError {
     match err {
         rmp_serde::encode::Error::InvalidValueWrite(_) => {
             // I/O error
-            ShellError::IOError {
-                msg: err.to_string(),
-            }
+            ShellError::Io(IoError::new_internal(
+                // TODO: get a better kind here
+                std::io::ErrorKind::Other,
+                "Could not encode with rmp",
+                nu_protocol::location!(),
+            ))
         }
         _ => {
             // Something else
@@ -87,9 +90,12 @@ fn rmp_decode_err<T>(err: rmp_serde::decode::Error) -> Result<Option<T>, ShellEr
                 Ok(None)
             } else {
                 // I/O error
-                Err(ShellError::IOError {
-                    msg: err.to_string(),
-                })
+                Err(ShellError::Io(IoError::new_internal(
+                    // TODO: get a better kind here
+                    std::io::ErrorKind::Other,
+                    "Could not decode with rmp",
+                    nu_protocol::location!(),
+                )))
             }
         }
         _ => {
diff --git a/crates/nu-plugin-engine/src/interface/mod.rs b/crates/nu-plugin-engine/src/interface/mod.rs
index 93d75d0ec9fa5..9eebe6aaed244 100644
--- a/crates/nu-plugin-engine/src/interface/mod.rs
+++ b/crates/nu-plugin-engine/src/interface/mod.rs
@@ -381,8 +381,13 @@ impl PluginInterfaceManager {
                 // don't block
                 this.state.writer.write(&PluginInput::EngineCallResponse(
                     engine_call_id,
-                    EngineCallResponse::Error(ShellError::IOError {
-                        msg: "Can't make engine call because the original caller hung up".into(),
+                    EngineCallResponse::Error(ShellError::GenericError {
+                        error: "Caller hung up".to_string(),
+                        msg: "Can't make engine call because the original caller hung up"
+                            .to_string(),
+                        span: None,
+                        help: None,
+                        inner: vec![],
                     }),
                 ))?;
                 this.state.writer.flush()
diff --git a/crates/nu-plugin-engine/src/persistent.rs b/crates/nu-plugin-engine/src/persistent.rs
index e664b37f87140..8d444a4d1c365 100644
--- a/crates/nu-plugin-engine/src/persistent.rs
+++ b/crates/nu-plugin-engine/src/persistent.rs
@@ -7,8 +7,9 @@ use super::{PluginInterface, PluginSource};
 use nu_plugin_core::CommunicationMode;
 use nu_protocol::{
     engine::{EngineState, Stack},
+    shell_error::io::IoError,
     HandlerGuard, Handlers, PluginGcConfig, PluginIdentity, PluginMetadata, RegisteredPlugin,
-    ShellError,
+    ShellError, Span,
 };
 use std::{
     collections::HashMap,
@@ -184,7 +185,14 @@ impl PersistentPlugin {
         })?;
 
         // Start the plugin garbage collector
-        let gc = PluginGc::new(mutable.gc_config.clone(), &self)?;
+        let gc = PluginGc::new(mutable.gc_config.clone(), &self).map_err(|err| {
+            IoError::new_with_additional_context(
+                err.kind(),
+                Span::unknown(),
+                None,
+                "Could not start plugin gc",
+            )
+        })?;
 
         let pid = child.id();
         let interface = make_plugin_interface(
diff --git a/crates/nu-plugin/src/plugin/interface/mod.rs b/crates/nu-plugin/src/plugin/interface/mod.rs
index be7b2e0dfc028..c3052448b1250 100644
--- a/crates/nu-plugin/src/plugin/interface/mod.rs
+++ b/crates/nu-plugin/src/plugin/interface/mod.rs
@@ -1046,8 +1046,12 @@ impl ForegroundGuard {
             {
                 use nix::unistd::{setpgid, Pid};
                 // This should always succeed, frankly, but handle the error just in case
-                setpgid(Pid::from_raw(0), Pid::from_raw(0)).map_err(|err| ShellError::IOError {
-                    msg: err.to_string(),
+                setpgid(Pid::from_raw(0), Pid::from_raw(0)).map_err(|err| {
+                    nu_protocol::shell_error::io::IoError::new_internal(
+                        std::io::Error::from(err).kind(),
+                        "Could not set pgid",
+                        nu_protocol::location!(),
+                    )
                 })?;
             }
             interface.leave_foreground()?;
diff --git a/crates/nu-plugin/src/plugin/mod.rs b/crates/nu-plugin/src/plugin/mod.rs
index c5af681f5eae0..d01a0e260038c 100644
--- a/crates/nu-plugin/src/plugin/mod.rs
+++ b/crates/nu-plugin/src/plugin/mod.rs
@@ -387,7 +387,7 @@ pub enum ServePluginError {
 impl From<ShellError> for ServePluginError {
     fn from(error: ShellError) -> Self {
         match error {
-            ShellError::IOError { .. } => ServePluginError::IOError(error),
+            ShellError::Io(_) => ServePluginError::IOError(error),
             ShellError::PluginFailedToLoad { .. } => ServePluginError::Incompatible(error),
             _ => ServePluginError::UnreportedError(error),
         }
diff --git a/crates/nu-protocol/src/engine/engine_state.rs b/crates/nu-protocol/src/engine/engine_state.rs
index 8270e4111a787..dbded4eb6322c 100644
--- a/crates/nu-protocol/src/engine/engine_state.rs
+++ b/crates/nu-protocol/src/engine/engine_state.rs
@@ -7,6 +7,7 @@ use crate::{
         Variable, Visibility, DEFAULT_OVERLAY_NAME,
     },
     eval_const::create_nu_constant,
+    shell_error::io::IoError,
     BlockId, Category, Config, DeclId, FileId, GetSpan, Handlers, HistoryConfig, Module, ModuleId,
     OverlayId, ShellError, SignalAction, Signals, Signature, Span, SpanId, Type, Value, VarId,
     VirtualPathId,
@@ -322,8 +323,14 @@ impl EngineState {
         }
 
         let cwd = self.cwd(Some(stack))?;
-        // TODO: better error
-        std::env::set_current_dir(cwd)?;
+        std::env::set_current_dir(cwd).map_err(|err| {
+            IoError::new_with_additional_context(
+                err.kind(),
+                Span::unknown(),
+                None,
+                "Could not set current dir",
+            )
+        })?;
 
         if let Some(config) = stack.config.take() {
             // If config was updated in the stack, replace it.
@@ -514,13 +521,12 @@ impl EngineState {
                 if err.kind() == std::io::ErrorKind::NotFound {
                     Ok(PluginRegistryFile::default())
                 } else {
-                    Err(ShellError::GenericError {
-                        error: "Failed to open plugin file".into(),
-                        msg: "".into(),
-                        span: None,
-                        help: None,
-                        inner: vec![err.into()],
-                    })
+                    Err(ShellError::Io(IoError::new_with_additional_context(
+                        err.kind(),
+                        Span::unknown(),
+                        PathBuf::from(plugin_path),
+                        "Failed to open plugin file",
+                    )))
                 }
             }
         }?;
@@ -531,14 +537,14 @@ impl EngineState {
         }
 
         // Write it to the same path
-        let plugin_file =
-            File::create(plugin_path.as_path()).map_err(|err| ShellError::GenericError {
-                error: "Failed to write plugin file".into(),
-                msg: "".into(),
-                span: None,
-                help: None,
-                inner: vec![err.into()],
-            })?;
+        let plugin_file = File::create(plugin_path.as_path()).map_err(|err| {
+            IoError::new_with_additional_context(
+                err.kind(),
+                Span::unknown(),
+                PathBuf::from(plugin_path),
+                "Failed to write plugin file",
+            )
+        })?;
 
         contents.write_to(plugin_file, None)
     }
diff --git a/crates/nu-protocol/src/errors/labeled_error.rs b/crates/nu-protocol/src/errors/labeled_error.rs
index cdd990463b88e..7104a1a8b1a1b 100644
--- a/crates/nu-protocol/src/errors/labeled_error.rs
+++ b/crates/nu-protocol/src/errors/labeled_error.rs
@@ -143,8 +143,16 @@ impl LabeledError {
     /// [`ShellError`] implements `miette::Diagnostic`:
     ///
     /// ```rust
-    /// # use nu_protocol::{ShellError, LabeledError};
-    /// let error = LabeledError::from_diagnostic(&ShellError::IOError { msg: "error".into() });
+    /// # use nu_protocol::{ShellError, LabeledError, shell_error::io::IoError, Span};
+    /// #
+    /// let error = LabeledError::from_diagnostic(
+    ///     &ShellError::Io(IoError::new_with_additional_context(
+    ///         std::io::ErrorKind::Other,
+    ///         Span::test_data(),
+    ///         None,
+    ///         "some error"
+    ///     ))
+    /// );
     /// assert!(error.to_string().contains("I/O error"));
     /// ```
     pub fn from_diagnostic(diag: &(impl miette::Diagnostic + ?Sized)) -> LabeledError {
diff --git a/crates/nu-protocol/src/errors/mod.rs b/crates/nu-protocol/src/errors/mod.rs
index 2e264066a9e73..5f385a21ee0c6 100644
--- a/crates/nu-protocol/src/errors/mod.rs
+++ b/crates/nu-protocol/src/errors/mod.rs
@@ -4,7 +4,7 @@ mod config_error;
 mod labeled_error;
 mod parse_error;
 mod parse_warning;
-mod shell_error;
+pub mod shell_error;
 
 pub use cli_error::{
     format_shell_error, report_parse_error, report_parse_warning, report_shell_error,
@@ -15,4 +15,4 @@ pub use config_error::ConfigError;
 pub use labeled_error::{ErrorLabel, LabeledError};
 pub use parse_error::{DidYouMean, ParseError};
 pub use parse_warning::ParseWarning;
-pub use shell_error::*;
+pub use shell_error::ShellError;
diff --git a/crates/nu-protocol/src/errors/shell_error/bridge.rs b/crates/nu-protocol/src/errors/shell_error/bridge.rs
new file mode 100644
index 0000000000000..eff8ba17e4a14
--- /dev/null
+++ b/crates/nu-protocol/src/errors/shell_error/bridge.rs
@@ -0,0 +1,47 @@
+use super::ShellError;
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+
+/// A bridge for transferring a [`ShellError`] between Nushell or similar processes.
+///
+/// This newtype encapsulates a [`ShellError`] to facilitate its transfer between Nushell processes
+/// or processes with similar behavior.
+/// By defining this type, we eliminate ambiguity about what is being transferred and avoid the
+/// need to implement [`From<io::Error>`](From) and [`Into<io::Error>`](Into) directly on
+/// `ShellError`.
+#[derive(Debug, Clone, PartialEq, Error, Serialize, Deserialize)]
+#[error("{0}")]
+pub struct ShellErrorBridge(pub ShellError);
+
+impl TryFrom<std::io::Error> for ShellErrorBridge {
+    type Error = std::io::Error;
+
+    fn try_from(value: std::io::Error) -> Result<Self, Self::Error> {
+        let kind = value.kind();
+        value
+            .downcast()
+            .inspect(|_| debug_assert_eq!(kind, std::io::ErrorKind::Other))
+    }
+}
+
+impl From<ShellErrorBridge> for std::io::Error {
+    fn from(value: ShellErrorBridge) -> Self {
+        std::io::Error::other(value)
+    }
+}
+
+#[test]
+fn test_bridge_io_error_roundtrip() {
+    let shell_error = ShellError::GenericError {
+        error: "some error".into(),
+        msg: "some message".into(),
+        span: None,
+        help: None,
+        inner: vec![],
+    };
+
+    let bridge = ShellErrorBridge(shell_error);
+    let io_error = std::io::Error::from(bridge.clone());
+    let bridge_again = ShellErrorBridge::try_from(io_error).unwrap();
+    assert_eq!(bridge.0, bridge_again.0);
+}
diff --git a/crates/nu-protocol/src/errors/shell_error/io.rs b/crates/nu-protocol/src/errors/shell_error/io.rs
new file mode 100644
index 0000000000000..87eaabc74d2d8
--- /dev/null
+++ b/crates/nu-protocol/src/errors/shell_error/io.rs
@@ -0,0 +1,418 @@
+use miette::{Diagnostic, LabeledSpan, SourceSpan};
+use std::{
+    fmt::Display,
+    path::{Path, PathBuf},
+};
+use thiserror::Error;
+
+use crate::Span;
+
+use super::{location::Location, ShellError};
+
+/// Represents an I/O error in the [`ShellError::Io`] variant.
+///
+/// This is the central I/O error for the [`ShellError::Io`] variant.
+/// It represents all I/O errors by encapsulating [`ErrorKind`], an extension of
+/// [`std::io::ErrorKind`].
+/// The `span` indicates where the error occurred in user-provided code.
+/// If the error is not tied to user-provided code, the `location` refers to the precise point in
+/// the Rust code where the error originated.
+/// The optional `path` provides the file or directory involved in the error.
+/// If [`ErrorKind`] alone doesn't provide enough detail, additional context can be added to clarify
+/// the issue.
+///
+/// For handling user input errors (e.g., commands), prefer using [`new`](Self::new).
+/// Alternatively, use the [`factory`](Self::factory) method to simplify error creation in repeated
+/// contexts.
+/// For internal errors, use [`new_internal`](Self::new_internal) to include the location in Rust
+/// code where the error originated.
+///
+/// # Examples
+///
+/// ## User Input Error
+/// ```rust
+/// # use nu_protocol::shell_error::io::{IoError, ErrorKind};
+/// # use nu_protocol::Span;
+/// use std::path::PathBuf;
+///
+/// # let span = Span::test_data();
+/// let path = PathBuf::from("/some/missing/file");
+/// let error = IoError::new(
+///     std::io::ErrorKind::NotFound,
+///     span,
+///     path
+/// );
+/// println!("Error: {:?}", error);
+/// ```
+///
+/// ## Internal Error
+/// ```rust
+/// # use nu_protocol::shell_error::io::{IoError, ErrorKind};
+//  #
+/// let error = IoError::new_internal(
+///     std::io::ErrorKind::UnexpectedEof,
+///     "Failed to read data from buffer",
+///     nu_protocol::location!()
+/// );
+/// println!("Error: {:?}", error);
+/// ```
+///
+/// ## Using the Factory Method
+/// ```rust
+/// # use nu_protocol::shell_error::io::{IoError, ErrorKind};
+/// # use nu_protocol::{Span, ShellError};
+/// use std::path::PathBuf;
+///
+/// # fn should_return_err() -> Result<(), ShellError> {
+/// # let span = Span::new(50, 60);
+/// let path = PathBuf::from("/some/file");
+/// let from_io_error = IoError::factory(span, Some(path.as_path()));
+///
+/// let content = std::fs::read_to_string(&path).map_err(from_io_error)?;
+/// # Ok(())
+/// # }
+/// #
+/// # assert!(should_return_err().is_err());
+/// ```
+///
+/// # ShellErrorBridge
+///
+/// The [`ShellErrorBridge`](super::bridge::ShellErrorBridge) struct is used to contain a
+/// [`ShellError`] inside a [`std::io::Error`].
+/// This allows seamless transfer of `ShellError` instances where `std::io::Error` is expected.
+/// When a `ShellError` needs to be packed into an I/O context, use this bridge.
+/// Similarly, when handling an I/O error that is expected to contain a `ShellError`,
+/// use the bridge to unpack it.
+///
+/// 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)]
+#[non_exhaustive]
+#[error("I/O error")]
+pub struct IoError {
+    /// The type of the underlying I/O error.
+    ///
+    /// [`std::io::ErrorKind`] provides detailed context about the type of I/O error that occurred
+    /// and is part of [`std::io::Error`].
+    /// If a kind cannot be represented by it, consider adding a new variant to [`ErrorKind`].
+    ///
+    /// Only in very rare cases should [`std::io::ErrorKind::Other`] be used, make sure you provide
+    /// `additional_context` to get useful errors in these cases.
+    pub kind: ErrorKind,
+
+    /// The source location of the error.
+    pub span: Span,
+
+    /// The path related to the I/O error, if applicable.
+    ///
+    /// Many I/O errors involve a file or directory path, but operating system error messages
+    /// often don't include the specific path.
+    /// Setting this to [`Some`] allows users to see which path caused the error.
+    pub path: Option<PathBuf>,
+
+    /// Additional details to provide more context about the error.
+    ///
+    /// Only set this field if it adds meaningful context.
+    /// If [`ErrorKind`] already contains all the necessary information, leave this as [`None`].
+    pub additional_context: Option<String>,
+
+    /// The precise location in the Rust code where the error originated.
+    ///
+    /// This field is particularly useful for debugging errors that stem from the Rust
+    /// implementation rather than user-provided Nushell code.
+    /// The original [`Location`] is converted to a string to more easily report the error
+    /// attributing the location.
+    ///
+    /// This value is only used if `span` is [`Span::unknown()`] as most of the time we want to
+    /// refer to user code than the Rust code.
+    pub location: Option<String>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Diagnostic)]
+pub enum ErrorKind {
+    Std(std::io::ErrorKind),
+    // TODO: in Rust 1.83 this can be std::io::ErrorKind::NotADirectory
+    NotADirectory,
+    NotAFile,
+    // TODO: in Rust 1.83 this can be std::io::ErrorKind::IsADirectory
+    IsADirectory,
+}
+
+impl IoError {
+    /// Creates a new [`IoError`] with the given kind, span, and optional path.
+    ///
+    /// This constructor should be used in all cases where the combination of the error kind, span,
+    /// and path provides enough information to describe the error clearly.
+    /// For example, errors like "File not found" or "Permission denied" are typically
+    /// self-explanatory when paired with the file path and the location in user-provided
+    /// Nushell code (`span`).
+    ///
+    /// # Constraints
+    /// If `span` is unknown, use:
+    /// - `new_internal` if no path is available.
+    /// - `new_internal_with_path` if a path is available.
+    pub fn new(kind: impl Into<ErrorKind>, span: Span, path: impl Into<Option<PathBuf>>) -> Self {
+        let path = path.into();
+
+        if span == Span::unknown() {
+            debug_assert!(
+                path.is_some(),
+                "for unknown spans with paths, use `new_internal_with_path`"
+            );
+            debug_assert!(
+                path.is_none(),
+                "for unknown spans without paths, use `new_internal`"
+            );
+        }
+
+        Self {
+            kind: kind.into(),
+            span,
+            path,
+            additional_context: None,
+            location: None,
+        }
+    }
+
+    /// Creates a new [`IoError`] with additional context.
+    ///
+    /// Use this constructor when the error kind, span, and path are not sufficient to fully
+    /// explain the error, and additional context can provide meaningful details.
+    /// Avoid redundant context (e.g., "Permission denied" for an error kind of
+    /// [`ErrorKind::PermissionDenied`](std::io::ErrorKind::PermissionDenied)).
+    ///
+    /// # Constraints
+    /// If `span` is unknown, use:
+    /// - `new_internal` if no path is available.
+    /// - `new_internal_with_path` if a path is available.
+    pub fn new_with_additional_context(
+        kind: impl Into<ErrorKind>,
+        span: Span,
+        path: impl Into<Option<PathBuf>>,
+        additional_context: impl ToString,
+    ) -> Self {
+        let path = path.into();
+
+        if span == Span::unknown() {
+            debug_assert!(
+                path.is_some(),
+                "for unknown spans with paths, use `new_internal_with_path`"
+            );
+            debug_assert!(
+                path.is_none(),
+                "for unknown spans without paths, use `new_internal`"
+            );
+        }
+
+        Self {
+            kind: kind.into(),
+            span,
+            path,
+            additional_context: Some(additional_context.to_string()),
+            location: None,
+        }
+    }
+
+    /// Creates a new [`IoError`] for internal I/O errors without a user-provided span or path.
+    ///
+    /// This constructor is intended for internal errors in the Rust implementation that still need
+    /// to be reported to the end user.
+    /// Since these errors are not tied to user-provided Nushell code, they generally have no
+    /// meaningful span or path.
+    ///
+    /// Instead, these errors provide:
+    /// - `additional_context`:
+    ///   Details about what went wrong internally.
+    /// - `location`:
+    ///   The location in the Rust code where the error occurred, allowing us to trace and debug
+    ///   the issue.
+    ///   Use the [`nu_protocol::location!`](crate::location) macro to generate the location
+    ///   information.
+    ///
+    /// # Examples
+    /// ```rust
+    /// use nu_protocol::shell_error::io::IoError;
+    ///
+    /// let error = IoError::new_internal(
+    ///     std::io::ErrorKind::UnexpectedEof,
+    ///     "Failed to read from buffer",
+    ///     nu_protocol::location!(),
+    /// );
+    /// ```
+    pub fn new_internal(
+        kind: impl Into<ErrorKind>,
+        additional_context: impl ToString,
+        location: Location,
+    ) -> Self {
+        Self {
+            kind: kind.into(),
+            span: Span::unknown(),
+            path: None,
+            additional_context: Some(additional_context.to_string()),
+            location: Some(location.to_string()),
+        }
+    }
+
+    /// Creates a new `IoError` for internal I/O errors with a specific path.
+    ///
+    /// This constructor is similar to [`new_internal`] but also includes a file or directory
+    /// path relevant to the error. Use this function in rare cases where an internal error
+    /// involves a specific path, and the combination of path and additional context is helpful.
+    ///
+    /// # Examples
+    /// ```rust
+    /// use std::path::PathBuf;
+    /// use nu_protocol::shell_error::io::IoError;
+    ///
+    /// let error = IoError::new_internal_with_path(
+    ///     std::io::ErrorKind::NotFound,
+    ///     "Could not find special file",
+    ///     nu_protocol::location!(),
+    ///     PathBuf::from("/some/file"),
+    /// );
+    /// ```
+    pub fn new_internal_with_path(
+        kind: impl Into<ErrorKind>,
+        additional_context: impl ToString,
+        location: Location,
+        path: PathBuf,
+    ) -> Self {
+        Self {
+            kind: kind.into(),
+            span: Span::unknown(),
+            path: path.into(),
+            additional_context: Some(additional_context.to_string()),
+            location: Some(location.to_string()),
+        }
+    }
+
+    /// Creates a factory closure for constructing [`IoError`] instances from [`std::io::Error`] values.
+    ///
+    /// This method is particularly useful when you need to handle multiple I/O errors which all
+    /// take the same span and path.
+    /// Instead of calling `.map_err(|err| IoError::new(err.kind(), span, path))` every time, you
+    /// can create the factory closure once and pass that into `.map_err`.
+    pub fn factory<'p, P>(span: Span, path: P) -> impl Fn(std::io::Error) -> Self + use<'p, P>
+    where
+        P: Into<Option<&'p Path>>,
+    {
+        let path = path.into();
+        move |err: std::io::Error| IoError::new(err.kind(), span, path.map(PathBuf::from))
+    }
+}
+
+impl Display for ErrorKind {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            ErrorKind::Std(error_kind) => {
+                let msg = error_kind.to_string();
+                let (first, rest) = msg.split_at(1);
+                write!(f, "{}{}", first.to_uppercase(), rest)
+            }
+            ErrorKind::NotADirectory => write!(f, "Not a directory"),
+            ErrorKind::NotAFile => write!(f, "Not a file"),
+            ErrorKind::IsADirectory => write!(f, "Is a directory"),
+        }
+    }
+}
+
+impl std::error::Error for ErrorKind {}
+
+impl Diagnostic for IoError {
+    fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
+        let mut code = String::from("nu::shell::io::");
+        match self.kind {
+            ErrorKind::Std(error_kind) => match error_kind {
+                std::io::ErrorKind::NotFound => code.push_str("not_found"),
+                std::io::ErrorKind::PermissionDenied => code.push_str("permission_denied"),
+                std::io::ErrorKind::ConnectionRefused => code.push_str("connection_refused"),
+                std::io::ErrorKind::ConnectionReset => code.push_str("connection_reset"),
+                std::io::ErrorKind::ConnectionAborted => code.push_str("connection_aborted"),
+                std::io::ErrorKind::NotConnected => code.push_str("not_connected"),
+                std::io::ErrorKind::AddrInUse => code.push_str("addr_in_use"),
+                std::io::ErrorKind::AddrNotAvailable => code.push_str("addr_not_available"),
+                std::io::ErrorKind::BrokenPipe => code.push_str("broken_pipe"),
+                std::io::ErrorKind::AlreadyExists => code.push_str("already_exists"),
+                std::io::ErrorKind::WouldBlock => code.push_str("would_block"),
+                std::io::ErrorKind::InvalidInput => code.push_str("invalid_input"),
+                std::io::ErrorKind::InvalidData => code.push_str("invalid_data"),
+                std::io::ErrorKind::TimedOut => code.push_str("timed_out"),
+                std::io::ErrorKind::WriteZero => code.push_str("write_zero"),
+                std::io::ErrorKind::Interrupted => code.push_str("interrupted"),
+                std::io::ErrorKind::Unsupported => code.push_str("unsupported"),
+                std::io::ErrorKind::UnexpectedEof => code.push_str("unexpected_eof"),
+                std::io::ErrorKind::OutOfMemory => code.push_str("out_of_memory"),
+                std::io::ErrorKind::Other => code.push_str("other"),
+                kind => code.push_str(&kind.to_string().to_lowercase().replace(" ", "_")),
+            },
+            ErrorKind::NotADirectory => code.push_str("not_a_directory"),
+            ErrorKind::NotAFile => code.push_str("not_a_file"),
+            ErrorKind::IsADirectory => code.push_str("is_a_directory"),
+        }
+
+        Some(Box::new(code))
+    }
+
+    fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
+        self.path
+            .as_ref()
+            .map(|path| format!("The error occurred at '{}'", path.display()))
+            .map(|s| Box::new(s) as Box<dyn std::fmt::Display>)
+    }
+
+    fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> {
+        let span_is_unknown = self.span == Span::unknown();
+        let span = match (span_is_unknown, self.location.as_ref()) {
+            (true, None) => return None,
+            (false, _) => SourceSpan::from(self.span),
+            (true, Some(location)) => SourceSpan::new(0.into(), location.len()),
+        };
+
+        let label = match self.additional_context.as_ref() {
+            Some(ctx) => format!("{ctx}\n{}", self.kind),
+            None => self.kind.to_string(),
+        };
+        let label = LabeledSpan::new_with_span(Some(label), span);
+        Some(Box::new(std::iter::once(label)))
+    }
+
+    fn diagnostic_source(&self) -> Option<&dyn Diagnostic> {
+        Some(&self.kind as &dyn Diagnostic)
+    }
+
+    fn source_code(&self) -> Option<&dyn miette::SourceCode> {
+        let span_is_unknown = self.span == Span::unknown();
+        match (span_is_unknown, self.location.as_ref()) {
+            (true, None) | (false, _) => None,
+            (true, Some(location)) => Some(location as &dyn miette::SourceCode),
+        }
+    }
+}
+
+impl From<IoError> for ShellError {
+    fn from(value: IoError) -> Self {
+        ShellError::Io(value)
+    }
+}
+
+impl From<IoError> for std::io::Error {
+    fn from(value: IoError) -> Self {
+        Self::new(value.kind.into(), value)
+    }
+}
+
+impl From<std::io::ErrorKind> for ErrorKind {
+    fn from(value: std::io::ErrorKind) -> Self {
+        ErrorKind::Std(value)
+    }
+}
+
+impl From<ErrorKind> for std::io::ErrorKind {
+    fn from(value: ErrorKind) -> Self {
+        match value {
+            ErrorKind::Std(error_kind) => error_kind,
+            _ => std::io::ErrorKind::Other,
+        }
+    }
+}
diff --git a/crates/nu-protocol/src/errors/shell_error/location.rs b/crates/nu-protocol/src/errors/shell_error/location.rs
new file mode 100644
index 0000000000000..340f9ada61679
--- /dev/null
+++ b/crates/nu-protocol/src/errors/shell_error/location.rs
@@ -0,0 +1,56 @@
+use thiserror::Error;
+
+/// Represents a specific location in the Rust code.
+///
+/// This data structure is used to provide detailed information about where in the Rust code
+/// an error occurred.
+/// While most errors in [`ShellError`](super::ShellError) are related to user-provided Nushell
+/// code, some originate from the underlying Rust implementation.
+/// With this type, we can pinpoint the exact location of such errors, improving debugging
+/// and error reporting.
+#[derive(Debug, Clone, PartialEq, Eq, Error)]
+#[error("{file}:{line}:{column}")]
+pub struct Location {
+    file: &'static str,
+    line: u32,
+    column: u32,
+}
+
+impl Location {
+    /// Internal constructor for [`Location`].
+    ///
+    /// This function is not intended to be called directly.
+    /// Instead, use the [`location!`] macro to create instances.
+    #[doc(hidden)]
+    #[deprecated(
+        note = "This function is not meant to be called directly. Use `nu_protocol::location` instead."
+    )]
+    pub fn new(file: &'static str, line: u32, column: u32) -> Self {
+        Location { file, line, column }
+    }
+}
+
+/// Macro to create a new [`Location`] for the exact position in your code.
+///
+/// This macro captures the current file, line, and column during compilation,
+/// providing an easy way to associate errors with specific locations in the Rust code.
+///
+/// # Note
+/// This macro relies on the [`file!`], [`line!`], and [`column!`] macros to fetch the
+/// compilation context.
+#[macro_export]
+macro_rules! location {
+    () => {{
+        #[allow(deprecated)]
+        $crate::shell_error::location::Location::new(file!(), line!(), column!())
+    }};
+}
+
+#[test]
+fn test_location_macro() {
+    let location = crate::location!();
+    let line = line!() - 1; // Adjust for the macro call being on the previous line.
+    let file = file!();
+    assert_eq!(location.line, line);
+    assert_eq!(location.file, file);
+}
diff --git a/crates/nu-protocol/src/errors/shell_error.rs b/crates/nu-protocol/src/errors/shell_error/mod.rs
similarity index 87%
rename from crates/nu-protocol/src/errors/shell_error.rs
rename to crates/nu-protocol/src/errors/shell_error/mod.rs
index 208f2178f6340..6f651836ca78d 100644
--- a/crates/nu-protocol/src/errors/shell_error.rs
+++ b/crates/nu-protocol/src/errors/shell_error/mod.rs
@@ -4,9 +4,13 @@ use crate::{
 };
 use miette::Diagnostic;
 use serde::{Deserialize, Serialize};
-use std::{io, num::NonZeroI32};
+use std::num::NonZeroI32;
 use thiserror::Error;
 
+pub mod bridge;
+pub mod io;
+pub mod location;
+
 /// The fundamental error type for the evaluation engine. These cases represent different kinds of errors
 /// the evaluator might face, along with helpful spans to label. An error renderer will take this error value
 /// and pass it into an error viewer to display to the user.
@@ -808,32 +812,6 @@ pub enum ShellError {
         span: Span,
     },
 
-    /// Failed to find a file during a nushell operation.
-    ///
-    /// ## Resolution
-    ///
-    /// Does the file in the error message exist? Is it readable and accessible? Is the casing right?
-    #[error("File not found")]
-    #[diagnostic(code(nu::shell::file_not_found), help("{file} does not exist"))]
-    FileNotFound {
-        file: String,
-        #[label("file not found")]
-        span: Span,
-    },
-
-    /// Failed to find a file during a nushell operation.
-    ///
-    /// ## Resolution
-    ///
-    /// Does the file in the error message exist? Is it readable and accessible? Is the casing right?
-    #[error("File not found")]
-    #[diagnostic(code(nu::shell::file_not_found))]
-    FileNotFoundCustom {
-        msg: String,
-        #[label("{msg}")]
-        span: Span,
-    },
-
     /// The registered plugin data for a plugin is invalid.
     ///
     /// ## Resolution
@@ -924,148 +902,14 @@ pub enum ShellError {
         span: Span,
     },
 
-    /// I/O operation interrupted.
-    ///
-    /// ## Resolution
-    ///
-    /// This is a generic error. Refer to the specific error message for further details.
-    #[error("I/O interrupted")]
-    #[diagnostic(code(nu::shell::io_interrupted))]
-    IOInterrupted {
-        msg: String,
-        #[label("{msg}")]
-        span: Span,
-    },
-
     /// An I/O operation failed.
     ///
     /// ## Resolution
     ///
-    /// This is a generic error. Refer to the specific error message for further details.
-    #[error("I/O error")]
-    #[diagnostic(code(nu::shell::io_error), help("{msg}"))]
-    IOError { msg: String },
-
-    /// An I/O operation failed.
-    ///
-    /// ## Resolution
-    ///
-    /// This is a generic error. Refer to the specific error message for further details.
-    #[error("I/O error")]
-    #[diagnostic(code(nu::shell::io_error))]
-    IOErrorSpanned {
-        msg: String,
-        #[label("{msg}")]
-        span: Span,
-    },
-
-    /// Tried to `cd` to a path that isn't a directory.
-    ///
-    /// ## Resolution
-    ///
-    /// Make sure the path is a directory. It currently exists, but is of some other type, like a file.
-    #[error("Cannot change to directory")]
-    #[diagnostic(code(nu::shell::cannot_cd_to_directory))]
-    NotADirectory {
-        #[label("is not a directory")]
-        span: Span,
-    },
-
-    /// Attempted to perform an operation on a directory that doesn't exist.
-    ///
-    /// ## Resolution
-    ///
-    /// Make sure the directory in the error message actually exists before trying again.
-    #[error("Directory not found")]
-    #[diagnostic(code(nu::shell::directory_not_found), help("{dir} does not exist"))]
-    DirectoryNotFound {
-        dir: String,
-        #[label("directory not found")]
-        span: Span,
-    },
-
-    /// The requested move operation cannot be completed. This is typically because both paths exist,
-    /// but are of different types. For example, you might be trying to overwrite an existing file with
-    /// a directory.
-    ///
-    /// ## Resolution
-    ///
-    /// Make sure the destination path does not exist before moving a directory.
-    #[error("Move not possible")]
-    #[diagnostic(code(nu::shell::move_not_possible))]
-    MoveNotPossible {
-        source_message: String,
-        #[label("{source_message}")]
-        source_span: Span,
-        destination_message: String,
-        #[label("{destination_message}")]
-        destination_span: Span,
-    },
-
-    /// Failed to create either a file or directory.
-    ///
-    /// ## Resolution
-    ///
-    /// This is a fairly generic error. Refer to the specific error message for further details.
-    #[error("Create not possible")]
-    #[diagnostic(code(nu::shell::create_not_possible))]
-    CreateNotPossible {
-        msg: String,
-        #[label("{msg}")]
-        span: Span,
-    },
-
-    /// Changing the access time ("atime") of this file is not possible.
-    ///
-    /// ## Resolution
-    ///
-    /// This can be for various reasons, such as your platform or permission flags. Refer to the specific error message for more details.
-    #[error("Not possible to change the access time")]
-    #[diagnostic(code(nu::shell::change_access_time_not_possible))]
-    ChangeAccessTimeNotPossible {
-        msg: String,
-        #[label("{msg}")]
-        span: Span,
-    },
-
-    /// Changing the modification time ("mtime") of this file is not possible.
-    ///
-    /// ## Resolution
-    ///
-    /// This can be for various reasons, such as your platform or permission flags. Refer to the specific error message for more details.
-    #[error("Not possible to change the modified time")]
-    #[diagnostic(code(nu::shell::change_modified_time_not_possible))]
-    ChangeModifiedTimeNotPossible {
-        msg: String,
-        #[label("{msg}")]
-        span: Span,
-    },
-
-    /// Unable to remove this item.
-    ///
-    /// ## Resolution
-    ///
-    /// Removal can fail for a number of reasons, such as permissions problems. Refer to the specific error message for more details.
-    #[error("Remove not possible")]
-    #[diagnostic(code(nu::shell::remove_not_possible))]
-    RemoveNotPossible {
-        msg: String,
-        #[label("{msg}")]
-        span: Span,
-    },
-
-    /// Error while trying to read a file
-    ///
-    /// ## Resolution
-    ///
-    /// The error will show the result from a file operation
-    #[error("Error trying to read file")]
-    #[diagnostic(code(nu::shell::error_reading_file))]
-    ReadingFile {
-        msg: String,
-        #[label("{msg}")]
-        span: Span,
-    },
+    /// This is the main I/O error, for further details check the error kind and additional context.
+    #[error(transparent)]
+    #[diagnostic(transparent)]
+    Io(io::IoError),
 
     /// A name was not found. Did you mean a different name?
     ///
@@ -1531,75 +1375,26 @@ impl ShellError {
     }
 }
 
-impl From<io::Error> for ShellError {
-    fn from(error: io::Error) -> ShellError {
-        if error.kind() == io::ErrorKind::Other {
-            match error.into_inner() {
-                Some(err) => match err.downcast() {
-                    Ok(err) => *err,
-                    Err(err) => Self::IOError {
-                        msg: err.to_string(),
-                    },
-                },
-                None => Self::IOError {
-                    msg: "unknown error".into(),
-                },
-            }
-        } else {
-            Self::IOError {
-                msg: error.to_string(),
-            }
-        }
-    }
-}
-
-impl From<Spanned<io::Error>> for ShellError {
-    fn from(error: Spanned<io::Error>) -> Self {
-        let Spanned { item: error, span } = error;
-        match error.kind() {
-            io::ErrorKind::Other => match error.into_inner() {
-                Some(err) => match err.downcast() {
-                    Ok(err) => *err,
-                    Err(err) => Self::IOErrorSpanned {
-                        msg: err.to_string(),
-                        span,
-                    },
-                },
-                None => Self::IOErrorSpanned {
-                    msg: "unknown error".into(),
-                    span,
-                },
-            },
-            io::ErrorKind::TimedOut => Self::NetworkFailure {
-                msg: error.to_string(),
-                span,
-            },
-            _ => Self::IOErrorSpanned {
-                msg: error.to_string(),
-                span,
-            },
-        }
-    }
-}
-
-impl From<ShellError> for io::Error {
-    fn from(error: ShellError) -> Self {
-        io::Error::new(io::ErrorKind::Other, error)
-    }
-}
-
 impl From<Box<dyn std::error::Error>> for ShellError {
     fn from(error: Box<dyn std::error::Error>) -> ShellError {
-        ShellError::IOError {
+        ShellError::GenericError {
+            error: format!("{error:?}"),
             msg: error.to_string(),
+            span: None,
+            help: None,
+            inner: vec![],
         }
     }
 }
 
 impl From<Box<dyn std::error::Error + Send + Sync>> for ShellError {
     fn from(error: Box<dyn std::error::Error + Send + Sync>) -> ShellError {
-        ShellError::IOError {
-            msg: format!("{error:?}"),
+        ShellError::GenericError {
+            error: format!("{error:?}"),
+            msg: error.to_string(),
+            span: None,
+            help: None,
+            inner: vec![],
         }
     }
 }
@@ -1682,3 +1477,26 @@ fn shell_error_serialize_roundtrip() {
         deserialized.help().map(|c| c.to_string())
     );
 }
+
+#[cfg(test)]
+mod test {
+    use super::*;
+
+    impl From<std::io::Error> for ShellError {
+        fn from(_: std::io::Error) -> ShellError {
+            unimplemented!("This implementation is defined in the test module to ensure no other implementation exists.")
+        }
+    }
+
+    impl From<Spanned<std::io::Error>> for ShellError {
+        fn from(_: Spanned<std::io::Error>) -> Self {
+            unimplemented!("This implementation is defined in the test module to ensure no other implementation exists.")
+        }
+    }
+
+    impl From<ShellError> for std::io::Error {
+        fn from(_: ShellError) -> Self {
+            unimplemented!("This implementation is defined in the test module to ensure no other implementation exists.")
+        }
+    }
+}
diff --git a/crates/nu-protocol/src/eval_const.rs b/crates/nu-protocol/src/eval_const.rs
index 25f5d8404abb2..86ce4d7385e76 100644
--- a/crates/nu-protocol/src/eval_const.rs
+++ b/crates/nu-protocol/src/eval_const.rs
@@ -143,8 +143,12 @@ pub(crate) fn create_nu_constant(engine_state: &EngineState, span: Span) -> Valu
             Value::string(canon_home_path.to_string_lossy(), span)
         } else {
             Value::error(
-                ShellError::IOError {
+                ShellError::GenericError {
+                    error: "setting $nu.home-path failed".into(),
                     msg: "Could not get home path".into(),
+                    span: Some(span),
+                    help: None,
+                    inner: vec![],
                 },
                 span,
             )
@@ -159,8 +163,12 @@ pub(crate) fn create_nu_constant(engine_state: &EngineState, span: Span) -> Valu
             Value::string(canon_data_path.to_string_lossy(), span)
         } else {
             Value::error(
-                ShellError::IOError {
+                ShellError::GenericError {
+                    error: "setting $nu.data-dir failed".into(),
                     msg: "Could not get data path".into(),
+                    span: Some(span),
+                    help: None,
+                    inner: vec![],
                 },
                 span,
             )
@@ -175,8 +183,12 @@ pub(crate) fn create_nu_constant(engine_state: &EngineState, span: Span) -> Valu
             Value::string(canon_cache_path.to_string_lossy(), span)
         } else {
             Value::error(
-                ShellError::IOError {
+                ShellError::GenericError {
+                    error: "setting $nu.cache-dir failed".into(),
                     msg: "Could not get cache path".into(),
+                    span: Some(span),
+                    help: None,
+                    inner: vec![],
                 },
                 span,
             )
@@ -248,8 +260,12 @@ pub(crate) fn create_nu_constant(engine_state: &EngineState, span: Span) -> Valu
             Value::string(current_exe.to_string_lossy(), span)
         } else {
             Value::error(
-                ShellError::IOError {
-                    msg: "Could not get current executable path".to_string(),
+                ShellError::GenericError {
+                    error: "setting $nu.current-exe failed".into(),
+                    msg: "Could not get current executable path".into(),
+                    span: Some(span),
+                    help: None,
+                    inner: vec![],
                 },
                 span,
             )
diff --git a/crates/nu-protocol/src/pipeline/byte_stream.rs b/crates/nu-protocol/src/pipeline/byte_stream.rs
index b5cbbff237ad2..e06a651088613 100644
--- a/crates/nu-protocol/src/pipeline/byte_stream.rs
+++ b/crates/nu-protocol/src/pipeline/byte_stream.rs
@@ -1,7 +1,13 @@
 //! Module managing the streaming of raw bytes between pipeline elements
+//!
+//! This module also handles conversions the [`ShellError`] <-> [`io::Error`](std::io::Error),
+//! so remember the usage of [`ShellErrorBridge`] where applicable.
 #[cfg(feature = "os")]
 use crate::process::{ChildPipe, ChildProcess};
-use crate::{ErrSpan, IntRange, IntoSpanned, PipelineData, ShellError, Signals, Span, Type, Value};
+use crate::{
+    shell_error::{bridge::ShellErrorBridge, io::IoError},
+    IntRange, PipelineData, ShellError, Signals, Span, Type, Value,
+};
 use serde::{Deserialize, Serialize};
 use std::ops::Bound;
 #[cfg(unix)]
@@ -225,7 +231,8 @@ impl ByteStream {
         let known_size = self.known_size.map(|len| len.saturating_sub(n));
         if let Some(mut reader) = self.reader() {
             // Copy the number of skipped bytes into the sink before proceeding
-            io::copy(&mut (&mut reader).take(n), &mut io::sink()).err_span(span)?;
+            io::copy(&mut (&mut reader).take(n), &mut io::sink())
+                .map_err(|err| IoError::new(err.kind(), span, None))?;
             Ok(
                 ByteStream::read(reader, span, Signals::empty(), ByteStreamType::Binary)
                     .with_known_size(known_size),
@@ -346,7 +353,7 @@ impl ByteStream {
     /// binary.
     #[cfg(feature = "os")]
     pub fn stdin(span: Span) -> Result<Self, ShellError> {
-        let stdin = os_pipe::dup_stdin().err_span(span)?;
+        let stdin = os_pipe::dup_stdin().map_err(|err| IoError::new(err.kind(), span, None))?;
         let source = ByteStreamSource::File(convert_file(stdin));
         Ok(Self::new(
             source,
@@ -573,15 +580,16 @@ impl ByteStream {
     /// Any trailing new lines are kept in the returned [`Vec`].
     pub fn into_bytes(self) -> Result<Vec<u8>, ShellError> {
         // todo!() ctrlc
+        let from_io_error = IoError::factory(self.span, None);
         match self.stream {
             ByteStreamSource::Read(mut read) => {
                 let mut buf = Vec::new();
-                read.read_to_end(&mut buf).err_span(self.span)?;
+                read.read_to_end(&mut buf).map_err(&from_io_error)?;
                 Ok(buf)
             }
             ByteStreamSource::File(mut file) => {
                 let mut buf = Vec::new();
-                file.read_to_end(&mut buf).err_span(self.span)?;
+                file.read_to_end(&mut buf).map_err(&from_io_error)?;
                 Ok(buf)
             }
             #[cfg(feature = "os")]
@@ -759,7 +767,12 @@ where
         while let Some(cursor) = self.cursor.as_mut() {
             let read = cursor.read(buf)?;
             if read == 0 {
-                self.cursor = self.iter.next().transpose()?.map(Cursor::new);
+                self.cursor = self
+                    .iter
+                    .next()
+                    .transpose()
+                    .map_err(ShellErrorBridge)?
+                    .map(Cursor::new);
             } else {
                 return Ok(read);
             }
@@ -782,7 +795,7 @@ impl Reader {
 
 impl Read for Reader {
     fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
-        self.signals.check(self.span)?;
+        self.signals.check(self.span).map_err(ShellErrorBridge)?;
         self.reader.read(buf)
     }
 }
@@ -826,7 +839,7 @@ impl Iterator for Lines {
                     trim_end_newline(&mut string);
                     Some(Ok(string))
                 }
-                Err(e) => Some(Err(e.into_spanned(self.span).into())),
+                Err(e) => Some(Err(IoError::new(e.kind(), self.span, None).into())),
             }
         }
     }
@@ -1022,7 +1035,15 @@ impl Iterator for SplitRead {
         if self.signals.interrupted() {
             return None;
         }
-        self.internal.next().map(|r| r.map_err(|e| e.into()))
+        self.internal.next().map(|r| {
+            r.map_err(|err| {
+                ShellError::Io(IoError::new_internal(
+                    err.kind(),
+                    "Could not get next value for SplitRead",
+                    crate::location!(),
+                ))
+            })
+        })
     }
 }
 
@@ -1057,12 +1078,17 @@ impl Chunks {
     }
 
     fn next_string(&mut self) -> Result<Option<String>, (Vec<u8>, ShellError)> {
+        let from_io_error = |err: std::io::Error| match ShellErrorBridge::try_from(err) {
+            Ok(err) => err.0,
+            Err(err) => IoError::new(err.kind(), self.span, None).into(),
+        };
+
         // Get some data from the reader
         let buf = self
             .reader
             .fill_buf()
-            .err_span(self.span)
-            .map_err(|err| (vec![], ShellError::from(err)))?;
+            .map_err(from_io_error)
+            .map_err(|err| (vec![], err))?;
 
         // If empty, this is EOF
         if buf.is_empty() {
@@ -1076,9 +1102,9 @@ impl Chunks {
         if buf.len() < 4 {
             consumed += buf.len();
             self.reader.consume(buf.len());
-            match self.reader.fill_buf().err_span(self.span) {
+            match self.reader.fill_buf() {
                 Ok(more_bytes) => buf.extend_from_slice(more_bytes),
-                Err(err) => return Err((buf, err.into())),
+                Err(err) => return Err((buf, from_io_error(err))),
             }
         }
 
@@ -1133,11 +1159,15 @@ impl Iterator for Chunks {
             match self.type_ {
                 // Binary should always be binary
                 ByteStreamType::Binary => {
-                    let buf = match self.reader.fill_buf().err_span(self.span) {
+                    let buf = match self.reader.fill_buf() {
                         Ok(buf) => buf,
                         Err(err) => {
                             self.error = true;
-                            return Some(Err(err.into()));
+                            return Some(Err(ShellError::Io(IoError::new(
+                                err.kind(),
+                                self.span,
+                                None,
+                            ))));
                         }
                     };
                     if !buf.is_empty() {
@@ -1206,15 +1236,19 @@ pub fn copy_with_signals(
     span: Span,
     signals: &Signals,
 ) -> Result<u64, ShellError> {
+    let from_io_error = IoError::factory(span, None);
     if signals.is_empty() {
         match io::copy(&mut reader, &mut writer) {
             Ok(n) => {
-                writer.flush().err_span(span)?;
+                writer.flush().map_err(&from_io_error)?;
                 Ok(n)
             }
             Err(err) => {
                 let _ = writer.flush();
-                Err(err.into_spanned(span).into())
+                match ShellErrorBridge::try_from(err) {
+                    Ok(ShellErrorBridge(shell_error)) => Err(shell_error),
+                    Err(err) => Err(from_io_error(err).into()),
+                }
             }
         }
     } else {
@@ -1224,7 +1258,7 @@ pub fn copy_with_signals(
         // }
         match generic_copy(&mut reader, &mut writer, span, signals) {
             Ok(len) => {
-                writer.flush().err_span(span)?;
+                writer.flush().map_err(&from_io_error)?;
                 Ok(len)
             }
             Err(err) => {
@@ -1242,6 +1276,7 @@ fn generic_copy(
     span: Span,
     signals: &Signals,
 ) -> Result<u64, ShellError> {
+    let from_io_error = IoError::factory(span, None);
     let buf = &mut [0; DEFAULT_BUF_SIZE];
     let mut len = 0;
     loop {
@@ -1250,10 +1285,13 @@ fn generic_copy(
             Ok(0) => break,
             Ok(n) => n,
             Err(e) if e.kind() == ErrorKind::Interrupted => continue,
-            Err(e) => return Err(e.into_spanned(span).into()),
+            Err(e) => match ShellErrorBridge::try_from(e) {
+                Ok(ShellErrorBridge(e)) => return Err(e),
+                Err(e) => return Err(from_io_error(e).into()),
+            },
         };
         len += n;
-        writer.write_all(&buf[..n]).err_span(span)?;
+        writer.write_all(&buf[..n]).map_err(&from_io_error)?;
     }
     Ok(len as u64)
 }
@@ -1278,7 +1316,7 @@ where
             self.buffer.set_position(0);
             self.buffer.get_mut().clear();
             // Ask the generator to generate data
-            if !(self.generator)(self.buffer.get_mut())? {
+            if !(self.generator)(self.buffer.get_mut()).map_err(ShellErrorBridge)? {
                 // End of stream
                 break;
             }
diff --git a/crates/nu-protocol/src/pipeline/pipeline_data.rs b/crates/nu-protocol/src/pipeline/pipeline_data.rs
index b9d523b3da1db..b4a1e9ca01eec 100644
--- a/crates/nu-protocol/src/pipeline/pipeline_data.rs
+++ b/crates/nu-protocol/src/pipeline/pipeline_data.rs
@@ -1,6 +1,7 @@
 use crate::{
     ast::{Call, PathMember},
     engine::{EngineState, Stack},
+    shell_error::io::IoError,
     ByteStream, ByteStreamType, Config, ListStream, OutDest, PipelineMetadata, Range, ShellError,
     Signals, Span, Type, Value,
 };
@@ -219,17 +220,47 @@ impl PipelineData {
             PipelineData::Empty => Ok(()),
             PipelineData::Value(value, ..) => {
                 let bytes = value_to_bytes(value)?;
-                dest.write_all(&bytes)?;
-                dest.flush()?;
+                dest.write_all(&bytes).map_err(|err| {
+                    IoError::new_internal(
+                        err.kind(),
+                        "Could not write PipelineData to dest",
+                        crate::location!(),
+                    )
+                })?;
+                dest.flush().map_err(|err| {
+                    IoError::new_internal(
+                        err.kind(),
+                        "Could not flush PipelineData to dest",
+                        crate::location!(),
+                    )
+                })?;
                 Ok(())
             }
             PipelineData::ListStream(stream, ..) => {
                 for value in stream {
                     let bytes = value_to_bytes(value)?;
-                    dest.write_all(&bytes)?;
-                    dest.write_all(b"\n")?;
+                    dest.write_all(&bytes).map_err(|err| {
+                        IoError::new_internal(
+                            err.kind(),
+                            "Could not write PipelineData to dest",
+                            crate::location!(),
+                        )
+                    })?;
+                    dest.write_all(b"\n").map_err(|err| {
+                        IoError::new_internal(
+                            err.kind(),
+                            "Could not write linebreak after PipelineData to dest",
+                            crate::location!(),
+                        )
+                    })?;
                 }
-                dest.flush()?;
+                dest.flush().map_err(|err| {
+                    IoError::new_internal(
+                        err.kind(),
+                        "Could not flush PipelineData to dest",
+                        crate::location!(),
+                    )
+                })?;
                 Ok(())
             }
             PipelineData::ByteStream(stream, ..) => stream.write_to(dest),
@@ -633,9 +664,23 @@ impl PipelineData {
     ) -> Result<(), ShellError> {
         if let PipelineData::Value(Value::Binary { val: bytes, .. }, _) = self {
             if to_stderr {
-                stderr_write_all_and_flush(bytes)?;
+                stderr_write_all_and_flush(bytes).map_err(|err| {
+                    IoError::new_with_additional_context(
+                        err.kind(),
+                        Span::unknown(),
+                        None,
+                        "Writing to stderr failed",
+                    )
+                })?
             } else {
-                stdout_write_all_and_flush(bytes)?;
+                stdout_write_all_and_flush(bytes).map_err(|err| {
+                    IoError::new_with_additional_context(
+                        err.kind(),
+                        Span::unknown(),
+                        None,
+                        "Writing to stdout failed",
+                    )
+                })?
             }
             Ok(())
         } else {
@@ -666,9 +711,23 @@ impl PipelineData {
                 }
 
                 if to_stderr {
-                    stderr_write_all_and_flush(out)?
+                    stderr_write_all_and_flush(out).map_err(|err| {
+                        IoError::new_with_additional_context(
+                            err.kind(),
+                            Span::unknown(),
+                            None,
+                            "Writing to stderr failed",
+                        )
+                    })?
                 } else {
-                    stdout_write_all_and_flush(out)?
+                    stdout_write_all_and_flush(out).map_err(|err| {
+                        IoError::new_with_additional_context(
+                            err.kind(),
+                            Span::unknown(),
+                            None,
+                            "Writing to stdout failed",
+                        )
+                    })?
                 }
             }
 
diff --git a/crates/nu-protocol/src/process/child.rs b/crates/nu-protocol/src/process/child.rs
index 8c4fb55c0db36..1c0ef513edd27 100644
--- a/crates/nu-protocol/src/process/child.rs
+++ b/crates/nu-protocol/src/process/child.rs
@@ -1,4 +1,4 @@
-use crate::{byte_stream::convert_file, ErrSpan, IntoSpanned, ShellError, Span};
+use crate::{byte_stream::convert_file, shell_error::io::IoError, ShellError, Span};
 use nu_system::{ExitStatus, ForegroundChild};
 use os_pipe::PipeReader;
 use std::{
@@ -74,13 +74,18 @@ impl ExitStatusFuture {
                         Ok(status)
                     }
                     Ok(Ok(status)) => Ok(status),
-                    Ok(Err(err)) => Err(ShellError::IOErrorSpanned {
-                        msg: format!("failed to get exit code: {err:?}"),
+                    Ok(Err(err)) => Err(ShellError::Io(IoError::new_with_additional_context(
+                        err.kind(),
                         span,
-                    }),
-                    Err(RecvError) => Err(ShellError::IOErrorSpanned {
+                        None,
+                        "failed to get exit code",
+                    ))),
+                    Err(err @ RecvError) => Err(ShellError::GenericError {
+                        error: err.to_string(),
                         msg: "failed to get exit code".into(),
-                        span,
+                        span: span.into(),
+                        help: None,
+                        inner: vec![],
                     }),
                 };
 
@@ -98,13 +103,19 @@ impl ExitStatusFuture {
             ExitStatusFuture::Running(receiver) => {
                 let code = match receiver.try_recv() {
                     Ok(Ok(status)) => Ok(Some(status)),
-                    Ok(Err(err)) => Err(ShellError::IOErrorSpanned {
-                        msg: format!("failed to get exit code: {err:?}"),
-                        span,
+                    Ok(Err(err)) => Err(ShellError::GenericError {
+                        error: err.to_string(),
+                        msg: "failed to get exit code".to_string(),
+                        span: span.into(),
+                        help: None,
+                        inner: vec![],
                     }),
-                    Err(TryRecvError::Disconnected) => Err(ShellError::IOErrorSpanned {
+                    Err(TryRecvError::Disconnected) => Err(ShellError::GenericError {
+                        error: "receiver disconnected".to_string(),
                         msg: "failed to get exit code".into(),
-                        span,
+                        span: span.into(),
+                        help: None,
+                        inner: vec![],
                     }),
                     Err(TryRecvError::Empty) => Ok(None),
                 };
@@ -180,7 +191,14 @@ impl ChildProcess {
         thread::Builder::new()
             .name("exit status waiter".into())
             .spawn(move || exit_status_sender.send(child.wait()))
-            .err_span(span)?;
+            .map_err(|err| {
+                IoError::new_with_additional_context(
+                    err.kind(),
+                    span,
+                    None,
+                    "Could now spawn exit status waiter",
+                )
+            })?;
 
         Ok(Self::from_raw(stdout, stderr, Some(exit_status), span))
     }
@@ -214,14 +232,17 @@ impl ChildProcess {
     pub fn into_bytes(mut self) -> Result<Vec<u8>, ShellError> {
         if self.stderr.is_some() {
             debug_assert!(false, "stderr should not exist");
-            return Err(ShellError::IOErrorSpanned {
-                msg: "internal error".into(),
-                span: self.span,
+            return Err(ShellError::GenericError {
+                error: "internal error".into(),
+                msg: "stderr should not exist".into(),
+                span: self.span.into(),
+                help: None,
+                inner: vec![],
             });
         }
 
         let bytes = if let Some(stdout) = self.stdout {
-            collect_bytes(stdout).err_span(self.span)?
+            collect_bytes(stdout).map_err(|err| IoError::new(err.kind(), self.span, None))?
         } else {
             Vec::new()
         };
@@ -236,6 +257,7 @@ impl ChildProcess {
     }
 
     pub fn wait(mut self) -> Result<(), ShellError> {
+        let from_io_error = IoError::factory(self.span, None);
         if let Some(stdout) = self.stdout.take() {
             let stderr = self
                 .stderr
@@ -246,7 +268,7 @@ impl ChildProcess {
                         .spawn(move || consume_pipe(stderr))
                 })
                 .transpose()
-                .err_span(self.span)?;
+                .map_err(&from_io_error)?;
 
             let res = consume_pipe(stdout);
 
@@ -254,7 +276,7 @@ impl ChildProcess {
                 handle
                     .join()
                     .map_err(|e| match e.downcast::<io::Error>() {
-                        Ok(io) => ShellError::from((*io).into_spanned(self.span)),
+                        Ok(io) => from_io_error(*io).into(),
                         Err(err) => ShellError::GenericError {
                             error: "Unknown error".into(),
                             msg: format!("{err:?}"),
@@ -263,12 +285,12 @@ impl ChildProcess {
                             inner: Vec::new(),
                         },
                     })?
-                    .err_span(self.span)?;
+                    .map_err(&from_io_error)?;
             }
 
-            res.err_span(self.span)?;
+            res.map_err(&from_io_error)?;
         } else if let Some(stderr) = self.stderr.take() {
-            consume_pipe(stderr).err_span(self.span)?;
+            consume_pipe(stderr).map_err(&from_io_error)?;
         }
 
         check_ok(
@@ -283,19 +305,20 @@ impl ChildProcess {
     }
 
     pub fn wait_with_output(mut self) -> Result<ProcessOutput, ShellError> {
+        let from_io_error = IoError::factory(self.span, None);
         let (stdout, stderr) = if let Some(stdout) = self.stdout {
             let stderr = self
                 .stderr
                 .map(|stderr| thread::Builder::new().spawn(move || collect_bytes(stderr)))
                 .transpose()
-                .err_span(self.span)?;
+                .map_err(&from_io_error)?;
 
-            let stdout = collect_bytes(stdout).err_span(self.span)?;
+            let stdout = collect_bytes(stdout).map_err(&from_io_error)?;
 
             let stderr = stderr
                 .map(|handle| {
                     handle.join().map_err(|e| match e.downcast::<io::Error>() {
-                        Ok(io) => ShellError::from((*io).into_spanned(self.span)),
+                        Ok(io) => from_io_error(*io).into(),
                         Err(err) => ShellError::GenericError {
                             error: "Unknown error".into(),
                             msg: format!("{err:?}"),
@@ -307,7 +330,7 @@ impl ChildProcess {
                 })
                 .transpose()?
                 .transpose()
-                .err_span(self.span)?;
+                .map_err(&from_io_error)?;
 
             (Some(stdout), stderr)
         } else {
@@ -315,7 +338,7 @@ impl ChildProcess {
                 .stderr
                 .map(collect_bytes)
                 .transpose()
-                .err_span(self.span)?;
+                .map_err(&from_io_error)?;
 
             (None, stderr)
         };
diff --git a/crates/nu-protocol/src/span.rs b/crates/nu-protocol/src/span.rs
index 9fe42c253016b..dd465b5a63ebb 100644
--- a/crates/nu-protocol/src/span.rs
+++ b/crates/nu-protocol/src/span.rs
@@ -115,10 +115,17 @@ impl Span {
         Self { start: 0, end: 0 }
     }
 
+    /// Span for testing purposes.
+    ///
+    /// The provided span does not point into any known source but is unequal to [`Span::unknown()`].
+    ///
     /// Note: Only use this for test data, *not* live data, as it will point into unknown source
-    /// when used in errors.
+    /// when used in errors
     pub const fn test_data() -> Self {
-        Self::unknown()
+        Self {
+            start: usize::MAX / 2,
+            end: usize::MAX / 2,
+        }
     }
 
     pub fn offset(&self, offset: usize) -> Self {
@@ -215,26 +222,14 @@ impl From<Span> for SourceSpan {
     }
 }
 
-/// An extension trait for `Result`, which adds a span to the error type.
+/// An extension trait for [`Result`], which adds a span to the error type.
+///
+/// This trait might be removed later, since the old [`Spanned<std::io::Error>`] to [`ShellError`]
+/// conversion was replaced by [`IoError`](io_error::IoError).
 pub trait ErrSpan {
     type Result;
 
-    /// Add the given span to the error type `E`, turning it into a `Spanned<E>`.
-    ///
-    /// Some auto-conversion methods to `ShellError` from other error types are available on spanned
-    /// errors, to give users better information about where an error came from. For example, it is
-    /// preferred when working with `std::io::Error`:
-    ///
-    /// ```no_run
-    /// use nu_protocol::{ErrSpan, ShellError, Span};
-    /// use std::io::Read;
-    ///
-    /// fn read_from(mut reader: impl Read, span: Span) -> Result<Vec<u8>, ShellError> {
-    ///     let mut vec = vec![];
-    ///     reader.read_to_end(&mut vec).err_span(span)?;
-    ///     Ok(vec)
-    /// }
-    /// ```
+    /// Adds the given span to the error type, turning it into a [`Spanned<E>`].
     fn err_span(self, span: Span) -> Self::Result;
 }
 
diff --git a/crates/nu_plugin_polars/src/dataframe/command/core/open.rs b/crates/nu_plugin_polars/src/dataframe/command/core/open.rs
index 97a44d8f4f869..b218530e64857 100644
--- a/crates/nu_plugin_polars/src/dataframe/command/core/open.rs
+++ b/crates/nu_plugin_polars/src/dataframe/command/core/open.rs
@@ -11,8 +11,8 @@ use url::Url;
 
 use nu_plugin::PluginCommand;
 use nu_protocol::{
-    Category, Example, LabeledError, PipelineData, ShellError, Signature, Span, Spanned,
-    SyntaxShape, Type, Value,
+    shell_error::io::IoError, Category, Example, LabeledError, PipelineData, ShellError, Signature,
+    Span, Spanned, SyntaxShape, Type, Value,
 };
 
 use std::{fmt::Debug, fs::File, io::BufReader, num::NonZeroUsize, path::PathBuf, sync::Arc};
@@ -221,10 +221,12 @@ fn command(
                 blamed,
             )),
         },
-        None => Err(ShellError::FileNotFoundCustom {
-            msg: "File without extension".into(),
-            span: spanned_file.span,
-        }),
+        None => Err(ShellError::Io(IoError::new_with_additional_context(
+            std::io::ErrorKind::NotFound,
+            spanned_file.span,
+            PathBuf::from(spanned_file.item),
+            "File without extension",
+        ))),
     }
     .map(|value| PipelineData::Value(value, None))
 }
diff --git a/crates/nu_plugin_polars/src/dataframe/command/core/save/mod.rs b/crates/nu_plugin_polars/src/dataframe/command/core/save/mod.rs
index 8619ec9d424fe..775baca9905b8 100644
--- a/crates/nu_plugin_polars/src/dataframe/command/core/save/mod.rs
+++ b/crates/nu_plugin_polars/src/dataframe/command/core/save/mod.rs
@@ -14,8 +14,8 @@ use crate::{
 use nu_path::expand_path_with;
 use nu_plugin::{EngineInterface, EvaluatedCall, PluginCommand};
 use nu_protocol::{
-    Category, Example, LabeledError, PipelineData, ShellError, Signature, Span, Spanned,
-    SyntaxShape, Type,
+    shell_error::io::IoError, Category, Example, LabeledError, PipelineData, ShellError, Signature,
+    Span, Spanned, SyntaxShape, Type,
 };
 use polars::error::PolarsError;
 
@@ -204,10 +204,12 @@ fn command(
                 blamed,
             )),
         },
-        None => Err(ShellError::FileNotFoundCustom {
-            msg: "File without extension".into(),
-            span: spanned_file.span,
-        }),
+        None => Err(ShellError::Io(IoError::new_with_additional_context(
+            std::io::ErrorKind::NotFound,
+            spanned_file.span,
+            spanned_file.item,
+            "File without extension",
+        ))),
     }?;
 
     Ok(PipelineData::empty())
diff --git a/src/ide.rs b/src/ide.rs
index 85023745fa9ad..6ce7e69c6fd83 100644
--- a/src/ide.rs
+++ b/src/ide.rs
@@ -3,7 +3,9 @@ use nu_cli::NuCompleter;
 use nu_parser::{flatten_block, parse, FlatShape};
 use nu_protocol::{
     engine::{EngineState, Stack, StateWorkingSet},
-    report_shell_error, DeclId, ShellError, Span, Value, VarId,
+    report_shell_error,
+    shell_error::io::IoError,
+    DeclId, ShellError, Span, Value, VarId,
 };
 use reedline::Completer;
 use serde_json::{json, Value as JsonValue};
@@ -54,15 +56,16 @@ fn read_in_file<'a>(
     file_path: &str,
 ) -> (Vec<u8>, StateWorkingSet<'a>) {
     let file = std::fs::read(file_path)
-        .into_diagnostic()
-        .unwrap_or_else(|e| {
-            report_shell_error(
-                engine_state,
-                &ShellError::FileNotFoundCustom {
-                    msg: format!("Could not read file '{}': {:?}", file_path, e.to_string()),
-                    span: Span::unknown(),
-                },
-            );
+        .map_err(|err| {
+            ShellError::Io(IoError::new_with_additional_context(
+                err.kind(),
+                Span::unknown(),
+                PathBuf::from(file_path),
+                "Could not read file",
+            ))
+        })
+        .unwrap_or_else(|err| {
+            report_shell_error(engine_state, &err);
             std::process::exit(1);
         });
 
diff --git a/src/main.rs b/src/main.rs
index 0e7c656fc7878..6beae6de774ed 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -428,7 +428,13 @@ fn main() -> Result<()> {
         for plugin_filename in plugins {
             // Make sure the plugin filenames are canonicalized
             let filename = canonicalize_with(&plugin_filename.item, &init_cwd)
-                .err_span(plugin_filename.span)
+                .map_err(|err| {
+                    nu_protocol::shell_error::io::IoError::new(
+                        err.kind(),
+                        plugin_filename.span,
+                        PathBuf::from(&plugin_filename.item),
+                    )
+                })
                 .map_err(ShellError::from)?;
 
             let identity = PluginIdentity::new(&filename, None)


Test patch:
diff --git a/crates/nu-command/tests/commands/cd.rs b/crates/nu-command/tests/commands/cd.rs
index 1b8e2dad5a473..7dd648d13571d 100644
--- a/crates/nu-command/tests/commands/cd.rs
+++ b/crates/nu-command/tests/commands/cd.rs
@@ -188,7 +188,7 @@ fn filesystem_not_a_directory() {
             actual.err
         );
         assert!(
-            actual.err.contains("is not a directory"),
+            actual.err.contains("nu::shell::io::not_a_directory"),
             "actual={:?}",
             actual.err
         );
@@ -210,7 +210,7 @@ fn filesystem_directory_not_found() {
             actual.err
         );
         assert!(
-            actual.err.contains("directory not found"),
+            actual.err.contains("nu::shell::io::not_found"),
             "actual={:?}",
             actual.err
         );
@@ -282,7 +282,7 @@ fn cd_permission_denied_folder() {
                 cd banned
             "
         );
-        assert!(actual.err.contains("Cannot change directory to"));
+        assert!(actual.err.contains("nu::shell::io::permission_denied"));
         nu!(
             cwd: dirs.test(),
             "
diff --git a/crates/nu-command/tests/commands/du.rs b/crates/nu-command/tests/commands/du.rs
index c88eb546be19a..5289a1cc95873 100644
--- a/crates/nu-command/tests/commands/du.rs
+++ b/crates/nu-command/tests/commands/du.rs
@@ -93,7 +93,7 @@ fn du_with_multiple_path() {
 
     // report errors if one path not exists
     let actual = nu!(cwd: "tests/fixtures", "du cp asdf | get path | path basename");
-    assert!(actual.err.contains("directory not found"));
+    assert!(actual.err.contains("nu::shell::io::not_found"));
     assert!(!actual.status.success());
 
     // du with spreading empty list should returns nothing.
diff --git a/crates/nu-command/tests/commands/ls.rs b/crates/nu-command/tests/commands/ls.rs
index 416a6aaf7fa7a..736a50493538c 100644
--- a/crates/nu-command/tests/commands/ls.rs
+++ b/crates/nu-command/tests/commands/ls.rs
@@ -610,7 +610,7 @@ fn can_list_system_folder() {
 fn list_a_directory_not_exists() {
     Playground::setup("ls_test_directory_not_exists", |dirs, _sandbox| {
         let actual = nu!(cwd: dirs.test(), "ls a_directory_not_exists");
-        assert!(actual.err.contains("directory not found"));
+        assert!(actual.err.contains("nu::shell::io::not_found"));
     })
 }
 
@@ -735,7 +735,7 @@ fn list_with_tilde() {
         assert!(actual.out.contains("f2.txt"));
         assert!(actual.out.contains("~tilde"));
         let actual = nu!(cwd: dirs.test(), "ls ~tilde");
-        assert!(actual.err.contains("does not exist"));
+        assert!(actual.err.contains("nu::shell::io::not_found"));
 
         // pass variable
         let actual = nu!(cwd: dirs.test(), "let f = '~tilde'; ls $f");
@@ -762,7 +762,7 @@ fn list_with_multiple_path() {
 
         // report errors if one path not exists
         let actual = nu!(cwd: dirs.test(), "ls asdf f1.txt");
-        assert!(actual.err.contains("directory not found"));
+        assert!(actual.err.contains("nu::shell::io::not_found"));
         assert!(!actual.status.success());
 
         // ls with spreading empty list should returns nothing.
diff --git a/crates/nu-command/tests/commands/move_/umv.rs b/crates/nu-command/tests/commands/move_/umv.rs
index 98e2a753bfbc9..82a2091c137a4 100644
--- a/crates/nu-command/tests/commands/move_/umv.rs
+++ b/crates/nu-command/tests/commands/move_/umv.rs
@@ -202,7 +202,7 @@ fn errors_if_source_doesnt_exist() {
             cwd: dirs.test(),
             "mv non-existing-file test_folder/"
         );
-        assert!(actual.err.contains("Directory not found"));
+        assert!(actual.err.contains("nu::shell::io::not_found"));
     })
 }
 
diff --git a/crates/nu-command/tests/commands/network/http/delete.rs b/crates/nu-command/tests/commands/network/http/delete.rs
index ec466944de430..c5037453468ad 100644
--- a/crates/nu-command/tests/commands/network/http/delete.rs
+++ b/crates/nu-command/tests/commands/network/http/delete.rs
@@ -140,10 +140,6 @@ fn http_delete_timeout() {
         format!("http delete --max-time 100ms {url}", url = server.url()).as_str()
     ));
 
-    assert!(&actual.err.contains("nu::shell::network_failure"));
-
-    #[cfg(not(target_os = "windows"))]
-    assert!(&actual.err.contains("timed out reading response"));
-    #[cfg(target_os = "windows")]
-    assert!(&actual.err.contains(super::WINDOWS_ERROR_TIMEOUT_SLOW_LINK));
+    assert!(&actual.err.contains("nu::shell::io::timed_out"));
+    assert!(&actual.err.contains("Timed out"));
 }
diff --git a/crates/nu-command/tests/commands/network/http/get.rs b/crates/nu-command/tests/commands/network/http/get.rs
index a4245ee4dbd44..a789f1e93aa57 100644
--- a/crates/nu-command/tests/commands/network/http/get.rs
+++ b/crates/nu-command/tests/commands/network/http/get.rs
@@ -334,10 +334,6 @@ fn http_get_timeout() {
         format!("http get --max-time 100ms {url}", url = server.url()).as_str()
     ));
 
-    assert!(&actual.err.contains("nu::shell::network_failure"));
-
-    #[cfg(not(target_os = "windows"))]
-    assert!(&actual.err.contains("timed out reading response"));
-    #[cfg(target_os = "windows")]
-    assert!(&actual.err.contains(super::WINDOWS_ERROR_TIMEOUT_SLOW_LINK));
+    assert!(&actual.err.contains("nu::shell::io::timed_out"));
+    assert!(&actual.err.contains("Timed out"));
 }
diff --git a/crates/nu-command/tests/commands/network/http/mod.rs b/crates/nu-command/tests/commands/network/http/mod.rs
index 07a042f6745d0..4a96a9c5ea78a 100644
--- a/crates/nu-command/tests/commands/network/http/mod.rs
+++ b/crates/nu-command/tests/commands/network/http/mod.rs
@@ -5,14 +5,3 @@ mod options;
 mod patch;
 mod post;
 mod put;
-
-/// String representation of the Windows error code for timeouts on slow links.
-///
-/// Use this constant in tests instead of matching partial error message content,
-/// such as `"did not properly respond after a period of time"`, which can vary by language.
-/// The specific string `"(os error 10060)"` is consistent across all locales, as it represents
-/// the raw error code rather than localized text.
-///
-/// For more details, see the [Microsoft docs](https://learn.microsoft.com/en-us/troubleshoot/windows-client/networking/10060-connection-timed-out-with-proxy-server).
-#[cfg(all(test, windows))]
-const WINDOWS_ERROR_TIMEOUT_SLOW_LINK: &str = "(os error 10060)";
diff --git a/crates/nu-command/tests/commands/network/http/options.rs b/crates/nu-command/tests/commands/network/http/options.rs
index cbe9c7bd8ed17..92ce747690873 100644
--- a/crates/nu-command/tests/commands/network/http/options.rs
+++ b/crates/nu-command/tests/commands/network/http/options.rs
@@ -59,10 +59,6 @@ fn http_options_timeout() {
         format!("http options --max-time 100ms {url}", url = server.url()).as_str()
     ));
 
-    assert!(&actual.err.contains("nu::shell::network_failure"));
-
-    #[cfg(not(target_os = "windows"))]
-    assert!(&actual.err.contains("timed out reading response"));
-    #[cfg(target_os = "windows")]
-    assert!(&actual.err.contains(super::WINDOWS_ERROR_TIMEOUT_SLOW_LINK));
+    assert!(&actual.err.contains("nu::shell::io::timed_out"));
+    assert!(&actual.err.contains("Timed out"));
 }
diff --git a/crates/nu-command/tests/commands/network/http/patch.rs b/crates/nu-command/tests/commands/network/http/patch.rs
index 660f335864e6d..6b1ed54d38fcb 100644
--- a/crates/nu-command/tests/commands/network/http/patch.rs
+++ b/crates/nu-command/tests/commands/network/http/patch.rs
@@ -184,10 +184,6 @@ fn http_patch_timeout() {
         .as_str()
     ));
 
-    assert!(&actual.err.contains("nu::shell::network_failure"));
-
-    #[cfg(not(target_os = "windows"))]
-    assert!(&actual.err.contains("timed out reading response"));
-    #[cfg(target_os = "windows")]
-    assert!(&actual.err.contains(super::WINDOWS_ERROR_TIMEOUT_SLOW_LINK));
+    assert!(&actual.err.contains("nu::shell::io::timed_out"));
+    assert!(&actual.err.contains("Timed out"));
 }
diff --git a/crates/nu-command/tests/commands/network/http/post.rs b/crates/nu-command/tests/commands/network/http/post.rs
index 99ef44acaffff..f90efba278a89 100644
--- a/crates/nu-command/tests/commands/network/http/post.rs
+++ b/crates/nu-command/tests/commands/network/http/post.rs
@@ -298,10 +298,6 @@ fn http_post_timeout() {
         .as_str()
     ));
 
-    assert!(&actual.err.contains("nu::shell::network_failure"));
-
-    #[cfg(not(target_os = "windows"))]
-    assert!(&actual.err.contains("timed out reading response"));
-    #[cfg(target_os = "windows")]
-    assert!(&actual.err.contains(super::WINDOWS_ERROR_TIMEOUT_SLOW_LINK));
+    assert!(&actual.err.contains("nu::shell::io::timed_out"));
+    assert!(&actual.err.contains("Timed out"));
 }
diff --git a/crates/nu-command/tests/commands/network/http/put.rs b/crates/nu-command/tests/commands/network/http/put.rs
index 3f3bae3998466..b66330a18b4be 100644
--- a/crates/nu-command/tests/commands/network/http/put.rs
+++ b/crates/nu-command/tests/commands/network/http/put.rs
@@ -184,10 +184,6 @@ fn http_put_timeout() {
         .as_str()
     ));
 
-    assert!(&actual.err.contains("nu::shell::network_failure"));
-
-    #[cfg(not(target_os = "windows"))]
-    assert!(&actual.err.contains("timed out reading response"));
-    #[cfg(target_os = "windows")]
-    assert!(&actual.err.contains(super::WINDOWS_ERROR_TIMEOUT_SLOW_LINK));
+    assert!(&actual.err.contains("nu::shell::io::timed_out"));
+    assert!(&actual.err.contains("Timed out"));
 }
diff --git a/crates/nu-command/tests/commands/nu_check.rs b/crates/nu-command/tests/commands/nu_check.rs
index ee8bf32c241df..971349778eeb3 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("file not found"));
+        assert!(actual.err.contains("nu::shell::io::not_found"));
     })
 }
 
diff --git a/crates/nu-command/tests/commands/open.rs b/crates/nu-command/tests/commands/open.rs
index 986b621c99805..09df04b97a30b 100644
--- a/crates/nu-command/tests/commands/open.rs
+++ b/crates/nu-command/tests/commands/open.rs
@@ -1,3 +1,5 @@
+use std::path::PathBuf;
+
 use nu_test_support::fs::Stub::EmptyFile;
 use nu_test_support::fs::Stub::FileWithContent;
 use nu_test_support::fs::Stub::FileWithContentToBeTrimmed;
@@ -248,14 +250,13 @@ fn errors_if_file_not_found() {
     //
     // This seems to be not directly affected by localization compared to the OS
     // provided error message
-    let expected = "File not found";
 
-    assert!(
-        actual.err.contains(expected),
-        "Error:\n{}\ndoes not contain{}",
-        actual.err,
-        expected
-    );
+    assert!(actual.err.contains("nu::shell::io::not_found"));
+    assert!(actual.err.contains(
+        &PathBuf::from_iter(["tests", "fixtures", "formats", "i_dont_exist.txt"])
+            .display()
+            .to_string()
+    ));
 }
 
 #[test]
diff --git a/crates/nu-command/tests/commands/path/self_.rs b/crates/nu-command/tests/commands/path/self_.rs
index b0c47195c8a7d..6a70f72c77a69 100644
--- a/crates/nu-command/tests/commands/path/self_.rs
+++ b/crates/nu-command/tests/commands/path/self_.rs
@@ -60,5 +60,5 @@ fn self_path_runtime() {
 fn self_path_repl() {
     let actual = nu!("const foo = path self; $foo");
     assert!(!actual.status.success());
-    assert!(actual.err.contains("nu::shell::file_not_found"));
+    assert!(actual.err.contains("nu::shell::io::not_found"));
 }
diff --git a/crates/nu-command/tests/commands/rm.rs b/crates/nu-command/tests/commands/rm.rs
index fdd7258bd49fc..3bfc093bd0c3f 100644
--- a/crates/nu-command/tests/commands/rm.rs
+++ b/crates/nu-command/tests/commands/rm.rs
@@ -454,14 +454,8 @@ fn rm_prints_filenames_on_error() {
 
         assert!(files_exist_at(&file_names, test_dir));
         for file_name in file_names {
-            let path = test_dir.join(file_name);
-            let substr = format!("Could not delete {}", path.to_string_lossy());
-            assert!(
-                actual.err.contains(&substr),
-                "Matching: {}\n=== Command stderr:\n{}\n=== End stderr",
-                substr,
-                actual.err
-            );
+            assert!(actual.err.contains("nu::shell::io::permission_denied"));
+            assert!(actual.err.contains(file_name));
         }
     });
 }
diff --git a/crates/nu-command/tests/commands/save.rs b/crates/nu-command/tests/commands/save.rs
index 8c2ea535b77e6..e37985eb38c6e 100644
--- a/crates/nu-command/tests/commands/save.rs
+++ b/crates/nu-command/tests/commands/save.rs
@@ -532,5 +532,5 @@ fn force_save_to_dir() {
         "aaa" | save -f ..
         "#);
 
-    assert!(actual.err.contains("Is a directory"));
+    assert!(actual.err.contains("nu::shell::io::is_a_directory"));
 }
diff --git a/crates/nu-plugin-core/src/interface/tests.rs b/crates/nu-plugin-core/src/interface/tests.rs
index 6b4ad85d7fa10..28093ecd62764 100644
--- a/crates/nu-plugin-core/src/interface/tests.rs
+++ b/crates/nu-plugin-core/src/interface/tests.rs
@@ -8,8 +8,8 @@ use nu_plugin_protocol::{
     StreamMessage,
 };
 use nu_protocol::{
-    engine::Sequence, ByteStream, ByteStreamSource, ByteStreamType, DataSource, ListStream,
-    PipelineData, PipelineMetadata, ShellError, Signals, Span, Value,
+    engine::Sequence, shell_error::io::IoError, ByteStream, ByteStreamSource, ByteStreamType,
+    DataSource, ListStream, PipelineData, PipelineMetadata, ShellError, Signals, Span, Value,
 };
 use std::{path::Path, sync::Arc};
 
@@ -245,7 +245,8 @@ fn read_pipeline_data_byte_stream() -> Result<(), ShellError> {
             match stream.into_source() {
                 ByteStreamSource::Read(mut read) => {
                     let mut buf = Vec::new();
-                    read.read_to_end(&mut buf)?;
+                    read.read_to_end(&mut buf)
+                        .map_err(|err| IoError::new(err.kind(), test_span, None))?;
                     let iter = buf.chunks_exact(out_pattern.len());
                     assert_eq!(iter.len(), iterations);
                     for chunk in iter {
diff --git a/crates/nu-plugin-core/src/serializers/tests.rs b/crates/nu-plugin-core/src/serializers/tests.rs
index b27f852ee4ced..76503fa96ea7a 100644
--- a/crates/nu-plugin-core/src/serializers/tests.rs
+++ b/crates/nu-plugin-core/src/serializers/tests.rs
@@ -36,18 +36,18 @@ macro_rules! generate_tests {
             let mut buffered = std::io::BufReader::new(ErrorProducer);
             match Encoder::<PluginInput>::decode(&encoder, &mut buffered) {
                 Ok(_) => panic!("decode: i/o error was not passed through"),
-                Err(ShellError::IOError { .. }) => (), // okay
+                Err(ShellError::Io(_)) => (), // okay
                 Err(other) => panic!(
                     "decode: got other error, should have been a \
-                    ShellError::IOError: {other:?}"
+                    ShellError::Io: {other:?}"
                 ),
             }
             match Encoder::<PluginOutput>::decode(&encoder, &mut buffered) {
                 Ok(_) => panic!("decode: i/o error was not passed through"),
-                Err(ShellError::IOError { .. }) => (), // okay
+                Err(ShellError::Io(_)) => (), // okay
                 Err(other) => panic!(
                     "decode: got other error, should have been a \
-                    ShellError::IOError: {other:?}"
+                    ShellError::Io: {other:?}"
                 ),
             }
         }
@@ -378,9 +378,11 @@ macro_rules! generate_tests {
                 .with_url("https://example.org/test/error")
                 .with_help("some help")
                 .with_label("msg", Span::new(2, 30))
-                .with_inner(ShellError::IOError {
-                    msg: "io error".into(),
-                });
+                .with_inner(ShellError::Io(IoError::new(
+                    std::io::ErrorKind::NotFound,
+                    Span::test_data(),
+                    None,
+                )));
 
             let response = PluginCallResponse::Error(error.clone());
             let output = PluginOutput::CallResponse(6, response);
diff --git a/crates/nu-plugin-engine/src/interface/tests.rs b/crates/nu-plugin-engine/src/interface/tests.rs
index 4761da6824a34..de9f379305132 100644
--- a/crates/nu-plugin-engine/src/interface/tests.rs
+++ b/crates/nu-plugin-engine/src/interface/tests.rs
@@ -6,6 +6,7 @@ use crate::{
     plugin_custom_value_with_source::WithSource, test_util::*, PluginCustomValueWithSource,
     PluginSource,
 };
+use nu_engine::command_prelude::IoError;
 use nu_plugin_core::{interface_test_util::TestCase, Interface, InterfaceManager};
 use nu_plugin_protocol::{
     test_util::{expected_test_custom_value, test_plugin_custom_value},
@@ -86,9 +87,12 @@ fn manager_consume_all_exits_after_streams_and_interfaces_are_dropped() -> Resul
 }
 
 fn test_io_error() -> ShellError {
-    ShellError::IOError {
-        msg: "test io error".into(),
-    }
+    ShellError::Io(IoError::new_with_additional_context(
+        std::io::ErrorKind::Other,
+        Span::test_data(),
+        None,
+        "test io error",
+    ))
 }
 
 fn check_test_io_error(error: &ShellError) {
diff --git a/crates/nu-plugin-test-support/src/spawn_fake_plugin.rs b/crates/nu-plugin-test-support/src/spawn_fake_plugin.rs
index d954f6e6832a6..710332fbec583 100644
--- a/crates/nu-plugin-test-support/src/spawn_fake_plugin.rs
+++ b/crates/nu-plugin-test-support/src/spawn_fake_plugin.rs
@@ -4,7 +4,7 @@ use nu_plugin::Plugin;
 use nu_plugin_core::{InterfaceManager, PluginRead, PluginWrite};
 use nu_plugin_engine::{PluginInterfaceManager, PluginSource};
 use nu_plugin_protocol::{PluginInput, PluginOutput};
-use nu_protocol::{PluginIdentity, ShellError};
+use nu_protocol::{shell_error::io::IoError, PluginIdentity, ShellError};
 
 use crate::fake_persistent_plugin::FakePersistentPlugin;
 
@@ -21,8 +21,12 @@ impl<T: Clone + Send> PluginWrite<T> for FakePluginWrite<T> {
     fn write(&self, data: &T) -> Result<(), ShellError> {
         self.0
             .send(data.clone())
-            .map_err(|err| ShellError::IOError {
-                msg: err.to_string(),
+            .map_err(|e| ShellError::GenericError {
+                error: "Error sending data".to_string(),
+                msg: e.to_string(),
+                span: None,
+                help: None,
+                inner: vec![],
             })
     }
 
@@ -59,7 +63,14 @@ pub(crate) fn spawn_fake_plugin(
     // Start the interface reader on another thread
     std::thread::Builder::new()
         .name(format!("fake plugin interface reader ({name})"))
-        .spawn(move || manager.consume_all(output_read).expect("Plugin read error"))?;
+        .spawn(move || manager.consume_all(output_read).expect("Plugin read error"))
+        .map_err(|err| {
+            IoError::new_internal(
+                err.kind(),
+                format!("Could not spawn fake plugin interface reader ({name})"),
+                nu_protocol::location!(),
+            )
+        })?;
 
     // Start the plugin on another thread
     let name_string = name.to_owned();
@@ -73,6 +84,13 @@ pub(crate) fn spawn_fake_plugin(
                 move || output_write,
             )
             .expect("Plugin runner error")
+        })
+        .map_err(|err| {
+            IoError::new_internal(
+                err.kind(),
+                format!("Could not spawn fake plugin runner ({name})"),
+                nu_protocol::location!(),
+            )
         })?;
 
     Ok(reg_plugin)
diff --git a/crates/nu-plugin/src/plugin/interface/tests.rs b/crates/nu-plugin/src/plugin/interface/tests.rs
index 5ff9f6f6cdc55..b6bf267dcbda7 100644
--- a/crates/nu-plugin/src/plugin/interface/tests.rs
+++ b/crates/nu-plugin/src/plugin/interface/tests.rs
@@ -1,6 +1,7 @@
 use crate::test_util::TestCaseExt;
 
 use super::{EngineInterfaceManager, ReceivedPluginCall};
+use nu_engine::command_prelude::IoError;
 use nu_plugin_core::{interface_test_util::TestCase, Interface, InterfaceManager};
 use nu_plugin_protocol::{
     test_util::{expected_test_custom_value, test_plugin_custom_value, TestCustomValue},
@@ -88,9 +89,12 @@ fn manager_consume_all_exits_after_streams_and_interfaces_are_dropped() -> Resul
 }
 
 fn test_io_error() -> ShellError {
-    ShellError::IOError {
-        msg: "test io error".into(),
-    }
+    ShellError::Io(IoError::new_with_additional_context(
+        std::io::ErrorKind::Other,
+        Span::test_data(),
+        None,
+        "test io error",
+    ))
 }
 
 fn check_test_io_error(error: &ShellError) {