Logo

التعامل مع Streams والمجلدات في C#

21 دقيقة قراءة
شرائح الدرس
1 / 16

نظم التشغيل 1 - الدرس الثالث

التعامل مع Streams والمجلدات في C#

الملفات كتدفق بايتات، وكيف تفتحها وتغلقها بأمان، وكيف تتنقل في شجرة المجلدات وتعالجها.

بنهاية هذا الدرس ستتمكن من قراءة وكتابة الملفات وأشجار المجلدات (directory trees) في C# باستخدام الـ streams والـ adapters والـ decorators، وتوسيع تطبيق console يشبه الـ shell بأوامر جديدة للملفات والمجلدات.

الأهداف

  • تشرح ما هو الـ stream ولماذا تقسم .NET الأنواع المرتبطة بالـ streams إلى backing stores و adapters و decorators.
  • تقرأ وتكتب الملفات باستخدام FileStream، وتختار القيم الصحيحة لـ FileMode، FileAccess، و FileShare.
  • تحرر resources الـ stream بشكل deterministic باستخدام جملة using، وتشرح علاقتها بـ IDisposable و Dispose().
  • تفحص الملفات والمجلدات من خلال خصائص FileInfo و DirectoryInfo مثل Length، Extension، LastWriteTime، Parent، و GetDirectoryRoot.
  • تستخدم الـ static convenience methods في File و Directory (WriteAllText، WriteAllBytes، WriteAllLines، Move، Delete، GetFiles، GetDirectories) لتنفيذ مهام شائعة بنداء واحد.
  • تقرأ بيانات binary primitive باستخدام BinaryReader/BinaryWriter، وتحول محتوى الـ stream باستخدام decorator مثل DeflateStream.
  • تعبر directory tree كاملا بشكل recursive لقياس حجمه، أو نسخه، أو البحث في ملفاته عن نص معين.
  • توسع تطبيق console يشبه shell نظام Linux بأوامر جديدة مبنية على هذه الأنواع.

المتطلبات السابقة

  • إنهاء تطبيق console الخاص بالدرس السابق (الـ Linux-shell emulator بأوامره pwd، ls، mkdir، touch، rm، cat، و nano).
  • أساسيات C#: classes، static methods، جمل switch، loops، وأنواع الـ exceptions.
  • .NET SDK يعمل بشكل صحيح، وIDE أو محرر نصوص يستطيع تشغيل مشروع C# console.
  • فهم namespace System.IO من الدرس السابق.
  • تحويل النص الذي يدخله المستخدم إلى قيم typed باستخدام int.Parse، double.Parse، و bool.Parse (كل واحدة منها ترمي FormatException إذا لم يطابق النص الشكل المتوقع).

ما هو الـ Stream

الـ stream هو سلسلة من الـ bytes تنتقل بين تطبيق ومصدر أو وجهة معينة: ملف على الـ disk، موقع على الشبكة، أو block في الذاكرة. في أي وقت يحتاج فيه برنامج إلى data موجودة خارج ذاكرته، مثل محتوى ملف أو رد من web request، تنقل .NET هذه الـ data عبر stream، chunk من الـ bytes في كل مرة. كل حرف يمكن رؤيته على الشاشة، حتى حرف واحد، مخزن على الـ disk كـ byte واحد أو أكثر: الحرف A مخزن كقيمة byte تساوي 65، والـ stream هو الذي يحمل هذا الـ byte من الـ disk إلى داخل البرنامج، أو من البرنامج رجوعا إلى الـ disk.

كل أنواع الـ stream في .NET ترث من الـ abstract class System.IO.Stream. هذا الـ base class يعرف العمليات التي يدعمها كل stream، مثل Read، Write، Seek، Close، وخصائص مثل CanRead، CanWrite، CanSeek، و Position. وبسبب أن كل concrete stream type يرث من Stream، فإن الكود الذي يعمل مع الـ base class يمكن أن يعمل مع ملف، أو block في الذاكرة، أو network connection، بالتبادل.

تصنيفات الـ Stream: Backing Stores، Adapters، و Decorators

تنظم .NET الأنواع المرتبطة بالـ stream في ثلاثة تصنيفات، ويستخدم هذا الدرس نوعا واحدا على الأقل من كل تصنيف:

  • Backing stores تتصل مباشرة بمصدر خارجي. FileStream هو backing store للملفات؛ يقرأ ويكتب raw bytes.
  • Adapters تغلف backing store وتغير شكل الـ data دون أن تغير المصدر. StreamReader/StreamWriter تحول byte stream إلى نص، و BinaryReader/BinaryWriter تحوله إلى قيم primitive مثل int، double، أو bool.
  • Decorators تحول المحتوى نفسه، مثلا تضغطه أو تشفره، وتعرض في الوقت نفسه نفس stream API. DeflateStream هو decorator يضغط الـ data وقت الكتابة ويفك ضغطها وقت القراءة؛ يعرض قسم "الـ Decorators و DeflateStream" أدناه مثالا كاملا عليه.

تقسيم الأنواع بهذا الشكل يجعل كل class مركزة على مهمة واحدة: backing store لا تعرف إلا كيف تحرك bytes داخل وخارج مصدرها، adapter لا تعرف إلا كيف تترجم bytes إلى شكل أغنى، وdecorator لا يعرف إلا كيف يحول المحتوى. هذا الفصل يعني أنه يمكن تبديل backing store (ملف اليوم، network connection غدا) دون إعادة كتابة كود الـ adapter المبني فوقه، وتبديل الـ adapter دون لمس backing store، وحتى إلحاق أكثر من decorator بنفس الـ stream في الوقت نفسه، لأن الـ decorator لا يحتاج إلا إلى stream آخر ليغلفه.

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، و FileShare

FileStream هو backing store الذي يستخدم لفتح ملف على مستوى الـ byte. الـ constructor الخاص به يأخذ path مضافا إليه تركيبة من ثلاثة enumerators:

FileStream fs = new FileStream("data.txt", FileMode.Open, FileAccess.Read, FileShare.Read);
  • FileMode تحدد ما يحصل للملف عند الفتح: Create تنشئ ملفا جديدا أو تكتب فوق الملف الموجود، CreateNew تفشل إذا كان الملف موجودا، Open تفشل إذا لم يكن الملف موجودا، OpenOrCreate تفتح الملف إذا كان موجودا وتنشئه إذا لم يكن، Append تفتح الملف وتتحرك إلى آخره، و Truncate تفتح ملفا موجودا وتفرغه.
  • FileAccess تحدد ما يسمح للـ stream بفعله بعد الفتح: Read، Write، أو ReadWrite.
  • FileShare تحدد ما يسمح لـ file handles أخرى، في نفس الـ process أو في process مختلفة، بفعله على نفس الملف بينما ملفك لا يزال مفتوحا. FileShare.None تحجز الملف بالكامل للـ stream الخاصة بك، FileShare.Read تسمح لغيرها بقراءته في الوقت نفسه، و FileShare.ReadWrite تسمح لغيرها بقراءته والكتابة فيه. اختيار FileShare.None عند كتابة data حساسة يمنع process آخر من قراءة ملف نصفه مكتوب.

بعد الفتح، تعرض FileStream خصائص CanRead، CanWrite، و CanSeek للتأكد من ما يدعمه الـ stream المفتوح فعلا، وتتبع الخاصية Position أين ستحدث عملية الـ read أو write التالية. تحرك Seek(offset, origin) هذا الموضع مباشرة، حيث origin قيمة من SeekOrigin؛ يقيس SeekOrigin.Begin الـ offset من بداية الـ stream، فـ Seek(0, SeekOrigin.Begin) هي الطريقة التي يعود بها البرنامج إلى بداية ملف لإعادة قراءة ما كتبه فعلا، دون إغلاق وفتح الـ stream من جديد. لا توجد أي مهمة في هذا الدرس تنادي Seek مباشرة.

تنظيف الـ Resources: IDisposable وجملة using

قبل أن يعمل برنامج C#، يترجم الـ compiler الخاص به الكود المصدري إلى intermediate language (IL) بدلا من ترجمته مباشرة إلى machine code؛ ينتج compiler كل لغة من لغات .NET نفس الـ IL، وهذا سبب أن الكود المكتوب بلغات .NET مختلفة يمكن أن ينادي بعضه بعضا. طبقة تسمى Common Language Runtime (CLR) تأخذ هذا الـ IL وتديره بالكامل: تحجز الذاكرة التي تحتاجها، تحجز مساحة الـ disk التي تحتاجها، وتحرر الذاكرة حين تنتهي الحاجة إليها. الكود الذي يعمل بهذه الطريقة، تحت سيطرة الـ CLR بالكامل، يسمى managed code، وcompiler من نوع just-in-time (JIT) هو الذي تستخدمه الـ CLR لترجمة الـ IL إلى machine code الذي يشغله نظام التشغيل فعلا، لحظة قبل تشغيل هذا الكود.

وبسبب أن الـ managed code يبقى تحت سيطرة الـ CLR طوال الوقت، يستطيع الـ garbage collector (GC) الخاص بالـ CLR تتبعه وتنظيفه أوتوماتيكيا: من الداخل، يشغل الـ GC مرورين على الأقل، الأول يمر على الـ objects لتحديد ما لم يعد accessible، والثاني يمر مرة أخرى ليجمعها ويحررها فعلا. بعض الكود، مع ذلك، ينادي خارج الـ managed pipeline بالكامل، مثل call يذهب مباشرة من الـ compiler إلى machine code دون أن يمر بالـ CLR. يسمى هذا الكود unmanaged code، ولا يستطيع garbage collector تنظيفه، لأنه لم يكن أصلا تحت إدارة الـ CLR. هذا بالضبط الموقف الذي وجد IDisposable من أجله: في أي وقت تغلف فيه class managed مثل FileStream resource unmanaged مثل file handle خاص بنظام التشغيل، تكون الـ class نفسها managed ويستطيع الـ GC استعادة ذاكرتها في نهاية المطاف، لكن الـ file handle الذي بداخلها ليس شيئا يمكن للـ GC تركه لوحده، فيجب على الـ class أن تعرض طريقة لتحريره بشكل deterministic.

فتح FileStream، أو أي adapter مبني فوق واحد منه مثل StreamReader، StreamWriter، BinaryReader، أو BinaryWriter، يقوم بأكثر من حجز ذاكرة: فهو يحجز resource خاصا بنظام التشغيل، وهو file handle، طالما بقي الـ object مفتوحا. إذا لم يحرر برنامج هذا الـ handle أبدا، فقد يبقى الملف مغلقا أمام processes أخرى، ويبقى الـ handle نفسه محجوزا إلى أن يستعيده نظام التشغيل في نهاية المطاف. هذا بالضبط سبب أن methods قراءة وكتابة الملفات الخاصة بالدرس السابق تنتهي بزوج من الـ calls الصريحة مثل sr.Close(); fs.Close(); أو w.Close(); fs.Close();: إغلاق الـ reader أو الـ writer أولا، ثم الـ stream التي تحته، يحرر الـ handle حين تنتهي الـ method من عملها معه.

تعطي .NET كل type يحمل هذا النوع من الـ resource عقد تنظيف standard يسمى IDisposable. كل class تطبق IDisposable تعرض method اسمها Dispose()، و FileStream، StreamReader، StreamWriter، BinaryReader، و BinaryWriter تطبقها جميعا، فنداء Dispose() على أي منها يقوم بنفس تنظيف Close(). جملة using هي feature في الـ compiler مبنية على هذا العقد: تغليف object في بلوك using، أو declaration بـ using، يخبر الـ compiler بأن ينادي Dispose() الخاصة بهذا الـ object أوتوماتيكيا حين يخرج الـ object من الـ scope، سواء أكمل الكود إلى النهاية أو خرج مبكرا بسبب exception. من الداخل، يعيد الـ compiler كتابة using كـ try/finally block مع نداء Dispose() موضوع في finally، وهذا ما يضمن حدوث التنظيف حتى إذا حدث خطأ في الوسط.

الشكلان الخاصان بـ using يظهران لاحقا في هذا الدرس. بلوك using يغلف scope الـ object بأقواس:

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

Declaration بـ using، دون أقواس، ينظف الـ object في نهاية الـ block المحيط به بدلا من فعل ذلك داخل أقواس:

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

أي شكل من الشكلين يستبدل نمط sr.Close(); fs.Close(); اليدوي الخاص بالدرس السابق بنفس التنظيف، يحدث أوتوماتيكيا وبأمان حتى إذا حدث exception في الوسط، فمن هذا الدرس فصاعدا يفتح كل object مرتبط بـ stream بـ using بدلا من إغلاقه باليد. إذا نسي using بالكامل، فإن الـ object لا يبقى دون تنظيف إلى الأبد: سيلاحظ garbage collector الخاص بـ .NET في نهاية المطاف أن الـ object غير accessible وسيستعيده عبر finalizer الخاص به، وهي method خاصة ينادي عليها الـ garbage collector على الـ object، وفق جدوله الخاص، لحظة قبل استعادة ذاكرة هذا الـ object، وهي method مختلفة عن Dispose() ولا ينادى عليها أبدا مباشرة من الكود الخاص بك. يحدث هذا التنظيف في أي وقت يصل فيه الـ garbage collector إليه بدلا من أن يحدث فورا، وهذا سبب أن using هي الطريقة الموصى بها لتحرير resource حين ينتهي الكود من العمل معه، بدلا من تركه للصدفة.

تطبيق IDisposable على الـ Class الخاصة بك

كل type تطبق IDisposable استخدمناها حتى الآن هي type كتبتها .NET مسبقا (FileStream، StreamReader، StreamWriter، BinaryReader، BinaryWriter)؛ هذا الدرس فقط يستخدمها عن طريق using. الـ class التي تكتبها أنت تحتاج نفس العقد مطبقا باليد في أي وقت تحمل فيه resource من المفترض أن تتحرر بشكل deterministic بدلا من تركها للـ garbage collector.

النمط الـ standard يقسم التنظيف إلى Dispose() public، وهي التي ينادي عليها الـ caller، و protected virtual void Dispose(bool disposing)، وهي التي تقوم بالعمل الفعلي وتكون مشتركة مع الـ 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);
    }
}
  • الـ constructor، public ResourceHolder(string path)، يفتح الـ resource التي تكون هذه الـ class مسؤولة عنها ويخزنها في field الـ private resource، فهذا الـ field هو الذي تحرره Dispose بعد ذلك.
  • Dispose() هي method يشغلها الـ caller، مباشرة أو عن طريق بلوك using. تنادي Dispose(true) لتقوم بالتنظيف الفعلي، ثم تنادي GC.SuppressFinalize(this) لتخبر garbage collector بأن الـ finalizer أدناه لا يحتاج إلى العمل مرة أخرى، لأن الـ resource قد تحررت بالفعل.
  • الـ field disposed يحمي من التنظيف مرتين: إذا نودي على Dispose() مرة أخرى بأي طريقة، أو إذا كانت قد نودي عليها من قبل وعمل الـ finalizer بعدها أيضا، فإن المحاولة الثانية ترجع فورا بدلا من تنظيف resource قد تم تنظيفها فعلا.
  • Dispose(bool disposing) تحتوي منطق التنظيف المشترك ويصل إليها من مسارين مختلفين، وهذا ما يميز بينهما الـ parameter disposing. حين تنادى من Dispose()، تكون disposing بقيمة true، ويكون آمنا تنظيف objects managed أخرى مثل resource. حين تنادى من الـ finalizer، تكون disposing بقيمة false، لأنه حين يعمل الـ finalizer، قد تكون objects managed أخرى قد جمعت بالفعل، فلا يكون آمنا هنا إلا تحرير resources خارج إدارة ذاكرة .NET.
  • ~ResourceHolder() هي الـ finalizer (تسمى أيضا destructor): ينادي عليها garbage collector وفق جدوله الخاص كـ safety net أخير، في حالة أن شيئا يحمل ResourceHolder لم يناد Dispose() أبدا. تنادي Dispose(false)، فيحدث التنظيف مع ذلك، لكن في وقت أبعد ومحدودا فقط بما يبقى آمنا لمسه في ذلك الوقت.

توجد أربع طرق يمكن بها تشغيل هذا التنظيف، من الأقل إلى الأكثر ثقة: نداء Dispose() باليد والأمل في أن كل مسار في الكود يصل إليه؛ تغليف الكود في try/catch/finally مع وضع نداء Dispose() في بلوك finally ليعمل حتى بعد exception؛ تغليف الـ object في بلوك using؛ أو declaration بـ using. الطريقتان الأخيرتان مفضلتان لأن الـ compiler يضمن أن النداء يحدث مرة واحدة بالضبط، في المكان الصحيح، دون الاعتماد على أن يتذكر المبرمج كتابته في كل مسار.

خصائص FileInfo و DirectoryInfo

FileInfo و DirectoryInfo كلاهما يرثان من FileSystemInfo ويصفان ملفا أو مجلدا واحدا دون فتح stream عليه. هما الأداة الصحيحة في أي وقت تحتاج فيه إلى metadata بدلا من المحتوى.

تعرض FileInfo خصائص FullName (الـ path الكامل)، Name (اسم الملف فقط)، Extension (شاملة النقطة التي تسبقها، مثلا .txtLength (الحجم بالـ bytes)، LastWriteTime (وقت آخر تغيير في محتوى الملف)، LastAccessTime، CreationTime، و Directory (نسخة DirectoryInfo للمجلد الذي يحتويه).

تعرض DirectoryInfo خصائص FullName، Name، Parent (نسخة DirectoryInfo للمجلد الذي يحتويه، أو null عند جذر الـ drive)، CreationTime، LastAccessTime، وmethods مثل GetFiles() و GetDirectories() تعيدان الـ entries الموجودة داخل المجلد مباشرة. Directory.GetDirectoryRoot(path) هي method static ذات صلة تعيد جذر الـ drive الذي يوجد عليه path معين (مثلا 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);

ينشئ هذا القسم FileInfo لـ data.txt دون فتح الملف، ثم يقرأ ثلاثا من خصائصه: Extension تعطي امتداد الملف، Length تعطي حجمه، و LastWriteTime تعطي timestamp آخر كتابة. لا يلمس أي سطر من هذه الأسطر الثلاثة محتوى الملف الفعلي.

يمكن أيضا استخدام DirectoryInfo لقراءة access control list الخاصة بالمجلد، وهي مجموعة القواعد التي تحدد أي users أو groups مسموح لهم بالقراءة أو الكتابة أو التشغيل فيه، عن طريق GetAccessControl(). إدارة هذه الـ permissions خارج نطاق هذا الدرس ولن تغطى أكثر من ذلك.

الـ Static Methods المختصرة في File و Directory

إلى جانب classes الـ stream، تعرض File و Directory static methods تقوم بعملية كاملة بنداء واحد، وتفتح وتغلق من الداخل أي stream تحتاجها.

في File: File.Exists(path)، File.Delete(path)، File.Move(source, destination) (التي تنقل وتغير الاسم معا، لأن تغيير الاسم هو نقل داخل نفس المجلد فقط)، File.WriteAllText(path, text) و File.ReadAllText(path)، File.WriteAllLines(path, lines) و File.ReadAllLines(path)، و File.WriteAllBytes(path, bytes) و File.ReadAllBytes(path). تقوم الأزواج الثلاثة WriteAll*/ReadAll* بنفس العمل، فتحا للـ stream وكتابة أو قراءة كل شيء وإغلاقها، لكن لشكل مختلف من الـ data: block واحد من النص، أو array من السطور، أو array من الـ raw bytes.

في Directory: Directory.CreateDirectory(path)، Directory.Exists(path)، Directory.Move(source, destination)، Directory.Delete(path) (التي ترمي exception إذا لم يكن المجلد فارغا)، Directory.Delete(path, true) (التي تحذف المجلد وكل ما بداخله بشكل recursive)، Directory.GetLogicalDrives() (حروف الـ drives، أو الـ roots المحمولة على نظام غير Windows)، Directory.GetCreationTime(path)، Directory.GetLastAccessTime(path)، و Directory.GetLastWriteTime(path). Directory.GetFileSystemEntries(path, pattern) هي method ذات صلة تعيد الملفات والمجلدات الفرعية المطابقة لـ pattern بنداء واحد، لوقت تحتاج فيه task إلى كل entry معا بدلا من سرد الملفات والمجلدات كل على حدة.

تقبل Directory.GetFiles و Directory.GetDirectories كلاهما search pattern اختياريا و SearchOption:

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

الـ argument الأول هو المجلد الذي سيتم البحث فيه، والثاني هو wildcard pattern الذي يجب أن يطابقه اسم الـ entry، والثالث يحدد إذا كان البحث في المجلد المعطى فقط (SearchOption.TopDirectoryOnly) أو في كل subdirectory متداخل أيضا (SearchOption.AllDirectories).

تعيد Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments) الـ path الكامل لمجلد معروف من النظام، مثل مجلد Documents الخاص بالمستخدم الحالي، دون الحاجة إلى كتابة path ثابت سيكون خاطئا على جهاز آخر أو نظام تشغيل آخر.

Adapters النص: StreamWriter و StreamReader

تحول StreamWriter و StreamReader byte stream إلى نص: تحول StreamWriter.Write/WriteLine الـ string إلى bytes وترسلها إلى الـ stream التي تحتها، وتسحب StreamReader.ReadLine/ReadToEnd bytes من الـ stream وتعيدها كـ string. تقوم كل من الـ classes بعمل buffer للـ data من الداخل، وهذا سبب أهمية StreamWriter.Flush(): فهي تفرض إخراج أي نص لا يزال في الـ buffer إلى الـ stream التي تحتها فورا، بدلا من الانتظار حتى يمتلئ الـ buffer أو يغلق الـ writer.

يمكن لـ StreamReader أيضا قراءة حرف واحد في كل مرة بدلا من سطر كامل: تنظر sr.Peek() إلى الحرف التالي دون استهلاكه، وتعيد -1 حين لا يبقى شيء في الـ stream، وتستهلك sr.Read() وتعيد الحرف التالي كـ int، وهذا سبب أنها تحول عادة إلى char قبل الطباعة. loop مثل while (sr.Peek() > -1) { Console.Write((char)sr.Read()); } يطبع محتوى ملف كامل حرفا بحرف، وهو بديل أقل level من نداء ReadLine() داخل loop.

Adapters الـ Binary: BinaryWriter و BinaryReader

النص ليس الشكل الوحيد للـ data. تكتب BinaryWriter قيم primitive، مثل int، double، و bool، مباشرة كـ raw bytes خاصة بها بدلا من حروف مقروءة، وتعيدها BinaryReader بنفس الترتيب الذي كتبت به.

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);
}

تفتح FileStream ملف data.bin للكتابة، وتغلفها BinaryWriter. كل نداء writer.Write(...) يكتب الـ byte representation الدقيق للـ argument الخاصة به: 4 bytes للـ int، 8 bytes للـ double، وbyte واحد للـ bool. يجب أن تحدث قراءة القيم رجوعا بنفس الترتيب، باستخدام الـ method المطابقة لنوعها، عن طريق BinaryReader تغلف FileStream مفتوحة بالطريقة نفسها لكن بـ FileMode.Open و FileAccess.Read بدلا من الأولى:

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();
}

هنا تفتح FileStream ملف data.bin الموجود للقراءة بدلا من إنشائه، وتغلفها BinaryReader بالطريقة نفسها التي غلفتها بها BinaryWriter في جهة الكتابة. كل من reader.ReadInt32()، و reader.ReadDouble()، و reader.ReadBoolean() تعيد نفس النوع الذي كتب، بنفس ترتيب الكتابة، وتعيد قيم الـ int، double، و bool الأصلية.

بسبب أن ملف binary يخزن raw bytes بدلا من حروف، فإن فتحه في text editor يظهر رموزا غير مقروءة بدلا من الأرقام التي كتبت؛ ولا يكون للـ data معنى إلا حين تقرأ رجوعا عن طريق BinaryReader تعرف الترتيب الدقيق للأنواع المتوقعة.

الـ Decorators و DeflateStream

على عكس الـ adapter، الذي يغير فقط شكل الـ data (bytes إلى نص، bytes إلى primitives)، يغير الـ decorator المحتوى نفسه ويعرض في الوقت نفسه نفس API القراءة والكتابة الخاصة بأي stream آخر. DeflateStream هو decorator يضغط الـ data وقت الكتابة ويفك ضغطها وقت القراءة، ويغلف أي stream يعطى له، عادة FileStream، بالطريقة نفسها التي تغلف بها StreamWriter أو BinaryWriter واحدة منها. وبسبب أن الـ decorator لا يحتاج إلا إلى stream آخر ليغلفه، يمكن إلحاق أكثر من decorator بنفس الـ stream في الوقت نفسه، مثل decorator ضغط يغلف decorator تشفير يغلف FileStream، وهذا أمر لم يبن الـ adapter لفعله.

تقرأ DeflateStream وتكتب bytes فقط. يكتب المثال أدناه قيمتين byte حرفيتين مباشرة بـ WriteByte، وهي نفس قيم الـ bytes الخاصة بحرفي A و B، لكي يبقى التركيز على الـ decorator نفسه. تتوسع المهمة 5 أدناه في هذه الفكرة أكثر من المثال: بدلا من قيمتين byte ثابتتين، تضغط سطرا كاملا من النص الذي يدخله المستخدم. وكما أثبت قسم "ما هو الـ Stream" أعلاه فعلا، كل حرف هو في الحقيقة قيمة byte رقمية تحت السطح (الحرف A هو قيمة byte تساوي 65)، فتلف المهمة 5 على كل حرف في النص المدخل وتكتب قيمة الـ byte الخاصة به بـ ds.WriteByte((byte)c)، حرفا واحدا في كل مرة، بدلا من كتابة قيمتين ثابتتين. وتطبق قراءتها رجوعا نفس الفكرة بالعكس: يحول كل byte تم فك ضغطه رجوعا إلى حرف بـ (char)b ويلحق بالنتيجة.

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);
}

تنشئ FileStream ملف data.bin، وتغلفها DeflateStream بـ CompressionMode.Compress، فيضغط كل byte يكتب عبر ds قبل أن يصل إلى الملف. تكتب WriteByte(65) و WriteByte(66) قيم الـ byte الخام لحرفي A و B. فتح data.bin بعد ذلك بأمر cat يظهر bytes غير مقروءة بدلا من حرفي A و B، لأن محتوى الملف الفعلي على الـ disk مضغوط.

تحتاج قراءة الـ data رجوعا إلى DeflateStream أخرى، مفتوحة بـ CompressionMode.Decompress، فوق FileStream تفتح نفس الملف للقراءة:

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() الـ byte التالي بعد فك ضغطه كـ int، أو -1 حين لا يبقى شيء يقرأ. الطول بعد فك الضغط لـ stream معينة غير معروف مسبقا، فيستمر هذا الـ loop في قراءة byte بعد byte إلى أن تشير -1 إلى نهايته، بدلا من محاولة قراءة عدد ثابت من الـ bytes من البداية. تشغيل هذا الكود على الملف المكتوب أعلاه يطبع 65 ثم 66، وهما نفس قيمتي الـ byte التي كتبت قبل الضغط، وهذا يؤكد أن الـ data تعود صحيحة عبر الـ decorator حتى إذا لم يكن الملف على الـ disk أبدا في شكل مقروء.

خوارزميات Recursive على المجلدات

ثلاث مهام شائعة (قياس حجم مجلد بالكامل، ونسخ directory tree، والبحث في كل ملف في tree عن نص معين) تتبع جميعها نفس الشكل: النظر إلى كل ما هو داخل المجلد الحالي مباشرة، ثم تكرار العملية لكل subdirectory.

يجمع قياس الحجم Length الخاصة بكل ملف موجود بـ SearchOption.AllDirectories، فيستبدل نداء واحد walk recursive باليد:

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

تعيد dirInfo.GetFiles("*", SearchOption.AllDirectories) كل ملف تحت dirInfo في أي عمق، كـ array واحد flat من objects FileInfo، فلا تحتاج الـ method أبدا إلى السير داخل الـ subdirectories بنفسها. يضيف الـ loop بعد ذلك Length الخاصة بكل ملف إلى size، فحين ينتهي، تحمل size حجم الـ byte الكلي للـ tree بالكامل.

لا يمكن أن يكون نسخ tree نداء واحد، لأن نسخ ملف واحد فقط ينسخ هذا الملف ولا يعرف شيئا عن الـ subdirectories، فيجب كتابة الـ recursion بشكل صريح: نسخ كل ملف في المجلد الحالي، ثم نداء نفس الـ method مرة أخرى لكل subdirectory. يستخدم نسخ ملف واحد FileInfo.CopyTo:

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

تنسخ file.CopyTo(destPath, overwrite) الملف الذي تشير إليه file، إلى destPath. الـ argument الثاني يحدد ما يحصل إذا كان ملف موجودا هناك بالفعل: false تجعل CopyTo ترمي exception بدلا من لمسه، و true تكتب فوقه بصمت. تنادي DirectoryCopy (تبنى في المهمة 6، تظهر كاملة في القائمة الكاملة للبرنامج أدناه) CopyTo بـ false لكل ملف تجده، وهذا سبب أن تشغيل cp -r مرة أخرى في نفس الوجهة يفشل عند أول ملف مكرر بدلا من الكتابة فوق النسخة الأولى بصمت.

تأخذ DirectoryCopy parameter ثالثا، bool copySubDirs = true، إلى جانب source و dest. هذا الـ flag هو ما تفحصه الخطوة الـ recursive أدناه قبل نسخ الـ subdirectories أصلا: حين تكون true، تنادي DirectoryCopy نفسها مرة أخرى لكل subdirectory تجده، وحين تكون false، تنسخ فقط الملفات داخل source مباشرة وتتوقف. الـ default الخاصة بها true يعني أن نداء عاديا مثل DirectoryCopy(source, dest) ينسخ الـ tree بالكامل دون الحاجة إلى ذكر الـ flag؛ يحتاج الـ caller فقط إلى تمرير false صريحة للحصول على نسخة shallow بالملفات فقط. أمر cp -r الخاص بهذا الدرس ينسخ دائما الـ tree بالكامل، فيستخدم دائما الـ default true، لكن الـ parameter موجودة لكي تدعم نفس الـ method بعد ذلك نسخا غير recursive دون إعادة كتابتها.

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

يتبع البحث عن نص في محتوى الملفات نفس الشكل الـ recursive، لكن بدلا من نسخ bytes يفتح كل ملف بـ StreamReader، ويقرأ نصه بالكامل بـ ReadToEnd()، ويفحص إذا كان هذا النص Contains نص البحث.

مهام المعمل

توسع المهام أدناه command loop الخاصة بـ shell emulator من الدرس السابق، أمرا واحدا في كل مرة. نتيجة تطبيق كل مهمة، تتوفر command loop الكاملة مع كل method تنادى منها، دفعة واحدة، في قسم "القائمة الكاملة للبرنامج" في آخر هذه الصفحة؛ استخدمها كـ reference implementation وأنت تبني تطبيقك بنفسك.

المهمة 1: معلومات أوسع عن الملفات والمجلدات

  1. حدث pwd بحيث تطبع، بالإضافة إلى اسم المجلد الكامل، جذر الـ drive أيضا باستخدام Directory.GetDirectoryRoot(currentDir).
  2. حدث ls بحيث يطبع كل سطر ملف file.Extension أيضا إلى جانب file.Length والـ timestamp الموجودين بالفعل، باستخدام file.LastWriteTime بدلا من LastAccessTime.
  3. أضف أمرا جديدا stat. يجب أن يطلب اسما، ثم يفحص File.Exists(name) أولا و Directory.Exists(name) ثانيا لتحديد أي فرع يشغل، ويطبع error ويتوقف إذا لم يكن أي منهما موجودا. إذا كان ملفا، اطبع، بشكل Label : value في كل سطر: Full name (FullNameExtension، Size (Length، متبوعة بـ bytesLast write time (LastWriteTime)، و Directory (المجلد الحاوي من fi.Directory، وهي DirectoryInfo، على FileInfo). إذا كان مجلدا، اطبع، بنفس شكل Label : value: Full name (FullNameParent (من DirectoryInfoDrive root (من Directory.GetDirectoryRoot)، والـ3 timestamps Created، Last access time، و Last write time (من Directory.GetCreationTime، Directory.GetLastAccessTime، و Directory.GetLastWriteTime).
  4. شغل البرنامج، أنشئ ملفا تجريبيا بـ touch demo.txt، ثم شغل pwd وتأكد من أن سطر جذر الـ drive ظاهر، ثم شغل ls وتأكد من أن عمود الامتداد ظاهر إلى جانب demo.txt.
  5. شغل stat وأدخل demo.txt لتجربة فرع الملف، ثم شغل stat مرة أخرى وأدخل . لتجربة فرع المجلد على المجلد الحالي.

الناتج المتوقع للخطوة 4، pwd:

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

الناتج المتوقع للخطوة 4، ls:

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

الناتج المتوقع للخطوة 5، فرع الملف:

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>

الناتج المتوقع للخطوة 5، فرع المجلد:

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>

المهمة 2: إعادة تسمية وحذف Trees كاملة

  1. أضف أمرا mv. يفترض أن يعيش منطقه في method اسمها Mv()، بالطريقة نفسها التي تسمي بها المهمة 6 method الـ recursive الخاصة بها DirectoryCopy. يجب أن تطلب الاسم الحالي والاسم الجديد، ثم تفحص إذا كان المصدر ملفا (استخدم File.Move) أو مجلدا (استخدم Directory.Move)، وتطبع File moved to <new name> أو Directory moved to <new name> حسب الحالة، أو تعرض error إذا لم يكن أي منهما موجودا.
  2. أضف أمرا rm -r، مطابقا كسطر كامل rm -r. يجب أن يطلب اسم مجلد، ويتأكد من وجوده، ثم ينادي Directory.Delete(name, true) لحذفه مع كل ما بداخله، ويطبع Directory and everything inside it was deleted.
  3. جرب mv: شغل mkdir olddir، ثم mv، وأدخل olddir و newdir. شغل ls للتأكد من أن olddir قد اختفى وأن newdir موجود.
  4. جرب rm -r: شغل mkdir tree، ثم، خارج الـ emulator (في file manager الخاص بنظام التشغيل الخاص بك أو terminal آخر)، ضع ملفا واحدا داخل مجلد tree الجديد لكي لا يبقى فارغا. عد إلى الـ emulator، وشغل rm -r وأدخل tree. هذا يؤكد أن Directory.Delete(name, true) تحذف مجلدا غير فارغ، وهو أمر ترفض Directory.Delete(name) العادية، غير الـ recursive، فعله.

الناتج المتوقع بعد الخطوة 3 (نقل olddir، وهو مجلد، فيشغل فرع المجلد الخاص بـ Mv()):

Directory moved to newdir

الناتج المتوقع بعد الخطوة 4، بينما لا يزال المجلد يحتوي على محتوى:

Directory and everything inside it was deleted.

المهمة 3: عمليات نص مختصرة بـ File.WriteAllLines و File.WriteAllBytes

  1. أضف أمرا writelines. يجب أن يطلب اسم ملف، ثم يقرأ سطورا من الـ console إلى أن يدخل سطر فارغ، وأخيرا ينادي File.WriteAllLines(name, lines).
  2. أضف أمرا readlines. يجب أن يتأكد من وجود الملف، وينادي File.ReadAllLines(name)، ويطبع كل سطر مع رقمه بترتيب يبدأ من 1.
  3. شغل writelines، سم الملف notes.txt، وأدخل ثلاثة سطور متبوعة بسطر فارغ لتتوقف.
  4. شغل readlines على notes.txt.

الناتج المتوقع للخطوة 4:

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

لاحظ أن File.WriteAllText (string واحدة)، و File.WriteAllLines (array أو list من الـ strings، سطر في كل عنصر)، و File.WriteAllBytes (byte array خام) تتبع جميعها النمط نفسه بالضبط: تمرير path والـ data، وتقوم الـ method بفتح الـ stream التي تحتها وكتابتها وإغلاقها من أجلك. أي واحدة تستخدم تعتمد فقط على شكل الـ data الموجودة عندك بالفعل.

المهمة 4: بيانات Binary ومشاركة الملفات

  1. أضف أمرا binwrite. يجب أن يطلب اسم ملف، ثم يطلب integer، ورقما decimal، وقيمة true/false كنص ويحول كل واحدة بـ int.Parse، double.Parse، و bool.Parse على الترتيب، ثم يفتح FileStream بـ FileMode.Create، FileAccess.Write، و FileShare.None، ويغلفها بـ BinaryWriter، ويكتب القيم الثلاث المحولة بهذا الترتيب.
  2. أضف أمرا binread. يجب أن يتأكد من وجود الملف، ويفتح FileStream بـ FileMode.Open، FileAccess.Read، و FileShare.Read، ويغلفها بـ BinaryReader، ويقرأ رجوعا الـ integer، والرقم decimal، والـ boolean بنفس ترتيب كتابتهم، ويطبعها واحدة في كل سطر بشكل Label : value: Integer، Decimal، و Boolean.
  3. شغل binwrite، سم الملف numbers.bin، وأدخل 20، 101.5، و true.
  4. شغل binread على numbers.bin.
  5. جرب فتح numbers.bin بـ cat. لاحظ أن المحتوى ليس نصا مقروءا.

الناتج المتوقع للخطوة 4:

Integer : 20
Decimal : 101.5
Boolean : True

اشرح بكلامك الخاص، بناء على المفاهيم أعلاه، لماذا تفتح binwrite الملف بـ FileShare.None بدلا من FileShare.ReadWrite.

المهمة 5: الـ Decorators: الضغط وفك الضغط بـ DeflateStream

  1. أضف أمرا compress. يجب أن يطلب اسم ملف وسطر نص، ويفتح FileStream بـ FileMode.Create، ويغلفها بـ DeflateStream بـ CompressionMode.Compress، ثم يلف على كل حرف c في النص المدخل ويكتبه بـ ds.WriteByte((byte)c).
  2. أضف أمرا decompress. يجب أن يتأكد من وجود الملف، ويفتح FileStream بـ FileMode.Open، ويغلفها بـ DeflateStream بـ CompressionMode.Decompress، ويقرأ bytes واحدا في كل مرة بـ ReadByte() إلى أن تعيد -1، ويحول كل واحدة رجوعا بـ (char)b ويلحقها بـ result string، ثم يطبع النتيجة.
  3. شغل compress، سم الملف message.bin، وأدخل hello world.
  4. شغل cat على message.bin ولاحظ أن المحتوى ليس نصا مقروءا.
  5. شغل decompress على message.bin.

الناتج المتوقع للخطوة 5:

hello world

المهمة 6: البحث والقياس ونسخ Trees المجلدات

  1. أضف أمرا drives يطبع كل entry تعيدها Directory.GetLogicalDrives().
  2. أضف أمرا ls -r، مطابقا كسطر كامل ls -r. يجب أن يطلب search pattern (بـ default * إذا كان الإدخال فارغا)، ثم يسرد كل ملف مطابق تحت المجلد الحالي باستخدام Directory.GetFiles(currentDir, pattern, SearchOption.AllDirectories)، متبوعا بكل subdirectory باستخدام Directory.GetDirectories(currentDir, "*", SearchOption.AllDirectories). اطبع Matching files: في سطر منفرد، ثم كل path كامل لملف مطابق في سطر منفرد؛ ثم اطبع Subdirectories: في سطر منفرد، ثم كل path كامل لـ subdirectory في سطر منفرد.
  3. أضف أمرا find. يجب أن يطلب مجلدا ونص بحث، ثم، لكل ملف تعيده Directory.GetFiles(dir, "*", SearchOption.AllDirectories)، يفتحه بـ StreamReader، ويقرأ نصه الكامل، ويطبع path الملف إذا كان النص يحتوي نص البحث، وفي النهاية يطبع <count> file(s) matched. بعدد الملفات المطابقة.
  4. أضف أمرا du. يجب أن يطلب مجلدا، ثم يجمع fi.Length لكل FileInfo تعيدها dirInfo.GetFiles("*", SearchOption.AllDirectories) ويطبع الإجمالي بشكل Total size: <n> bytes.
  5. أضف أمرا cp -r، مطابقا كسطر كامل cp -r. يجب أن يطلب مجلد مصدر ومجلد وجهة، ثم ينادي method الـ DirectoryCopy الـ recursive الموصوفة أعلاه (مع ترك copySubDirs على الـ default الخاصة بها true)، التي تنشئ الوجهة إذا لم تكن موجودة، وتنسخ كل ملف في المجلد الحالي، وبسبب أن copySubDirs بقيمة true، تنادي نفسها مرة أخرى لكل subdirectory. اطبع Copy finished. حين تعود.
  6. أضف أمرا home يطبع Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments).
  7. خارج الـ emulator (لا يوجد في الـ emulator أمر cd، فبناء محتوى متداخل أسهل من file manager الخاص بنظام التشغيل الخاص بك أو terminal آخر)، أنشئ مجلدا اسمه tree، يحتوي على ملفين نصيين وسبفولدر واحد اسمه notes، وهذا السبفولدر نفسه يحتوي على ملف نص ثالث اسمه greeting.txt تحتوي على كلمة "hello" بداخله. عد إلى الـ emulator، شغل du على path مجلد tree، ثم شغل cp -r، وأدخل path مجلد tree كمصدر وpath وجهة ينتهي بـ tree-copy (مثلا، نفس المجلد الأب متبوعا بـ tree-copy)، ثم شغل find على مجلد tree-copy بحثا عن كلمة "hello".

الناتج المتوقع للخطوة 7، du على tree (الرقم بالضبط يعتمد على حجم الملفين النصيين اللذين أنشأتهما):

Total size: <n> bytes

الناتج المتوقع للخطوة 7، cp -r:

Copy finished.

الناتج المتوقع للخطوة 7، find (على افتراض أن كلمة "hello" ظاهرة في ملف واحد بالضبط):

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

الخلاصة

  • الـ stream هو قناة byte-oriented بين تطبيق ومصدر خارجي؛ كل نوع stream فعلي في .NET يرث من الـ abstract class System.IO.Stream.
  • تفصل .NET أنواع الـ stream إلى backing stores تلمس المصدر الخارجي مباشرة (FileStream)، وadapters تعيد تشكيل الـ bytes إلى شيء أكثر فائدة دون الحاجة إلى معرفة أو الاهتمام بأي backing store تحتها (StreamReader/StreamWriter للنص، BinaryReader/BinaryWriter للـ primitives)، وdecorators (DeflateStream) تحول المحتوى نفسه وتعرض في الوقت نفسه نفس API الـ stream.
  • تحدد FileMode، FileAccess، و FileShare معا كيف تفتح FileStream الملف، وما يسمح بفعله به، وما يسمح لـ handles أخرى بفعله به في الوقت نفسه.
  • الـ objects المرتبطة بالـ stream تحمل resource خاصة بنظام التشغيل يجب أن تتحرر؛ تنادي جملة using Dispose() الخاصة بـ object من نوع IDisposable أوتوماتيكيا في نهاية الـ scope الخاصة بها (تتحول إلى try/finally)، وتستبدل نداءات Close() اليدوية الخاصة بالدرس السابق بنمط يعمل بأمان أيضا إذا حدث exception.
  • الـ class التي تكتبها أنت تطبق IDisposable بـ Dispose() public تنادي protected virtual Dispose(bool disposing) مشتركة ثم GC.SuppressFinalize(this)، محمية بـ flag disposed من التنظيف مرتين؛ تنادي finalizer (~ClassName()) نفس الـ method كـ safety net أخير لـ resource لم ينظفها أحد صريحا.
  • تصف FileInfo و DirectoryInfo ملفا أو مجلدا (الحجم، الامتداد، الـ timestamps، الأب، الـ path الكامل) دون فتح stream لقراءة محتواه.
  • تغطي الـ static methods في File و Directory (WriteAllText/WriteAllLines/WriteAllBytes، Move، Delete، Delete(path, true)، GetFiles/GetDirectories مع pattern و SearchOption) أغلبية مهام الملفات والمجلدات اليومية بنداء واحد لكل منها.
  • الخوارزميات الـ recursive على directory tree (قياس الحجم الكلي، ونسخ tree، والبحث في محتوى الملفات) تتشارك جميعها نفس الشكل: التعامل مع ما هو داخل المجلد الحالي مباشرة، ثم التكرار لكل subdirectory.
  • الحفاظ على نواة الـ Linux-shell emulator (pwd، ls، mkdir، touch، rm، cat، nano) وتوسيعها بـ mv، rm -r، stat، writelines، readlines، binwrite، binread، compress، decompress، drives، ls -r، find، du، cp -r، و home يوضح إلى أي مدى يمكن أن تصل building blocks الصغيرة الخاصة بـ namespace System.IO.

القائمة الكاملة للبرنامج

القائمة أدناه هي تطبيق console الكامل بكل أمر من هذا الدرس موصولا في command loop واحدة، شاملة الأوامر الموروثة من الدرس السابق (pwd، ls، mkdir، touch، rm، cat، nano) والأوامر المضافة في المهام أعلاه.

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;
    }
}