The source content for blog.juliobiason.me
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

147 lines
5.1 KiB

+++
title = "Running a Command and Saving Its Output to File in Rust"
date = 2023-09-01
[taxonomies]
tags = ["random", "rust", "command", "log"]
+++
I had an issue: I needed to run a command inside Rust, but I needed that all
its output should go to a file, and I needed to check if there were certain
phrases in it.
<!-- more -->
So, first step: Create a script that could "replicate" the output of a command,
with the expected strings to be captured:
```bash
#!/usr/bin/env bash
for loop in {1..1000}
do
echo "Hello, I'm a script!"
echo "I write stuff in the output."
echo "Everything should go to a file."
echo "But also, you need to capture warnings:"
if (( $loop%7 == 0)); then
echo "WARNING: This is a warning"
echo " It continues if the line starts with spaces"
echo " And keeps going till there are no more spaces-prefixes"
fi
if (( $loop%8 == 0)); then
# ERR is just to make sure we find it easily in the logs
echo "ERR: Sometimes, I also write in stderr!" >&2
echo "ERR: Just for funsies!" >&2
fi
echo "Like this."
echo "Then you're good to go."
echo ""
done
```
What this script does is to print a message over 1,000 times, and sometimes it
will display a "WARNING" text -- which is the special output I need to capture --
and sometimes it will print things to stderr.
For the code, what we need to do is:
1. Spawn the command;
2. Take the stdour (and stderr) from it.
3. Spawn a thread that will keep listening to the output, doing the search,
and writing everything to a file (our log).
4. The thread returns the list of captured messages, which we can get back
when we `.join()` it.
5. Since I was expecting stderr to be smaller enough, I did the capturing of
it after the thread completes (which would also close the file, so we can
be sure that we can open it again without any issues).
The first step is quite easy: Just use `std::process::Command` and use the
`.spawn()` function to create the `Child` controller.
For the second step, we use the `Child` structure and use `.take()` on both
stdout and stderr. This will give us the file descriptor for both (think about
them as `File`s).
The third step is quite easy, actualy: `std::thread::spawn()` to create a
thread, and just read the content from the file descriptors from step 2. In
this, I used `BufReader`, which gives access to reading the content line by
line, which is way easier than reading to a buffer and processing it.
```rust
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Read, Write};
use std::path::PathBuf;
use std::process::Command;
fn main() {
// this requires always running with `cargo run`
let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let the_script = base.join("src").join("the_script.sh");
let mut cmd = Command::new("bash")
.arg(the_script)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
// capture both the stdout and stderr as File structs (actually FDs, but basically the same
// thing)
let stdout = cmd.stdout.take().unwrap();
let mut stderr = cmd.stderr.take().unwrap();
// spawn a thread to keep capturing and processing the stdout.
let writer_pid = std::thread::spawn(move || {
let reader = BufReader::new(stdout);
let lines = reader.lines();
let mut log_file = File::create("script.log").unwrap();
let mut in_warning = false;
let mut result = Vec::new();
for line in lines {
let line = line.unwrap();
log_file.write(line.as_bytes()).unwrap();
log_file.write(b"\n").unwrap(); // 'cause lines() eat it
if line.starts_with("WARNING:") {
in_warning = true;
} else if line.starts_with(" ") && in_warning {
result.push(line);
} else if in_warning {
in_warning = false;
}
}
result
});
// run the command till it finishes
cmd.wait().unwrap();
// ... and wait till the thread finishes processing the whole output.
let warnings = writer_pid.join().unwrap();
// this is somewhat a hack: Instead of spawning a thread for stderr and trying to fight with
// stdout for the lock to be able to write in the log file, we do this after the thread ends
// (which closes the file) and then open it again and write the stderr in the end. We do this
// 'cause we expect that the stderr is way smaller than stdout and can fit in memory without
// any issues.
let mut buffer = String::new();
stderr.read_to_string(&mut buffer).unwrap();
let mut file = OpenOptions::new().append(true).open("script.log").unwrap();
file.write(buffer.as_bytes()).unwrap();
// This is purely for diagnostic purposes. We could put the warnings in another file, or pass
// it along to something else to process it. Here, we just display them.
// Same for stderr: Since we already put them in the file, this is used just to make sure we
// are capturing the errors without looking at the file.
println!("Warnings:\n{:?}", warnings);
println!("ERR:\n{:?}", buffer)
}
```