Capstone 2: the command line

The tracker has a model and a file, and no way to reach either from a terminal. This lesson adds the command line: a parser that turns std::env::args into a Command enum, a dispatch that runs one handler per variant, a help text and a status for a wrong command.

The split matters for the tests of lesson 132: parse_args takes a slice of strings rather than reading the process, so a test can call it with any arguments and no process. The handlers take a path for the same reason, and the real main is the only place that names tasks.txt.

Each section shows the whole src/main.rs of that step, run with cargo run -q -- in the package directory.

Arguments into a command

A command is an enum, so a misspelled option is a parse error at the edge rather than a string that travels into the handlers. add carries a title and an optional due day, done carries an id, and list and help carry nothing.

The parser consumes the iterator of arguments in order. A missing value after a command or an option ends in a message that names what was missing, and an unknown command ends in a message that names the word.

The program below is only the parser and a table of argument lists. The five cases cover the empty command line, the two commands with values, a missing title and an unknown word.

#[derive(Debug, Clone, PartialEq)] enum Command { List, Add { title: String, due: Option<u32> }, Done { id: u32 }, Help, } fn parse_args(args: &[String]) -> Result<Command, String> { let mut rest = args.iter(); let name = match rest.next() { Some(name) => name.as_str(), None => return Ok(Command::Help), }; match name { "list" => Ok(Command::List), "help" => Ok(Command::Help), "add" => { let title = rest.next().ok_or("add needs a title")?.clone(); let mut due = None; while let Some(flag) = rest.next() { match flag.as_str() { "--due" => { let value = rest.next().ok_or("--due needs a day")?; due = Some(value.parse().map_err(|_| format!("bad day: {value}"))?); } other => return Err(format!("unknown option {other}")), } } Ok(Command::Add { title, due }) } "done" => { let value = rest.next().ok_or("done needs an id")?; let id = value.parse().map_err(|_| format!("bad id: {value}"))?; Ok(Command::Done { id }) } other => Err(format!("unknown command {other}")), } } fn main() { let cases: [&[&str]; 5] = [ &[], &["list"], &["add", "write the model", "--due", "110"], &["done", "3"], &["fly"], ]; for case in cases { let args: Vec<String> = case.iter().map(|item| item.to_string()).collect(); match parse_args(&args) { Ok(command) => println!("{command:?}"), Err(error) => println!("error {error}"), } } }

cargo run -q --

Help
List
Add { title: "write the model", due: Some(110) }
Done { id: 3 }
error unknown command fly

The empty command line becomes Help, which is the friendly default and the one thing a user types when the program is new. The parsed command prints in the debug format, which is how the section avoids a file and still shows that every field arrived.

The ?Sized-free signature, a slice of String, is what the test of lesson 132 uses; the real process passes env::args().skip(1) and drops the program name. The lesson on arguments covers the iterator and the first argument.

Dispatching to handlers

A dispatch table keeps the growth of the tool cheap: a new command is an enum variant, one match arm and one function, and the parser and the help text are the only other places that mention it. The alternative, a chain of string comparisons in main, works for three commands and becomes a second parser for thirty.

The dispatch loads the file once and matches on the command. Each arm is one handler: the list prints every task and a count, the add builds a task with the next free id and saves, and the done finds a task by id, completes it and saves.

The program below is the whole tool. The transcript creates two tasks, lists them, completes the first and lists the result, all in the same file, so the second run of list shows the change.

use std::process::ExitCode; #[derive(Debug, Clone, Copy, PartialEq)] enum State { Open, Done, } #[derive(Debug, Clone, PartialEq)] struct Task { id: u32, title: String, state: State, due: Option<u32>, } #[derive(Debug, PartialEq)] #[allow(dead_code)] enum ModelError { EmptyTitle, AlreadyDone, AlreadyOpen, } #[allow(dead_code)] impl Task { fn new(id: u32, title: &str, due: Option<u32>) -> Result<Task, ModelError> { if title.trim().is_empty() { return Err(ModelError::EmptyTitle); } Ok(Task { id, title: title.trim().to_string(), state: State::Open, due, }) } fn complete(&mut self) -> Result<(), ModelError> { if self.state == State::Done { return Err(ModelError::AlreadyDone); } self.state = State::Done; Ok(()) } fn reopen(&mut self) -> Result<(), ModelError> { if self.state == State::Open { return Err(ModelError::AlreadyOpen); } self.state = State::Open; Ok(()) } fn describe(&self) -> String { let state = match self.state { State::Open => "open", State::Done => "done", }; match self.due { Some(due) => format!("#{} {} [{}] due {}", self.id, self.title, state, due), None => format!("#{} {} [{}] no due date", self.id, self.title, state), } } } fn state_name(state: State) -> &'static str { match state { State::Open => "open", State::Done => "done", } } fn save(path: &str, tasks: &[Task]) -> std::io::Result<()> { let mut out = String::new(); for task in tasks { let due = task.due.map(|day| day.to_string()).unwrap_or_default(); out.push_str(&format!("{}|{}|{}|{}\n", task.id, state_name(task.state), due, task.title)); } std::fs::write(path, out) } fn parse_line(line: &str) -> Option<Task> { let mut parts = line.split('|'); let id: u32 = parts.next()?.parse().ok()?; let state = match parts.next()? { "open" => State::Open, "done" => State::Done, _ => return None, }; let due_text = parts.next()?; let due = if due_text.is_empty() { None } else { Some(due_text.parse().ok()?) }; let title = parts.next()?; let mut task = Task::new(id, title, due).ok()?; if state == State::Done { task.complete().ok()?; } Some(task) } fn load(path: &str) -> std::io::Result<(Vec<Task>, usize)> { let text = std::fs::read_to_string(path)?; let mut tasks = Vec::new(); let mut skipped = 0; for line in text.lines() { match parse_line(line) { Some(task) => tasks.push(task), None => skipped += 1, } } Ok((tasks, skipped)) } #[derive(Debug, Clone, PartialEq)] enum Command { List, Add { title: String, due: Option<u32> }, Done { id: u32 }, Help, } fn parse_args(args: &[String]) -> Result<Command, String> { let mut rest = args.iter(); let name = match rest.next() { Some(name) => name.as_str(), None => return Ok(Command::Help), }; match name { "list" => Ok(Command::List), "help" => Ok(Command::Help), "add" => { let title = rest.next().ok_or("add needs a title")?.clone(); let mut due = None; while let Some(flag) = rest.next() { match flag.as_str() { "--due" => { let value = rest.next().ok_or("--due needs a day")?; due = Some(value.parse().map_err(|_| format!("bad day: {value}"))?); } other => return Err(format!("unknown option {other}")), } } Ok(Command::Add { title, due }) } "done" => { let value = rest.next().ok_or("done needs an id")?; let id = value.parse().map_err(|_| format!("bad id: {value}"))?; Ok(Command::Done { id }) } other => Err(format!("unknown command {other}")), } } fn load_or_empty(path: &str) -> Vec<Task> { match load(path) { Ok((tasks, skipped)) => { if skipped > 0 { eprintln!("skipped {skipped} lines"); } tasks } Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), Err(error) => { eprintln!("cannot read {path}: {error}"); Vec::new() } } } fn next_id(tasks: &[Task]) -> u32 { tasks.iter().map(|task| task.id).max().unwrap_or(0) + 1 } fn run(command: Command, path: &str) -> Result<(), String> { let mut tasks = load_or_empty(path); match command { Command::List => { for task in &tasks { println!("{}", task.describe()); } println!("{} tasks", tasks.len()); } Command::Add { title, due } => { let id = next_id(&tasks); let task = Task::new(id, &title, due).map_err(|error| format!("{error:?}"))?; tasks.push(task); save(path, &tasks).map_err(|error| error.to_string())?; println!("added {id}"); } Command::Done { id } => match tasks.iter_mut().find(|task| task.id == id) { Some(task) => { task.complete().map_err(|error| format!("{error:?}"))?; save(path, &tasks).map_err(|error| error.to_string())?; println!("done {id}"); } None => return Err(format!("no task {id}")), }, Command::Help => print_help(), } Ok(()) } fn print_help() { println!("tasks list"); println!("tasks add <title> [--due <day>]"); println!("tasks done <id>"); println!("tasks help"); } fn main() -> ExitCode { let args: Vec<String> = std::env::args().skip(1).collect(); match parse_args(&args) { Ok(command) => match run(command, "tasks.txt") { Ok(()) => ExitCode::SUCCESS, Err(error) => { eprintln!("error: {error}"); ExitCode::FAILURE } }, Err(error) => { eprintln!("error: {error}"); ExitCode::from(2) } } }

rm -f tasks.txt
cargo run -q -- add "write the model" --due 110
cargo run -q -- add "read the tests"
cargo run -q -- list
cargo run -q -- done 1
cargo run -q -- list

added 1
added 2
#1 write the model [open] due 110
#2 read the tests [open] no due date
2 tasks
done 1
#1 write the model [done] due 110
#2 read the tests [open] no due date
2 tasks

Each command is a separate process and the file is the only thing that carries state between them, which is the shape of every command-line tool. The next id is the largest id in the file plus one, so a deleted task leaves its number unused rather than handing it to a new task.

The file is read and written on every command, which is fine for a few hundred tasks and would be wrong for a database. The lesson on vectors covers the iter_mut and find that the done handler uses, and paths covers the path a real tool would take from a configuration file instead of a constant.

The help text

A tool that prints its help to stdout for help and to stderr for an error is doing two different things with the same text: the first is the answer to a request, the second is part of a complaint. Both are useful and both are short. The help text is four lines, and it is printed by the same handler that an empty command line reaches. A wrong command prints its message to stderr and then the help, which is the pattern a user needs: the error says what was wrong and the help says what was possible.

The transcript shows the help, the empty command line and a command that needs a value it did not get.

use std::process::ExitCode; #[derive(Debug, Clone, Copy, PartialEq)] enum State { Open, Done, } #[derive(Debug, Clone, PartialEq)] struct Task { id: u32, title: String, state: State, due: Option<u32>, } #[derive(Debug, PartialEq)] #[allow(dead_code)] enum ModelError { EmptyTitle, AlreadyDone, AlreadyOpen, } #[allow(dead_code)] impl Task { fn new(id: u32, title: &str, due: Option<u32>) -> Result<Task, ModelError> { if title.trim().is_empty() { return Err(ModelError::EmptyTitle); } Ok(Task { id, title: title.trim().to_string(), state: State::Open, due, }) } fn complete(&mut self) -> Result<(), ModelError> { if self.state == State::Done { return Err(ModelError::AlreadyDone); } self.state = State::Done; Ok(()) } fn reopen(&mut self) -> Result<(), ModelError> { if self.state == State::Open { return Err(ModelError::AlreadyOpen); } self.state = State::Open; Ok(()) } fn describe(&self) -> String { let state = match self.state { State::Open => "open", State::Done => "done", }; match self.due { Some(due) => format!("#{} {} [{}] due {}", self.id, self.title, state, due), None => format!("#{} {} [{}] no due date", self.id, self.title, state), } } } fn state_name(state: State) -> &'static str { match state { State::Open => "open", State::Done => "done", } } fn save(path: &str, tasks: &[Task]) -> std::io::Result<()> { let mut out = String::new(); for task in tasks { let due = task.due.map(|day| day.to_string()).unwrap_or_default(); out.push_str(&format!("{}|{}|{}|{}\n", task.id, state_name(task.state), due, task.title)); } std::fs::write(path, out) } fn parse_line(line: &str) -> Option<Task> { let mut parts = line.split('|'); let id: u32 = parts.next()?.parse().ok()?; let state = match parts.next()? { "open" => State::Open, "done" => State::Done, _ => return None, }; let due_text = parts.next()?; let due = if due_text.is_empty() { None } else { Some(due_text.parse().ok()?) }; let title = parts.next()?; let mut task = Task::new(id, title, due).ok()?; if state == State::Done { task.complete().ok()?; } Some(task) } fn load(path: &str) -> std::io::Result<(Vec<Task>, usize)> { let text = std::fs::read_to_string(path)?; let mut tasks = Vec::new(); let mut skipped = 0; for line in text.lines() { match parse_line(line) { Some(task) => tasks.push(task), None => skipped += 1, } } Ok((tasks, skipped)) } #[derive(Debug, Clone, PartialEq)] enum Command { List, Add { title: String, due: Option<u32> }, Done { id: u32 }, Help, } fn parse_args(args: &[String]) -> Result<Command, String> { let mut rest = args.iter(); let name = match rest.next() { Some(name) => name.as_str(), None => return Ok(Command::Help), }; match name { "list" => Ok(Command::List), "help" => Ok(Command::Help), "add" => { let title = rest.next().ok_or("add needs a title")?.clone(); let mut due = None; while let Some(flag) = rest.next() { match flag.as_str() { "--due" => { let value = rest.next().ok_or("--due needs a day")?; due = Some(value.parse().map_err(|_| format!("bad day: {value}"))?); } other => return Err(format!("unknown option {other}")), } } Ok(Command::Add { title, due }) } "done" => { let value = rest.next().ok_or("done needs an id")?; let id = value.parse().map_err(|_| format!("bad id: {value}"))?; Ok(Command::Done { id }) } other => Err(format!("unknown command {other}")), } } fn load_or_empty(path: &str) -> Vec<Task> { match load(path) { Ok((tasks, skipped)) => { if skipped > 0 { eprintln!("skipped {skipped} lines"); } tasks } Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), Err(error) => { eprintln!("cannot read {path}: {error}"); Vec::new() } } } fn next_id(tasks: &[Task]) -> u32 { tasks.iter().map(|task| task.id).max().unwrap_or(0) + 1 } fn run(command: Command, path: &str) -> Result<(), String> { let mut tasks = load_or_empty(path); match command { Command::List => { for task in &tasks { println!("{}", task.describe()); } println!("{} tasks", tasks.len()); } Command::Add { title, due } => { let id = next_id(&tasks); let task = Task::new(id, &title, due).map_err(|error| format!("{error:?}"))?; tasks.push(task); save(path, &tasks).map_err(|error| error.to_string())?; println!("added {id}"); } Command::Done { id } => match tasks.iter_mut().find(|task| task.id == id) { Some(task) => { task.complete().map_err(|error| format!("{error:?}"))?; save(path, &tasks).map_err(|error| error.to_string())?; println!("done {id}"); } None => return Err(format!("no task {id}")), }, Command::Help => print_help(), } Ok(()) } fn print_help() { println!("tasks list"); println!("tasks add <title> [--due <day>]"); println!("tasks done <id>"); println!("tasks help"); } fn main() -> ExitCode { let args: Vec<String> = std::env::args().skip(1).collect(); match parse_args(&args) { Ok(command) => match run(command, "tasks.txt") { Ok(()) => ExitCode::SUCCESS, Err(error) => { eprintln!("error: {error}"); print_help(); ExitCode::FAILURE } }, Err(error) => { eprintln!("error: {error}"); print_help(); ExitCode::from(2) } } }

cargo run -q -- help
cargo run -q --
cargo run -q -- done; echo "status $?"

tasks list
tasks add <title> [--due <day>]
tasks done <id>
tasks help
tasks list
tasks add <title> [--due <day>]
tasks done <id>
tasks help
error: done needs an id
tasks list
tasks add <title> [--due <day>]
tasks done <id>
tasks help
status 2

The last command prints the error, the help and a status of two, and the two are the difference between a program that told the user something and one that failed silently. The help is written by hand rather than generated from the parser, which is the right size for four commands and would be wrong for forty.

The lesson on error propagation covers the layers that a larger tool puts between the parser and the handlers, and the next lesson replaces the debug format of the errors with messages for the user.

The exit code of a wrong command

Three outcomes need three codes: success is zero, a command that parsed but failed is one, and a command that could not be parsed is two. A script that calls the tool can tell a missing task from a typo without reading the text.

The program prints the usage line for a parse error and returns two, and returns one for a handler that failed. The transcript shows both.

use std::process::ExitCode; #[derive(Debug, Clone, Copy, PartialEq)] enum State { Open, Done, } #[derive(Debug, Clone, PartialEq)] struct Task { id: u32, title: String, state: State, due: Option<u32>, } #[derive(Debug, PartialEq)] #[allow(dead_code)] enum ModelError { EmptyTitle, AlreadyDone, AlreadyOpen, } #[allow(dead_code)] impl Task { fn new(id: u32, title: &str, due: Option<u32>) -> Result<Task, ModelError> { if title.trim().is_empty() { return Err(ModelError::EmptyTitle); } Ok(Task { id, title: title.trim().to_string(), state: State::Open, due, }) } fn complete(&mut self) -> Result<(), ModelError> { if self.state == State::Done { return Err(ModelError::AlreadyDone); } self.state = State::Done; Ok(()) } fn reopen(&mut self) -> Result<(), ModelError> { if self.state == State::Open { return Err(ModelError::AlreadyOpen); } self.state = State::Open; Ok(()) } fn describe(&self) -> String { let state = match self.state { State::Open => "open", State::Done => "done", }; match self.due { Some(due) => format!("#{} {} [{}] due {}", self.id, self.title, state, due), None => format!("#{} {} [{}] no due date", self.id, self.title, state), } } } fn state_name(state: State) -> &'static str { match state { State::Open => "open", State::Done => "done", } } fn save(path: &str, tasks: &[Task]) -> std::io::Result<()> { let mut out = String::new(); for task in tasks { let due = task.due.map(|day| day.to_string()).unwrap_or_default(); out.push_str(&format!("{}|{}|{}|{}\n", task.id, state_name(task.state), due, task.title)); } std::fs::write(path, out) } fn parse_line(line: &str) -> Option<Task> { let mut parts = line.split('|'); let id: u32 = parts.next()?.parse().ok()?; let state = match parts.next()? { "open" => State::Open, "done" => State::Done, _ => return None, }; let due_text = parts.next()?; let due = if due_text.is_empty() { None } else { Some(due_text.parse().ok()?) }; let title = parts.next()?; let mut task = Task::new(id, title, due).ok()?; if state == State::Done { task.complete().ok()?; } Some(task) } fn load(path: &str) -> std::io::Result<(Vec<Task>, usize)> { let text = std::fs::read_to_string(path)?; let mut tasks = Vec::new(); let mut skipped = 0; for line in text.lines() { match parse_line(line) { Some(task) => tasks.push(task), None => skipped += 1, } } Ok((tasks, skipped)) } #[derive(Debug, Clone, PartialEq)] enum Command { List, Add { title: String, due: Option<u32> }, Done { id: u32 }, Help, } fn parse_args(args: &[String]) -> Result<Command, String> { let mut rest = args.iter(); let name = match rest.next() { Some(name) => name.as_str(), None => return Ok(Command::Help), }; match name { "list" => Ok(Command::List), "help" => Ok(Command::Help), "add" => { let title = rest.next().ok_or("add needs a title")?.clone(); let mut due = None; while let Some(flag) = rest.next() { match flag.as_str() { "--due" => { let value = rest.next().ok_or("--due needs a day")?; due = Some(value.parse().map_err(|_| format!("bad day: {value}"))?); } other => return Err(format!("unknown option {other}")), } } Ok(Command::Add { title, due }) } "done" => { let value = rest.next().ok_or("done needs an id")?; let id = value.parse().map_err(|_| format!("bad id: {value}"))?; Ok(Command::Done { id }) } other => Err(format!("unknown command {other}")), } } fn load_or_empty(path: &str) -> Vec<Task> { match load(path) { Ok((tasks, skipped)) => { if skipped > 0 { eprintln!("skipped {skipped} lines"); } tasks } Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), Err(error) => { eprintln!("cannot read {path}: {error}"); Vec::new() } } } fn next_id(tasks: &[Task]) -> u32 { tasks.iter().map(|task| task.id).max().unwrap_or(0) + 1 } fn run(command: Command, path: &str) -> Result<(), String> { let mut tasks = load_or_empty(path); match command { Command::List => { for task in &tasks { println!("{}", task.describe()); } println!("{} tasks", tasks.len()); } Command::Add { title, due } => { let id = next_id(&tasks); let task = Task::new(id, &title, due).map_err(|error| format!("{error:?}"))?; tasks.push(task); save(path, &tasks).map_err(|error| error.to_string())?; println!("added {id}"); } Command::Done { id } => match tasks.iter_mut().find(|task| task.id == id) { Some(task) => { task.complete().map_err(|error| format!("{error:?}"))?; save(path, &tasks).map_err(|error| error.to_string())?; println!("done {id}"); } None => return Err(format!("no task {id}")), }, Command::Help => print_help(), } Ok(()) } fn print_help() { println!("tasks list"); println!("tasks add <title> [--due <day>]"); println!("tasks done <id>"); println!("tasks help"); } fn main() -> ExitCode { let args: Vec<String> = std::env::args().skip(1).collect(); match parse_args(&args) { Ok(command) => match run(command, "tasks.txt") { Ok(()) => ExitCode::SUCCESS, Err(error) => { eprintln!("error: {error}"); ExitCode::FAILURE } }, Err(error) => { eprintln!("error: {error}"); println!("usage: tasks <list|add|done|help>"); ExitCode::from(2) } } }

cargo run -q -- fly; echo "status $?"
cargo run -q -- done 99; echo "status $?"

error: unknown command fly
usage: tasks <list|add|done|help>
status 2
error: no task 99
status 1

The two errors go to stderr and the status carries the rest, which is what makes the tool usable in a script: tasks done 99 || echo missing prints the word without looking at the output. The lesson on exit codes covers ExitCode, and the next lesson tests all of this without starting a process.

Exercises

  1. Add a remove <id> command and decide what it prints when the id is unknown.
  2. Accept --due before the title as well and keep the parser readable.
  3. Read the file name from an environment variable and fall back to tasks.txt.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. Why is a command an enum rather than a string?

    Answer

    A misspelled option fails at the parser, and the handlers match on variants

    The enum makes the set of commands closed and the values typed.

  2. Why does parse_args take a slice of strings?

    Answer

    A test can call it with any arguments and no process

    Reading the process is kept in main, so the parser is an ordinary function.

  3. What does an empty command line do?

    Answer

    It prints the help text

    No arguments parse to Help, which is the friendly default.

  4. What is the next id of a file with tasks 1, 2 and 4?

    Answer

    5

    The next id is the largest present plus one, so a removed number is not reused.

  5. Why are parse errors and handler errors different exit codes?

    Answer

    A script can tell a typo from a failed operation without reading the text

    Two for usage, one for a failed operation, zero for success is a small and useful contract.

  6. What does this program print? #[derive(Debug, PartialEq)] enum Command { List, Done(u32), Help, } fn parse(args: &[&str]) -> Result<Command, String> { match args.first() { None => Ok(Command::Help), Some(&"list") => Ok(Command::List), Some(&"done") => match args.get(1) { Some(id) => id.parse().map(Command::Done).map_err(|_| format!("bad id {id}")), None => Err("done needs an id".to_string()), }, Some(other) => Err(format!("unknown {other}")), } } fn main() { println!("{:?}", parse(&[])); println!("{:?}", parse(&["done", "7"])); println!("{:?}", parse(&["done", "x"])); }

    Answer

    Ok(Help) / Ok(Done(7)) / Err("bad id x")

    The empty list is help, the number parses and the letter does not.

  7. What does this program print? fn main() { let args = ["add", "title", "--due", "9", "--due", "10"]; let mut due = None; let mut index = 1; while index < args.len() { if args[index] == "--due" { due = args[index + 1].parse::<u32>().ok(); index += 2; } else { index += 1; } } println!("{:?}", due); }

    Answer

    Some(10)

    The loop overwrites the first value with the second, which is the last one parsed.

  8. What does this program print? fn main() { let tasks = [(1u32, true), (2, false), (4, false)]; let next = tasks.iter().map(|(id, _)| *id).max().unwrap_or(0) + 1; let open = tasks.iter().filter(|(_, done)| !done).count(); println!("{next} {open}"); }

    Answer

    5 2

    The largest id is four and two of the three tasks are open.

  9. What does this program print? use std::process::ExitCode; fn status(name: &str) -> ExitCode { match name { "ok" => ExitCode::SUCCESS, "fail" => ExitCode::FAILURE, _ => ExitCode::from(2), } } fn main() -> ExitCode { println!("{}", if status("bad") == ExitCode::from(2) { "usage" } else { "other" }); status("bad") }

    Answer

    usage

    The comparison picks the first branch; the returned code becomes two for the shell.

  10. What does this program print? fn main() { let mut out = String::new(); let commands = [("list", 1u32), ("add", 2), ("done", 3)]; for (command, count) in commands { out.push_str(&format!("{command}={count} ")); } println!("{}", out.trim_end()); }

    Answer

    list=1 add=2 done=3

    The loop appends each pair and trim_end removes the last space.

The code on this page was built with cargo 1.96.1 and rustc 1.96.1 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev