First Ownership Issue

2026-03-19

This is the first post in my Rust journey series.

To tackle this migration, I’m using Claude. The reason is simple: while I want to learn Rust, and I do, I still want to finish that project at some point.

As I mentioned in the introduction post, this project is a mess and there is a lot of unwarranted complexity.

So I’m using Claude to plan and write some of the easier stuff. Then I go over it and try to improve it.

For instance, one part of the changes I’m making for this rewrite is to use SQLite as the database. Right now, the original project uses Airtable (a excel like SaaS). So I’ve exported the tables and loaded them into a JSON file. From there, I want to build a CLI with Rust to load the records into a SQLite database.

So I worked on some plan with Claude and created a first PR to load the configuration table into SQLite.

Then I worked on a second plan to load the clients tables.

At this point, during the review, I realized that I was reading the JSON file twice, so I refactored the code to do it only once.

The original code did:

let config_records = load_config_from_json(&args.src).await?;
let client_records = load_clients_from_json(&args.src).await?;

As you can see, the path is passed to two different functions. Each of which will load the file completely and only partially map it to a data structure.

Instead I wanted something like:

let export = load_json(&args.src).await?;
let config_records = load_config_from_export(export.config).await?;
let client_records = load_clients_from_export(export.clients).await?;

Where load JSON would map to a struct like:

/// Root JSON structure matching Airtable export format
#[derive(Debug, Deserialize)]
pub struct AirtableExport {
    pub config: AirtableConfigExport,
    pub clients: AirtableClientsExport,
}

So I wrote the small function, inspired from the original implementation:

/// Load config records from Airtable JSON export
pub async fn load_json(json_path: &std::path::Path) -> Result<AirtableExport> {
    let json_content = fs::read_to_string(json_path)
        .with_context(|| format!("Failed to read JSON file: {}", json_path.display()))?;

    let export: AirtableExport = serde_json::from_str(&json_content)
        .context("Failed to parse JSON")?;

    Ok(export)
}

This went fine, no surprise here. Then I updated the load_clients and load_config functions to take in the export struct instead of the file path.

pub async fn load_config_from_export(export: AirtableExport) -> Result<Vec<ClientRow>> {
    ...
}
pub async fn load_clients_from_export(export: AirtableExport) -> Result<Vec<ClientRow>> {
    ...
}

But when I tried to re-work the calling site, I stumbled upon the following error:

use of moved value: `export`
value used here after move

Or more interesting in the IDE:

Screenshot of an ownership issue in Rust

To me, this is really counter-intuitive. All the experiences I have had so far are into higher level languages like Javascript and Java (and Scala). In none of those languages is that a problem to just pass an argument to two functions.

So I opened the Rust book website and reached out for the ownership section. I had already some intuition of what I was looking for because I had stumbled on that when working on that small project at Disney, but I never took the time to fully understand why this was happening.

This snippet in the Rust book shows exactly what my issue is:

Screenshot of a section of the Rust book about ownership

Here is how I’d try to summarize the issue that I have.

We say that it has been moved there. It was moved because it does not implement the Copy trait. We can’t implement the Copy trait because you can only implement the Copy trait if all the members of your struct are of a known size. That’s not our case, here, the records member is of type Vec which is of an unknown size at compile time and can only live on the heap.

The compiler keeps track of the movement of a variable and because of that it knows, that after the first function call, the variable is out of scope, so it refuses to compile.

After reading that, I went back and made the most basic change: instead of sending in the full export struct, what if I just send the config and the clients parts to each function, load_config_from_export and load_clients_from_export, respectively.

So that’s what I did, and it worked.

Reading further into the book, I reached the section about References and Borrowing. It mentions that if you want to call a function without transferring the ownership of the variable to that function, you can use a reference. In that case that meant I could have just done: load_config_from_export(&export) and that would also work.

Borrowing seems to be the best solution here: it does not involve updating the definition of the function but also, it means that the functions don’t take the ownership of partial data.

In the next post, I’ll try to write a trait and it’s implementation to abstract the function of converting and inserting records into SQLite. I’ll do it from scratch, and then compare with what Claude comes up with.