In dieser Aufgabe werden Sie beginnen, eine einfache, aber robuste Shell zu erstellen. In den nächsten Aufgaben werden Sie diese Shell dann weiter ausbauen.
Dafür werden zu Beginn 2 Objekte (Structs) benötigt:
Die Unit Tests dieser Objekte schreiben Sie bitte wieder in eigene Dateien, siehe folgendes Diagramm:
Die Methoden der Datenstruktur Shell beinhalten das Setup und die Loop einer Shell. In der Loop wird der Prompt ausgegeben und auf die Eingaben des Benutzers gewartet. Schließt der Benutzer seine Eingabe mit RETURN ab, so wertet eine Methode die Eingabezeile aus und erstellt daraus die vom Benutzer eingegeben Kommandos. Diese Loop kann der Benutzer mit dem Kommando exit beenden.
Das Handling der Kommandos übernehmen die Methoden der Datenstruktur Command. Beachten Sie, Ihre derzeitige Shell, mit der Sie selbst im Labor arbeiten (labshell), wird nicht einfach beendet wenn ein Kommando fehlerhaft ist oder nicht ausgeführt werden kann. Ebenso soll Ihre Shell nur bei dem Kommando exit beendet werden.
Die main() Funktion selbst hat somit wenig zu tun, sie muss lediglich eine Instanz des Datentyps Shell starten und das Ergebnis auswerten. Ist das Ergebnis ‘Ok’ so wird das Programm mit dem Exitcode 0 beendet. Tritt intern bei der Benutzung einer Funktionalität Ihrer Shell ein Fehler auf, welcher nicht von Ihnen behandelt werden kann, so wird die Shell mit dem Exitcode 1 beendet.
...
let mut s = Shell::new(..);
match s.start() {
Ok(_) => process::exit(0),
Err(_) => process::exit(1),
}
...
Die Datenstruktur ‘Shell’ hat folgende Felder:
struct Shell<R, W> {
pub reader: R,
pub writer: W,
pub should_exit: bool,
pub name: String,
}
‘reader’ ist der Input Kanal Ihrer Shell und ‘writer’ der Output Kanal. Das Flag ‘should_exit’ signalisiert Ihrer Loop (siehe unten), dass die Loop beendet werden soll. ‘name’ ist der Name der Shell, den diese immer am Anfang des Prompts ausgibt, damit diese sich von Ihrer normalen Shell unterscheidet.
Für Ihre Implementierung verwenden Sie folgende Traitbounds:
Um das Verhalten einer echten Shell zu erhalten benötigen Sie für den Reader die Standard-Eingabe und für den Writer die Standard-Ausgabe.
In den Unit Tests werden andere Typen für R und W benutzt. Schauen Sie sich dazu die Tests in unit_tests_shell.rs an. Diese sollen Ihnen als Beispiel für eigene Tests dienen.
pub fn new(input: R, output: W, name: String) -> Self
Eine Instanz der Shell wird erstellt.
Die public Methode start() ruft die private Methode shell_loop() auf. Die Funktion start() ist für evtl. spätere Erweiterungen gedacht, wenn in der Shell vor dem Starten der Loop noch weitere Initialisierungsarbeiten durchzuführen sind.
Die Basis Loop einer Shell haben wir bereits in der Vorlesung besprochen. Die Loop wartet auf Eingaben des Benutzers, die dieser mit RETURN abschließt. Die Eingaben werden dann ausgewertet und das Kommando, das sich u.U. daraus ergibt ausgeführt. Danach steht die Loop wieder für Eingaben bereit.
Die Funktion gibt den Namen der Shell, den aktuellen Pfad (siehe std::env) sowie das ‘ >’ Zeichen, gefolgt von einem Leerzeichen aus:
bsys-shell /Users/username >
Nach der Ausgabe wartet die Funktion auf Eingaben des Benutzers.
Benutzen Sie zum Einlesen die read_line() Funktion. Liefert read_line() kein Zeichen, so gibt die Funktion None zurück. Werden Zeichen von read_line() eingelesen, so liefert die Funktion die Eingabe des Benutzers zurück.
Damit bei einem write!() Aufruf auf der Konsole auch der String erscheint, muss nach dem write!() Aufruf ein flush() Aufruf folgen
Wie am Funktionskopf zu erkennen ist, liefert diese Funktion im Erfolgsfall den eingelesenen String zurück.
Diese Methode wird von shell_loop() aufgerufen, wenn der Benutzer Eingaben gemacht hat, und daraus ein Kommando geparst werden konnte. Das eigentliche Parsen des Strings geschieht im ‘Command’ Modul. Das Modul liefert nach einem erfolgreichen Parse-Vorgang ein Kommando zurück, welches dann in run() aufgerufen wird.
Sowohl die Funktion zum Parsen des Strings, als auch die Methoden um die eigentlichen Kommandos auszuführen, platzieren Sie bitte in die Datei command.rs.
Die Datenstruktur ‘Command’ in command.rs hat zunächst folgende Felder:
enum Command {
Empty,
Exit,
Cd(Option<OsString>),
}
Die Kommandos bedeuten:
Im Modul command.rs wird die Trait Methode FromStr bereit gestellt, so dass das Objekt Command dieses Trait unterstützt.
...
fn from_str(s: &str) -> Result<Command, .....> {
Diese Funktion können Sie benutzen, um sich den Input des Users in der Shell analysieren zu lassen. Als Rückgabewert erhalten Sie den entsprechenden Command. Somit können Sie im Modul shell.rs komfortabel über folgenden Aufruf den Input String in ein Kommando wandeln lassen:
Command::from_str(&line).and_then(|cmd| self.run(cmd))
In der obigen Trait Methode können die einfachen Kommandos wie:
direkt - je nach Input des User - zurück gegeben werden. Für komplexere Funktionen wie cd bietet es sich an, in der Trait Methode spezifische Methoden des Datentyps Command aufzurufen. Für jedes Kommando sind in der command.rs zwei Methoden zu implementieren:
Für das cd Kommando somit:
Die parse_command() Methode wird im from_str() Trait aufgerufen. Die exec_()> Methode wird aus dem shell Modul an geeigneter Stelle aufgerufen.
Ihr cd Kommando sollte sich so verhalten wie das cd Kommando der labshell. Somit kommen Sie beim Aufruf von cd ohne Parameter in Ihr Home Verzeichnis (siehe env::var_os(“HOME”)). Mit cd .. in das Verzeichnis ‘darüber’ usw.
Benutzen Sie für Ihre Implementierung nur die externe Crate nix.
Ob und wie Sie den Code in weitere Module aufteilen wollen ist Ihnen überlassen. Schreiben Sie jedoch Ihre Unit-Tests in der Datei unit_test_shell.rs oder als eigenständigen Test, der von ‘cargo test’ aufgerufen wird, siehe auch Testing. Einfache Tests können auch direkt in die Dokumentation ‘codiert’ werden, siehe Documentation Tests.
Wichtig: Erstellen Sie ausreichend Unit Tests, um möglichst alle Methoden aus shell.rs und command.rs ausreichend testen zu können.
Bei dieser Aufgabe ist Ihre Dokumentation wichtig, um Ihren Programmablauf nachvollziehen zu können. Bitte dokumentieren Sie Ihre Funktionen entsprechend umfangreicher und kommentieren Sie spezielle Kniffe im Code, die Sie verwendet haben.
Da Sie alle Zeicheneingaben, außer Whitespaces (Leerzeichen, TAB usw.) und exit als Kommandoaufruf interpretieren, wird sich Ihre Shell beim Aufruf Ihrer Programme genauso verhalten wie die normale Shell. Die zur Prozessverwaltung verwendeten Sonderzeichen wie &, | usw. werden in dieser Aufgabe nicht behandelt. Somit wird immer nur ein Programm inklusive aller angegebenen Parameter aufgerufen, auf dessen Ende die Shell wartet, um dann wieder den Prompt auszugeben und auf neue Eingaben zu warten.
bsys-shell /Users/maechtel$ ps 1
PID TT STAT TIME COMMAND
1 ?? Ss 28:42.16 /sbin/launchd
bsys-shell /Users/maechtel$ cat blabla.txt
cat: blabla.txt: No such file or directory
bsys-shell /Users/maechtel$ cat testdatei.txt
Hello you snoopy user ....
bsys-shell /Users/maechtel$ file testdatei.txt
testdatei.txt: ASCII text
bsys-shell /Users/maechtel$ exit
exit
#[derive(Debug)]
pub enum Command {
/// Execute a command with arguments.
Exec { prog: CString, argv: Vec<CString> },
/// Empty command.
Empty,
/// Exit.
Exit,
}
Mehr Kommandos kommen in der optionalen Aufgabe Task3 dazu.
Die Funktion parse_exec enthält den generischen Typen I mit dem Trait Bound Iterator. Das bedeutet, die Funktion verlangt von dem generischen Typen I, dass er vom Typ Iterator mit Items auf &str ist. Somit können Sie in der Funktion über den Parameter args iterieren. Bei diesen Arten von Funktionen mit Iteratoren müssen typischerweise zusätzlich Lifetime Parameter (`a) mit angegeben werden. Auf dieses Thema kommen wir noch in den nächsten Wochen zu sprechen.