First Rust project after a long break. bulk image resizer CLI

Hey everyone! I've been programming as a hobby since I was 15, but had to take a 3-4 year break for personal health reasons. I'm finally back and decided to restart with Rust worked through the Rust Book and built something I'd actually use day-to-day.

What it does: ahtapot is a CLI tool for resizing images in bulk. At work I constantly need to batch-resize images and was tired of uploading them to random online tools.

Why I'm posting: I have no idea if my code is idiomatic Rust or not. I wrote everything solo, so I'd love honest feedback on:

  • Am I using ownership/borrowing the way I should?
  • Error handling .unwrap() abuse? Better patterns I should know?
  • Anything that makes a Rustacean cringe

GitHub: GitHub - kaanboraoz/ahtapot: A CLI tool for bulk image renaming, resizing, and format conversion. · GitHub

One thing I want to be upfront about: This project is 100% hand-written no vibe coding, no AI-generated code. Every line came from actually sitting down, thinking it through, and writing it myself. I genuinely enjoy this process even if I spend an entire day wrestling with a single Rust concept, I barely notice the time passing. That's how I know I actually love this.

A small note on language: English isn't my first language and I'm still learning it. For writing things like commit messages, README, and this post I occasionally used Google Translate, DeepL, or Gemini to help me express myself clearly.

That looks great & I'd love to give some tips.
I'll try to put some meaningful feedback together later today / early tomorrow ...

I know how (de-)motivating it can be to (not) know whether anyone is looking at your code

I'd say it's pretty good for what you're trying to do. Couple of small things to note:

  • Usually we have the test directory next to the src not in it. This is usually where your integration tests go, as unit tests usually got in the relevant file wrapped in a #[cfg(test)]
  • your project is pretty small for now (not a problem) so for now it's fine, but if/as your project grows I'd recommend putting things like the cli args, and the image struct in their own file
  • Testing through a cli can be a bit cumbersome so what I really like doing, or having a library src/lib.rs where all the logic lives which you can then directly test by calling functions etc, and test all your edge cases, and then have the binary (you can have both) just be a really thin wrapper around the library that basically only calls the clap argument parsing and kicks off your main function with the relevant args. That tends to be much easier for testing than trying to build long command args.
  • The current behavious may be what you want, but if you're interested you could consider using walkdir so that you can find files in subdirectories as well. Up to you though :slight_smile:

Hopefully you found this helpful!

P.S. as per your request nothing I saw made me cringe.

  • You also seem to use errors and the ? quite nicely which is a good one! if you want to make this process a bit easier you might want to look at either thiserror if you want to be very correct about it, or eyre if you want to go fast.
  • Generally if the borrow checker is happy then ownership/borrowing is pretty good, it's not until you get to much higher performance things that you really need to worry about that imo. just cloneing everything is still quite fast compared to other langs, and often gets optimised away by LLVM anyway
  • well done on going through the process yourself!
fn main() -> Result<(), ImageError> {

Unfortunately, Rust’s default printing of errors returned from main() is not good. A pattern to fix this is to use a separate function:

fn main() {
    if let Err(e) = inner_main() {
        // TODO: Also, the error's `source()`s should be printed if they are present.
        eprintln!("Error: {e}");
        std::process::exit(1);
    }
}

fn inner_main() -> Result<(), ImageError> {
    // your code that returns errors
}

Ideally, you would have tests that check that your user-facing errors are correct and clear. trycmd can help you with that and other end-to-end tests.

But the tests that are there are unit tests, not integration tests. The tests are in the right crate. The real problem is the double nesting of modules — the tests are in test::tests. The extra module should be removed, so that main.rs contains

#[cfg(test)]
mod tests;

and the tests are in either src/tests.rs or src/tests/mod.rs.

Thank you all so very much. I've been going through a difficult health journey for quite a long time, and after that I learned Rust, and then built a Rust project. Having such wonderful people like you here, willing to help me, made me incredibly happy and motivated. Thank you so, so much for your support I'm truly glad you exist. The Rust community is amazing.

I'm sorry to hear about your health journey, but I'm very glad to hear you're having a good time :slight_smile: Good luck with both your health and rust journey in the future!

Let me start by saying I really loved reading your code, respect for on your approach to learning rust and congratulations on such a great first project. It took me a little longer to reply than I wanted, I see you got some great feedback already and have implemented it!

Here are a few more idiomatic patterns & thoughts.

These are snippets and I wrote them without an IDE, so please forgive any typos... They won't work just by copy-paste but they should get you to a point that the compiler guides you the rest of the way.

Code organisation

main wraps lib

Placing your logic in a lib and wrapping it with a binary that simply handles the CLI:

  • allows for better testing of the main functionality
  • let's you and others reuse the logic outside the app in future
  • increases your reach - the lib is on crates.io & docs.rs
  • saves the main() / inner_main() split

Like this:

ahtapot
  - src
    - main.rs
    - lib.rs
  - tests
    - file_formats.rs
    - invalid_formats.rs
    - assets
      - image1.gif
      - image1.png
      - not_an_image.gif
      - text_file.txt
- Cargo.toml

Cargo.toml

[package]
name = "ahtapot"
version = "0.1.0"
edition = "2024"

[lib]

[[bin]]
name = "aht"
path = "src/main.rs"

[dependencies]
clap = { version = "4.6.1", features = ["derive"] }
image = "0.25.10"

main.rs

use ahtapot::{Image}

...

Handle input validation directly in main

I always find it easiest to have main read like a "list of tasks to do".

I know others, with lots of experience have other views, but I also find that main returning Result is fine, as long as I'm happy just having exit codes 0 / 1 and a slightly more technical error message.

  • The output formatting is "Error: {:?}" which means the debug representation of the error prefixed by "Error: "
    • depending on the error this might be nice or might be uglier than the Display formatted version
    • if stderr is unavailable (unlikely) Result handles this without panicing
    • you can find out more here std::process::Termination

either way you could simplify by bringing the logic into main/inner_main

fn main() -> Result<(), AhtError> {
    let args = Args::try_parse()?;
    
    let dir = fs::read_dir(Args.dir)?;
    if dir.is_empty() {
        ...
    }
    
    for (i, filename) in dir.iter().enumerate() {
        ...
    }

    Ok(())
}

Take advantage of standard traits & conventions

Consolidated Error handling with From

One of the first traits to look at is std::convert::From. This will also let you create your own error type, which is very idiomatic:

lib.rs

/// Error types used by aht lib & ahtapot binary
pub enum AhtError {
    /// An error for the CLI app ahtapot
    CLIError(clap::Error),
    IOError(io::Error),
    ImageError,
}

/// io::Errors give an AhtError::IOError
impl From<io::Error> for AhtError {
    fn from(err: io::Error) -> AhtError {
        AhtError::IOError(err)
    }
}
// you might not need IOError if ImageError already does this, or you might want it yourself ...

...

// Also think about (start and let the compiler & docs.rs guide you to get everything right):
impl Error for AhtError {
    fn source ...
}

Document everything

You might notice any comments above that could stay in the code have /// (3) not just //(2).

These comments will be turned into documentation and automatically published to docs.rs if you publish your crate to crates.io

They will also show up in your IDE to help you when coding.

Use ? rather than LBYL checks

Do you really need to check path.is_file() or will Image::new()? handle this fine.

Or do you actually have a logic bug ... and any resizing error will break your for loop when you would rather just skip that file?

Derive everything you can for your types

The API guidelines explain this really well.

tldr; it's really annoying to try to do something with a type you made and be told Foo doesn't implement Clone etc. It's even more annoying when it's from someone else's library.

Put the following on top of every struct and enum then delete the bits the compiler complains about:

#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord PartialOrd)]

Use well known & popular crates (once you've understood how to do something yourself)

clap for argument parsing

Great, that you first looked at how argument parsing works by doing it yourself!

derive_more

To make your types easier to use

thiserror & anyhow for errors

Once you've hand-rolled an Error enum of your own you might want to use thiserror - lots of people do, personally I like to do errors by hand.

Anyhow provides an even lazier option, everything can be an anyhow::Error. It's good for quick & dirty (e.g. in your main/inner_main) if you want to keep lib errors & app errors as separate concerns. You can just return anyhow::Result<()> and use ? on anything: image::ImageError, io::Error, aht::AhtError, ...