Repository: reubeno/brush
Pull request #482: Implement ulimit
Author: login: lu-zero
Labels: name: state: needs review
description: PR or issue author is waiting for a review
name: area: builtins
description: Issues or PRs specific to built-in commands
Created: 2025-05-10T13:57:34Z | Merged: 2025-05-19T21:50:18Z

Closes #473



Linked issue #473: Implement builtin: `ulimit`
_This was raised by @ghuls in #469 but it deserves its own top-level issue._

We haven't really looked at `ulimit` yet, but I'm hoping it won't be as tricky as some of the other last few builtins. We should look into the [rlimit crate](https://crates.io/crates/rlimit) as well as what the nix crate (and others) expose.

It would also be reasonable to start small, perhaps first just installing the read-only parts of it?
Linked issue comments: user: login: reubeno
created_at: 2025-05-09T16:41:07Z
body: _Marking this as a good first issue. The full scope of it is a bit bigger, but the "minimum viable first commit should" be a reasonable one for someone to start tackling. If there's interest, just holler -- I can provide some pointers to get you started._
author_association: OWNER
user: login: lu-zero
created_at: 2025-05-09T23:21:36Z
body: The way ulimit works requires https://github.com/clap-rs/clap/issues/2621 if you want something nice and not an horrible tangle.
author_association: CONTRIBUTOR
user: login: lu-zero
created_at: 2025-05-09T23:51:44Z
body: ```
    Options:
      -S	use the `soft' resource limit
      -H	use the `hard' resource limit
      -a	all current limits are reported
      -b	the socket buffer size
      -c	the maximum size of core files created
      -d	the maximum size of a process's data segment
      -e	the maximum scheduling priority (`nice')
      -f	the maximum size of files written by the shell and its children
      -i	the maximum number of pending signals
      -k	the maximum number of kqueues allocated for this process
      -l	the maximum size a process may lock into memory
      -m	the maximum resident set size
      -n	the maximum number of open file descriptors
      -p	the pipe buffer size
      -q	the maximum number of bytes in POSIX message queues
      -r	the maximum real-time scheduling priority
      -s	the maximum stack size
      -t	the maximum amount of cpu time in seconds
      -u	the maximum number of user processes
      -v	the size of virtual memory
      -x	the maximum number of file locks
      -P	the maximum number of pseudoterminals
      -R	the maximum time a real-time process can run before blocking
      -T	the maximum number of threads

    Not all options are available on all platforms.

    If LIMIT is given, it is the new value of the specified resource; the
    special LIMIT values `soft', `hard', and `unlimited' stand for the
    current soft limit, the current hard limit, and no limit, respectively.
    Otherwise, the current value of the specified resource is printed.  If
    no option is given, then -f is assumed.

    Values are in 1024-byte increments, except for -t, which is in seconds,
    -p, which is in increments of 512 bytes, and -u, which is an unscaled
    number of processes.

    Exit Status:
    Returns success unless an invalid option is supplied or an error occurs.
```

Notes:
- `ulimit -c -l` is valid, prints the specific limits
- `ulimit -c unlimited -l unlimited` is valid, set the specific limits
- `ulimit -c unlimited -l` is invalid either you set or you read
author_association: CONTRIBUTOR
user: login: lu-zero
created_at: 2025-05-10T14:06:43Z
body: Probably we can get away with using nix directly, but if we want to add something that isn't supported by all the platforms using nix directly would require to duplicate a bit of `cfg` logic to pick only the supported ones.
author_association: CONTRIBUTOR

Diff:
diff --git a/Cargo.lock b/Cargo.lock
index 51f879f71..93291e374 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -284,6 +284,7 @@ dependencies = [
  "pprof",
  "procfs",
  "rand 0.9.1",
+ "rlimit",
  "strum 0.27.1",
  "strum_macros 0.27.1",
  "terminfo",
@@ -2097,6 +2098,15 @@ dependencies = [
  "bytemuck",
 ]
 
+[[package]]
+name = "rlimit"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7043b63bd0cd1aaa628e476b80e6d4023a3b50eb32789f2728908107bd0c793a"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "roff"
 version = "0.2.2"
diff --git a/brush-core/Cargo.toml b/brush-core/Cargo.toml
index 28d728ebf..2e053e593 100644
--- a/brush-core/Cargo.toml
+++ b/brush-core/Cargo.toml
@@ -65,6 +65,7 @@ nix = { version = "0.30.1", features = [
     "term",
     "user",
 ] }
+rlimit = "0.10.2"
 terminfo = "0.9.0"
 uzers = "0.12.1"
 
diff --git a/brush-core/src/builtins.rs b/brush-core/src/builtins.rs
index fcec84720..166a20a4f 100644
--- a/brush-core/src/builtins.rs
+++ b/brush-core/src/builtins.rs
@@ -57,6 +57,8 @@ mod trap;
 mod true_;
 mod type_;
 #[cfg(unix)]
+mod ulimit;
+#[cfg(unix)]
 mod umask;
 mod unalias;
 mod unimp;
diff --git a/brush-core/src/builtins/factory.rs b/brush-core/src/builtins/factory.rs
index 3a401f967..20a2b74d1 100644
--- a/brush-core/src/builtins/factory.rs
+++ b/brush-core/src/builtins/factory.rs
@@ -263,6 +263,8 @@ pub(crate) fn get_default_builtins(
     m.insert("true".into(), builtin::<true_::TrueCommand>());
     m.insert("type".into(), builtin::<type_::TypeCommand>());
     #[cfg(unix)]
+    m.insert("ulimit".into(), builtin::<ulimit::ULimitCommand>());
+    #[cfg(unix)]
     m.insert("umask".into(), builtin::<umask::UmaskCommand>());
     m.insert("unalias".into(), builtin::<unalias::UnaliasCommand>());
     m.insert("wait".into(), builtin::<wait::WaitCommand>());
@@ -270,9 +272,6 @@ pub(crate) fn get_default_builtins(
     // TODO: implement fc builtin; should be done after history.
     m.insert("fc".into(), builtin::<unimp::UnimplementedCommand>());
 
-    // TODO(#473): implement ulimit builtin
-    m.insert("ulimit".into(), builtin::<unimp::UnimplementedCommand>());
-
     if !options.sh_mode {
         m.insert(
             "builtin".into(),
diff --git a/brush-core/src/builtins/ulimit.rs b/brush-core/src/builtins/ulimit.rs
new file mode 100644
index 000000000..c86d77fba
--- /dev/null
+++ b/brush-core/src/builtins/ulimit.rs
@@ -0,0 +1,498 @@
+use clap::{
+    builder::{IntoResettable, StyledStr},
+    Parser,
+};
+use std::{
+    io::{self, ErrorKind, Write},
+    str::FromStr,
+};
+
+use crate::{builtins, commands};
+
+#[derive(Clone, Copy)]
+enum Unit {
+    Block,
+    Bytes,
+    HalfKBytes,
+    KBytes,
+    Micros,
+    Number,
+    Seconds,
+}
+
+impl Unit {
+    fn scale(self) -> u64 {
+        match self {
+            Unit::Block | Unit::HalfKBytes => 512,
+            Unit::KBytes => 1024,
+            _ => 1,
+        }
+    }
+}
+
+#[derive(Clone, Copy)]
+enum Virtual {
+    Pipe,
+    VMem,
+}
+
+impl Virtual {
+    fn get(self) -> std::io::Result<(u64, u64)> {
+        match self {
+            Virtual::Pipe => {
+                let lim = nix::unistd::PathconfVar::PIPE_BUF as u64 * 512;
+                Ok((lim, lim))
+            }
+            Virtual::VMem => rlimit::Resource::AS
+                .get()
+                .or_else(|_| rlimit::Resource::VMEM.get()),
+        }
+    }
+    fn set(self, soft: u64, hard: u64) -> std::io::Result<()> {
+        match self {
+            Virtual::Pipe => Err(std::io::Error::from(ErrorKind::Unsupported)),
+            Virtual::VMem => rlimit::Resource::AS
+                .set(soft, hard)
+                .or_else(|_| rlimit::Resource::VMEM.set(soft, hard)),
+        }
+    }
+    fn is_supported(self) -> bool {
+        match self {
+            Virtual::Pipe => true,
+            Virtual::VMem => {
+                rlimit::Resource::AS.is_supported() || rlimit::Resource::VMEM.is_supported()
+            }
+        }
+    }
+}
+
+#[derive(Clone, Copy)]
+enum Resource {
+    Phy(rlimit::Resource),
+    Virt(Virtual),
+}
+
+impl Resource {
+    fn get(self) -> std::io::Result<(u64, u64)> {
+        match self {
+            Resource::Phy(res) => res.get(),
+            Resource::Virt(res) => res.get(),
+        }
+    }
+    fn set(self, soft: u64, hard: u64) -> std::io::Result<()> {
+        match self {
+            Resource::Phy(res) => res.set(soft, hard),
+            Resource::Virt(res) => res.set(soft, hard),
+        }
+    }
+    fn is_supported(self) -> bool {
+        match self {
+            Resource::Phy(res) => res.is_supported(),
+            Resource::Virt(res) => res.is_supported(),
+        }
+    }
+}
+
+#[derive(Clone, Copy)]
+struct ResourceDescription {
+    resource: Resource,
+    help: &'static str,
+    description: &'static str,
+    short: char,
+    unit: Unit,
+}
+
+impl ResourceDescription {
+    const SBSIZE: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::SBSIZE),
+        help: "the socket buffer size",
+        description: "socket buffer size",
+        short: 'b',
+        unit: Unit::Bytes,
+    };
+    const CORE: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::CORE),
+        help: "the maximum size of core files created",
+        description: "core file size",
+        short: 'c',
+        unit: Unit::Block,
+    };
+    const DATA: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::DATA),
+        help: "the maximum size of a process's data segment",
+        description: "data seg size",
+        short: 'd',
+        unit: Unit::KBytes,
+    };
+    const NICE: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::NICE),
+        help: "the maximum scheduling priority (`nice`)",
+        description: "scheduling priority",
+        short: 'e',
+        unit: Unit::Number,
+    };
+    const FSIZE: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::FSIZE),
+        help: "the maximum size of files written by the shell and its children",
+        description: "file size",
+        short: 'f',
+        unit: Unit::Block,
+    };
+    const SIGPENDING: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::SIGPENDING),
+        help: "the maximum number of pending signals",
+        description: "pending signals",
+        short: 'i',
+        unit: Unit::Number,
+    };
+    const MEMLOCK: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::MEMLOCK),
+        help: "the maximum size a process may lock into memory",
+        description: "max locked memory",
+        short: 'l',
+        unit: Unit::KBytes,
+    };
+    const KQUEUES: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::KQUEUES),
+        help: "the maximum number of kqueues allocated for this process",
+        description: "max kqueues",
+        short: 'k',
+        unit: Unit::Number,
+    };
+    const RSS: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::RSS),
+        help: "the maximum resident set size",
+        description: "max memory size",
+        short: 'm',
+        unit: Unit::KBytes,
+    };
+    const LOCKS: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::LOCKS),
+        help: "the maximum number of file locks",
+        description: "file locks",
+        short: 'x',
+        unit: Unit::Number,
+    };
+    const NOFILE: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::NOFILE),
+        help: "the maximum number of open file descriptors",
+        description: "open files",
+        short: 'n',
+        unit: Unit::Number,
+    };
+    const MSGQUEUE: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::MSGQUEUE),
+        help: "the maximum number of bytes in POSIX message queues",
+        description: "POSIX message queues",
+        short: 'q',
+        unit: Unit::Bytes,
+    };
+    const PIPE: ResourceDescription = ResourceDescription {
+        resource: Resource::Virt(Virtual::Pipe),
+        help: "the pipe buffer size",
+        description: "pipe size",
+        short: 'p',
+        unit: Unit::HalfKBytes,
+    };
+    const RTPRIO: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::RTPRIO),
+        help: "the maximum real-time scheduling priority",
+        description: "real-time priority",
+        short: 'r',
+        unit: Unit::Number,
+    };
+    const RTTIME: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::RTTIME),
+        help: "the maximum real-time scheduling priority",
+        description: "real-time non-blocking time",
+        short: 'R',
+        unit: Unit::Micros,
+    };
+    const STACK: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::STACK),
+        help: "the maximum stack size",
+        description: "stack size",
+        short: 's',
+        unit: Unit::KBytes,
+    };
+    const CPU: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::CPU),
+        help: "the maximum amount of cpu time in seconds",
+        description: "cpu time",
+        short: 't',
+        unit: Unit::Seconds,
+    };
+    const NPROC: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::NPROC),
+        help: "the maximum number of user processes",
+        description: "max user processes",
+        short: 'u',
+        unit: Unit::Number,
+    };
+    const VMEM: ResourceDescription = ResourceDescription {
+        resource: Resource::Virt(Virtual::VMem),
+        help: "the size of virtual memory",
+        description: "virtual memory",
+        short: 'v',
+        unit: Unit::KBytes,
+    };
+    const THREADS: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::THREADS),
+        help: "the maximum number of threads",
+        description: "number of threads",
+        short: 'T',
+        unit: Unit::Number,
+    };
+    const NPTS: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::NPTS),
+        help: "the maximum number of pseudoterminals",
+        description: "number of pseudoterminals",
+        short: 'P',
+        unit: Unit::Number,
+    };
+
+    fn get(&self, hard: bool) -> std::io::Result<String> {
+        let (soft_limit, hard_limit) = self.resource.get()?;
+        let val = if hard { hard_limit } else { soft_limit };
+
+        if val == rlimit::INFINITY {
+            Ok("unlimited".into())
+        } else {
+            Ok(format!("{}", val / self.unit.scale()))
+        }
+    }
+
+    fn set(&self, set_hard: bool, value: LimitValue) -> std::io::Result<()> {
+        let (soft, hard) = self.resource.get()?;
+        let value = match value {
+            LimitValue::Soft => soft,
+            LimitValue::Hard => hard,
+            LimitValue::Unlimited => rlimit::INFINITY,
+            LimitValue::Value(v) => v * self.unit.scale(),
+            LimitValue::Unset => return Ok(()),
+        };
+
+        if set_hard {
+            self.resource.set(soft, value)
+        } else {
+            self.resource.set(value, hard)
+        }
+    }
+
+    /// Print either soft or hard limit
+    fn print(&self, context: &commands::ExecutionContext<'_>, hard: bool) -> io::Result<()> {
+        if !self.resource.is_supported() {
+            return Ok(());
+        }
+        let unit = match self.unit {
+            Unit::Block => format!("(block, -{})", self.short),
+            Unit::Bytes => format!("(bytes, -{})", self.short),
+            Unit::HalfKBytes => format!("(512 bytes, -{})", self.short),
+            Unit::KBytes => format!("(kbytes, -{})", self.short),
+            Unit::Micros => format!("(microseconds, -{})", self.short),
+            Unit::Number => format!("(-{})", self.short),
+            Unit::Seconds => format!("(seconds, -{})", self.short),
+        };
+        let resource = self.get(hard).unwrap_or_else(|e| format!("{e}"));
+        writeln!(
+            context.stdout(),
+            "{:<26}{:>16} {}",
+            self.description,
+            unit,
+            resource
+        )
+    }
+
+    /// Provide the matching help String
+    fn help(&self) -> String {
+        format!(
+            "{} {}",
+            self.help,
+            if self.resource.is_supported() {
+                "(supported)"
+            } else {
+                "(unsupported)"
+            }
+        )
+    }
+}
+
+impl IntoResettable<StyledStr> for ResourceDescription {
+    fn into_resettable(self) -> clap::builder::Resettable<StyledStr> {
+        clap::builder::Resettable::Value(self.help().into())
+    }
+}
+
+#[derive(Debug, Clone, Copy)]
+enum LimitValue {
+    Unset,
+    Unlimited,
+    Soft,
+    Hard,
+    Value(u64),
+}
+
+impl FromStr for LimitValue {
+    type Err = <u64 as FromStr>::Err;
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let v = match s {
+            "" => LimitValue::Unset,
+            "unlimited" => LimitValue::Unlimited,
+            "soft" => LimitValue::Soft,
+            "hard" => LimitValue::Hard,
+            _ => LimitValue::Value(s.parse()?),
+        };
+        Ok(v)
+    }
+}
+
+/// Modify shell resource limits.
+///
+/// Provides control over the resources available to the shell and processes
+/// it creates, on systems that allow such control.
+#[derive(Parser, Debug)]
+pub(crate) struct ULimitCommand {
+    /// use the `soft` resource limit
+    #[arg(short = 'S')]
+    soft: bool,
+    /// use the `hard` resource limit
+    #[arg(short = 'H')]
+    hard: bool,
+    /// all current limits are reported
+    #[arg(short = 'a')]
+    all: bool,
+    /// the maximum socket buffer size
+    #[arg(short = 'b', default_missing_value = "", num_args(0..=1), help = ResourceDescription::SBSIZE)]
+    sbsize: Option<LimitValue>,
+    /// the maximum size of core files created
+    #[arg(short = 'c', default_missing_value = "", num_args(0..=1), help = ResourceDescription::CORE)]
+    core: Option<LimitValue>,
+    /// the maximum size of a process's data segment
+    #[arg(short = 'd', default_missing_value = "", num_args(0..=1), help = ResourceDescription::DATA)]
+    data: Option<LimitValue>,
+    /// the maximum scheduling priority (`nice`)
+    #[arg(short = 'e', default_missing_value = "", num_args(0..=1), help = ResourceDescription::NICE)]
+    nice: Option<LimitValue>,
+    /// the maximum size of files written by the shell and its children
+    #[arg(short = 'f', default_missing_value = "", num_args(0..=1), help = ResourceDescription::FSIZE)]
+    file_size: Option<LimitValue>,
+    /// the maximum number of pending signals
+    #[arg(short = 'i', default_missing_value = "", num_args(0..=1), help = ResourceDescription::SIGPENDING)]
+    sigpending: Option<LimitValue>,
+    /// the maximum size a process may lock into memory
+    #[arg(short = 'l', default_missing_value = "", num_args(0..=1), help = ResourceDescription::MEMLOCK)]
+    memlock: Option<LimitValue>,
+    /// the maximum number of kqueues allocated for this process
+    #[arg(short = 'k', default_missing_value = "", num_args(0..=1), help = ResourceDescription::KQUEUES)]
+    kqueues: Option<LimitValue>,
+    /// the maximum resident set size
+    #[arg(short = 'm', default_missing_value = "", num_args(0..=1), help = ResourceDescription::RSS)]
+    rss: Option<LimitValue>,
+    /// the maximum number of open file descriptors
+    #[arg(short = 'n', default_missing_value = "", num_args(0..=1), help = ResourceDescription::NOFILE)]
+    file_open: Option<LimitValue>,
+    /// the pipe buffer size
+    #[arg(short = 'p', default_missing_value = "", num_args(0..=1), help = ResourceDescription::PIPE)]
+    pipe: Option<LimitValue>,
+    /// the maximum number of bytes in POSIX message queues
+    #[arg(short = 'q', default_missing_value = "", num_args(0..=1), help = ResourceDescription::MSGQUEUE)]
+    msgqueue: Option<LimitValue>,
+    /// the maximum real-time scheduling priority
+    #[arg(short = 'r', default_missing_value = "", num_args(0..=1), help = ResourceDescription::RTPRIO)]
+    rtprio: Option<LimitValue>,
+    /// the maximum stack size
+    #[arg(short = 's', default_missing_value = "", num_args(0..=1), help = ResourceDescription::STACK)]
+    stack: Option<LimitValue>,
+    /// the maximum amount of cpu time in seconds
+    #[arg(short = 't', default_missing_value = "", num_args(0..=1), help = ResourceDescription::CPU)]
+    cpu: Option<LimitValue>,
+    /// the size of virtual memory
+    #[arg(short = 'u', default_missing_value = "", num_args(0..=1), help = ResourceDescription::NPROC)]
+    nproc: Option<LimitValue>,
+    /// the size of virtual memory
+    #[arg(short = 'v', default_missing_value = "", num_args(0..=1), help = ResourceDescription::VMEM)]
+    vmem: Option<LimitValue>,
+    /// the maximum number of file locks
+    #[arg(short = 'x', default_missing_value = "", num_args(0..=1), help = ResourceDescription::LOCKS)]
+    file_lock: Option<LimitValue>,
+    /// the maximum number of pseudoterminals
+    #[arg(short = 'P', default_missing_value = "", num_args(0..=1), help = ResourceDescription::NPTS)]
+    npts: Option<LimitValue>,
+    /// real-time non-blocking time
+    #[arg(short = 'R', default_missing_value = "", num_args(0..=1), help = ResourceDescription::RTTIME)]
+    rttime: Option<LimitValue>,
+    /// the maximum number of threads
+    #[arg(short = 'T', default_missing_value = "", num_args(0..=1), help = ResourceDescription::THREADS)]
+    threads: Option<LimitValue>,
+
+    /// argument for the implicit limit (`-f`)
+    limit: Option<LimitValue>,
+}
+
+impl builtins::Command for ULimitCommand {
+    async fn execute(
+        &self,
+        context: commands::ExecutionContext<'_>,
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
+        let exit_code = builtins::ExitCode::Success;
+        let mut resources_to_set = Vec::new();
+        let mut resources_to_get = Vec::new();
+
+        let mut set_or_get = |val, descr| {
+            match val {
+                Some(LimitValue::Unset) => resources_to_get.push(descr),
+                Some(v) => resources_to_set.push((descr, v)),
+                None => {}
+            }
+            if self.all {
+                resources_to_get.push(descr);
+            }
+        };
+
+        set_or_get(self.sbsize, ResourceDescription::SBSIZE);
+        set_or_get(self.core, ResourceDescription::CORE);
+        set_or_get(self.data, ResourceDescription::DATA);
+        set_or_get(self.file_size, ResourceDescription::FSIZE);
+        set_or_get(self.sigpending, ResourceDescription::SIGPENDING);
+        set_or_get(self.kqueues, ResourceDescription::KQUEUES);
+        set_or_get(self.memlock, ResourceDescription::MEMLOCK);
+        set_or_get(self.rss, ResourceDescription::RSS);
+        set_or_get(self.file_lock, ResourceDescription::LOCKS);
+        set_or_get(self.file_open, ResourceDescription::NOFILE);
+        set_or_get(self.pipe, ResourceDescription::PIPE);
+        set_or_get(self.npts, ResourceDescription::NPTS);
+        set_or_get(self.nice, ResourceDescription::NICE);
+        set_or_get(self.msgqueue, ResourceDescription::MSGQUEUE);
+        set_or_get(self.rtprio, ResourceDescription::RTPRIO);
+        set_or_get(self.rttime, ResourceDescription::RTTIME);
+        set_or_get(self.stack, ResourceDescription::STACK);
+        set_or_get(self.threads, ResourceDescription::THREADS);
+        set_or_get(self.cpu, ResourceDescription::CPU);
+        set_or_get(self.nproc, ResourceDescription::NPROC);
+        set_or_get(self.vmem, ResourceDescription::VMEM);
+
+        if resources_to_set.is_empty() {
+            if resources_to_get.is_empty() {
+                if let Some(fsize) = self.limit {
+                    resources_to_set.push((ResourceDescription::FSIZE, fsize));
+                } else {
+                    resources_to_get.push(ResourceDescription::FSIZE);
+                }
+            }
+        }
+
+        for (resource, value) in resources_to_set {
+            resource.set(self.hard, value)?;
+        }
+
+        if resources_to_get.len() == 1 {
+            writeln!(context.stdout(), "{}", resources_to_get[0].get(self.hard)?)?;
+        } else {
+            for resource in resources_to_get {
+                resource.print(&context, self.hard)?;
+            }
+        }
+
+        Ok(exit_code)
+    }
+}
diff --git a/brush-shell/tests/cases/builtins/ulimit.yaml b/brush-shell/tests/cases/builtins/ulimit.yaml
new file mode 100644
index 000000000..bb64fface
--- /dev/null
+++ b/brush-shell/tests/cases/builtins/ulimit.yaml
@@ -0,0 +1,37 @@
+name: "Builtins: ulimit"
+cases:
+  - name: "ulimit -c"
+    stdin: |
+      ulimit -c
+
+  - name: "ulimit -d"
+    stdin: |
+      ulimit -d
+
+  - name: "ulimit -f"
+    stdin: |
+      ulimit -f
+
+  - name: "ulimit -l"
+    stdin: |
+      ulimit -l
+
+  - name: "ulimit -m"
+    stdin: |
+      ulimit -m
+
+  - name: "ulimit -n"
+    stdin: |
+      ulimit -n
+
+  - name: "ulimit -d unlimited"
+    stdin: |
+      ulimit -d unlimited
+
+  - name: "ulimit -f unlimited"
+    stdin: |
+      ulimit -f unlimited
+
+  - name: "ulimit -m unlimited"
+    stdin: |
+      ulimit -m unlimited
diff --git a/rustfmt.toml b/rustfmt.toml
index 31c3d14f1..65b036209 100644
--- a/rustfmt.toml
+++ b/rustfmt.toml
@@ -1,2 +1,3 @@
+edition = "2021"
 comment_width = 100
 wrap_comments = true


Reviews:
user: login: reubeno
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-11T06:10:06Z
user: login: lu-zero
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-11T09:07:26Z
user: login: lu-zero
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-11T09:09:42Z
user: login: lu-zero
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-11T09:14:28Z
user: login: lu-zero
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-11T09:16:01Z
user: login: lu-zero
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-11T11:57:38Z
user: login: lu-zero
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-11T12:07:13Z
user: login: reubeno
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-12T20:41:33Z
user: login: lu-zero
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T21:42:31Z
user: login: reubeno
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-12T22:26:57Z
user: login: lu-zero
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T11:00:52Z
user: login: reubeno
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-18T05:46:07Z
user: login: reubeno
state: APPROVED
author_association: OWNER
submitted_at: 2025-05-19T16:06:37Z

Review comments:
diff_hunk: @@ -64,6 +64,7 @@ nix = { version = "0.30.1", features = [
     "user",
 ] }
 uzers = "0.12.1"
+rlimit = "0.10.2"
path: brush-core/Cargo.toml
user: login: reubeno
body: For readability, would you please move this one line up to keep the crates in sorted order?
created_at: 2025-05-11T05:33:03Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,206 @@
+use clap::Parser;
+use rlimit::Resource;
+use std::str::FromStr;
+
+use crate::{builtins, commands};
+
+#[derive(Clone, Copy, Hash, Debug)]
+enum Unit {
+    Block,
+    KBytes,
+    Number,
+}
+
+#[derive(Clone, Copy, Hash, Debug)]
+struct ResourceDescription {
+    resource: Resource,
+    description: &'static str,
+    short: char,
+    unit: Unit,
+}
+
+impl ResourceDescription {
+    const CORE: ResourceDescription = ResourceDescription {
+        resource: Resource::CORE,
+        description: "core file size",
+        short: 'c',
+        unit: Unit::Block,
+    };
+    const FSIZE: ResourceDescription = ResourceDescription {
+        resource: Resource::FSIZE,
+        description: "file size",
+        short: 'f',
+        unit: Unit::Block,
+    };
+    const RSS: ResourceDescription = ResourceDescription {
+        resource: Resource::RSS,
+        description: "max memory size",
+        short: 'm',
+        unit: Unit::KBytes,
+    };
+    const NOFILE: ResourceDescription = ResourceDescription {
+        resource: Resource::NOFILE,
+        description: "open files",
+        short: 'n',
+        unit: Unit::Number,
+    };
+
+    fn get(&self, hard: bool) -> std::io::Result<String> {
+        let val = if hard {
+            self.resource.get_hard()?
+        } else {
+            self.resource.get_soft()?
+        };
+
+        if val == rlimit::INFINITY {
+            Ok("unlimited".into())
+        } else {
+            Ok(format!("{val}"))
+        }
+    }
+
+    fn set(&self, set_hard: bool, value: LimitValue) -> std::io::Result<()> {
+        let (soft, hard) = self.resource.get()?;
+        let value = match value {
+            LimitValue::Soft => soft,
+            LimitValue::Hard => hard,
+            LimitValue::Unlimited => rlimit::INFINITY,
+            LimitValue::Value(v) => v,
+            LimitValue::Unset => return Ok(()),
+        };
+
+        if set_hard {
+            self.resource.set(soft, value)
+        } else {
+            self.resource.set(value, hard)
+        }
+    }
+
+    /// Print either soft or hard limit
+    fn print(&self, hard: bool) -> std::io::Result<()> {
+        let unit = match self.unit {
+            Unit::Block => format!("(block, -{})", self.short),
+            Unit::KBytes => format!("(kbytes, -{})", self.short),
+            Unit::Number => format!("(-{})", self.short),
+        };
+        let resource = self.get(hard)?;
+        println!("{:<26}{:>16} {}", self.description, unit, resource);
+        Ok(())
+    }
+}
+
+const ALL_RESOURCES: [ResourceDescription; 4] = [
+    ResourceDescription::CORE,
+    ResourceDescription::FSIZE,
+    ResourceDescription::NOFILE,
+    ResourceDescription::RSS,
+];
+
+#[derive(Debug, Clone, Copy)]
+enum LimitValue {
+    Unset,
+    Unlimited,
+    Soft,
+    Hard,
+    Value(u64),
+}
+
+impl FromStr for LimitValue {
+    type Err = <u64 as FromStr>::Err;
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let v = match s {
+            "" => LimitValue::Unset,
+            "unlimited" => LimitValue::Unlimited,
+            "soft" => LimitValue::Soft,
+            "hard" => LimitValue::Hard,
+            _ => LimitValue::Value(s.parse()?),
+        };
+        Ok(v)
+    }
+}
+
+/// Modify shell resource limits.
+///
+/// Provides control over the resources available to the shell and processes
+/// it creates, on systems that allow such control.
+#[derive(Parser, Debug)]
+pub(crate) struct ULimitCommand {
+    /// use the `soft` resource limit
+    #[arg(short = 'S')]
+    soft: bool,
+    /// use the `hard` resource limit
+    #[arg(short = 'H')]
+    hard: bool,
+    /// all current limits are reported
+    #[arg(short)]
+    all: bool,
+    /// the maximum size of core files created
+    #[arg(short, default_missing_value = "", num_args(0..=1))]
+    core: Option<LimitValue>,
+    /// the maximum size of files written by the shell and its children
+    #[arg(short = 'f', default_missing_value = "", num_args(0..=1))]
+    file_size: Option<LimitValue>,
+    /// the maximum resident set size
+    #[arg(short = 'm', default_missing_value = "", num_args(0..=1))]
+    rss: Option<LimitValue>,
+    /// the maximum number of open file descriptors
+    #[arg(short = 'n', default_missing_value = "", num_args(0..=1))]
+    file_open: Option<LimitValue>,
+
+    /// argument for the implicit limit (`-f`)
+    limit: Option<LimitValue>,
+}
+
+impl builtins::Command for ULimitCommand {
+    async fn execute(
+        &self,
+        _context: commands::ExecutionContext<'_>,
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
+        let exit_code = builtins::ExitCode::Success;
+        let mut resources_to_set = Vec::new();
+        let mut resources_to_get = Vec::new();
+
+        let mut set_or_get = |val, descr| match val {
+            Some(LimitValue::Unset) => resources_to_get.push(descr),
+            Some(v) => resources_to_set.push((descr, v)),
+            None => {}
+        };
+
+        set_or_get(self.core, ResourceDescription::CORE);
+        set_or_get(self.file_size, ResourceDescription::FSIZE);
+        set_or_get(self.rss, ResourceDescription::RSS);
+        set_or_get(self.file_open, ResourceDescription::NOFILE);
+
+        if self.all {
+            resources_to_get = ALL_RESOURCES.into();
+        }
+
+        if !resources_to_get.is_empty() && !resources_to_set.is_empty() {
+            return Err(crate::Error::InvalidArguments);
path: brush-core/src/builtins/ulimit.rs
user: login: reubeno
body: Is there an error we could/should report here to `stderr`?
created_at: 2025-05-11T05:57:30Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,206 @@
+use clap::Parser;
+use rlimit::Resource;
+use std::str::FromStr;
+
+use crate::{builtins, commands};
+
+#[derive(Clone, Copy, Hash, Debug)]
+enum Unit {
+    Block,
+    KBytes,
+    Number,
+}
+
+#[derive(Clone, Copy, Hash, Debug)]
+struct ResourceDescription {
path: brush-core/src/builtins/ulimit.rs
user: login: reubeno
body: I appreciate the data-driven definition of the resources!
created_at: 2025-05-11T05:58:46Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,206 @@
+use clap::Parser;
+use rlimit::Resource;
+use std::str::FromStr;
+
+use crate::{builtins, commands};
+
+#[derive(Clone, Copy, Hash, Debug)]
+enum Unit {
+    Block,
+    KBytes,
+    Number,
+}
+
+#[derive(Clone, Copy, Hash, Debug)]
+struct ResourceDescription {
+    resource: Resource,
+    description: &'static str,
+    short: char,
+    unit: Unit,
+}
+
+impl ResourceDescription {
+    const CORE: ResourceDescription = ResourceDescription {
+        resource: Resource::CORE,
+        description: "core file size",
+        short: 'c',
+        unit: Unit::Block,
+    };
+    const FSIZE: ResourceDescription = ResourceDescription {
+        resource: Resource::FSIZE,
+        description: "file size",
+        short: 'f',
+        unit: Unit::Block,
+    };
+    const RSS: ResourceDescription = ResourceDescription {
+        resource: Resource::RSS,
+        description: "max memory size",
+        short: 'm',
+        unit: Unit::KBytes,
+    };
+    const NOFILE: ResourceDescription = ResourceDescription {
+        resource: Resource::NOFILE,
+        description: "open files",
+        short: 'n',
+        unit: Unit::Number,
+    };
+
+    fn get(&self, hard: bool) -> std::io::Result<String> {
+        let val = if hard {
+            self.resource.get_hard()?
+        } else {
+            self.resource.get_soft()?
+        };
+
+        if val == rlimit::INFINITY {
+            Ok("unlimited".into())
+        } else {
+            Ok(format!("{val}"))
+        }
+    }
+
+    fn set(&self, set_hard: bool, value: LimitValue) -> std::io::Result<()> {
+        let (soft, hard) = self.resource.get()?;
+        let value = match value {
+            LimitValue::Soft => soft,
+            LimitValue::Hard => hard,
+            LimitValue::Unlimited => rlimit::INFINITY,
+            LimitValue::Value(v) => v,
+            LimitValue::Unset => return Ok(()),
+        };
+
+        if set_hard {
+            self.resource.set(soft, value)
+        } else {
+            self.resource.set(value, hard)
+        }
+    }
+
+    /// Print either soft or hard limit
+    fn print(&self, hard: bool) -> std::io::Result<()> {
+        let unit = match self.unit {
+            Unit::Block => format!("(block, -{})", self.short),
+            Unit::KBytes => format!("(kbytes, -{})", self.short),
+            Unit::Number => format!("(-{})", self.short),
+        };
+        let resource = self.get(hard)?;
+        println!("{:<26}{:>16} {}", self.description, unit, resource);
+        Ok(())
+    }
+}
+
+const ALL_RESOURCES: [ResourceDescription; 4] = [
+    ResourceDescription::CORE,
+    ResourceDescription::FSIZE,
+    ResourceDescription::NOFILE,
+    ResourceDescription::RSS,
+];
+
+#[derive(Debug, Clone, Copy)]
+enum LimitValue {
+    Unset,
+    Unlimited,
+    Soft,
+    Hard,
+    Value(u64),
+}
+
+impl FromStr for LimitValue {
+    type Err = <u64 as FromStr>::Err;
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let v = match s {
+            "" => LimitValue::Unset,
+            "unlimited" => LimitValue::Unlimited,
+            "soft" => LimitValue::Soft,
+            "hard" => LimitValue::Hard,
+            _ => LimitValue::Value(s.parse()?),
+        };
+        Ok(v)
+    }
+}
+
+/// Modify shell resource limits.
+///
+/// Provides control over the resources available to the shell and processes
+/// it creates, on systems that allow such control.
+#[derive(Parser, Debug)]
+pub(crate) struct ULimitCommand {
+    /// use the `soft` resource limit
+    #[arg(short = 'S')]
+    soft: bool,
+    /// use the `hard` resource limit
+    #[arg(short = 'H')]
+    hard: bool,
+    /// all current limits are reported
+    #[arg(short)]
+    all: bool,
+    /// the maximum size of core files created
+    #[arg(short, default_missing_value = "", num_args(0..=1))]
+    core: Option<LimitValue>,
+    /// the maximum size of files written by the shell and its children
+    #[arg(short = 'f', default_missing_value = "", num_args(0..=1))]
+    file_size: Option<LimitValue>,
+    /// the maximum resident set size
+    #[arg(short = 'm', default_missing_value = "", num_args(0..=1))]
+    rss: Option<LimitValue>,
+    /// the maximum number of open file descriptors
+    #[arg(short = 'n', default_missing_value = "", num_args(0..=1))]
+    file_open: Option<LimitValue>,
+
+    /// argument for the implicit limit (`-f`)
+    limit: Option<LimitValue>,
+}
+
+impl builtins::Command for ULimitCommand {
+    async fn execute(
+        &self,
+        _context: commands::ExecutionContext<'_>,
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
+        let exit_code = builtins::ExitCode::Success;
+        let mut resources_to_set = Vec::new();
+        let mut resources_to_get = Vec::new();
+
+        let mut set_or_get = |val, descr| match val {
+            Some(LimitValue::Unset) => resources_to_get.push(descr),
+            Some(v) => resources_to_set.push((descr, v)),
+            None => {}
+        };
+
+        set_or_get(self.core, ResourceDescription::CORE);
+        set_or_get(self.file_size, ResourceDescription::FSIZE);
+        set_or_get(self.rss, ResourceDescription::RSS);
+        set_or_get(self.file_open, ResourceDescription::NOFILE);
+
+        if self.all {
path: brush-core/src/builtins/ulimit.rs
user: login: reubeno
body: If we folded checking `self.all` into the body of `set_or_get`, that could allow us to remove `ALL_RESOURCES` and leave only one place where we need to explicitly list all resource types (i.e., on lines 169-172). It would also complicate `set_or_get`.

What do you think about that trade-off?
created_at: 2025-05-11T06:03:21Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,206 @@
+use clap::Parser;
+use rlimit::Resource;
+use std::str::FromStr;
+
+use crate::{builtins, commands};
+
+#[derive(Clone, Copy, Hash, Debug)]
+enum Unit {
+    Block,
+    KBytes,
+    Number,
+}
+
+#[derive(Clone, Copy, Hash, Debug)]
+struct ResourceDescription {
+    resource: Resource,
+    description: &'static str,
+    short: char,
+    unit: Unit,
+}
+
+impl ResourceDescription {
+    const CORE: ResourceDescription = ResourceDescription {
+        resource: Resource::CORE,
+        description: "core file size",
+        short: 'c',
+        unit: Unit::Block,
+    };
+    const FSIZE: ResourceDescription = ResourceDescription {
+        resource: Resource::FSIZE,
+        description: "file size",
+        short: 'f',
+        unit: Unit::Block,
+    };
+    const RSS: ResourceDescription = ResourceDescription {
+        resource: Resource::RSS,
+        description: "max memory size",
+        short: 'm',
+        unit: Unit::KBytes,
+    };
+    const NOFILE: ResourceDescription = ResourceDescription {
+        resource: Resource::NOFILE,
+        description: "open files",
+        short: 'n',
+        unit: Unit::Number,
+    };
+
+    fn get(&self, hard: bool) -> std::io::Result<String> {
+        let val = if hard {
+            self.resource.get_hard()?
+        } else {
+            self.resource.get_soft()?
+        };
+
+        if val == rlimit::INFINITY {
+            Ok("unlimited".into())
+        } else {
+            Ok(format!("{val}"))
+        }
+    }
+
+    fn set(&self, set_hard: bool, value: LimitValue) -> std::io::Result<()> {
+        let (soft, hard) = self.resource.get()?;
+        let value = match value {
+            LimitValue::Soft => soft,
+            LimitValue::Hard => hard,
+            LimitValue::Unlimited => rlimit::INFINITY,
+            LimitValue::Value(v) => v,
+            LimitValue::Unset => return Ok(()),
+        };
+
+        if set_hard {
+            self.resource.set(soft, value)
+        } else {
+            self.resource.set(value, hard)
+        }
+    }
+
+    /// Print either soft or hard limit
+    fn print(&self, hard: bool) -> std::io::Result<()> {
+        let unit = match self.unit {
+            Unit::Block => format!("(block, -{})", self.short),
+            Unit::KBytes => format!("(kbytes, -{})", self.short),
+            Unit::Number => format!("(-{})", self.short),
+        };
+        let resource = self.get(hard)?;
+        println!("{:<26}{:>16} {}", self.description, unit, resource);
+        Ok(())
+    }
+}
+
+const ALL_RESOURCES: [ResourceDescription; 4] = [
+    ResourceDescription::CORE,
+    ResourceDescription::FSIZE,
+    ResourceDescription::NOFILE,
+    ResourceDescription::RSS,
+];
+
+#[derive(Debug, Clone, Copy)]
+enum LimitValue {
+    Unset,
+    Unlimited,
+    Soft,
+    Hard,
+    Value(u64),
+}
+
+impl FromStr for LimitValue {
+    type Err = <u64 as FromStr>::Err;
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let v = match s {
+            "" => LimitValue::Unset,
+            "unlimited" => LimitValue::Unlimited,
+            "soft" => LimitValue::Soft,
+            "hard" => LimitValue::Hard,
+            _ => LimitValue::Value(s.parse()?),
+        };
+        Ok(v)
+    }
+}
+
+/// Modify shell resource limits.
+///
+/// Provides control over the resources available to the shell and processes
+/// it creates, on systems that allow such control.
+#[derive(Parser, Debug)]
+pub(crate) struct ULimitCommand {
path: brush-core/src/builtins/ulimit.rs
user: login: reubeno
body: For most built-ins we've implemented, we've tried to leave placeholder stubs for all of the options -- so runtime use of them results in an "unimplemented error" that we can get reports on and track (vs. argument parsing errors), e.g.:

https://github.com/reubeno/brush/blob/7a7c642454aa0e26027d2abb436d3c7ba40dd8fc/brush-core/src/builtins/mapfile.rs#L57-L59

I'm supportive of getting this change in as incremental progress without implementing the other options but would like to see similar "unimplemented stubs" here. Would that be feasible?
created_at: 2025-05-11T06:08:22Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,206 @@
+use clap::Parser;
+use rlimit::Resource;
+use std::str::FromStr;
+
+use crate::{builtins, commands};
+
+#[derive(Clone, Copy, Hash, Debug)]
path: brush-core/src/builtins/ulimit.rs
user: login: reubeno
body: Are `Hash` and `Debug` actually required on these types?
created_at: 2025-05-11T06:09:06Z
author_association: OWNER
diff_hunk: @@ -64,6 +64,7 @@ nix = { version = "0.30.1", features = [
     "user",
 ] }
 uzers = "0.12.1"
+rlimit = "0.10.2"
path: brush-core/Cargo.toml
user: login: lu-zero
body: sure
created_at: 2025-05-11T09:07:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,206 @@
+use clap::Parser;
+use rlimit::Resource;
+use std::str::FromStr;
+
+use crate::{builtins, commands};
+
+#[derive(Clone, Copy, Hash, Debug)]
+enum Unit {
+    Block,
+    KBytes,
+    Number,
+}
+
+#[derive(Clone, Copy, Hash, Debug)]
+struct ResourceDescription {
+    resource: Resource,
+    description: &'static str,
+    short: char,
+    unit: Unit,
+}
+
+impl ResourceDescription {
+    const CORE: ResourceDescription = ResourceDescription {
+        resource: Resource::CORE,
+        description: "core file size",
+        short: 'c',
+        unit: Unit::Block,
+    };
+    const FSIZE: ResourceDescription = ResourceDescription {
+        resource: Resource::FSIZE,
+        description: "file size",
+        short: 'f',
+        unit: Unit::Block,
+    };
+    const RSS: ResourceDescription = ResourceDescription {
+        resource: Resource::RSS,
+        description: "max memory size",
+        short: 'm',
+        unit: Unit::KBytes,
+    };
+    const NOFILE: ResourceDescription = ResourceDescription {
+        resource: Resource::NOFILE,
+        description: "open files",
+        short: 'n',
+        unit: Unit::Number,
+    };
+
+    fn get(&self, hard: bool) -> std::io::Result<String> {
+        let val = if hard {
+            self.resource.get_hard()?
+        } else {
+            self.resource.get_soft()?
+        };
+
+        if val == rlimit::INFINITY {
+            Ok("unlimited".into())
+        } else {
+            Ok(format!("{val}"))
+        }
+    }
+
+    fn set(&self, set_hard: bool, value: LimitValue) -> std::io::Result<()> {
+        let (soft, hard) = self.resource.get()?;
+        let value = match value {
+            LimitValue::Soft => soft,
+            LimitValue::Hard => hard,
+            LimitValue::Unlimited => rlimit::INFINITY,
+            LimitValue::Value(v) => v,
+            LimitValue::Unset => return Ok(()),
+        };
+
+        if set_hard {
+            self.resource.set(soft, value)
+        } else {
+            self.resource.set(value, hard)
+        }
+    }
+
+    /// Print either soft or hard limit
+    fn print(&self, hard: bool) -> std::io::Result<()> {
+        let unit = match self.unit {
+            Unit::Block => format!("(block, -{})", self.short),
+            Unit::KBytes => format!("(kbytes, -{})", self.short),
+            Unit::Number => format!("(-{})", self.short),
+        };
+        let resource = self.get(hard)?;
+        println!("{:<26}{:>16} {}", self.description, unit, resource);
+        Ok(())
+    }
+}
+
+const ALL_RESOURCES: [ResourceDescription; 4] = [
+    ResourceDescription::CORE,
+    ResourceDescription::FSIZE,
+    ResourceDescription::NOFILE,
+    ResourceDescription::RSS,
+];
+
+#[derive(Debug, Clone, Copy)]
+enum LimitValue {
+    Unset,
+    Unlimited,
+    Soft,
+    Hard,
+    Value(u64),
+}
+
+impl FromStr for LimitValue {
+    type Err = <u64 as FromStr>::Err;
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let v = match s {
+            "" => LimitValue::Unset,
+            "unlimited" => LimitValue::Unlimited,
+            "soft" => LimitValue::Soft,
+            "hard" => LimitValue::Hard,
+            _ => LimitValue::Value(s.parse()?),
+        };
+        Ok(v)
+    }
+}
+
+/// Modify shell resource limits.
+///
+/// Provides control over the resources available to the shell and processes
+/// it creates, on systems that allow such control.
+#[derive(Parser, Debug)]
+pub(crate) struct ULimitCommand {
+    /// use the `soft` resource limit
+    #[arg(short = 'S')]
+    soft: bool,
+    /// use the `hard` resource limit
+    #[arg(short = 'H')]
+    hard: bool,
+    /// all current limits are reported
+    #[arg(short)]
+    all: bool,
+    /// the maximum size of core files created
+    #[arg(short, default_missing_value = "", num_args(0..=1))]
+    core: Option<LimitValue>,
+    /// the maximum size of files written by the shell and its children
+    #[arg(short = 'f', default_missing_value = "", num_args(0..=1))]
+    file_size: Option<LimitValue>,
+    /// the maximum resident set size
+    #[arg(short = 'm', default_missing_value = "", num_args(0..=1))]
+    rss: Option<LimitValue>,
+    /// the maximum number of open file descriptors
+    #[arg(short = 'n', default_missing_value = "", num_args(0..=1))]
+    file_open: Option<LimitValue>,
+
+    /// argument for the implicit limit (`-f`)
+    limit: Option<LimitValue>,
+}
+
+impl builtins::Command for ULimitCommand {
+    async fn execute(
+        &self,
+        _context: commands::ExecutionContext<'_>,
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
+        let exit_code = builtins::ExitCode::Success;
+        let mut resources_to_set = Vec::new();
+        let mut resources_to_get = Vec::new();
+
+        let mut set_or_get = |val, descr| match val {
+            Some(LimitValue::Unset) => resources_to_get.push(descr),
+            Some(v) => resources_to_set.push((descr, v)),
+            None => {}
+        };
+
+        set_or_get(self.core, ResourceDescription::CORE);
+        set_or_get(self.file_size, ResourceDescription::FSIZE);
+        set_or_get(self.rss, ResourceDescription::RSS);
+        set_or_get(self.file_open, ResourceDescription::NOFILE);
+
+        if self.all {
+            resources_to_get = ALL_RESOURCES.into();
+        }
+
+        if !resources_to_get.is_empty() && !resources_to_set.is_empty() {
+            return Err(crate::Error::InvalidArguments);
path: brush-core/src/builtins/ulimit.rs
user: login: lu-zero
body: We can even avoid that restriction.
created_at: 2025-05-11T09:09:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,206 @@
+use clap::Parser;
+use rlimit::Resource;
+use std::str::FromStr;
+
+use crate::{builtins, commands};
+
+#[derive(Clone, Copy, Hash, Debug)]
+enum Unit {
+    Block,
+    KBytes,
+    Number,
+}
+
+#[derive(Clone, Copy, Hash, Debug)]
+struct ResourceDescription {
+    resource: Resource,
+    description: &'static str,
+    short: char,
+    unit: Unit,
+}
+
+impl ResourceDescription {
+    const CORE: ResourceDescription = ResourceDescription {
+        resource: Resource::CORE,
+        description: "core file size",
+        short: 'c',
+        unit: Unit::Block,
+    };
+    const FSIZE: ResourceDescription = ResourceDescription {
+        resource: Resource::FSIZE,
+        description: "file size",
+        short: 'f',
+        unit: Unit::Block,
+    };
+    const RSS: ResourceDescription = ResourceDescription {
+        resource: Resource::RSS,
+        description: "max memory size",
+        short: 'm',
+        unit: Unit::KBytes,
+    };
+    const NOFILE: ResourceDescription = ResourceDescription {
+        resource: Resource::NOFILE,
+        description: "open files",
+        short: 'n',
+        unit: Unit::Number,
+    };
+
+    fn get(&self, hard: bool) -> std::io::Result<String> {
+        let val = if hard {
+            self.resource.get_hard()?
+        } else {
+            self.resource.get_soft()?
+        };
+
+        if val == rlimit::INFINITY {
+            Ok("unlimited".into())
+        } else {
+            Ok(format!("{val}"))
+        }
+    }
+
+    fn set(&self, set_hard: bool, value: LimitValue) -> std::io::Result<()> {
+        let (soft, hard) = self.resource.get()?;
+        let value = match value {
+            LimitValue::Soft => soft,
+            LimitValue::Hard => hard,
+            LimitValue::Unlimited => rlimit::INFINITY,
+            LimitValue::Value(v) => v,
+            LimitValue::Unset => return Ok(()),
+        };
+
+        if set_hard {
+            self.resource.set(soft, value)
+        } else {
+            self.resource.set(value, hard)
+        }
+    }
+
+    /// Print either soft or hard limit
+    fn print(&self, hard: bool) -> std::io::Result<()> {
+        let unit = match self.unit {
+            Unit::Block => format!("(block, -{})", self.short),
+            Unit::KBytes => format!("(kbytes, -{})", self.short),
+            Unit::Number => format!("(-{})", self.short),
+        };
+        let resource = self.get(hard)?;
+        println!("{:<26}{:>16} {}", self.description, unit, resource);
+        Ok(())
+    }
+}
+
+const ALL_RESOURCES: [ResourceDescription; 4] = [
+    ResourceDescription::CORE,
+    ResourceDescription::FSIZE,
+    ResourceDescription::NOFILE,
+    ResourceDescription::RSS,
+];
+
+#[derive(Debug, Clone, Copy)]
+enum LimitValue {
+    Unset,
+    Unlimited,
+    Soft,
+    Hard,
+    Value(u64),
+}
+
+impl FromStr for LimitValue {
+    type Err = <u64 as FromStr>::Err;
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let v = match s {
+            "" => LimitValue::Unset,
+            "unlimited" => LimitValue::Unlimited,
+            "soft" => LimitValue::Soft,
+            "hard" => LimitValue::Hard,
+            _ => LimitValue::Value(s.parse()?),
+        };
+        Ok(v)
+    }
+}
+
+/// Modify shell resource limits.
+///
+/// Provides control over the resources available to the shell and processes
+/// it creates, on systems that allow such control.
+#[derive(Parser, Debug)]
+pub(crate) struct ULimitCommand {
path: brush-core/src/builtins/ulimit.rs
user: login: lu-zero
body: let me add one
created_at: 2025-05-11T09:14:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,206 @@
+use clap::Parser;
+use rlimit::Resource;
+use std::str::FromStr;
+
+use crate::{builtins, commands};
+
+#[derive(Clone, Copy, Hash, Debug)]
path: brush-core/src/builtins/ulimit.rs
user: login: lu-zero
body: Not needed, initially I thought of having an `HashMap`.

Btw can we remove `Cargo.lock` from git, it causes more churn than anything :/
created_at: 2025-05-11T09:16:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,206 @@
+use clap::Parser;
+use rlimit::Resource;
+use std::str::FromStr;
+
+use crate::{builtins, commands};
+
+#[derive(Clone, Copy, Hash, Debug)]
+enum Unit {
+    Block,
+    KBytes,
+    Number,
+}
+
+#[derive(Clone, Copy, Hash, Debug)]
+struct ResourceDescription {
+    resource: Resource,
+    description: &'static str,
+    short: char,
+    unit: Unit,
+}
+
+impl ResourceDescription {
+    const CORE: ResourceDescription = ResourceDescription {
+        resource: Resource::CORE,
+        description: "core file size",
+        short: 'c',
+        unit: Unit::Block,
+    };
+    const FSIZE: ResourceDescription = ResourceDescription {
+        resource: Resource::FSIZE,
+        description: "file size",
+        short: 'f',
+        unit: Unit::Block,
+    };
+    const RSS: ResourceDescription = ResourceDescription {
+        resource: Resource::RSS,
+        description: "max memory size",
+        short: 'm',
+        unit: Unit::KBytes,
+    };
+    const NOFILE: ResourceDescription = ResourceDescription {
+        resource: Resource::NOFILE,
+        description: "open files",
+        short: 'n',
+        unit: Unit::Number,
+    };
+
+    fn get(&self, hard: bool) -> std::io::Result<String> {
+        let val = if hard {
+            self.resource.get_hard()?
+        } else {
+            self.resource.get_soft()?
+        };
+
+        if val == rlimit::INFINITY {
+            Ok("unlimited".into())
+        } else {
+            Ok(format!("{val}"))
+        }
+    }
+
+    fn set(&self, set_hard: bool, value: LimitValue) -> std::io::Result<()> {
+        let (soft, hard) = self.resource.get()?;
+        let value = match value {
+            LimitValue::Soft => soft,
+            LimitValue::Hard => hard,
+            LimitValue::Unlimited => rlimit::INFINITY,
+            LimitValue::Value(v) => v,
+            LimitValue::Unset => return Ok(()),
+        };
+
+        if set_hard {
+            self.resource.set(soft, value)
+        } else {
+            self.resource.set(value, hard)
+        }
+    }
+
+    /// Print either soft or hard limit
+    fn print(&self, hard: bool) -> std::io::Result<()> {
+        let unit = match self.unit {
+            Unit::Block => format!("(block, -{})", self.short),
+            Unit::KBytes => format!("(kbytes, -{})", self.short),
+            Unit::Number => format!("(-{})", self.short),
+        };
+        let resource = self.get(hard)?;
+        println!("{:<26}{:>16} {}", self.description, unit, resource);
+        Ok(())
+    }
+}
+
+const ALL_RESOURCES: [ResourceDescription; 4] = [
+    ResourceDescription::CORE,
+    ResourceDescription::FSIZE,
+    ResourceDescription::NOFILE,
+    ResourceDescription::RSS,
+];
+
+#[derive(Debug, Clone, Copy)]
+enum LimitValue {
+    Unset,
+    Unlimited,
+    Soft,
+    Hard,
+    Value(u64),
+}
+
+impl FromStr for LimitValue {
+    type Err = <u64 as FromStr>::Err;
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let v = match s {
+            "" => LimitValue::Unset,
+            "unlimited" => LimitValue::Unlimited,
+            "soft" => LimitValue::Soft,
+            "hard" => LimitValue::Hard,
+            _ => LimitValue::Value(s.parse()?),
+        };
+        Ok(v)
+    }
+}
+
+/// Modify shell resource limits.
+///
+/// Provides control over the resources available to the shell and processes
+/// it creates, on systems that allow such control.
+#[derive(Parser, Debug)]
+pub(crate) struct ULimitCommand {
+    /// use the `soft` resource limit
+    #[arg(short = 'S')]
+    soft: bool,
+    /// use the `hard` resource limit
+    #[arg(short = 'H')]
+    hard: bool,
+    /// all current limits are reported
+    #[arg(short)]
+    all: bool,
+    /// the maximum size of core files created
+    #[arg(short, default_missing_value = "", num_args(0..=1))]
+    core: Option<LimitValue>,
+    /// the maximum size of files written by the shell and its children
+    #[arg(short = 'f', default_missing_value = "", num_args(0..=1))]
+    file_size: Option<LimitValue>,
+    /// the maximum resident set size
+    #[arg(short = 'm', default_missing_value = "", num_args(0..=1))]
+    rss: Option<LimitValue>,
+    /// the maximum number of open file descriptors
+    #[arg(short = 'n', default_missing_value = "", num_args(0..=1))]
+    file_open: Option<LimitValue>,
+
+    /// argument for the implicit limit (`-f`)
+    limit: Option<LimitValue>,
+}
+
+impl builtins::Command for ULimitCommand {
+    async fn execute(
+        &self,
+        _context: commands::ExecutionContext<'_>,
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
+        let exit_code = builtins::ExitCode::Success;
+        let mut resources_to_set = Vec::new();
+        let mut resources_to_get = Vec::new();
+
+        let mut set_or_get = |val, descr| match val {
+            Some(LimitValue::Unset) => resources_to_get.push(descr),
+            Some(v) => resources_to_set.push((descr, v)),
+            None => {}
+        };
+
+        set_or_get(self.core, ResourceDescription::CORE);
+        set_or_get(self.file_size, ResourceDescription::FSIZE);
+        set_or_get(self.rss, ResourceDescription::RSS);
+        set_or_get(self.file_open, ResourceDescription::NOFILE);
+
+        if self.all {
path: brush-core/src/builtins/ulimit.rs
user: login: lu-zero
body: It is simple enough
created_at: 2025-05-11T11:57:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,206 @@
+use clap::Parser;
+use rlimit::Resource;
+use std::str::FromStr;
+
+use crate::{builtins, commands};
+
+#[derive(Clone, Copy, Hash, Debug)]
+enum Unit {
+    Block,
+    KBytes,
+    Number,
+}
+
+#[derive(Clone, Copy, Hash, Debug)]
+struct ResourceDescription {
+    resource: Resource,
+    description: &'static str,
+    short: char,
+    unit: Unit,
+}
+
+impl ResourceDescription {
+    const CORE: ResourceDescription = ResourceDescription {
+        resource: Resource::CORE,
+        description: "core file size",
+        short: 'c',
+        unit: Unit::Block,
+    };
+    const FSIZE: ResourceDescription = ResourceDescription {
+        resource: Resource::FSIZE,
+        description: "file size",
+        short: 'f',
+        unit: Unit::Block,
+    };
+    const RSS: ResourceDescription = ResourceDescription {
+        resource: Resource::RSS,
+        description: "max memory size",
+        short: 'm',
+        unit: Unit::KBytes,
+    };
+    const NOFILE: ResourceDescription = ResourceDescription {
+        resource: Resource::NOFILE,
+        description: "open files",
+        short: 'n',
+        unit: Unit::Number,
+    };
+
+    fn get(&self, hard: bool) -> std::io::Result<String> {
+        let val = if hard {
+            self.resource.get_hard()?
+        } else {
+            self.resource.get_soft()?
+        };
+
+        if val == rlimit::INFINITY {
+            Ok("unlimited".into())
+        } else {
+            Ok(format!("{val}"))
+        }
+    }
+
+    fn set(&self, set_hard: bool, value: LimitValue) -> std::io::Result<()> {
+        let (soft, hard) = self.resource.get()?;
+        let value = match value {
+            LimitValue::Soft => soft,
+            LimitValue::Hard => hard,
+            LimitValue::Unlimited => rlimit::INFINITY,
+            LimitValue::Value(v) => v,
+            LimitValue::Unset => return Ok(()),
+        };
+
+        if set_hard {
+            self.resource.set(soft, value)
+        } else {
+            self.resource.set(value, hard)
+        }
+    }
+
+    /// Print either soft or hard limit
+    fn print(&self, hard: bool) -> std::io::Result<()> {
+        let unit = match self.unit {
+            Unit::Block => format!("(block, -{})", self.short),
+            Unit::KBytes => format!("(kbytes, -{})", self.short),
+            Unit::Number => format!("(-{})", self.short),
+        };
+        let resource = self.get(hard)?;
+        println!("{:<26}{:>16} {}", self.description, unit, resource);
+        Ok(())
+    }
+}
+
+const ALL_RESOURCES: [ResourceDescription; 4] = [
+    ResourceDescription::CORE,
+    ResourceDescription::FSIZE,
+    ResourceDescription::NOFILE,
+    ResourceDescription::RSS,
+];
+
+#[derive(Debug, Clone, Copy)]
+enum LimitValue {
+    Unset,
+    Unlimited,
+    Soft,
+    Hard,
+    Value(u64),
+}
+
+impl FromStr for LimitValue {
+    type Err = <u64 as FromStr>::Err;
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let v = match s {
+            "" => LimitValue::Unset,
+            "unlimited" => LimitValue::Unlimited,
+            "soft" => LimitValue::Soft,
+            "hard" => LimitValue::Hard,
+            _ => LimitValue::Value(s.parse()?),
+        };
+        Ok(v)
+    }
+}
+
+/// Modify shell resource limits.
+///
+/// Provides control over the resources available to the shell and processes
+/// it creates, on systems that allow such control.
+#[derive(Parser, Debug)]
+pub(crate) struct ULimitCommand {
path: brush-core/src/builtins/ulimit.rs
user: login: lu-zero
body: Overall takes more or less the same time to complete it though...
created_at: 2025-05-11T12:07:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,206 @@
+use clap::Parser;
+use rlimit::Resource;
+use std::str::FromStr;
+
+use crate::{builtins, commands};
+
+#[derive(Clone, Copy, Hash, Debug)]
+enum Unit {
+    Block,
+    KBytes,
+    Number,
+}
+
+#[derive(Clone, Copy, Hash, Debug)]
+struct ResourceDescription {
+    resource: Resource,
+    description: &'static str,
+    short: char,
+    unit: Unit,
+}
+
+impl ResourceDescription {
+    const CORE: ResourceDescription = ResourceDescription {
+        resource: Resource::CORE,
+        description: "core file size",
+        short: 'c',
+        unit: Unit::Block,
+    };
+    const FSIZE: ResourceDescription = ResourceDescription {
+        resource: Resource::FSIZE,
+        description: "file size",
+        short: 'f',
+        unit: Unit::Block,
+    };
+    const RSS: ResourceDescription = ResourceDescription {
+        resource: Resource::RSS,
+        description: "max memory size",
+        short: 'm',
+        unit: Unit::KBytes,
+    };
+    const NOFILE: ResourceDescription = ResourceDescription {
+        resource: Resource::NOFILE,
+        description: "open files",
+        short: 'n',
+        unit: Unit::Number,
+    };
+
+    fn get(&self, hard: bool) -> std::io::Result<String> {
+        let val = if hard {
+            self.resource.get_hard()?
+        } else {
+            self.resource.get_soft()?
+        };
+
+        if val == rlimit::INFINITY {
+            Ok("unlimited".into())
+        } else {
+            Ok(format!("{val}"))
+        }
+    }
+
+    fn set(&self, set_hard: bool, value: LimitValue) -> std::io::Result<()> {
+        let (soft, hard) = self.resource.get()?;
+        let value = match value {
+            LimitValue::Soft => soft,
+            LimitValue::Hard => hard,
+            LimitValue::Unlimited => rlimit::INFINITY,
+            LimitValue::Value(v) => v,
+            LimitValue::Unset => return Ok(()),
+        };
+
+        if set_hard {
+            self.resource.set(soft, value)
+        } else {
+            self.resource.set(value, hard)
+        }
+    }
+
+    /// Print either soft or hard limit
+    fn print(&self, hard: bool) -> std::io::Result<()> {
+        let unit = match self.unit {
+            Unit::Block => format!("(block, -{})", self.short),
+            Unit::KBytes => format!("(kbytes, -{})", self.short),
+            Unit::Number => format!("(-{})", self.short),
+        };
+        let resource = self.get(hard)?;
+        println!("{:<26}{:>16} {}", self.description, unit, resource);
+        Ok(())
+    }
+}
+
+const ALL_RESOURCES: [ResourceDescription; 4] = [
+    ResourceDescription::CORE,
+    ResourceDescription::FSIZE,
+    ResourceDescription::NOFILE,
+    ResourceDescription::RSS,
+];
+
+#[derive(Debug, Clone, Copy)]
+enum LimitValue {
+    Unset,
+    Unlimited,
+    Soft,
+    Hard,
+    Value(u64),
+}
+
+impl FromStr for LimitValue {
+    type Err = <u64 as FromStr>::Err;
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let v = match s {
+            "" => LimitValue::Unset,
+            "unlimited" => LimitValue::Unlimited,
+            "soft" => LimitValue::Soft,
+            "hard" => LimitValue::Hard,
+            _ => LimitValue::Value(s.parse()?),
+        };
+        Ok(v)
+    }
+}
+
+/// Modify shell resource limits.
+///
+/// Provides control over the resources available to the shell and processes
+/// it creates, on systems that allow such control.
+#[derive(Parser, Debug)]
+pub(crate) struct ULimitCommand {
path: brush-core/src/builtins/ulimit.rs
user: login: reubeno
body: Got it. I agree that, if it takes just as much time to add the stub as the real implementation, then we'd appreciate the real implementation :smile:

_(I wasn't sure if you were intentionally limiting the implementation because (a) you wanted to leave some work for other interested contributors, (b) you were short on time to look at the other options, or (c) the other options were trickier. All of those are, of course, reasonable explanations.)_
created_at: 2025-05-12T20:41:33Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,206 @@
+use clap::Parser;
+use rlimit::Resource;
+use std::str::FromStr;
+
+use crate::{builtins, commands};
+
+#[derive(Clone, Copy, Hash, Debug)]
+enum Unit {
+    Block,
+    KBytes,
+    Number,
+}
+
+#[derive(Clone, Copy, Hash, Debug)]
+struct ResourceDescription {
+    resource: Resource,
+    description: &'static str,
+    short: char,
+    unit: Unit,
+}
+
+impl ResourceDescription {
+    const CORE: ResourceDescription = ResourceDescription {
+        resource: Resource::CORE,
+        description: "core file size",
+        short: 'c',
+        unit: Unit::Block,
+    };
+    const FSIZE: ResourceDescription = ResourceDescription {
+        resource: Resource::FSIZE,
+        description: "file size",
+        short: 'f',
+        unit: Unit::Block,
+    };
+    const RSS: ResourceDescription = ResourceDescription {
+        resource: Resource::RSS,
+        description: "max memory size",
+        short: 'm',
+        unit: Unit::KBytes,
+    };
+    const NOFILE: ResourceDescription = ResourceDescription {
+        resource: Resource::NOFILE,
+        description: "open files",
+        short: 'n',
+        unit: Unit::Number,
+    };
+
+    fn get(&self, hard: bool) -> std::io::Result<String> {
+        let val = if hard {
+            self.resource.get_hard()?
+        } else {
+            self.resource.get_soft()?
+        };
+
+        if val == rlimit::INFINITY {
+            Ok("unlimited".into())
+        } else {
+            Ok(format!("{val}"))
+        }
+    }
+
+    fn set(&self, set_hard: bool, value: LimitValue) -> std::io::Result<()> {
+        let (soft, hard) = self.resource.get()?;
+        let value = match value {
+            LimitValue::Soft => soft,
+            LimitValue::Hard => hard,
+            LimitValue::Unlimited => rlimit::INFINITY,
+            LimitValue::Value(v) => v,
+            LimitValue::Unset => return Ok(()),
+        };
+
+        if set_hard {
+            self.resource.set(soft, value)
+        } else {
+            self.resource.set(value, hard)
+        }
+    }
+
+    /// Print either soft or hard limit
+    fn print(&self, hard: bool) -> std::io::Result<()> {
+        let unit = match self.unit {
+            Unit::Block => format!("(block, -{})", self.short),
+            Unit::KBytes => format!("(kbytes, -{})", self.short),
+            Unit::Number => format!("(-{})", self.short),
+        };
+        let resource = self.get(hard)?;
+        println!("{:<26}{:>16} {}", self.description, unit, resource);
+        Ok(())
+    }
+}
+
+const ALL_RESOURCES: [ResourceDescription; 4] = [
+    ResourceDescription::CORE,
+    ResourceDescription::FSIZE,
+    ResourceDescription::NOFILE,
+    ResourceDescription::RSS,
+];
+
+#[derive(Debug, Clone, Copy)]
+enum LimitValue {
+    Unset,
+    Unlimited,
+    Soft,
+    Hard,
+    Value(u64),
+}
+
+impl FromStr for LimitValue {
+    type Err = <u64 as FromStr>::Err;
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let v = match s {
+            "" => LimitValue::Unset,
+            "unlimited" => LimitValue::Unlimited,
+            "soft" => LimitValue::Soft,
+            "hard" => LimitValue::Hard,
+            _ => LimitValue::Value(s.parse()?),
+        };
+        Ok(v)
+    }
+}
+
+/// Modify shell resource limits.
+///
+/// Provides control over the resources available to the shell and processes
+/// it creates, on systems that allow such control.
+#[derive(Parser, Debug)]
+pub(crate) struct ULimitCommand {
path: brush-core/src/builtins/ulimit.rs
user: login: lu-zero
body: Mostly `a`, plus running out of time that weekend. All the options have potentially only the annoyance of having to pick the right name for the resource since some are also synonyms, but it literally boils down to fill in the structure and add the line to use it.
created_at: 2025-05-12T21:42:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,206 @@
+use clap::Parser;
+use rlimit::Resource;
+use std::str::FromStr;
+
+use crate::{builtins, commands};
+
+#[derive(Clone, Copy, Hash, Debug)]
+enum Unit {
+    Block,
+    KBytes,
+    Number,
+}
+
+#[derive(Clone, Copy, Hash, Debug)]
+struct ResourceDescription {
+    resource: Resource,
+    description: &'static str,
+    short: char,
+    unit: Unit,
+}
+
+impl ResourceDescription {
+    const CORE: ResourceDescription = ResourceDescription {
+        resource: Resource::CORE,
+        description: "core file size",
+        short: 'c',
+        unit: Unit::Block,
+    };
+    const FSIZE: ResourceDescription = ResourceDescription {
+        resource: Resource::FSIZE,
+        description: "file size",
+        short: 'f',
+        unit: Unit::Block,
+    };
+    const RSS: ResourceDescription = ResourceDescription {
+        resource: Resource::RSS,
+        description: "max memory size",
+        short: 'm',
+        unit: Unit::KBytes,
+    };
+    const NOFILE: ResourceDescription = ResourceDescription {
+        resource: Resource::NOFILE,
+        description: "open files",
+        short: 'n',
+        unit: Unit::Number,
+    };
+
+    fn get(&self, hard: bool) -> std::io::Result<String> {
+        let val = if hard {
+            self.resource.get_hard()?
+        } else {
+            self.resource.get_soft()?
+        };
+
+        if val == rlimit::INFINITY {
+            Ok("unlimited".into())
+        } else {
+            Ok(format!("{val}"))
+        }
+    }
+
+    fn set(&self, set_hard: bool, value: LimitValue) -> std::io::Result<()> {
+        let (soft, hard) = self.resource.get()?;
+        let value = match value {
+            LimitValue::Soft => soft,
+            LimitValue::Hard => hard,
+            LimitValue::Unlimited => rlimit::INFINITY,
+            LimitValue::Value(v) => v,
+            LimitValue::Unset => return Ok(()),
+        };
+
+        if set_hard {
+            self.resource.set(soft, value)
+        } else {
+            self.resource.set(value, hard)
+        }
+    }
+
+    /// Print either soft or hard limit
+    fn print(&self, hard: bool) -> std::io::Result<()> {
+        let unit = match self.unit {
+            Unit::Block => format!("(block, -{})", self.short),
+            Unit::KBytes => format!("(kbytes, -{})", self.short),
+            Unit::Number => format!("(-{})", self.short),
+        };
+        let resource = self.get(hard)?;
+        println!("{:<26}{:>16} {}", self.description, unit, resource);
+        Ok(())
+    }
+}
+
+const ALL_RESOURCES: [ResourceDescription; 4] = [
+    ResourceDescription::CORE,
+    ResourceDescription::FSIZE,
+    ResourceDescription::NOFILE,
+    ResourceDescription::RSS,
+];
+
+#[derive(Debug, Clone, Copy)]
+enum LimitValue {
+    Unset,
+    Unlimited,
+    Soft,
+    Hard,
+    Value(u64),
+}
+
+impl FromStr for LimitValue {
+    type Err = <u64 as FromStr>::Err;
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let v = match s {
+            "" => LimitValue::Unset,
+            "unlimited" => LimitValue::Unlimited,
+            "soft" => LimitValue::Soft,
+            "hard" => LimitValue::Hard,
+            _ => LimitValue::Value(s.parse()?),
+        };
+        Ok(v)
+    }
+}
+
+/// Modify shell resource limits.
+///
+/// Provides control over the resources available to the shell and processes
+/// it creates, on systems that allow such control.
+#[derive(Parser, Debug)]
+pub(crate) struct ULimitCommand {
path: brush-core/src/builtins/ulimit.rs
user: login: reubeno
body: I definitely appreciate you being supportive of other contributors :smile:.

I'd prefer stubs of some kind, but in this case I'd compromise on at least getting placeholder `// TODO` comment(s) so we have some way to track the missing functionality and hold ourselves accountable for getting it filled in.
created_at: 2025-05-12T22:26:57Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,206 @@
+use clap::Parser;
+use rlimit::Resource;
+use std::str::FromStr;
+
+use crate::{builtins, commands};
+
+#[derive(Clone, Copy, Hash, Debug)]
+enum Unit {
+    Block,
+    KBytes,
+    Number,
+}
+
+#[derive(Clone, Copy, Hash, Debug)]
+struct ResourceDescription {
+    resource: Resource,
+    description: &'static str,
+    short: char,
+    unit: Unit,
+}
+
+impl ResourceDescription {
+    const CORE: ResourceDescription = ResourceDescription {
+        resource: Resource::CORE,
+        description: "core file size",
+        short: 'c',
+        unit: Unit::Block,
+    };
+    const FSIZE: ResourceDescription = ResourceDescription {
+        resource: Resource::FSIZE,
+        description: "file size",
+        short: 'f',
+        unit: Unit::Block,
+    };
+    const RSS: ResourceDescription = ResourceDescription {
+        resource: Resource::RSS,
+        description: "max memory size",
+        short: 'm',
+        unit: Unit::KBytes,
+    };
+    const NOFILE: ResourceDescription = ResourceDescription {
+        resource: Resource::NOFILE,
+        description: "open files",
+        short: 'n',
+        unit: Unit::Number,
+    };
+
+    fn get(&self, hard: bool) -> std::io::Result<String> {
+        let val = if hard {
+            self.resource.get_hard()?
+        } else {
+            self.resource.get_soft()?
+        };
+
+        if val == rlimit::INFINITY {
+            Ok("unlimited".into())
+        } else {
+            Ok(format!("{val}"))
+        }
+    }
+
+    fn set(&self, set_hard: bool, value: LimitValue) -> std::io::Result<()> {
+        let (soft, hard) = self.resource.get()?;
+        let value = match value {
+            LimitValue::Soft => soft,
+            LimitValue::Hard => hard,
+            LimitValue::Unlimited => rlimit::INFINITY,
+            LimitValue::Value(v) => v,
+            LimitValue::Unset => return Ok(()),
+        };
+
+        if set_hard {
+            self.resource.set(soft, value)
+        } else {
+            self.resource.set(value, hard)
+        }
+    }
+
+    /// Print either soft or hard limit
+    fn print(&self, hard: bool) -> std::io::Result<()> {
+        let unit = match self.unit {
+            Unit::Block => format!("(block, -{})", self.short),
+            Unit::KBytes => format!("(kbytes, -{})", self.short),
+            Unit::Number => format!("(-{})", self.short),
+        };
+        let resource = self.get(hard)?;
+        println!("{:<26}{:>16} {}", self.description, unit, resource);
+        Ok(())
+    }
+}
+
+const ALL_RESOURCES: [ResourceDescription; 4] = [
+    ResourceDescription::CORE,
+    ResourceDescription::FSIZE,
+    ResourceDescription::NOFILE,
+    ResourceDescription::RSS,
+];
+
+#[derive(Debug, Clone, Copy)]
+enum LimitValue {
+    Unset,
+    Unlimited,
+    Soft,
+    Hard,
+    Value(u64),
+}
+
+impl FromStr for LimitValue {
+    type Err = <u64 as FromStr>::Err;
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let v = match s {
+            "" => LimitValue::Unset,
+            "unlimited" => LimitValue::Unlimited,
+            "soft" => LimitValue::Soft,
+            "hard" => LimitValue::Hard,
+            _ => LimitValue::Value(s.parse()?),
+        };
+        Ok(v)
+    }
+}
+
+/// Modify shell resource limits.
+///
+/// Provides control over the resources available to the shell and processes
+/// it creates, on systems that allow such control.
+#[derive(Parser, Debug)]
+pub(crate) struct ULimitCommand {
path: brush-core/src/builtins/ulimit.rs
user: login: lu-zero
body: Once I have another timeslice I'll fill in more, shouldn't take much either way. We might need some creative solution to deal with synonyms or contribute the failover to the rlimit crate itself.
created_at: 2025-05-13T11:00:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,489 @@
+use clap::{
+    builder::{IntoResettable, StyledStr},
+    Parser,
+};
+use std::{io::ErrorKind, str::FromStr};
+
+use crate::{builtins, commands};
+
+#[derive(Clone, Copy)]
+enum Unit {
+    Block,
+    Bytes,
+    HalfKBytes,
+    KBytes,
+    Micros,
+    Number,
+    Seconds,
+}
+
+impl Unit {
+    fn scale(self) -> u64 {
+        match self {
+            Unit::Block | Unit::HalfKBytes => 512,
+            Unit::KBytes => 1024,
+            _ => 1,
+        }
+    }
+}
+
+#[derive(Clone, Copy)]
+enum Virtual {
+    Pipe,
+    VMem,
+}
+
+impl Virtual {
+    fn get(self) -> std::io::Result<(u64, u64)> {
+        match self {
+            Virtual::Pipe => {
+                let lim = nix::unistd::PathconfVar::PIPE_BUF as u64 * 512;
+                Ok((lim, lim))
+            }
+            Virtual::VMem => rlimit::Resource::AS
+                .get()
+                .or_else(|_| rlimit::Resource::VMEM.get()),
+        }
+    }
+    fn set(self, soft: u64, hard: u64) -> std::io::Result<()> {
+        match self {
+            Virtual::Pipe => Err(std::io::Error::from(ErrorKind::Unsupported)),
+            Virtual::VMem => rlimit::Resource::AS
+                .set(soft, hard)
+                .or_else(|_| rlimit::Resource::VMEM.set(soft, hard)),
+        }
+    }
+    fn is_supported(self) -> bool {
+        match self {
+            Virtual::Pipe => true,
+            Virtual::VMem => {
+                rlimit::Resource::AS.is_supported() || rlimit::Resource::VMEM.is_supported()
+            }
+        }
+    }
+}
+
+#[derive(Clone, Copy)]
+enum Resource {
+    Phy(rlimit::Resource),
+    Virt(Virtual),
+}
+
+impl Resource {
+    fn get(self) -> std::io::Result<(u64, u64)> {
+        match self {
+            Resource::Phy(res) => res.get(),
+            Resource::Virt(res) => res.get(),
+        }
+    }
+    fn set(self, soft: u64, hard: u64) -> std::io::Result<()> {
+        match self {
+            Resource::Phy(res) => res.set(soft, hard),
+            Resource::Virt(res) => res.set(soft, hard),
+        }
+    }
+    fn is_supported(self) -> bool {
+        match self {
+            Resource::Phy(res) => res.is_supported(),
+            Resource::Virt(res) => res.is_supported(),
+        }
+    }
+}
+
+#[derive(Clone, Copy)]
+struct ResourceDescription {
+    resource: Resource,
+    help: &'static str,
+    description: &'static str,
+    short: char,
+    unit: Unit,
+}
+
+impl ResourceDescription {
+    const SBSIZE: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::SBSIZE),
+        help: "the socket buffer size",
+        description: "socket buffer size",
+        short: 'b',
+        unit: Unit::Bytes,
+    };
+    const CORE: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::CORE),
+        help: "the maximum size of core files created",
+        description: "core file size",
+        short: 'c',
+        unit: Unit::Block,
+    };
+    const DATA: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::DATA),
+        help: "the maximum size of a process's data segment",
+        description: "data seg size",
+        short: 'd',
+        unit: Unit::KBytes,
+    };
+    const NICE: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::NICE),
+        help: "the maximum scheduling priority (`nice`)",
+        description: "scheduling priority",
+        short: 'e',
+        unit: Unit::Number,
+    };
+    const FSIZE: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::FSIZE),
+        help: "the maximum size of files written by the shell and its children",
+        description: "file size",
+        short: 'f',
+        unit: Unit::Block,
+    };
+    const SIGPENDING: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::SIGPENDING),
+        help: "the maximum number of pending signals",
+        description: "pending signals",
+        short: 'i',
+        unit: Unit::Number,
+    };
+    const MEMLOCK: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::MEMLOCK),
+        help: "the maximum size a process may lock into memory",
+        description: "max locked memory",
+        short: 'l',
+        unit: Unit::KBytes,
+    };
+    const KQUEUES: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::KQUEUES),
+        help: "the maximum number of kqueues allocated for this process",
+        description: "max kqueues",
+        short: 'k',
+        unit: Unit::Number,
+    };
+    const RSS: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::RSS),
+        help: "the maximum resident set size",
+        description: "max memory size",
+        short: 'm',
+        unit: Unit::KBytes,
+    };
+    const LOCKS: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::LOCKS),
+        help: "the maximum number of file locks",
+        description: "file locks",
+        short: 'x',
+        unit: Unit::Number,
+    };
+    const NOFILE: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::NOFILE),
+        help: "the maximum number of open file descriptors",
+        description: "open files",
+        short: 'n',
+        unit: Unit::Number,
+    };
+    const MSGQUEUE: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::MSGQUEUE),
+        help: "the maximum number of bytes in POSIX message queues",
+        description: "POSIX message queues",
+        short: 'q',
+        unit: Unit::Bytes,
+    };
+    const PIPE: ResourceDescription = ResourceDescription {
+        resource: Resource::Virt(Virtual::Pipe),
+        help: "the pipe buffer size",
+        description: "pipe size",
+        short: 'p',
+        unit: Unit::HalfKBytes,
+    };
+    const RTPRIO: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::RTPRIO),
+        help: "the maximum real-time scheduling priority",
+        description: "real-time priority",
+        short: 'r',
+        unit: Unit::Number,
+    };
+    const RTTIME: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::RTTIME),
+        help: "the maximum real-time scheduling priority",
+        description: "real-time non-blocking time",
+        short: 'R',
+        unit: Unit::Micros,
+    };
+    const STACK: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::STACK),
+        help: "the maximum stack size",
+        description: "stack size",
+        short: 's',
+        unit: Unit::KBytes,
+    };
+    const CPU: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::CPU),
+        help: "the maximum amount of cpu time in seconds",
+        description: "cpu time",
+        short: 't',
+        unit: Unit::Seconds,
+    };
+    const NPROC: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::NPROC),
+        help: "the maximum number of user processes",
+        description: "max user processes",
+        short: 'u',
+        unit: Unit::Number,
+    };
+    const VMEM: ResourceDescription = ResourceDescription {
+        resource: Resource::Virt(Virtual::VMem),
+        help: "the size of virtual memory",
+        description: "virtual memory",
+        short: 'v',
+        unit: Unit::KBytes,
+    };
+    const THREADS: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::THREADS),
+        help: "the maximum number of threads",
+        description: "number of threads",
+        short: 'T',
+        unit: Unit::Number,
+    };
+    const NPTS: ResourceDescription = ResourceDescription {
+        resource: Resource::Phy(rlimit::Resource::NPTS),
+        help: "the maximum number of pseudoterminals",
+        description: "number of pseudoterminals",
+        short: 'P',
+        unit: Unit::Number,
+    };
+
+    fn get(&self, hard: bool) -> std::io::Result<String> {
+        let (soft_limit, hard_limit) = self.resource.get()?;
+        let val = if hard { hard_limit } else { soft_limit };
+
+        if val == rlimit::INFINITY {
+            Ok("unlimited".into())
+        } else {
+            Ok(format!("{}", val / self.unit.scale()))
+        }
+    }
+
+    fn set(&self, set_hard: bool, value: LimitValue) -> std::io::Result<()> {
+        let (soft, hard) = self.resource.get()?;
+        let value = match value {
+            LimitValue::Soft => soft,
+            LimitValue::Hard => hard,
+            LimitValue::Unlimited => rlimit::INFINITY,
+            LimitValue::Value(v) => v * self.unit.scale(),
+            LimitValue::Unset => return Ok(()),
+        };
+
+        if set_hard {
+            self.resource.set(soft, value)
+        } else {
+            self.resource.set(value, hard)
+        }
+    }
+
+    /// Print either soft or hard limit
+    fn print(&self, hard: bool) {
+        if !self.resource.is_supported() {
+            return;
+        }
+        let unit = match self.unit {
+            Unit::Block => format!("(block, -{})", self.short),
+            Unit::Bytes => format!("(bytes, -{})", self.short),
+            Unit::HalfKBytes => format!("(512 bytes, -{})", self.short),
+            Unit::KBytes => format!("(kbytes, -{})", self.short),
+            Unit::Micros => format!("(microseconds, -{})", self.short),
+            Unit::Number => format!("(-{})", self.short),
+            Unit::Seconds => format!("(seconds, -{})", self.short),
+        };
+        let resource = self.get(hard).unwrap_or_else(|e| format!("{e}"));
+        println!("{:<26}{:>16} {}", self.description, unit, resource);
path: brush-core/src/builtins/ulimit.rs
user: login: reubeno
body: I forgot to note this earlier -- instead of calling `print*`, builtins should use `write*` with `context.stdout()` or `context.stderr()`. That ensures it will go to the right output stream for the current execution context (in case it was redirected).

Ditto for the other calls to `print*` in this file.
created_at: 2025-05-18T05:46:06Z
author_association: OWNER

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-10T14:09:07Z
body: # Performance Benchmark Report
| Benchmark name                         | Baseline (μs) |  Test/PR (μs) |    Delta (μs) | Delta %         |
| -------------------------------------- | ------------- | ------------- | ------------- | --------------- |
| `clone_shell_object                  ` | `   18.23 μs` | `   18.03 μs` | `   -0.20 μs` | `🟢 -1.11%    ` |
| `eval_arithmetic                     ` | `    0.17 μs` | `    0.17 μs` | `   -0.00 μs` | `⚪  Unchanged` |
| `expand_one_string                   ` | `    1.76 μs` | `    1.82 μs` | `    0.06 μs` | `⚪  Unchanged` |
| `for_loop                            ` | `   22.92 μs` | `   22.66 μs` | `   -0.26 μs` | `⚪  Unchanged` |
| `function_call                       ` | `    2.41 μs` | `    2.42 μs` | `    0.01 μs` | `⚪  Unchanged` |
| `instantiate_shell                   ` | `   56.92 μs` | `   57.70 μs` | `    0.77 μs` | `🟠 +1.36%    ` |
| `instantiate_shell_with_init_scripts ` | `23663.16 μs` | `23534.67 μs` | ` -128.49 μs` | `⚪  Unchanged` |
| `parse_bash_completion               ` | ` 1689.83 μs` | ` 1677.89 μs` | `  -11.93 μs` | `⚪  Unchanged` |
| `parse_sample_script                 ` | `    1.77 μs` | `    1.78 μs` | `    0.00 μs` | `⚪  Unchanged` |
| `run_echo_builtin_command            ` | `   15.45 μs` | `   15.18 μs` | `   -0.27 μs` | `⚪  Unchanged` |
| `run_one_external_command            ` | ` 3117.46 μs` | ` 2272.35 μs` | ` -845.11 μs` | `🟢 -27.11%   ` |
| `tokenize_sample_script              ` | `    2.85 μs` | `    2.88 μs` | `    0.03 μs` | `⚪  Unchanged` |

# Code Coverage Report: Only Changed Files listed

| Package | Base Coverage | New Coverage | Difference |
| ------- | ------------- | ------------ | ---------- |
| brush-core/src/builtins/factory.rs | 🟢 95.35% | 🟢 95.33% | 🔴 -0.02% |
| brush-core/src/builtins/ulimit.rs | 🔴 0% | 🟠 65.19% | 🟢 65.19% |
| **Overall Coverage** | **🟢 75.02%** | **🟢 74.93%** | **🔴 -0.09%** |

_Minimum allowed coverage is_ `70%`_, this run produced_ `74.93%`

# Test Summary: bash-completion test suite
| Outcome            | Count                   | Percentage |
| ------------------ | ----------------------: | ---------: |
| ✅ Pass            | 1313            | <span style='color:green'>62.26</span> |
| ❗️ Error           | 229           | <span style='color:red'>10.86</span> |
| ❌ Fail            | 184            | <span style='color:red'>8.72</span> |
| ⏩ Skip            | 367            | 17.40 |
| ❎ Expected Fail   | 15   | 0.71 |
| ✔️ Unexpected Pass | 1 | <span style='color:red'>0.05</span> |
| 📊 Total           | 2109           | 100.00 |

<!-- Sticky Pull Request Comment -->
author_association: NONE
user: login: reubeno
created_at: 2025-05-10T15:33:38Z
body: Thanks for tackling this one (and for the rustfmt config fix)! I should be able to properly review it this weekend.
author_association: OWNER
user: login: lu-zero
created_at: 2025-05-10T15:57:37Z
body: The way I wrote it should make quite easy to add additional entries to newcomers, sadly it hits an annoying corner case in clap or two so there is a bit more code than it could be once those are addressed.
author_association: CONTRIBUTOR
user: login: lu-zero
created_at: 2025-05-15T19:41:49Z
body: Now I added the unsupported flags, some aren't real rlimits
author_association: CONTRIBUTOR
user: login: reubeno
created_at: 2025-05-15T21:08:16Z
body: > Now I added the unsupported flags, some aren't real rlimits

Thanks, @lu-zero! We appreciate you putting more time into this.

I'll be able to re-review with your updates within the next ~day.
author_association: OWNER
user: login: lu-zero
created_at: 2025-05-15T21:09:24Z
body: Now I added an example also for a virtual resource
author_association: CONTRIBUTOR
user: login: reubeno
created_at: 2025-05-16T13:37:19Z
body: This is looking pretty good to me; thanks for the extra time you've put into it.

I know some of this may be tricky to test; are there any basic cases you would recommend us adding to the compat tests to prevent us regressing this functionality in the future?
author_association: OWNER
user: login: lu-zero
created_at: 2025-05-16T15:43:00Z
body: The best way we have to test the getters is to compare flag by flags with bash ulimit, but I'm fairly sure even in that there may be a discrepancy. Setters might be way trickier

author_association: CONTRIBUTOR
user: login: lu-zero
created_at: 2025-05-16T16:21:23Z
body: But I'll need you to tell me how to write such tests
author_association: CONTRIBUTOR
user: login: reubeno
created_at: 2025-05-17T14:50:11Z
body: > But I'll need you to tell me how to write such tests

There's some basic documentation [here](https://github.com/reubeno/brush/blob/main/docs/reference/integration-testing.md). The main idea of these compat tests is finding sequences of commands that we'd expect to behave identically between `bash` and `brush`. 

In this case, if you expect the getters to retrieve the same values, then invoking those in a test case would be a great way to pin the behavior. 

To be clear, I'm okay with us deferring some testing to subsequent PRs. If there's a very basic test that you'd recommend, that might be a good addition now. 

What do you think?
author_association: OWNER
user: login: lu-zero
created_at: 2025-05-17T17:23:52Z
body: Let me give you an example:
```
❯ ulimit -a
core file size              (blocks, -c) 0
data seg size               (kbytes, -d) unlimited
file size                   (blocks, -f) unlimited
max locked memory           (kbytes, -l) unlimited
max memory size             (kbytes, -m) unlimited
open files                          (-n) 256
pipe size                (512 bytes, -p) 1
stack size                  (kbytes, -s) 8176
cpu time                   (seconds, -t) unlimited
max user processes                  (-u) 10666
virtual memory              (kbytes, -v) unlimited
```
```
❯ cargo run -- -c "ulimit -a"
   Compiling brush-shell v0.2.17 (/Users/lu_zero/Sources/rust/brush/brush-shell)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.01s
     Running `target/debug/brush -c 'ulimit -a'`
core file size                 (block, -c) 0
data seg size                 (kbytes, -d) unlimited
file size                      (block, -f) unlimited
max locked memory             (kbytes, -l) unlimited
max memory size               (kbytes, -m) unlimited
open files                            (-n) 256
pipe size                  (512 bytes, -p) 6
stack size                    (kbytes, -s) 8372224
cpu time                     (seconds, -t) unlimited
max user processes                    (-u) 10666
virtual memory                (kbytes, -v) unlimited
```

Our ulimit is providing a better estimated value for `-p`, while bash somehow is falling back to 1 for reasons I'd rather not figure out, but the pipe size is always a fake (read only) limit to I wouldn't care much about it.

For the rest we can basically call `ulimit -{not p} {some sensible value or unlimited}` and see if brush ulimit returns the same value and the other way round.


author_association: CONTRIBUTOR
user: login: lu-zero
created_at: 2025-05-17T17:29:15Z
body: Discussing this I found a bug already :)
author_association: CONTRIBUTOR
user: login: reubeno
created_at: 2025-05-17T17:58:15Z
body: My recommendation would be to spend a very limited amount of time adding test case(s) that pins the behavior that seems reliably identical. Where there is some deviation, let's leave that as a future concern to worry about (or not, if it's not worth tracking down) 😀
author_association: OWNER
user: login: lu-zero
created_at: 2025-05-17T18:36:34Z
body: Looks like the yaml didn't make the CI happy at all.
author_association: CONTRIBUTOR
user: login: reubeno
created_at: 2025-05-17T18:51:08Z
body: > Looks like the yaml didn't make the CI happy at all.

Do some of those test cases try to change the limits? The CI tests failing are all running in more constrained containers.

I think we can dig into more of this separately, so I'd recommend limiting to the simpler tests that are non-side-effectful and look like they'll run reliably in CI and filing follow-up issues for any additional coverage you think we should invest in later.
author_association: OWNER
user: login: lu-zero
created_at: 2025-05-17T18:56:02Z
body: posix ulimit has a single `-f` limit, so I wouldn't spend more time on it until we have somebody needing it.
author_association: CONTRIBUTOR
user: login: reubeno
created_at: 2025-05-19T16:08:21Z
body: Thanks for all the work on this, @lu-zero; I plan to merge this today.
author_association: OWNER