Rust er et språk som oppmuntrer til både effektivitet og sikkerhet. Når man utvikler applikasjoner som skal håndtere tekst, som for eksempel et lite erstatningsprogram for echo, er det viktig å tenke på både struktur og testbarhet. I denne artikkelen skal vi gjennomgå hvordan vi kan skrive et slikt program og sikre at det fungerer som forventet, gjennom testskriving og sammenligning av utdata.

I vårt tilfelle ønsker vi å lage et program som etterligner funksjonaliteten til det velkjente echo-kommandoen, men med tilpasningene som gir oss mulighet til å kontrollere hvordan og når linjeskift legges til i utdataene. Ved å bruke et argument kalt omit_newline kan brukeren velge om programmet skal skrive ut en linje med et linjeskift på slutten eller ikke.

Programmet begynner med å samle inn tekst og vurdere om linjeskift skal inkluderes. Dette kan implementeres på en mer "rustisk" måte ved å bruke if-uttrykket, som i Rust er en uttrykkstype, ikke en setning. Dette gjør at vi kan bruke det for å direkte tildele en verdi til variabelen ending basert på innstillingen som brukes, uten behov for ekstra kontrollstrukturer.

rust
let ending = if omit_newline { "" } else { "\n" };

Med denne tilnærmingen kan programmet skrive ut tekst på en enkel og effektiv måte:

rust
fn main() { let matches = ...; // Hent de nødvendige argumentene
let text: Vec<String> = matches.get_many("text").unwrap().cloned().collect();
let omit_newline = matches.get_flag("omit_newline"); print!("{}{}", text.join(" "), if omit_newline { "" } else { "\n" }); }

Når koden er skrevet, er det på tide å sørge for at den fungerer som forventet. En måte å gjøre dette på er å skrive integrasjonstester som verifiserer at programmet håndterer både vanlige tilfeller og feiltilfeller på riktig måte.

Skriving av Tester for Rust-applikasjoner

For å teste applikasjonen effektivt, benytter vi oss av noen nyttige crates: assert_cmd, pretty_assertions, og predicates. Disse verktøyene gjør det enklere å sammenligne forventet og faktisk utdata.

Først kan vi begynne med å sikre at programmet mislykkes når det ikke gis noen argumenter:

rust
#[test]
fn dies_no_args() { let mut cmd = Command::cargo_bin("echor").unwrap(); cmd.assert() .failure() .stderr(predicate::str::contains("Usage")); }

Dette testtilfellet sørger for at programmet skriver ut hjelpetekst og mislykkes når det ikke mottar nødvendige argumenter. For å kjøre testen kan man bruke cargo test, og det vil bekrefte om programmet håndterer denne situasjonen korrekt.

Neste steg er å teste om programmet fungerer riktig når et argument er gitt:

rust
#[test]
fn runs() { let mut cmd = Command::cargo_bin("echor").unwrap(); cmd.arg("hello").assert().success(); }

Her sørger vi for at programmet kjøres vellykket når det mottar et enkelt argument.

Sammenligning av Utdata

Et viktig neste skritt er å sammenligne utdataene fra vårt program med de fra det originale echo. Vi kan gjøre dette ved å bruke enkle bash-skript for å generere eksempelfiler som inneholder forventet utdata. Når vi har disse filene, kan vi lese dem inn i testene våre og sammenligne resultatene med hva programmet faktisk skriver ut.

For å fange utdataene fra den originale echo-kommandoen kan vi bruke et bash-skript som genererer forskjellige utdata for flere mulige innganger. Dette kan være nyttig for å sammenligne hva som skjer med ulike tekstkombinasjoner og ulike innstillinger for linjeskift.

bash
#!/usr/bin/env bash
OUTDIR="tests/expected" [[ ! -d "$OUTDIR" ]] && mkdir -p "$OUTDIR" echo "Hello there" > $OUTDIR/hello1.txt
echo "Hello" "there" > $OUTDIR/hello2.txt
echo -n "Hello there" > $OUTDIR/hello1.n.txt echo -n "Hello" "there" > $OUTDIR/hello2.n.txt

Når disse utdataene er generert, kan vi sammenligne dem mot vårt eget program for å sikre at det produserer de forventede resultatene.

Bruk av Result-typen for Feilhåndtering

Under utviklingen har vi brukt unwrap for å håndtere mulige feil, men i mer robuste applikasjoner bør man håndtere feilsituasjoner mer forsiktig. Ved å bruke anyhow::Result kan vi lettere håndtere feil på en ryddig måte, uten å forårsake unødvendige krasj.

For eksempel, når vi leser filene som inneholder forventet utdata, bør vi ikke anta at filene alltid finnes og kan åpnes. Ved å bruke ?-operatoren kan vi håndtere eventuelle feil på en elegant måte:

rust
use anyhow::Result; #[test]
fn hello1() -> Result<()> {
let outfile = "tests/expected/hello1.txt"; let expected = fs::read_to_string(outfile)?; let mut cmd = Command::cargo_bin("echor").unwrap(); cmd.arg("Hello there").assert().success().stdout(expected); Ok(()) }

Med denne tilnærmingen kan vi forsikre oss om at feil blir riktig håndtert, og at programmet ikke krasjer unødvendig.

Hva er Viktig å Huske på

Rust krever at utviklere tenker nøye gjennom hvordan data blir håndtert og hvordan feil skal håndteres. Det er lett å bli fristet til å bruke unwrap eller anta at alt vil gå bra, men i produksjonsklare applikasjoner må man være mer forsiktig. Bruk av robuste feilhåndteringsmetoder og testdekning sikrer at programmet er stabilt og at eventuelle feil kan fanges opp tidlig.

I tillegg er det viktig å sikre at utdataene fra programmet er i samsvar med de originale spesifikasjonene. Dette er spesielt viktig når man lager programmer som etterligner funksjonaliteten til eksisterende verktøy som echo. Ved å benytte systematiske tester og sammenligning av utdata kan man være sikker på at programmet oppfører seg som forventet under forskjellige forhold.

Hvordan bygge et program i Rust som etterligner grep-kommandoen

I Rust kan man bygge et program som etterligner funksjonaliteten til den velkjente grep-kommandoen ved hjelp av argumentbehandling og regulære uttrykk. Denne artikkelen beskriver hvordan man kan lage et slikt program, med vekt på hvordan man definerer og bruker kommando-linjeargumenter, samt hvordan man bygger og tester regulære uttrykk.

Først må vi definere en struktur som kan holde programargumentene. Denne strukturen vil inkludere et mønster for søk, en liste over filer, samt flere opsjoner som kontrollerer hvordan søket skal utføres.

rust
#[derive(Debug)] struct Args { pattern: String, files: Vec<String>, insensitive: bool, recursive: bool, count: bool, invert: bool, }

Her er pattern et obligatorisk søkemønster, mens files er en liste med filnavn (eller en liste med plassholdere for input). De øvrige feltene er boolske variabler som bestemmer spesifikasjoner for søket: insensitive bestemmer om søket skal være case-insensitivt, recursive styrer om søket skal gå gjennom undermapper, count teller antall treff, og invert reverserer resultatene ved å vise linjer som ikke matcher mønsteret.

Videre kan vi bruke clap-biblioteket for å håndtere argumentene. Dette gjør at vi kan definere argumentene med en deklarativ syntaks som gir både fleksibilitet og enkelhet i implementeringen. Her er et eksempel på hvordan man kan bruke clap for å hente argumentene:

rust
use clap::{Arg, Command}; fn get_args() -> Args {
let matches = Command::new("grepr")
.
version("0.1.0") .author("Ken Youens-Clark") .about("Rust version of `grep`") .arg(Arg::new("pattern").required(true).help("Search pattern")) .arg(Arg::new("files").num_args(1..).default_value("-").help("Input file(s)"))
.arg(Arg::new("insensitive").short('i').long("insensitive").help("Case-insensitive"))
.
arg(Arg::new("recursive").short('r').long("recursive").help("Recursive search"))
.arg(Arg::new("count").short('c').long("count").help("Count occurrences"))
.
arg(Arg::new("invert").short('v').long("invert-match").help("Invert match")) .get_matches(); Args {
pattern: matches.get_one::<String>("pattern").unwrap().to_string(),
files: matches.get_many::<
String>("files").unwrap().cloned().collect(), insensitive: matches.get_flag("insensitive"), recursive: matches.get_flag("recursive"), count: matches.get_flag("count"), invert: matches.get_flag("invert"), } }

Denne funksjonen bruker clap til å definere og hente argumenter, som deretter lagres i Args-strukturen. Når programmet kjøres, vil disse argumentene være tilgjengelige for videre behandling.

I hovedfunksjonen (main) kan vi deretter hente og vise de parsed argumentene for å sikre at alt fungerer som forventet:

rust
fn main() { let args = get_args(); println!("{:?}", args); }

Når programmet kjøres, vil argumentene bli hentet og printet på skjermen. For eksempel, om vi gir programmet mønsteret "dog", uten spesifiserte filer, skal resultatet være:

plaintext
Args {
pattern: "dog", files: ["-"], insensitive: false, recursive: false, count: false, invert: false, }

Når argumentene er korrekt definert og hentet, kan vi bruke disse til å lage et regulært uttrykk som kan brukes til å matche mønstre i tekst. Dette krever at vi bruker Rusts regex-bibliotek, som gir en kraftig måte å håndtere og jobbe med regulære uttrykk på. Med RegexBuilder kan vi bygge et regulært uttrykk basert på argumentene som er gitt til programmet:

rust
use regex::{RegexBuilder};
use anyhow::{anyhow, Result}; fn run(args: Args) -> Result<()> { let pattern = RegexBuilder::new(&args.pattern) .case_insensitive(args.insensitive) .build() .map_err(|_| anyhow!(r#"Invalid pattern "{}""#, args.pattern))?; println!("pattern \"{}\"", pattern); Ok(()) }

Denne koden bygger et regulært uttrykk med muligheten til å håndtere case-insensitive søk, basert på flagget -i. Hvis mønsteret er ugyldig, som for eksempel et mønster med en feilaktig syntaks som * (som krever et gyldig mønster foran), vil programmet kaste en feil.

Når mønsteret er bygget og validert, kan vi bruke det til å søke i de spesifiserte filene eller strømmen (STDIN). Programmet skal da kunne håndtere de spesifikasjonene som er angitt via kommando-linjen, og vise treffene på en effektiv måte.

Et viktig aspekt ved denne prosessen er å teste at programmet fungerer korrekt, særlig i situasjoner med ugyldige mønstre. For eksempel, et mønster som er ufullstendig, som *, bør føre til en feilmelding som informerer brukeren om at mønsteret er feilaktig. Dette kan testes ved å kjøre:

bash
cargo run -- "*"

Resultatet bør være en feilmelding som sier at mønsteret er ugyldig. Dette viser at programmet er i stand til å håndtere vanlige feil i mønsterdefinisjonen og gir en god brukeropplevelse ved å informere om eventuelle problemer.

I tillegg er det viktig å merke seg at rekkefølgen på de posisjonelle argumentene er viktig i clap. Den første posisjonelle parameteren vil alltid være det obligatoriske mønsteret, mens de øvrige parameterne er valgfrie og kan defineres før eller etter det obligatoriske mønsteret.

Ved å bruke denne tilnærmingen, får vi et fleksibelt og robust verktøy for tekstsøk og mønstermatching i Rust, som etterligner grep-kommandoen i Unix.

Hvordan finne og lese filer for et Fortune-program i Rust?

Når du arbeider med et komplekst program som dette, er det viktig å dele opp løsningen i mindre, testbare funksjoner. Et av de første stegene i utviklingen er å finne filene som skal brukes som input til programmet. Dette kan være en utfordring, spesielt når man håndterer både filer og kataloger. Her vil vi gå gjennom hvordan man kan finne inputfiler i Rust, hvordan man håndterer forskjellige filtyper, og hvordan man leser innholdet i disse filene for videre behandling.

Rust tilbyr flere nyttige verktøy for håndtering av filstier og filsystemoperasjoner. En av de mest nyttige er Path, som lar deg inspisere filbaner og utføre operasjoner som å bryte dem ned i komponenter, hente filnavn eller avgjøre om banen er absolutt. Selv om Path er et nyttig verktøy, er det viktig å merke seg at det er et ikke-størrelsesbestemt type, og det kreves derfor at det brukes bak en peker som en referanse (&) eller en Box. Den modifiserbare versjonen av Path kalles PathBuf, og det er denne som ofte brukes når du trenger å gjøre endringer på filbanen. Dette gir deg mer fleksibilitet, og du unngår potensielle problemer med henvisninger til "døde" verdier.

For å finne filer i et gitt sett med kilder, som kan være både filnavn eller kataloger, kan man lage en funksjon som tar en liste med filbaner og returnerer en liste med PathBuf-objekter. Når du arbeider med kataloger, vil alle filene i katalogen bli inkludert. Det er viktig å merke seg at visse filtyper, som .dat-filer generert av strfile, skal ignoreres, da disse ikke brukes i dette programmet. Programmet ditt bør enten filtrere bort slike filer eller sørge for at de ikke blir inkludert ved å skrive logikk for å håndtere dem.

Funksjonen som finner filene kan ha følgende signatur:

rust
fn find_files(paths: &[String]) -> Result<Vec<PathBuf>, std::io::Error> { unimplemented!(); }

For å teste funksjonen kan du lage enhetstester som verifiserer at filene finnes og at de blir returnert i riktig rekkefølge. Husk at det er viktig at filene blir returnert i sortert rekkefølge for å unngå problemer med testene og for å sikre konsistens. Du kan bruke funksjoner som Vec::sort og Vec::dedup for å sikre at du får unike filbaner og at de er sortert før de returneres.

Etter at filene er funnet, kan du skrive ut resultatene ved å bruke println!, slik at programmet gir en oversikt over hvilke filer som er blitt funnet:

rust
fn run(args: Args) -> Result<()> { let files = find_files(&args.sources)?; println!("{:?}", files); Ok(()) }

Når du har en liste med filene, er neste steg å lese innholdet i disse filene. For et program som håndterer "fortune"-filer, vil du ofte måtte hente ut teksten fra hver fil og strukturere den på en måte som gjør det mulig å finne og vise spesifikke mønstre. I dette tilfellet kan en Fortune-struktur være nyttig. Denne strukturen kan inneholde både filkilden (filnavnet) og tekstinnholdet, som er teksten før prosenttegn (%).

En slik Fortune-struktur kan defineres som følger:

rust
#[derive(Debug)]
struct Fortune { source: String, text: String, }

For å lese innholdet fra filene kan du lage en funksjon som tar en liste med PathBuf-objekter og returnerer en liste med Fortune-objekter. Denne funksjonen kan ha følgende signatur:

rust
fn read_fortunes(paths: &[PathBuf]) -> Result<Vec<Fortune>, std::io::Error> { unimplemented!(); }

I likhet med filfunksjonen er det viktig at programmet håndterer feil på en forsvarlig måte, slik at hvis en fil er utilgjengelig eller ikke kan leses, blir dette håndtert uten at programmet krasjer. Når programmet er i stand til å lese inn tekstene fra filene, kan det begynne å finne og vise de passende "fortune"-oppskriftene basert på de mønstrene som brukeren har angitt.

En annen viktig detalj som kan være nyttig er hvordan programmet håndterer spesielle filtyper, som skjulte filer eller kataloger som ikke inneholder faktiske data. Hvis kataloger er tomme eller bare inneholder skjulte filer som .gitkeep, kan det være lurt å ignorere disse for å unngå unødvendige feil under prosesseringen.

En annen utfordring som kan oppstå under utviklingen av slike programmer er håndtering av feil som oppstår når en fil ikke eksisterer eller ikke er lesbar. Dette kan forårsake problemer hvis programmet ikke håndterer feilen riktig. Du bør alltid sørge for at feil behandles på en måte som gir mening i konteksten av programmet, for eksempel ved å logge feilen og gi brukeren en forståelig feilmelding.

Det er også viktig å forstå at når programmet er laget for å kunne håndtere ulike operativsystemer, må filhåndtering og filbaner være plattformuavhengige. Ved å bruke Rusts standardbibliotek for filoperasjoner kan du være trygg på at programmet ditt vil fungere på forskjellige systemer uten å måtte gjøre spesifikke tilpasninger for hvert operativsystem.