Logo

Streams and Directory Operations in C#

23 min read
Lesson slides
1 / 17

Operating Systems I - Lesson 3

Streams and Directory Operations in C#

Read and write files and directory trees in C# using streams, adapters, and decorators, then extend a shell-style console application with new commands.

By the end of this lesson you will be able to read and write files and directory trees in C# using streams, adapters, and decorators, and extend a shell-style console application with new file and directory commands.

Objectives

  • Explain what a stream is and why .NET groups stream-related types into backing stores, adapters, and decorators.
  • Read and write files using FileStream, choosing the correct FileMode, FileAccess, and FileShare values.
  • Release stream resources deterministically with the using statement, and explain how it relates to IDisposable and Dispose().
  • Inspect files and directories through FileInfo and DirectoryInfo properties such as Length, Extension, LastWriteTime, Parent, and GetDirectoryRoot.
  • Use the static convenience methods on File and Directory (WriteAllText, WriteAllBytes, WriteAllLines, Move, Delete, GetFiles, GetDirectories) to perform common tasks in a single call.
  • Read binary primitive data with BinaryReader/BinaryWriter, and transform stream content with a decorator such as DeflateStream.
  • Traverse an entire directory tree recursively to measure its size, copy it, or search its files for a piece of text.
  • Extend a Linux-shell-style console application with new commands built on top of these types.

Prerequisites

  • Completion of the previous lesson's console application (the Linux-shell emulator with pwd, ls, mkdir, touch, rm, cat, and nano).
  • Basic C# syntax: classes, static methods, switch statements, loops, and exception types.
  • A working .NET SDK and an IDE or editor capable of running a C# console project.
  • Familiarity with the System.IO namespace from the previous lesson.
  • Converting text entered by the user into typed values with int.Parse, double.Parse, and bool.Parse (each throws FormatException if the text does not match the expected shape).

What Is a Stream

A stream is a sequence of bytes that flows between an application and some source or destination: a file on disk, a location on the network, or a block of memory. Whenever a program needs data that lives outside its own memory, for example the contents of a file or the response from a web request, .NET moves that data through a stream, one chunk of bytes at a time. Every character you can see on screen, even a single letter, is stored on disk as one or more bytes: the letter A is stored as the byte value 65, and a stream is what carries that byte from the disk into your program, or from your program back to the disk.

All stream types in .NET derive from the abstract class System.IO.Stream. This base class defines the operations every stream supports, such as Read, Write, Seek, Close, and properties like CanRead, CanWrite, CanSeek, and Position. Because every concrete stream type inherits from Stream, code that works with the base class can work with a file, a block of memory, or a network connection interchangeably.

Stream Categories: Backing Stores, Adapters, and Decorators

.NET organizes its stream-related types into three categories, and this lesson uses at least one type from each:

  • Backing stores connect directly to an external source. FileStream is a backing store for files; it reads and writes raw bytes.
  • Adapters wrap a backing store and change the shape of the data without changing the source. StreamReader/StreamWriter adapt a byte stream into text, and BinaryReader/BinaryWriter adapt a byte stream into primitive values such as int, double, or bool.
  • Decorators transform the content itself, for example compressing or encrypting it, while still exposing the same stream API. DeflateStream is a decorator that compresses data on write and decompresses it on read; the "Decorators and DeflateStream" section below works through it with a full example.

Splitting the types this way keeps each class focused on one job: a backing store only knows how to move bytes in and out of its source, an adapter only knows how to translate bytes into a richer shape, and a decorator only knows how to transform content. This separation means you can swap the backing store (a file today, a network connection tomorrow) without rewriting the adapter code built on top of it, swap the adapter without touching the backing store, and even attach more than one decorator to the same stream at the same time, since a decorator only needs another stream to wrap.

flowchart LR
    Ext[External source: file, network, memory] --> BS[Backing store: FileStream]
    BS --> AD1[Adapter: StreamReader / StreamWriter - text]
    BS --> AD2[Adapter: BinaryReader / BinaryWriter - primitives]
    BS --> DEC[Decorator: DeflateStream - compress / decompress]
    AD1 --> App[Your application]
    AD2 --> App
    DEC --> App

FileStream, FileMode, FileAccess, and FileShare

FileStream is the backing store you use to open a file at a byte level. Its constructor takes a path plus a combination of three enumerators:

FileStream fs = new FileStream("data.txt", FileMode.Open, FileAccess.Read, FileShare.Read);
  • FileMode decides what happens to the file on open: Create makes a new file or overwrites an existing one, CreateNew fails if the file already exists, Open fails if the file does not exist, OpenOrCreate opens the file if it exists and creates it otherwise, Append opens the file and moves to its end, and Truncate opens an existing file and empties it.
  • FileAccess decides what the stream is allowed to do once it is open: Read, Write, or ReadWrite.
  • FileShare decides what other file handles, in the same process or a different one, are allowed to do to the same file while yours is still open. FileShare.None reserves the file exclusively for your stream, FileShare.Read lets others read it at the same time, and FileShare.ReadWrite lets others both read and write it. Choosing FileShare.None when you are about to write critical data prevents another process from reading a half-written file.

Once open, a FileStream exposes CanRead, CanWrite, and CanSeek so you can check what an already-open stream supports, and a Position property that tracks where the next read or write will happen. Seek(offset, origin) moves that position explicitly, where origin is a SeekOrigin value; SeekOrigin.Begin measures the offset from the start of the stream, so Seek(0, SeekOrigin.Begin) is how a program jumps back to the beginning of a file to re-read what it just wrote, without closing and reopening the stream. No task in this lesson calls Seek directly.

Resource Cleanup: IDisposable and the using Statement

Before a C# program runs, its compiler translates the source code into an intermediate language (IL) rather than directly into machine code; every .NET language's compiler produces the same IL, which is why code written in different .NET languages can call into each other. A layer called the Common Language Runtime (CLR) takes that IL and manages it completely: allocating memory for it, allocating disk space it needs, and releasing memory once it is no longer needed. Code that runs this way, entirely under the CLR's control, is called managed code, and a just-in-time (JIT) compiler is what the CLR uses to translate the IL into the machine code that the operating system actually executes, right before that code runs.

Because managed code stays under the CLR's control the whole time, the CLR's garbage collector (GC) can track it and clean it up automatically: internally, the GC runs in at least two passes, first walking through objects to mark which ones are no longer reachable, then walking through again to actually collect and free them. Some code, however, calls out of the managed pipeline entirely, for example a call that goes straight from the compiler to machine code without passing through the CLR. That code is called unmanaged code, and the garbage collector does not know how to clean it up, because it was never under the CLR's management in the first place. This is exactly the situation IDisposable exists for: whenever a managed class such as FileStream wraps an unmanaged resource such as an operating-system file handle, the class itself is managed and the GC can eventually reclaim its memory, but the file handle inside it is not something the GC can release on its own, so the class must expose a way to release it deterministically.

Opening a FileStream, or any adapter built on top of one such as StreamReader, StreamWriter, BinaryReader, or BinaryWriter, does more than allocate memory: it reserves an operating-system resource, a file handle, for as long as the object stays open. If a program never releases that handle, the file can stay locked to other processes, and the handle itself stays reserved until the operating system eventually reclaims it. This is exactly why the previous lesson's file-reading and file-writing methods end with an explicit pair of calls such as sr.Close(); fs.Close(); or w.Close(); fs.Close();: closing the reader or writer first, then the underlying stream, releases the handle as soon as the method is done with it.

.NET gives every type that holds this kind of resource a standard cleanup contract called IDisposable. A class that implements IDisposable exposes a Dispose() method, and FileStream, StreamReader, StreamWriter, BinaryReader, and BinaryWriter all implement it, so calling Dispose() on one of them does the same cleanup as calling Close(). The using statement is a compiler feature built on this contract: wrapping an object in a using block, or declaring it with a using variable declaration, tells the compiler to call that object's Dispose() automatically once the object goes out of scope, whether the code ran to completion or exited early because of an exception. Internally, the compiler rewrites a using into a try/finally block with the Dispose() call placed in the finally, which is what guarantees the cleanup still runs even when something goes wrong partway through.

Both forms of using appear later in this lesson. A using block wraps the object's scope in braces:

using (FileStream fs = new FileStream("data.bin", FileMode.Open, FileAccess.Read))
{
    // fs.Dispose() runs automatically here, at the closing brace
}

A using declaration, without braces, disposes the object at the end of the enclosing block instead:

using StreamReader sr = new StreamReader("data.txt");
// sr.Dispose() runs automatically at the end of this method

Either form replaces the previous lesson's manual sr.Close(); fs.Close(); pattern with the same cleanup, done automatically and safely even if an exception happens in between, so from this lesson onward every stream-related object is opened with using instead of closed by hand. If a using is forgotten entirely, the object is not left uncleaned forever: .NET's garbage collector will eventually notice the object is unreachable and reclaim it through the object's finalizer, a special method the garbage collector calls on an object, on its own schedule, right before reclaiming that object's memory, and which is distinct from Dispose() and never called directly by your own code. That cleanup happens whenever the garbage collector gets to it rather than immediately, which is why using is the recommended way to release a resource as soon as the code is done with it, instead of leaving it to chance.

Implementing IDisposable on Your Own Class

Every IDisposable type used so far is one .NET already wrote (FileStream, StreamReader, StreamWriter, BinaryReader, BinaryWriter); this lesson only ever consumes it through using. A class you write yourself needs the same contract implemented by hand whenever it holds a resource that should be released deterministically rather than left for the garbage collector.

The standard pattern splits the cleanup into a public Dispose(), which is what a caller invokes, and a protected virtual void Dispose(bool disposing), which does the actual work and is shared with the finalizer:

public class ResourceHolder : IDisposable
{
    private FileStream resource;
    private bool disposed = false;
 
    public ResourceHolder(string path)
    {
        resource = new FileStream(path, FileMode.OpenOrCreate);
    }
 
    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }
 
    protected virtual void Dispose(bool disposing)
    {
        if (disposed)
        {
            return;
        }
 
        if (disposing)
        {
            resource.Dispose();
        }
 
        disposed = true;
    }
 
    ~ResourceHolder()
    {
        Dispose(false);
    }
}
  • The constructor, public ResourceHolder(string path), opens the resource this class is responsible for and stores it in the private resource field, so that field is what Dispose later releases.
  • Dispose() is the method a caller runs, directly or through a using block. It calls Dispose(true) to do the real cleanup, then calls GC.SuppressFinalize(this) to tell the garbage collector that the finalizer below no longer needs to run, because the resource has already been released.
  • The disposed field guards against cleaning up twice: if Dispose() were somehow called again, or if it already ran and the finalizer runs afterward anyway, the second attempt returns immediately instead of disposing an already-disposed resource.
  • Dispose(bool disposing) holds the shared cleanup logic and is reached from two different paths, which is what the disposing parameter distinguishes. When called from Dispose(), disposing is true, and it is safe to dispose other managed objects such as resource. When called from the finalizer, disposing is false, because by the time a finalizer runs, other managed objects may already have been collected, so only resources outside of .NET's own memory management would be safe to release there.
  • ~ResourceHolder() is the finalizer (also called a destructor): the garbage collector calls it on its own schedule as a last-resort safety net, in case something holding a ResourceHolder never called Dispose(). It calls Dispose(false), so cleanup still happens, just later and limited to what remains safe to touch by then.

Four ways exist to trigger this cleanup, from least to most reliable: calling Dispose() by hand and hoping every code path reaches it; wrapping the code in try/catch/finally with the Dispose() call placed in the finally block so it runs even after an exception; wrapping the object in a using block; or a using declaration. The last two are preferred because the compiler guarantees the call happens exactly once, in the right place, without depending on the programmer remembering to write it on every path.

FileInfo and DirectoryInfo Properties

FileInfo and DirectoryInfo both derive from FileSystemInfo and describe a single file or directory without opening a stream to it. They are the right tool whenever you need metadata rather than content.

FileInfo exposes FullName (the complete path), Name (just the file name), Extension (including the leading dot, for example .txt), Length (the size in bytes), LastWriteTime (when the file's content was last changed), LastAccessTime, CreationTime, and Directory (a DirectoryInfo instance for the folder that contains it).

DirectoryInfo exposes FullName, Name, Parent (a DirectoryInfo for the containing directory, or null at a drive root), CreationTime, LastAccessTime, and methods such as GetFiles() and GetDirectories() that return the entries directly inside it. Directory.GetDirectoryRoot(path) is a related static method that returns the root of the drive a given path lives on (for example C:\).

FileInfo fi = new FileInfo("data.txt");
Console.WriteLine("Extension : {0}", fi.Extension);
Console.WriteLine("Size      : {0} bytes", fi.Length);
Console.WriteLine("Modified  : {0}", fi.LastWriteTime);

This snippet creates a FileInfo for data.txt without opening the file, then reads three of its properties: Extension gives the file's extension, Length gives its size, and LastWriteTime gives the timestamp of the last write. None of these three lines touch the file's actual content.

DirectoryInfo can also read a directory's access control list, the set of rules that say which users or groups may read, write, or execute it, through GetAccessControl(). Managing permissions is out of scope for this lesson and is not covered further here.

Static Convenience Methods on File and Directory

Alongside the stream classes, File and Directory expose static methods that do a whole operation in one call, internally opening and closing whatever stream they need.

On File: File.Exists(path), File.Delete(path), File.Move(source, destination) (which both moves and renames, since renaming is just moving within the same folder), File.WriteAllText(path, text) and File.ReadAllText(path), File.WriteAllLines(path, lines) and File.ReadAllLines(path), and File.WriteAllBytes(path, bytes) and File.ReadAllBytes(path). All three WriteAll*/ReadAll* pairs do the same job, opening a stream, writing or reading everything, and closing it, but for a different shape of data: a single block of text, an array of lines, or an array of raw bytes.

On Directory: Directory.CreateDirectory(path), Directory.Exists(path), Directory.Move(source, destination), Directory.Delete(path) (which throws if the directory is not empty), Directory.Delete(path, true) (which deletes the directory and everything inside it, recursively), Directory.GetLogicalDrives() (the drive letters, or the mounted roots on a non-Windows system), Directory.GetCreationTime(path), Directory.GetLastAccessTime(path), and Directory.GetLastWriteTime(path). Directory.GetFileSystemEntries(path, pattern) is a related method that returns both the files and the subdirectories matching a pattern in one call, for when a task needs every entry together rather than files and directories listed separately.

Directory.GetFiles and Directory.GetDirectories both accept an optional search pattern and a SearchOption:

string[] textFiles = Directory.GetFiles(path, "*.txt", SearchOption.AllDirectories);

The first argument is the directory to search, the second is a wildcard pattern the entry's name must match, and the third controls whether only the given directory is searched (SearchOption.TopDirectoryOnly) or every nested subdirectory as well (SearchOption.AllDirectories).

Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments) returns the full path to a well-known system folder, such as the current user's Documents folder, without you having to hardcode a path that would be wrong on a different machine or a different operating system.

Text Adapters: StreamWriter and StreamReader

StreamWriter and StreamReader adapt a byte stream into text: StreamWriter.Write/WriteLine turn a string into bytes and send them to the underlying stream, and StreamReader.ReadLine/ReadToEnd pull bytes from the stream and turn them back into a string. Both classes buffer their data internally, which is why StreamWriter.Flush() matters: it forces whatever text is still sitting in the buffer out to the underlying stream immediately, instead of waiting for the buffer to fill up or the writer to close.

StreamReader can also read one character at a time instead of a whole line: sr.Peek() looks at the next available character without consuming it, returning -1 once the stream has nothing left, and sr.Read() consumes and returns the next character as an int, which is why it is usually cast back to char before printing. A loop such as while (sr.Peek() > -1) { Console.Write((char)sr.Read()); } prints an entire file's content one character at a time, a lower-level alternative to calling ReadLine() in a loop.

Binary Adapters: BinaryWriter and BinaryReader

Text is not the only shape of data. BinaryWriter writes primitive values, such as int, double, and bool, directly as their raw bytes rather than as readable characters, and BinaryReader reads them back in the same order they were written.

using (FileStream fs = new FileStream("data.bin", FileMode.Create, FileAccess.Write))
using (BinaryWriter writer = new BinaryWriter(fs))
{
    writer.Write(20);
    writer.Write(101.5);
    writer.Write(true);
}

The FileStream opens data.bin for writing, and the BinaryWriter wraps it. Each writer.Write(...) call writes the exact byte representation of its argument: four bytes for the int, eight bytes for the double, and one byte for the bool. Reading them back must happen in the same order, using the matching typed method, through a BinaryReader that wraps a FileStream opened the same way but with FileMode.Open and FileAccess.Read instead:

using (FileStream fs = new FileStream("data.bin", FileMode.Open, FileAccess.Read))
using (BinaryReader reader = new BinaryReader(fs))
{
    int i = reader.ReadInt32();
    double d = reader.ReadDouble();
    bool b = reader.ReadBoolean();
}

This time the FileStream opens the existing data.bin for reading instead of creating it, and the BinaryReader wraps it the same way the BinaryWriter did on the write side. reader.ReadInt32(), reader.ReadDouble(), and reader.ReadBoolean() each read back the exact type that was written, in the same order it was written, restoring the original int, double, and bool values.

Because a binary file stores raw bytes instead of characters, opening it in a text editor shows unreadable symbols rather than the numbers you wrote; the data is only meaningful when read back through a BinaryReader that knows the exact sequence of types to expect.

Decorators and DeflateStream

Unlike an adapter, which only changes the shape of the data (bytes into text, bytes into primitives), a decorator changes the content itself while still exposing the same stream-reading and stream-writing API as every other stream. DeflateStream is a decorator that compresses data as it is written and decompresses it as it is read, wrapping whatever stream it is given, typically a FileStream, the same way StreamWriter or BinaryWriter wrap one. Because a decorator only needs another stream to wrap, more than one decorator can be attached to the same stream at the same time, for example a compression decorator wrapping an encryption decorator wrapping a FileStream, which is not something an adapter is built to do.

DeflateStream only reads and writes bytes. The example below writes two literal byte values directly with WriteByte, the same byte values as the letters A and B, to keep the focus on the decorator itself. Task 5 further down extends this idea beyond the example: instead of two fixed byte values, it compresses a whole line of user-entered text. As the "What Is a Stream" section above already established, every character is really a numeric byte value underneath (the letter A is the byte value 65), so Task 5 loops over every character in the entered text and writes its byte value with ds.WriteByte((byte)c), one character at a time, instead of writing two fixed values. Reading it back applies the same idea in reverse: each decompressed byte is cast back to a character with (char)b and appended to the result.

using (FileStream fs = new FileStream("data.bin", FileMode.Create, FileAccess.Write))
using (DeflateStream ds = new DeflateStream(fs, CompressionMode.Compress))
{
    ds.WriteByte(65);
    ds.WriteByte(66);
}

The FileStream creates data.bin, and the DeflateStream wraps it with CompressionMode.Compress, so every byte written through ds is compressed before it reaches the file. WriteByte(65) and WriteByte(66) write the raw byte values for the characters A and B. Opening data.bin afterward with the cat command shows unreadable bytes rather than the letters A and B, because the file's real content on disk is compressed.

Reading the data back needs a second DeflateStream, opened with CompressionMode.Decompress, over a FileStream that opens the same file for reading:

using (FileStream fs = new FileStream("data.bin", FileMode.Open, FileAccess.Read))
using (DeflateStream ds = new DeflateStream(fs, CompressionMode.Decompress))
{
    int b;
    while ((b = ds.ReadByte()) != -1)
    {
        Console.WriteLine(b);
    }
}

ds.ReadByte() returns the next decompressed byte as an int, or -1 once there is nothing left to read. A compressed stream's decompressed length is not known ahead of time, so this loop keeps reading byte by byte until -1 signals the end, rather than trying to read a fixed number of bytes up front. Running this against the file written above prints 65 then 66, the same two byte values that were written before compression, confirming that the data round-trips correctly through the decorator even though the file on disk was never in a readable format.

Recursive Directory Algorithms

Three common tasks (measuring a directory's total size, copying a directory tree, and searching every file in a tree for some text) all follow the same shape: look at everything directly inside the current directory, then repeat the process for every subdirectory.

Measuring size sums the Length of every file found with SearchOption.AllDirectories, so a single call replaces a manual recursive walk:

long size = 0;
foreach (FileInfo fi in dirInfo.GetFiles("*", SearchOption.AllDirectories))
{
    size += fi.Length;
}

dirInfo.GetFiles("*", SearchOption.AllDirectories) returns every file anywhere under dirInfo, at any depth, as a single flat array of FileInfo objects, so the method never has to walk into subdirectories itself. The loop then adds each file's Length to size, so once it finishes, size holds the combined byte size of the entire tree.

Copying a tree cannot be a single call, because copying one file only copies that one file and does not know about subdirectories, so the recursion has to be written explicitly: copy every file in the current directory, then call the same method again for every subdirectory. Copying a single file uses FileInfo.CopyTo:

string destPath = Path.Combine(destDir, file.Name);
file.CopyTo(destPath, false);

file.CopyTo(destPath, overwrite) copies the file file refers to, to destPath. The second argument controls what happens if a file already exists there: false makes CopyTo throw instead of touching it, while true overwrites it silently. DirectoryCopy (built in Task 6, shown in full in the complete program listing below) calls CopyTo with false for every file it finds, which is why running cp -r a second time into the same destination fails on the first duplicate file instead of quietly overwriting the earlier copy.

DirectoryCopy takes a third parameter, bool copySubDirs = true, alongside source and dest. This flag is what the recursive step below checks before copying subdirectories at all: when it is true, DirectoryCopy calls itself again for every subdirectory it finds, and when it is false, it copies only the files directly inside source and stops. The default of true means a normal call such as DirectoryCopy(source, dest) copies the whole tree without needing to mention the flag; a caller only has to pass false explicitly to get a shallow, files-only copy. This lesson's cp -r always copies the whole tree, so it always uses the default true, but the parameter exists so the same method could later support a non-recursive copy without being rewritten.

flowchart TD
    A[DirectoryCopy source, dest, copySubDirs = true] --> B{Source exists?}
    B -->|No| C[Throw DirectoryNotFoundException]
    B -->|Yes| D[Create dest if missing]
    D --> E[Copy every file in source to dest]
    E --> F{copySubDirs is true?}
    F -->|No| G[Done]
    F -->|Yes| H[For each subdirectory: call DirectoryCopy again]
    H --> G

Searching file content for a piece of text follows the same recursive shape, but instead of copying bytes it opens each file with a StreamReader, reads its entire text with ReadToEnd(), and checks whether that text Contains the search string.

Lab Tasks

The tasks below extend the shell emulator's command loop from the previous lesson, one command at a time. The result of applying every task, the full command loop plus every method it calls, is given in one piece in the "Complete program listing" section at the end of this page; use it as the reference implementation while you build your own.

Task 1: Richer Directory and File Metadata

  1. Update pwd so that, in addition to the directory's full name, it also prints the drive root using Directory.GetDirectoryRoot(currentDir).
  2. Update ls so that each file line also prints file.Extension alongside the existing file.Length and a timestamp, using file.LastWriteTime instead of LastAccessTime.
  3. Add a new stat command. It should ask for a name, then check File.Exists(name) first and Directory.Exists(name) second to decide which branch to run, printing an error and stopping if neither exists. If it is a file, print, one label per line in the form Label : value: Full name (FullName), Extension, Size (Length, followed by bytes), Last write time (LastWriteTime), and Directory (the containing folder from fi.Directory, a DirectoryInfo, on a FileInfo). If it is a directory, print, in the same Label : value form: Full name (FullName), Parent (from a DirectoryInfo), Drive root (from Directory.GetDirectoryRoot), and the three timestamps Created, Last access time, and Last write time (from Directory.GetCreationTime, Directory.GetLastAccessTime, and Directory.GetLastWriteTime).
  4. Run the program, create a test file with touch demo.txt, then run pwd and confirm the drive root line appears, then run ls and confirm the extension column appears next to demo.txt.
  5. Run stat and enter demo.txt to exercise the file branch, then run stat again and enter . to exercise the directory branch on the current directory.

Expected output for step 4, pwd:

Full name  : <full path of the current directory>
Drive root : <drive root, for example / on Linux>

Expected output for step 4, ls:

demo.txt      .txt      0 bytes      <today's date and time>

Expected output for step 5, file branch:

Full name       : <full path to demo.txt>
Extension       : .txt
Size            : 0 bytes
Last write time : <today's date and time>
Directory       : <full path to the containing directory>

Expected output for step 5, directory branch:

Full name        : <full path of the current directory>
Parent           : <full path of the parent directory>
Drive root       : <drive root, for example / on Linux>
Created          : <today's date and time>
Last access time : <today's date and time>
Last write time  : <today's date and time>

Task 2: Renaming and Removing Whole Trees

  1. Add an mv command. Its logic should live in a method named Mv(), the same way Task 6 names its recursive method DirectoryCopy. It should ask for the current name and the new name, then check whether the source is a file (use File.Move) or a directory (use Directory.Move), printing File moved to <new name> or Directory moved to <new name> accordingly, or reporting an error if neither exists.
  2. Add an rm -r command, matched as the full line rm -r. It should ask for a directory name, confirm it exists, then call Directory.Delete(name, true) to remove it along with everything inside it, and print Directory and everything inside it was deleted.
  3. Test mv: run mkdir olddir, then mv, entering olddir and newdir. Run ls to confirm olddir is gone and newdir is present.
  4. Test rm -r: run mkdir tree, then, outside the emulator (in your operating system's file manager or a separate terminal), place one file inside the new tree folder so it is not empty. Back in the emulator, run rm -r and enter tree. This confirms that Directory.Delete(name, true) removes a non-empty directory, which the plain, non-recursive Directory.Delete(name) would refuse to do.

Expected output after step 3 (moving olddir, a directory, so the directory branch of Mv() runs):

Directory moved to newdir

Expected output after step 4, when the directory still has content:

Directory and everything inside it was deleted.

Task 3: Convenience Text I/O with File.WriteAllLines and File.WriteAllBytes

  1. Add a writelines command. It should ask for a file name, then repeatedly read lines from the console into a list until an empty line is entered, and finally call File.WriteAllLines(name, lines).
  2. Add a readlines command. It should confirm the file exists, call File.ReadAllLines(name), and print each line with its 1-based line number.
  3. Run writelines, name the file notes.txt, and enter three lines followed by an empty line to stop.
  4. Run readlines on notes.txt.

Expected output for step 4:

1: first line you typed
2: second line you typed
3: third line you typed

Note that File.WriteAllText (a single string), File.WriteAllLines (an array or list of strings, one per line), and File.WriteAllBytes (a raw byte array) all follow the identical pattern: pass a path and the data, and the method opens, writes, and closes the underlying stream for you. Which one you reach for depends only on the shape of the data you already have.

Task 4: Binary Data and File Sharing

  1. Add a binwrite command. It should ask for a file name, then ask for an integer, a decimal number, and a true/false value as text and convert each with int.Parse, double.Parse, and bool.Parse respectively, then open a FileStream with FileMode.Create, FileAccess.Write, and FileShare.None, wrap it in a BinaryWriter, and write the three converted values in that order.
  2. Add a binread command. It should confirm the file exists, open a FileStream with FileMode.Open, FileAccess.Read, and FileShare.Read, wrap it in a BinaryReader, read back the integer, the decimal number, and the boolean in the same order they were written, and print them one per line in the form Label : value: Integer, Decimal, and Boolean.
  3. Run binwrite, name the file numbers.bin, and enter 20, 101.5, and true.
  4. Run binread on numbers.bin.
  5. Try opening numbers.bin with cat. Observe that the content is not readable text.

Expected output for step 4:

Integer : 20
Decimal : 101.5
Boolean : True

Explain in your own words, based on the concepts above, why binwrite opens the file with FileShare.None rather than FileShare.ReadWrite.

Task 5: Decorators: Compressing and Decompressing with DeflateStream

  1. Add a compress command. It should ask for a file name and a line of text, open a FileStream with FileMode.Create, wrap it in a DeflateStream with CompressionMode.Compress, then loop over every character c in the entered text and write it with ds.WriteByte((byte)c).
  2. Add a decompress command. It should confirm the file exists, open a FileStream with FileMode.Open, wrap it in a DeflateStream with CompressionMode.Decompress, read bytes one at a time with ReadByte() until it returns -1, casting each one back with (char)b and appending it to a result string, then print the result.
  3. Run compress, name the file message.bin, and enter hello world.
  4. Run cat on message.bin and observe that the content is not readable text.
  5. Run decompress on message.bin.

Expected output for step 5:

hello world

Task 6: Searching, Measuring, and Copying Directory Trees

  1. Add a drives command that prints every entry returned by Directory.GetLogicalDrives().
  2. Add a ls -r command, matched as the full line ls -r. It should ask for a search pattern (defaulting to * if the input is empty), then list every matching file under the current directory using Directory.GetFiles(currentDir, pattern, SearchOption.AllDirectories), followed by every subdirectory using Directory.GetDirectories(currentDir, "*", SearchOption.AllDirectories). Print Matching files: on its own line, then each matching file's full path on its own line; then print Subdirectories: on its own line, then each subdirectory's full path on its own line.
  3. Add a find command. It should ask for a directory and a search text, then, for every file returned by Directory.GetFiles(dir, "*", SearchOption.AllDirectories), open it with a StreamReader, read its full text, and print the file's path if the text contains the search string, finally printing <count> file(s) matched. with the number of files that matched.
  4. Add a du command. It should ask for a directory, then sum fi.Length for every FileInfo returned by dirInfo.GetFiles("*", SearchOption.AllDirectories) and print the total in the form Total size: <n> bytes.
  5. Add a cp -r command, matched as the full line cp -r. It should ask for a source and a destination directory, then call the recursive DirectoryCopy method described above (with copySubDirs left at its default true), which creates the destination if it does not exist, copies every file in the current directory, and, since copySubDirs is true, calls itself again for each subdirectory. Print Copy finished. once it returns.
  6. Add a home command that prints Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments).
  7. Outside the emulator (the emulator has no cd command, so building nested content is easier from your operating system's file manager or a separate terminal), create a folder named tree, containing two text files and one subfolder named notes that itself contains a third text file named greeting.txt with the word "hello" inside it. Back in the emulator, run du on the tree folder's path, then run cp -r, entering the tree folder's path as the source and a destination path ending in tree-copy (for example, the same parent folder followed by tree-copy), then run find on the tree-copy folder for the word "hello".

Expected output for step 7's du on tree (the exact number depends on the size of the two text files you created):

Total size: <n> bytes

Expected output for step 7's cp -r:

Copy finished.

Expected output for step 7's find (assuming the word "hello" appears in exactly one file):

<full path to tree-copy/notes/greeting.txt>
1 file(s) matched.

Summary

  • A stream is a byte-oriented channel between an application and an external source; every concrete stream type in .NET derives from the abstract System.IO.Stream class.
  • .NET separates stream types into backing stores that touch the external source directly (FileStream), adapters that reshape the bytes into something more useful without needing to know or care which backing store is underneath (StreamReader/StreamWriter for text, BinaryReader/BinaryWriter for primitives), and decorators (DeflateStream) that transform the content itself while exposing the same stream API.
  • FileMode: FileAccess, and FileShare together decide how a FileStream opens a file, what it is allowed to do with it, and what other handles are allowed to do to it at the same time.
  • Stream-related objects hold an operating-system resource that must be released; the using statement calls an IDisposable object's Dispose() automatically at the end of its scope (compiling down to a try/finally), replacing the previous lesson's manual Close() calls with a pattern that also runs safely if an exception is thrown.
  • A class you write yourself implements IDisposable with a public Dispose() that calls a shared protected virtual Dispose(bool disposing) and then GC.SuppressFinalize(this), guarded by a disposed flag against double cleanup; a finalizer (~ClassName()) calls the same method as a last-resort safety net for a resource nobody explicitly disposed.
  • FileInfo and DirectoryInfo describe a file or directory (size, extension, timestamps, parent, full path) without opening a stream to read its content.
  • The static methods on File and Directory (WriteAllText/WriteAllLines/WriteAllBytes, Move, Delete, Delete(path, true), GetFiles/GetDirectories with a pattern and a SearchOption) cover the majority of everyday file and directory tasks in a single call each.
  • Recursive algorithms over a directory tree (measuring total size, copying a tree, searching file contents) all share the same shape: handle what is directly inside the current directory, then repeat for every subdirectory.
  • Keeping the Linux-shell emulator's spine (pwd, ls, mkdir, touch, rm, cat, nano) and extending it with mv, rm -r, stat, writelines, readlines, binwrite, binread, compress, decompress, drives, ls -r, find, du, cp -r, and home demonstrates how far the System.IO namespace's small set of building blocks can go.

Complete program listing

The listing below is the full console application with every command from this lesson wired into one command loop, including the ones inherited from the previous lesson (pwd, ls, mkdir, touch, rm, cat, nano) and the ones added in the tasks above.

namespace LapExample;
 
public class Program
{
    public static void Main(string[] args)
    {
        bool exit = false;
        while (!exit)
        {
            Console.Write("student@SDOps:~$ ");
            string input = Console.ReadLine() ?? "";
            switch (input.Trim())
            {
                case "exit":
                    exit = true;
                    break;
                case "pwd":
                    Pwd();
                    break;
                case "ls":
                    Ls();
                    break;
                case "ls -r":
                    LsRecursive();
                    break;
                case "mkdir":
                    MkDir();
                    break;
                case "touch":
                    Touch();
                    break;
                case "rm":
                    Rm();
                    break;
                case "rm -r":
                    RmRecursive();
                    break;
                case "mv":
                    Mv();
                    break;
                case "cat":
                    Cat();
                    break;
                case "nano":
                    Nano();
                    break;
                case "nano-append":
                    NanoAppend();
                    break;
                case "stat":
                    Stat();
                    break;
                case "writelines":
                    WriteLines();
                    break;
                case "readlines":
                    ReadLines();
                    break;
                case "binwrite":
                    BinWrite();
                    break;
                case "binread":
                    BinRead();
                    break;
                case "strdemo":
                    StrDemo();
                    break;
                case "drives":
                    Drives();
                    break;
                case "find":
                    Find();
                    break;
                case "du":
                    Du();
                    break;
                case "cp -r":
                    CpDir();
                    break;
                case "home":
                    Home();
                    break;
                default:
                    Console.WriteLine("Unknown command.");
                    break;
            }
        }
    }
 
    // ----- Directory metadata -----
 
    public static void Pwd()
    {
        var currentDir = Directory.GetCurrentDirectory();
        DirectoryInfo dirinfo = new DirectoryInfo(currentDir);
        Console.WriteLine("Full name  : {0}", dirinfo.FullName);
        Console.WriteLine("Drive root : {0}", Directory.GetDirectoryRoot(currentDir));
    }
 
    public static void Ls()
    {
        var currentDir = Directory.GetCurrentDirectory();
        DirectoryInfo dirinfo = new DirectoryInfo(currentDir);
 
        FileInfo[] filesInDir = dirinfo.GetFiles();
        DirectoryInfo[] dirsInDir = dirinfo.GetDirectories();
 
        foreach (var directoryInfo in dirsInDir)
        {
            Console.WriteLine("{0}      -----      {1}",
                directoryInfo.Name, directoryInfo.LastWriteTime);
        }
        foreach (FileInfo file in filesInDir)
        {
            Console.WriteLine("{0}      {1}      {2} bytes      {3}",
                file.Name, file.Extension, file.Length, file.LastWriteTime);
        }
    }
 
    private static void LsRecursive()
    {
        Console.Write("Enter a search pattern (for example *.txt), or * for every file: ");
        var pattern = Console.ReadLine() ?? "";
        if (string.IsNullOrEmpty(pattern)) pattern = "*";
 
        var currentDir = Directory.GetCurrentDirectory();
 
        string[] files = Directory.GetFiles(currentDir, pattern, SearchOption.AllDirectories);
        Console.WriteLine("Matching files:");
        foreach (var file in files)
        {
            Console.WriteLine(file);
        }
 
        string[] dirs = Directory.GetDirectories(currentDir, "*", SearchOption.AllDirectories);
        Console.WriteLine("Subdirectories:");
        foreach (var dir in dirs)
        {
            Console.WriteLine(dir);
        }
    }
 
    public static void MkDir()
    {
        Console.Write("Enter the name of the directory: ");
        var name = Console.ReadLine() ?? "";
 
        if (string.IsNullOrEmpty(name))
        {
            Console.WriteLine("Directory name cannot be empty.");
            return;
        }
 
        if (Directory.Exists(name))
        {
            Console.WriteLine("Directory already exists.");
            return;
        }
 
        Directory.CreateDirectory(name);
        Console.WriteLine("Directory created.");
    }
 
    // ----- File creation, deletion and moving -----
 
    public static void Touch()
    {
        Console.Write("Enter the name of the file: ");
        var name = Console.ReadLine() ?? "";
 
        if (string.IsNullOrEmpty(name))
        {
            Console.WriteLine("File name cannot be empty.");
            return;
        }
 
        if (File.Exists(name))
        {
            Console.WriteLine("File already exists.");
            return;
        }
 
        var fs = File.Create(name);
        fs.Close(); // Always close the stream after use
        Console.WriteLine("File created.");
    }
 
    private static void Rm()
    {
        Console.Write("Enter the name of the file: ");
        var name = Console.ReadLine() ?? "";
        if (!ValidateExists(name)) return;
 
        File.Delete(name);
        Console.WriteLine("File deleted.");
    }
 
    private static void RmRecursive()
    {
        Console.Write("Enter the name of the directory: ");
        var name = Console.ReadLine() ?? "";
        if (!ValidateDirExists(name)) return;
 
        Directory.Delete(name, true);
        Console.WriteLine("Directory and everything inside it was deleted.");
    }
 
    private static void Mv()
    {
        Console.Write("Enter the current name: ");
        var oldName = Console.ReadLine() ?? "";
        Console.Write("Enter the new name: ");
        var newName = Console.ReadLine() ?? "";
 
        if (string.IsNullOrEmpty(oldName) || string.IsNullOrEmpty(newName))
        {
            Console.WriteLine("Names cannot be empty.");
            return;
        }
 
        if (File.Exists(oldName))
        {
            File.Move(oldName, newName);
            Console.WriteLine("File moved to {0}", newName);
        }
        else if (Directory.Exists(oldName))
        {
            Directory.Move(oldName, newName);
            Console.WriteLine("Directory moved to {0}", newName);
        }
        else
        {
            Console.WriteLine("Source file or directory does not exist.");
        }
    }
 
    // ----- Reading and displaying content -----
 
    private static void Cat()
    {
        Console.Write("Enter the name of the file: ");
        var name = Console.ReadLine() ?? "";
        if (!ValidateExists(name)) return;
 
        var content = File.ReadAllText(name);
        Console.WriteLine(content);
    }
 
    private static void NanoAppend()
    {
        Console.Write("Enter the name of the file: ");
        var name = Console.ReadLine() ?? "";
        if (!ValidateExists(name)) return;
 
        Console.Write("Enter the text to append: ");
        var text = Console.ReadLine() ?? "";
 
        if (string.IsNullOrEmpty(text))
        {
            Console.WriteLine("Text cannot be empty.");
            return;
        }
 
        File.AppendAllText(name, text);
    }
 
    private static void Nano()
    {
        Console.Write("Enter the name of the file: ");
        var name = Console.ReadLine() ?? "";
        if (!ValidateExists(name)) return;
 
        Console.WriteLine("----- ------ ----- [Old Content] ----- ------ -----");
        ReadFileData(name);
 
        Console.WriteLine("----- ------ ----- [Please write the new content] ----- ------ -----");
        WriteFileData(name);
    }
 
    private static void ReadFileData(string? name)
    {
        FileStream fs = new FileStream(name!, FileMode.OpenOrCreate, FileAccess.ReadWrite);
        StreamReader sr = new StreamReader(fs);
        sr.BaseStream.Seek(0, SeekOrigin.Begin); // Move to the start of the file
 
        string? str = sr.ReadLine();
        while (str != null)
        {
            Console.WriteLine("{0}", str);
            str = sr.ReadLine();
        }
 
        sr.Close();
        fs.Close();
    }
 
    private static void WriteFileData(string? name)
    {
        FileStream fs = new FileStream(name!, FileMode.Create, FileAccess.ReadWrite);
        StreamWriter w = new StreamWriter(fs);
 
        var text = Console.ReadLine() ?? "";
        if (string.IsNullOrEmpty(text))
        {
            Console.WriteLine("Text cannot be empty.");
            fs.Close();
            return;
        }
 
        w.Write(text);
        w.Flush(); // Forces the buffered data onto disk
        w.Close();
        fs.Close();
    }
 
    // ----- FileInfo / DirectoryInfo metadata -----
 
    private static void Stat()
    {
        Console.Write("Enter the name of the file or directory: ");
        var name = Console.ReadLine() ?? "";
 
        if (string.IsNullOrEmpty(name))
        {
            Console.WriteLine("Name cannot be empty.");
            return;
        }
 
        if (File.Exists(name))
        {
            FileInfo fi = new FileInfo(name);
            Console.WriteLine("Full name       : {0}", fi.FullName);
            Console.WriteLine("Extension       : {0}", fi.Extension);
            Console.WriteLine("Size            : {0} bytes", fi.Length);
            Console.WriteLine("Last write time : {0}", fi.LastWriteTime);
            Console.WriteLine("Directory       : {0}", fi.DirectoryName);
        }
        else if (Directory.Exists(name))
        {
            DirectoryInfo di = new DirectoryInfo(name);
            Console.WriteLine("Full name        : {0}", di.FullName);
            Console.WriteLine("Parent           : {0}", di.Parent);
            Console.WriteLine("Drive root       : {0}", Directory.GetDirectoryRoot(di.FullName));
            Console.WriteLine("Created          : {0}", Directory.GetCreationTime(name));
            Console.WriteLine("Last access time : {0}", Directory.GetLastAccessTime(name));
            Console.WriteLine("Last write time  : {0}", Directory.GetLastWriteTime(name));
        }
        else
        {
            Console.WriteLine("File or directory does not exist.");
        }
    }
 
    // ----- Convenience File.WriteAllLines / ReadAllLines -----
 
    private static void WriteLines()
    {
        Console.Write("Enter the name of the file: ");
        var name = Console.ReadLine() ?? "";
 
        if (string.IsNullOrEmpty(name))
        {
            Console.WriteLine("File name cannot be empty.");
            return;
        }
 
        Console.WriteLine("Enter lines of text. Type an empty line to stop:");
        var lines = new List<string>();
        string? line;
        while (!string.IsNullOrEmpty(line = Console.ReadLine()))
        {
            lines.Add(line);
        }
 
        File.WriteAllLines(name, lines);
        Console.WriteLine("{0} line(s) written to {1}", lines.Count, name);
    }
 
    private static void ReadLines()
    {
        Console.Write("Enter the name of the file: ");
        var name = Console.ReadLine() ?? "";
        if (!ValidateExists(name)) return;
 
        string[] lines = File.ReadAllLines(name);
        for (int i = 0; i < lines.Length; i++)
        {
            Console.WriteLine("{0}: {1}", i + 1, lines[i]);
        }
    }
 
    // ----- Binary data with BinaryReader / BinaryWriter -----
 
    private static void BinWrite()
    {
        Console.Write("Enter the name of the file: ");
        var name = Console.ReadLine() ?? "";
 
        if (string.IsNullOrEmpty(name))
        {
            Console.WriteLine("File name cannot be empty.");
            return;
        }
 
        Console.Write("Enter an integer: ");
        int i = int.Parse(Console.ReadLine() ?? "0");
        Console.Write("Enter a decimal number: ");
        double d = double.Parse(Console.ReadLine() ?? "0");
        Console.Write("Enter true or false: ");
        bool b = bool.Parse(Console.ReadLine() ?? "false");
 
        using (FileStream fs = new FileStream(name, FileMode.Create, FileAccess.Write, FileShare.None))
        using (BinaryWriter writer = new BinaryWriter(fs))
        {
            writer.Write(i);
            writer.Write(d);
            writer.Write(b);
        }
 
        Console.WriteLine("Binary data written to {0}", name);
    }
 
    private static void BinRead()
    {
        Console.Write("Enter the name of the file: ");
        var name = Console.ReadLine() ?? "";
        if (!ValidateExists(name)) return;
 
        using (FileStream fs = new FileStream(name, FileMode.Open, FileAccess.Read, FileShare.Read))
        using (BinaryReader reader = new BinaryReader(fs))
        {
            int i = reader.ReadInt32();
            double d = reader.ReadDouble();
            bool b = reader.ReadBoolean();
 
            Console.WriteLine("Integer : {0}", i);
            Console.WriteLine("Decimal : {0}", d);
            Console.WriteLine("Boolean : {0}", b);
        }
    }
 
    // ----- In-memory text with StringReader / StringWriter -----
 
    private static void StrDemo()
    {
        using StringWriter sw = new StringWriter();
        sw.WriteLine("Line one from StringWriter");
        sw.WriteLine("Line two from StringWriter");
 
        string builtText = sw.ToString();
 
        Console.WriteLine("----- Built in memory -----");
        using (StringReader sr = new StringReader(builtText))
        {
            string? line;
            while ((line = sr.ReadLine()) != null)
            {
                Console.WriteLine("> {0}", line);
            }
        }
 
        Console.Write("Save this text to a file? Enter a file name or leave empty to skip: ");
        var name = Console.ReadLine() ?? "";
        if (!string.IsNullOrEmpty(name))
        {
            File.WriteAllText(name, builtText);
            Console.WriteLine("Saved to {0}", name);
        }
    }
 
    // ----- Drives, search and recursive copy -----
 
    private static void Drives()
    {
        string[] drives = Directory.GetLogicalDrives();
        foreach (var drive in drives)
        {
            Console.WriteLine(drive);
        }
    }
 
    private static void Find()
    {
        Console.Write("Enter the directory to search: ");
        var dir = Console.ReadLine() ?? "";
        if (!ValidateDirExists(dir)) return;
 
        Console.Write("Enter the text to search for: ");
        var searchText = Console.ReadLine() ?? "";
 
        string[] files = Directory.GetFiles(dir, "*", SearchOption.AllDirectories);
        int matches = 0;
 
        foreach (var file in files)
        {
            try
            {
                using StreamReader sr = new StreamReader(file);
                string contents = sr.ReadToEnd();
                if (contents.Contains(searchText))
                {
                    Console.WriteLine(file);
                    matches++;
                }
            }
            catch (IOException)
            {
                // Skip files that cannot be opened as text (locked, or not text)
            }
        }
 
        Console.WriteLine("{0} file(s) matched.", matches);
    }
 
    private static void Du()
    {
        Console.Write("Enter the directory: ");
        var dir = Console.ReadLine() ?? "";
        if (!ValidateDirExists(dir)) return;
 
        DirectoryInfo dirInfo = new DirectoryInfo(dir);
        long size = 0;
        foreach (FileInfo fi in dirInfo.GetFiles("*", SearchOption.AllDirectories))
        {
            size += fi.Length;
        }
 
        Console.WriteLine("Total size: {0} bytes", size);
    }
 
    private static void CpDir()
    {
        Console.Write("Enter the source directory: ");
        var source = Console.ReadLine() ?? "";
        if (!ValidateDirExists(source)) return;
 
        Console.Write("Enter the destination directory: ");
        var dest = Console.ReadLine() ?? "";
        if (string.IsNullOrEmpty(dest))
        {
            Console.WriteLine("Destination cannot be empty.");
            return;
        }
 
        DirectoryCopy(source, dest, true);
        Console.WriteLine("Copy finished.");
    }
 
    private static void DirectoryCopy(string source, string dest, bool copySubDirs = true)
    {
        DirectoryInfo dir = new DirectoryInfo(source);
 
        if (!dir.Exists)
        {
            throw new DirectoryNotFoundException(
                $"Source directory does not exist or could not be found: {source}");
        }
 
        DirectoryInfo[] dirs = dir.GetDirectories();
 
        if (!Directory.Exists(dest))
        {
            Directory.CreateDirectory(dest);
        }
 
        FileInfo[] files = dir.GetFiles();
        foreach (FileInfo file in files)
        {
            string tempPath = Path.Combine(dest, file.Name);
            file.CopyTo(tempPath, false);
        }
 
        if (copySubDirs)
        {
            foreach (DirectoryInfo subdir in dirs)
            {
                string tempPath = Path.Combine(dest, subdir.Name);
                DirectoryCopy(subdir.FullName, tempPath, copySubDirs);
            }
        }
    }
 
    private static void Home()
    {
        var docPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
        Console.WriteLine("Documents folder: {0}", docPath);
    }
 
    // ----- Shared validation helpers -----
 
    private static bool ValidateExists(string? name)
    {
        if (string.IsNullOrEmpty(name))
        {
            Console.WriteLine("File name cannot be empty.");
            return false;
        }
 
        if (!File.Exists(name))
        {
            Console.WriteLine("File does not exist.");
            return false;
        }
 
        return true;
    }
 
    private static bool ValidateDirExists(string? name)
    {
        if (string.IsNullOrEmpty(name))
        {
            Console.WriteLine("Directory name cannot be empty.");
            return false;
        }
 
        if (!Directory.Exists(name))
        {
            Console.WriteLine("Directory does not exist.");
            return false;
        }
 
        return true;
    }
}