Streams and Directory Operations in C#
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 correctFileMode,FileAccess, andFileSharevalues. - Release stream resources deterministically with the
usingstatement, and explain how it relates toIDisposableandDispose(). - Inspect files and directories through
FileInfoandDirectoryInfoproperties such asLength,Extension,LastWriteTime,Parent, andGetDirectoryRoot. - Use the static convenience methods on
FileandDirectory(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 asDeflateStream. - 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, andnano). - Basic C# syntax: classes, static methods,
switchstatements, loops, and exception types. - A working .NET SDK and an IDE or editor capable of running a C# console project.
- Familiarity with the
System.IOnamespace from the previous lesson. - Converting text entered by the user into typed values with
int.Parse,double.Parse, andbool.Parse(each throwsFormatExceptionif 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.
FileStreamis 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/StreamWriteradapt a byte stream into text, andBinaryReader/BinaryWriteradapt a byte stream into primitive values such asint,double, orbool. - Decorators transform the content itself, for example compressing or encrypting it, while still exposing the same stream API.
DeflateStreamis 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 --> AppFileStream, 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);FileModedecides what happens to the file on open:Createmakes a new file or overwrites an existing one,CreateNewfails if the file already exists,Openfails if the file does not exist,OpenOrCreateopens the file if it exists and creates it otherwise,Appendopens the file and moves to its end, andTruncateopens an existing file and empties it.FileAccessdecides what the stream is allowed to do once it is open:Read,Write, orReadWrite.FileSharedecides 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.Nonereserves the file exclusively for your stream,FileShare.Readlets others read it at the same time, andFileShare.ReadWritelets others both read and write it. ChoosingFileShare.Nonewhen 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 methodEither 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 privateresourcefield, so that field is whatDisposelater releases. Dispose()is the method a caller runs, directly or through ausingblock. It callsDispose(true)to do the real cleanup, then callsGC.SuppressFinalize(this)to tell the garbage collector that the finalizer below no longer needs to run, because the resource has already been released.- The
disposedfield guards against cleaning up twice: ifDispose()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 thedisposingparameter distinguishes. When called fromDispose(),disposingistrue, and it is safe to dispose other managed objects such asresource. When called from the finalizer,disposingisfalse, 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 aResourceHoldernever calledDispose(). It callsDispose(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 --> GSearching 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
- Update
pwdso that, in addition to the directory's full name, it also prints the drive root usingDirectory.GetDirectoryRoot(currentDir). - Update
lsso that each file line also printsfile.Extensionalongside the existingfile.Lengthand a timestamp, usingfile.LastWriteTimeinstead ofLastAccessTime. - Add a new
statcommand. It should ask for a name, then checkFile.Exists(name)first andDirectory.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 formLabel : value:Full name(FullName),Extension,Size(Length, followed bybytes),Last write time(LastWriteTime), andDirectory(the containing folder fromfi.Directory, aDirectoryInfo, on aFileInfo). If it is a directory, print, in the sameLabel : valueform:Full name(FullName),Parent(from aDirectoryInfo),Drive root(fromDirectory.GetDirectoryRoot), and the three timestampsCreated,Last access time, andLast write time(fromDirectory.GetCreationTime,Directory.GetLastAccessTime, andDirectory.GetLastWriteTime). - Run the program, create a test file with
touch demo.txt, then runpwdand confirm the drive root line appears, then runlsand confirm the extension column appears next todemo.txt. - Run
statand enterdemo.txtto exercise the file branch, then runstatagain 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
- Add an
mvcommand. Its logic should live in a method namedMv(), the same way Task 6 names its recursive methodDirectoryCopy. It should ask for the current name and the new name, then check whether the source is a file (useFile.Move) or a directory (useDirectory.Move), printingFile moved to <new name>orDirectory moved to <new name>accordingly, or reporting an error if neither exists. - Add an
rm -rcommand, matched as the full linerm -r. It should ask for a directory name, confirm it exists, then callDirectory.Delete(name, true)to remove it along with everything inside it, and printDirectory and everything inside it was deleted. - Test
mv: runmkdir olddir, thenmv, enteringolddirandnewdir. Runlsto confirmolddiris gone andnewdiris present. - Test
rm -r: runmkdir tree, then, outside the emulator (in your operating system's file manager or a separate terminal), place one file inside the newtreefolder so it is not empty. Back in the emulator, runrm -rand entertree. This confirms thatDirectory.Delete(name, true)removes a non-empty directory, which the plain, non-recursiveDirectory.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 newdirExpected 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
- Add a
writelinescommand. 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 callFile.WriteAllLines(name, lines). - Add a
readlinescommand. It should confirm the file exists, callFile.ReadAllLines(name), and print each line with its 1-based line number. - Run
writelines, name the filenotes.txt, and enter three lines followed by an empty line to stop. - Run
readlinesonnotes.txt.
Expected output for step 4:
1: first line you typed
2: second line you typed
3: third line you typedNote 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
- Add a
binwritecommand. It should ask for a file name, then ask for an integer, a decimal number, and atrue/falsevalue as text and convert each withint.Parse,double.Parse, andbool.Parserespectively, then open aFileStreamwithFileMode.Create,FileAccess.Write, andFileShare.None, wrap it in aBinaryWriter, and write the three converted values in that order. - Add a
binreadcommand. It should confirm the file exists, open aFileStreamwithFileMode.Open,FileAccess.Read, andFileShare.Read, wrap it in aBinaryReader, 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 formLabel : value:Integer,Decimal, andBoolean. - Run
binwrite, name the filenumbers.bin, and enter20,101.5, andtrue. - Run
binreadonnumbers.bin. - Try opening
numbers.binwithcat. Observe that the content is not readable text.
Expected output for step 4:
Integer : 20
Decimal : 101.5
Boolean : TrueExplain 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
- Add a
compresscommand. It should ask for a file name and a line of text, open aFileStreamwithFileMode.Create, wrap it in aDeflateStreamwithCompressionMode.Compress, then loop over every charactercin the entered text and write it withds.WriteByte((byte)c). - Add a
decompresscommand. It should confirm the file exists, open aFileStreamwithFileMode.Open, wrap it in aDeflateStreamwithCompressionMode.Decompress, read bytes one at a time withReadByte()until it returns-1, casting each one back with(char)band appending it to a result string, then print the result. - Run
compress, name the filemessage.bin, and enterhello world. - Run
catonmessage.binand observe that the content is not readable text. - Run
decompressonmessage.bin.
Expected output for step 5:
hello worldTask 6: Searching, Measuring, and Copying Directory Trees
- Add a
drivescommand that prints every entry returned byDirectory.GetLogicalDrives(). - Add a
ls -rcommand, matched as the full linels -r. It should ask for a search pattern (defaulting to*if the input is empty), then list every matching file under the current directory usingDirectory.GetFiles(currentDir, pattern, SearchOption.AllDirectories), followed by every subdirectory usingDirectory.GetDirectories(currentDir, "*", SearchOption.AllDirectories). PrintMatching files:on its own line, then each matching file's full path on its own line; then printSubdirectories:on its own line, then each subdirectory's full path on its own line. - Add a
findcommand. It should ask for a directory and a search text, then, for every file returned byDirectory.GetFiles(dir, "*", SearchOption.AllDirectories), open it with aStreamReader, 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. - Add a
ducommand. It should ask for a directory, then sumfi.Lengthfor everyFileInforeturned bydirInfo.GetFiles("*", SearchOption.AllDirectories)and print the total in the formTotal size: <n> bytes. - Add a
cp -rcommand, matched as the full linecp -r. It should ask for a source and a destination directory, then call the recursiveDirectoryCopymethod described above (withcopySubDirsleft at its defaulttrue), which creates the destination if it does not exist, copies every file in the current directory, and, sincecopySubDirsistrue, calls itself again for each subdirectory. PrintCopy finished.once it returns. - Add a
homecommand that printsEnvironment.GetFolderPath(Environment.SpecialFolder.MyDocuments). - Outside the emulator (the emulator has no
cdcommand, so building nested content is easier from your operating system's file manager or a separate terminal), create a folder namedtree, containing two text files and one subfolder namednotesthat itself contains a third text file namedgreeting.txtwith the word "hello" inside it. Back in the emulator, runduon thetreefolder's path, then runcp -r, entering thetreefolder's path as the source and a destination path ending intree-copy(for example, the same parent folder followed bytree-copy), then runfindon thetree-copyfolder 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> bytesExpected 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.Streamclass. - .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/StreamWriterfor text,BinaryReader/BinaryWriterfor primitives), and decorators (DeflateStream) that transform the content itself while exposing the same stream API. FileMode:FileAccess, andFileSharetogether decide how aFileStreamopens 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
usingstatement calls anIDisposableobject'sDispose()automatically at the end of its scope (compiling down to atry/finally), replacing the previous lesson's manualClose()calls with a pattern that also runs safely if an exception is thrown. - A class you write yourself implements
IDisposablewith a publicDispose()that calls a sharedprotected virtual Dispose(bool disposing)and thenGC.SuppressFinalize(this), guarded by adisposedflag against double cleanup; a finalizer (~ClassName()) calls the same method as a last-resort safety net for a resource nobody explicitly disposed. FileInfoandDirectoryInfodescribe a file or directory (size, extension, timestamps, parent, full path) without opening a stream to read its content.- The static methods on
FileandDirectory(WriteAllText/WriteAllLines/WriteAllBytes,Move,Delete,Delete(path, true),GetFiles/GetDirectorieswith a pattern and aSearchOption) 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 withmv,rm -r,stat,writelines,readlines,binwrite,binread,compress,decompress,drives,ls -r,find,du,cp -r, andhomedemonstrates how far theSystem.IOnamespace'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;
}
}