[Deno/TypeScript] コマンドラインスクリプトのテンプレート(サブコマンド対応)

・コマンドライン スクリプトのテンプレート
・単一の.tsファイル
・サブコマンド対応
・環境
 deno 2.7.10 (stable, release, x86_64-pc-windows-msvc)
  v8 14.7.173.7-rusty
  typescript 5.9.2
・ダウンロード
#!/usr/bin/env -S deno run --allow-all
/**
 * 汎用コマンドラインツールのテンプレート
 *
 * ### 提供コマンド:
 * - `renumber`: 指定したディレクトリ内の画像を連番で一括リネームする
 * - `downloadfiles`: ファイルをダウンロードする(連番対応)
 * - `resizeimages`: 画像ファイルを伸縮する(ImageMagickが必要)
 * - `filecompress`: 指定パスを圧縮ファイル化する
 * - `dirtotext`: プロジェクトディレクトリ構造とソースファイルをテキスト化。
 *                AIにプログラムの構造を理解させる手助けにもなるはず。
 *
 * ### 使用例:
 * ```bash
 * deno run -A utils.ts renumber --prefix "vacation_" --start 1
 * ```
 *
 * ### 開発・動作環境:
 * - Deno 2.7.10 / TypeScript 準拠
 * - ImageMagick
 *
 * ### タグ:
 * - NOTE: なぜこのような実装にしたのか、設計の背景や理由
 * - TODO: 未実装の機能、後で追加予定の処理
 * - HACK: 綺麗ではないが動かすために一時的に書いた暫定コード
 * - FIXME: バグがある、または正しく動くがコードが汚い場所
 *
 * 【備忘録】Java/Python と似ているけどTypeScriptの違う点
 * 1. OSやファイルシステムなどのI/Oはランタイム固有APIを使う必要がある。
 *    DenoはWeb標準APIを実装しているので、ある程度はWebブラウザと共通した
 *    コードが書ける。
 * 2. try-catchのerrorはError|unknownであり、型が不確定。
 * 3. メソッドチェーン(.then/.catch/.pipe / .map/.filter)
 *   「未来の予約」=「ヒープアロケーション」。
 *   高負荷時:ループ内では関数オブジェクト生成を避け、
 *             try-catch / for-await / for...of を使う。
 *   一般領域:可読性優先なら許容するが、裏でアロケーションが走るコストを
 *             意識する。
 *   ※無名関数(アロー関数も含む)は無条件にスコープ全体をキャプチャし
 *     ヒープ上にクロージャを形成するため、メモリ消費量とGCに回収されにくい
 *     事に注意が必要
 * 4. スプレッド演算子([...])による隠れた O(N) コピーがあるので多用は無用。
 *    大量データ時は push() 等の破壊的変更(In-place)による最適化を選択する。
 * 5. オブジェクトの構造は生成時に固定し、後からのプロパティ追加は禁止。
 *    生成後にプロパティを追加するとランタイムの最適化(Hidden Class)から
 *    外れる。
 *
 * 【備忘録】 設定しておくと便利なオプション
 * deno.jsonc
 * ```jsonc
 * {
 *   "compilerOptions": {
 *     "strict": true,                      // 厳格モード
 *     "exactOptionalPropertyTypes" : true, // 正確なプロパティタイプに限定
 *     "noImplicitOverride": true,          // 暗黙のオーバーライドを禁止
 *     "noImplicitReturns": true,           // 暗黙の戻り値を禁止
 *     "noFallthroughCasesInSwitch": true,  // switch文の突き抜け防止
 *     "noImplicitAny": true                // 暗黙のanyを禁止
 *   },
 *   "lint": {
 *     "rules": {
 *       "include": [
 *         "explicit-function-return-type", // 暗黙の戻り値型を禁止
 *         "no-explicit-any",               // any 型を禁止 (unknown等への移行)
 *         "no-var",                        // var を禁止 (let/const を強制)
 *         "prefer-const",                  // 再代入しない変数の let を禁止
 *         "eqeqeq",                        // == ではなく === を強制
 *         "no-irregular-whitespace",       // 全角スペース等の混入を排除
 *         "no-throw-literal"               // Errorオブジェクト以外のThrow禁止
 *                                          // ※この指定をしても
 *                                          //   catch内部で型チェックは必須
 *       ]
 *     }
 *   },
 *   "fmt": {
 *     "nextControlFlowPosition": "nextLine", // elseとcatchの位置を次行に
 *     "operatorPosition": "nextLine",        // 演算子の位置を次行に
 *     "newLineKind": "lf"                    // 改行コードを LF に
 *   }
 * }
 * ```
 * @module
 */
// deno-lint-ignore-file no-external-import no-import-prefix
import { parseArgs } from "jsr:@std/cli@^1.0.28/parse-args";
import * as path from "jsr:@std/path@^1.1.4";
import * as posix from "jsr:@std/path@^1.1.4/posix";
import { delay } from "jsr:@std/async@^1.2.0";
import { walk } from "jsr:@std/fs@^1.0.23";
import * as zip_js from "jsr:@zip-js/zip-js@^2.8.26";

const PROG_VERSION = "2026.04.30";

// ==================================================================
// 共通関数
// ==================================================================

/**
 * 不明なエラーオブジェクトを Error インスタンスに正規化する。
 *
 * 言語仕様上の必然性:
 * JavaScript の `throw` は任意の型を許容する仕様(ECMAScript 準拠)であり、
 * TypeScript 5.9 においても `catch(error)` は `unknown` 型として定義される。
 * 本関数は、この不確実な入力を後続処理で安全に扱うための「型ガード」および
 * 「正規化」として機能する。
 *
 * @param error 判定対象のエラー。ランタイムから流入する不確実な型。
 * @returns 判定済み、または正規化された Error インスタンス。
 */
function toError(error: unknown): Error {
  return error instanceof Error ? error : new Error(String(error));
}

// ==================================================================
// ランタイム固有処理の抽象化
//
// NOTE: テスト用モックや異なるランタイムに切り替えやすくするため
// ==================================================================

/**
 * ファイル情報を抽象化した型。
 *
 * {@link Deno.FileInfo}を元にした構造であり現時点で必要な項目だけを
 * 記述している。
 */
interface RuntimeFileInfo {
  size: number;
}

/**
 * ディレクトリーエントリー。
 *
 * {@link Deno.DirEntry}を元にした構造。
 */
interface RuntimeDirEntry {
  /** ファイル名・ディレクトリ名。 */
  name: string;

  /** ファイルなら true。 */
  isFile: boolean;

  /** ディレクトリなら true。 */
  isDirectory: boolean;

  /** シンボリックリンクなら true。 */
  isSymlink: boolean;
}

/**
 * 読み込み用ストリームと解放処理をセットにしたリソース。
 *
 * [Symbol.asyncDispose] により、await using での自動解放に対応する。
 */
interface ReadableResource extends AsyncDisposable {
  readonly stream: ReadableStream<Uint8Array>;
}

/**
 * 外部プロセスの実行結果を格納する構造体。
 *
 * 終了ステータスおよび標準入出力のバイナリデータを保持します。
 */
type SubProcessResult = {
  /**
   * プロセスの終了コード。
   *
   * 一般に 0 は成功、それ以外はエラーまたは特定の状態を示します。
   */
  code: number;

  /**
   * プロセスから出力された標準出力(stdout)の生データ。
   *
   * テキストとして扱う場合は、必要に応じて TextDecoder 等でデコードする。
   */
  stdout: Uint8Array;

  /**
   * プロセスから出力された標準エラー出力(stderr)の生データ。
   *
   * 主にエラーメッセージや診断情報が格納される。
   */
  stderr: Uint8Array;
};

/**
 * 実行環境(ランタイム)固有の操作を抽象化するインターフェース。
 *
 * @remarks
 * 異なるランタイム環境への適応を多少容易にするための抽象レイヤー。
 * ランタイム固有機能はこの一箇所に纏める。
 * @internal
 */
interface Runtime {
  /** 指定した終了コードでプロセスを終了する。 */
  exit(code: number): void;

  /** コマンドライン引数を取得する。 */
  getArgs(): string[];

  /** ディレクトリを作成する。 */
  mkdir(
    path: string,
    options?: {
      recursive?: boolean;
      mode?: number;
    },
  ): Promise<void>;

  /** ファイルをコピーする。 */
  copyFile(src: string, dest: string): Promise<void>;

  /** ディレクトリ内のエントリを走査する。 */
  readDir(path: string): AsyncIterable<RuntimeDirEntry>;

  /** ファイルまたはディレクトリをリネーム(移動)する。 */
  rename(oldPath: string, newPath: string): Promise<void>;

  /** ファイルのメタデータを取得する。 */
  stat(path: string): Promise<RuntimeFileInfo>;

  /** パスが存在するか確認する。 */
  exists(path: string): Promise<boolean>;

  /** ストリームからデータを読み取り、指定パスへ書き込む。 */
  writeFile(
    path: string,
    stream: ReadableStream<Uint8Array> | null,
  ): Promise<boolean>;

  /** 指定パスのファイルをリソースとして取得する。 */
  getReadableResource(path: string): Promise<ReadableResource>;

  /**
   * 指定されたパスのファイルを同期的に開く。
   *
   * @param path ファイルパス
   * @param options {@link Deno.OpenOptions} に準拠したオプション
   * @returns {@link Deno.openSync}に準拠したファイル操作オブジェクト
   */
  openSync(path: string, options: {
    write?: boolean;
    create?: boolean;
    truncate?: boolean;
    read?: boolean;
  }): {
    writeSync(p: Uint8Array): number;
    close(): void;
  };

  /**
   * 外部コマンドを実行する。
   *
   * @param command 実行するコマンド名またはパス
   * @param args コマンドに渡す引数の配列
   */
  exec(command: string, args: string[]): Promise<SubProcessResult>;

  /** 実行環境情報を出力する。 */
  outputEnv(): void;
}

/**
 * Deno ランタイム標準 API を使用する {@link Runtime} の本番用実装。
 *
 * @remarks
 * - ランタイム固有の外部ライブラリを使う場合は、ここの中でインポートする。
 */
const nativeRuntime: Runtime = {
  exit(code: number): void {
    Deno.exit(code);
  },

  getArgs(): string[] {
    return Deno.args;
  },

  mkdir: Deno.mkdir,
  copyFile: Deno.copyFile,
  readDir: Deno.readDir,
  rename: Deno.rename,

  async stat(path: string): Promise<RuntimeFileInfo> {
    return await Deno.stat(path);
  },

  // @std/fs/existsの競合状態回避策
  async exists(path: string): Promise<boolean> {
    try {
      await Deno.lstat(path);
      return true;
    }
    catch {
      return false;
    }
  },

  async writeFile(
    path: string,
    stream: ReadableStream<Uint8Array> | null,
  ): Promise<boolean> {
    try {
      using file = await Deno.open(path, {
        write: true,
        create: true,
        truncate: true,
      });

      if (stream !== null) {
        await stream.pipeTo(file.writable);
      }

      return true;
    }
    catch {
      return false;
    }
  },

  async getReadableResource(path: string): Promise<ReadableResource> {
    const file = await Deno.open(path, { read: true });

    return {
      stream: file.readable,

      [Symbol.asyncDispose](): Promise<void> {
        try {
          file.close();
        }
        catch { /** ignore */ }

        return Promise.resolve();
      },
    };
  },

  openSync(path: string, options: {
    write?: boolean;
    create?: boolean;
    truncate?: boolean;
    read?: boolean;
  }): {
    writeSync(p: Uint8Array): number;
    close(): void;
  } {
    const file = Deno.openSync(path, options);
    return {
      writeSync: (p: Uint8Array): number => file.writeSync(p),
      close: (): void => file.close(),
    };
  },

  async exec(command: string, args: string[]): Promise<SubProcessResult> {
    const cmd = new Deno.Command(command, { args });
    return await cmd.output();
  },

  outputEnv(): void {
    console.log("--- Deno Built-in Properties ---");

    const envInfo = {
      "Deno.hostname": Deno.hostname(),
      "Deno.execPath()": Deno.execPath(),
      "Deno.noColor": Deno.noColor,
      "Deno.version": Deno.inspect(Deno.version),
      "Deno.build": Deno.inspect(Deno.build),
    };
    for (const [key, value] of Object.entries(envInfo)) {
      console.log(`${key}: ${value}`);
    }

    console.log("--- Deno Related Environment Variables ---");

    // deno-fmt-ignore
    const denoRelatedVars = [
      "DENO_DIR",            // キャッシュルート
      "DENO_INSTALL",        // インストール先
      "DENO_AUTH_TOKENS",    // 秘密リポジトリ等の認証情報
      "DENO_CERT",           // TLS証明書のパス
      "DENO_WEBGPU_ADAPTER", // WebGPUの使用アダプタ
      "NO_COLOR",            // コンソール出力の色抑制
      "HTTP_PROXY",          // ネットワークプロキシ
      "HTTPS_PROXY",         // ネットワークプロキシ
      "NO_PROXY",            // プロキシ除外設定
    ];
    for (const name of denoRelatedVars) {
      const value = Deno.env.get(name);
      console.log(`${name}: ${value ?? "(not set)"}`);
    }
  },
};

// ==================================================================
// 画像処理ラッパー
// ==================================================================

/**
 * 画像操作に関する高レベルな機能を提供するプロセッサ。
 *
 * 内部で実行される具体的なエンジンの詳細は抽象化されている。
 * 利用側は環境の違いを意識することなく画像解析や加工が可能。
 */
class ImageProcessor {
  constructor(private readonly runtime: Runtime) {}

  /**
   * 指定された画像ファイルの統計情報(寸法、容量等)を取得する。
   *
   * @param path 解析対象ファイルのパス
   * @returns 寸法(w, h)、容量(size)、識別名(name)を含むキー・値ペア
   *          解析出来ない場合は`null`。
   */
  async getStats(path: string): Promise<Record<string, unknown> | null> {
    const fileInfo = await this.runtime.stat(path);
    if (fileInfo.size === 0) {
      // 解析不能
      return null;
    }

    const { stdout } = await this.runtime.exec("magick", [
      "identify",
      "-format",
      '{"w":%[width],"h":%[height],"size":"%[size]", "name":"%f"}',
      path,
    ]);

    if (stdout.length === 0) {
      // 解析不能
      return null;
    }

    const decoded = new TextDecoder().decode(stdout);
    if (!decoded.trim()) {
      // JSONとして正しくない
      return null;
    }

    return JSON.parse(decoded) as Record<string, unknown>;
  }

  /**
   * 指定されたパラメータに基づいて画像をリサイズする。
   *
   * 幅または高さのどちらか一方に 0 を指定した場合、アスペクト比を維持して
   * 計算される。
   *
   * @param input 入力ソースのパス
   * @param output 出力先のパス
   * @param width リサイズ後の幅(ピクセル)
   * @param height リサイズ後の高さ(ピクセル)
   * @returns 実行結果のステータスと、エラー発生時の詳細メッセージ
   */
  async resize(
    input: string,
    output: string,
    width: number,
    height: number,
  ): Promise<{
    code: number;
    message: string;
  }> {
    const params = [input, "-resize"];
    if (width > 0 && height > 0) params.push(`${width}x${height}`);
    else if (width > 0) params.push(`${width}x`);
    else if (height > 0) params.push(`x${height}`);
    params.push(output);

    const { code, stderr } = await this.runtime.exec("magick", params);

    return {
      code,
      message: code !== 0 ? new TextDecoder().decode(stderr) : "",
    };
  }
}

// ==================================================================
// fetchのラッパー
// ==================================================================

/**
 * fetch 実行の結果を分類して表す。
 *
 * 判別共用体 (Discriminated Union)。
 *
 * @remarks
 * ネットワークの成否だけでなく、HTTP ステータスコードに基づく
 * 論理的な成功・失敗も型レベルで区別するために使用する。
 */
type SafeFetchResult =
  | ({ type: "success"; response: Response } & AsyncDisposable)
  | ({ type: "http_error"; response: Response } & AsyncDisposable)
  | ({ type: "network_error"; error: Error } & AsyncDisposable);

/**
 * fetch を実行し、例外をスローせずに実行結果を型安全なオブジェクトとして返す。
 *
 * @param url - リクエスト対象の URL 文字列
 * @returns 実行結果の状態 (`type`) に応じた {@link SafeFetchResult} を含む
 *
 * @remarks
 * 素のfetchではエラーハンドリングが面倒くさいので例外を全て吸収し
 * 戻り値として返す。
 */
async function safeFetch(
  url: string,
  headers: HeadersInit = {},
): Promise<SafeFetchResult> {
  try {
    const response = await fetch(url, { method: "GET", headers });

    const disposer = {
      // すでに bodyUsed(読み込み完了またはキャンセル済み)なら何もしない
      async [Symbol.asyncDispose](): Promise<void> {
        if (!response.bodyUsed && response.body) {
          await response.body.cancel();
        }
      },
    };

    return {
      type: response.ok ? "success" : "http_error",
      response,
      ...disposer,
    };
  }
  catch (error) {
    return {
      type: "network_error",
      error: toError(error),
      [Symbol.asyncDispose](): Promise<void> {
        return Promise.resolve();
      },
    };
  }
}

// ==================================================================
// コマンドの定義
// ==================================================================

/**
 * コマンドの実行結果を表す型定義。
 *
 * `success` プロパティの値によって、成功時と失敗時の状態を区別する。
 * 判別共用体 (Discriminated Union)
 */
type CommandResult =
  | { success: true; code: 0; message?: string }
  | { success: false; code: number; message: string; error?: Error };

/**
 * サブコマンドの「クラス定義そのもの」が満たすべき共通ルール。
 *
 * @remarks
 * 抽象クラスでは制限できない `static` メンバの実装を、コンパイル時に強制するた
 * めの仕組み。
 *
 * これにより、クラスをインスタンス化(new)する前の段階で、
 * 識別用の `static ID` などが正しく定義されているかがチェックされる。
 */
interface SubCommandConstructor {
  /**
   * コマンドの識別名(例: "renumber", "download")。
   *
   * @remarks
   * 具象クラスでは `static readonly ID: string` として定義する。
   */
  readonly ID: string;

  /**
   * ヘルプ画面に表示されるコマンドの簡潔な説明文。
   *
   * @remaerks
   * 具象クラスでは `static readonly DESCRIPTION: string` として定義する。
   */
  readonly DESCRIPTION: string;

  /**
   * サブコマンドのインスタンスを生成するためのコンストラクタ。
   *
   * @param runtime ランタイムの実装
   * @returns BaseCommand を継承したサブコマンドのインスタンス
   */
  new (runtime?: Runtime): BaseCommand<unknown>;
}

/**
 * 全てのサブコマンドの基底となる抽象クラス。
 *
 * 引数の解析(Template Methodパターンの構成)と実行フローを制御する。
 * 継承先では、具体的なオプション型 `T` を定義し、解析と実行のロジックを
 * 実装すること。
 *
 * @template T コマンドが使用する解析済みオプションの型
 */
abstract class BaseCommand<T> {
  /**
   * コンストラクター。
   *
   * @param runtime - 使用するファイルシステム操作の実装。
   * デフォルトは `DenoFS`(Deno 標準 API を使用する実体)を使用。
   * 単体テスト時には、ここへモックオブジェクトを注入することで、
   * 実際のファイルシステムを汚さずにロジックを検証できる。
   * @example
   * // 本番用(通常はこちらを使用)
   * const cmd = new ReNumberCommand();
   * @example
   * // テスト用(ディレクトリ内のファイル一覧を偽装する)
   * const mockFS: FileSystem = {
   * readDir: async function* () { yield { name: "file1.txt", isFile: true, isDirectory: false, isSymlink: false }; },
   * exists: async () => false,
   * rename: async (s, d) => console.log(`Moved: ${s} -> ${d}`),
   * };
   * const cmd = new ReNumberCommand(mockFS);
   */
  constructor(protected runtime: Runtime = nativeRuntime) {}

  /**
   * コマンド固有のヘルプ情報(使用法、オプションの詳細など)を
   * 標準出力に表示する。
   * @protected
   */
  protected abstract showHelp(): void;

  /**
   * コマンドライン引数の配列を解析し、型 `T` のオブジェクトに変換する。
   *
   * @param args サブコマンド名を除いた、解析対象の引数リスト
   * @returns 解析・構造化されたオプションオブジェクト
   * @protected
   */
  protected abstract parseOptions(args: string[]): T;

  /**
   * コマンドの具体的な実行ロジックを実装する。
   *
   * @param options `parseOptions` によって解析されたオプションオブジェクト
   * @returns 実行結果(成功・失敗のステータスとメッセージ)を含むPromise
   * @protected
   */
  protected abstract runImpl(options: T): Promise<CommandResult>;

  /**
   * コマンドの実行エントリポイント。
   *
   * 引数の解析 (`parseOptions`) を行い、その結果を元に実行 (`runImpl`) を
   * 呼び出す。
   *
   * @param args コマンドラインから渡された未解析の引数リスト
   * @returns コマンドの実行結果を保持するオブジェクト
   */
  async run(args: string[]): Promise<CommandResult> {
    const subArgs = parseArgs(args, {
      boolean: ["help"],
      alias: { h: "help" },
    });
    if (args.length === 0 || subArgs.help) {
      this.showHelp();

      return { success: true, code: 0 };
    }

    const options = this.parseOptions(args);
    return await this.runImpl(options);
  }
}

// ==================================================================
// SubCommand: renumber (連番リネーム)
// ==================================================================
//                           ~~中略~~
// ==================================================================
// SubCommand: downloadfiles (ファイルダウンロード)
// ==================================================================
//                           ~~中略~~
// ==================================================================
// SubCommand: resizeimages (画像ファイルサイズ変換)
// ==================================================================
//                           ~~中略~~
// ==================================================================
// SubCommand: filecompress (圧縮ファイル化)
// ==================================================================
//                           ~~中略~~
// ==================================================================
// SubCommand:  dirtotext (構成内容一括出力)
// ==================================================================

/**
 * `dirtotext` コマンドで使用するパース済みオプション。
 */
interface DirToTextOptions {
  rootPath: string;
  isTreeOnly: boolean;
  outputPath?: string | undefined;
}

type DirToTextFileInfo = {
  fullPath: string;
  relPath: string;
};

/**
 * 内部用の Writer 型定義
 */
type DirToTextWriter = {
  (content: string): void;
  close: () => void;
  [Symbol.dispose](): void;
};

/**
 * 開発プロジェクト内部のディレクトリ構造をテキストに一括変換し、ソースファイル
 * に相当する内容を結合して1つにする。
 */
class DirToTextCommand extends BaseCommand<DirToTextOptions> {
  static readonly ID = "dirtotext";
  static readonly DESCRIPTION =
    "プロジェクトディレクトリ構造とソースファイルを1つのテキストに変換";

  private static readonly IGNORE_DIRS: ReadonlySet<string> = new Set([
    "node_modules",
    "vedor",
    ".git",
    ".github",
    ".gitlab",
    "test",
    "dist",
    "build",
    "out",
    "coverage",
    ".venv",
    "__pycache__",
    // ".vscode",
  ]);

  /**
   * 処理対象とする拡張子のリスト。
   * 全て小文字、ドット付きで定義。
   */
  // deno-fmt-ignore
  private static readonly TARGET_EXTS: ReadonlySet<string> = new Set([
    // Web / Script
    ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".d.ts",
    // Python
    ".py", ".pyi",
    // Java
    ".java", ".jsp", ".properties", ".kt", ".kts",
    // C / C++
    ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hh", ".c",
    // SQL
    ".sql",
    // Configs
    ".json", ".jsonc", ".yaml", ".yml", ".toml", ".ini", ".conf", ".cfg",
    ".properties", ".setting.json",
    ".code-workspace",
  ]);

  /** 日本語環境に最適化された自然順ソート用の比較器。 */
  private static readonly I18N_COLLATOR: Readonly<Intl.Collator> = new Intl
    .Collator(
    "ja",
    {
      numeric: true,
      sensitivity: "variant",
    },
  );

  /**
   * ヘルプ情報を標準出力。
   */
  protected override showHelp(): void {
    console.log(`
Description:
  ${DirToTextCommand.DESCRIPTION}

Usage:
  deno run -A utils.ts ${DirToTextCommand.ID} [Options]

Options:
  -p, --rootPath         出力ファイル
  -t, --tree             ディレクトリ構造のみ出力
  -o, --out              出力ファイルパス (未指定時は標準出力)
  -h, --help             このヘルプを表示`);
  }

  /**
   * 引数を `DirToTextOptions` 型にパースする。
   *
   * @param rawArgs コマンドライン引数の配列
   * @returns パース済みのオプションオブジェクト
   */
  protected override parseOptions(rawArgs: string[]): DirToTextOptions {
    const args = parseArgs(rawArgs, {
      string: ["p", "o"],
      boolean: ["t"],
      alias: {
        p: "rootPath",
        t: "tree",
        o: "out",
      },
      default: {
        p: ".",
        t: false,
      },
    });

    return {
      rootPath: String(args.rootPath),
      isTreeOnly: Boolean(args.tree),
      ...(args.out ? { outputPath: String(args.out) } : {}),
    };
  }

  /**
   * コマンドのメインロジックを実行する。
   *
   * @param options `parseOptions` によって解析されたオプションオブジェク
   *
   * @returns 実行結果(成功・失敗のステータスとメッセージ)を含むPromise
   * @throws {Error} ファイルシステム操作に失敗した場合
   */
  protected override async runImpl(
    options: DirToTextOptions,
  ): Promise<CommandResult> {
    const absoluteRoot = path.resolve(options.rootPath);
    const treeLine: string[] = [];
    const files: DirToTextFileInfo[] = [];

    await this.scanDirectory(
      absoluteRoot,
      absoluteRoot,
      treeLine,
      files,
    );

    using writer = this.createWriter(options.outputPath);

    writer("PROJECT STRUCTURE:");
    writer(treeLine.join("\n"));

    if (options.isTreeOnly) {
      return { success: true, code: 0 };
    }

    writer("\n" + "=".repeat(40));
    writer("FILE CONTENTS:");

    for (const file of files) {
      writer(`\n--- ${file.relPath} ---`);

      await using res = await this.runtime.getReadableResource(file.fullPath);
      const text = await new Response(res.stream).text();
      writer(text);
    }

    return { success: true, code: 0 };
  }

  /**
   * ディレクトリ構造を再帰的に走査し、ツリー表示用の文字列配列と
   * ファイル情報のリストを構築する。
   *
   * @param rootPath - 走査の起点となるルートディレクトリの絶対パス。
   * @param currentPath - 現在走査中のディレクトリの絶対パス。
   * @param treeLines - ツリー形式の文字列を格納する`出力用`配列。
   * @param files - 抽出されたファイル情報を格納する`出力用`配列。
   * @param prefix - ツリー表示の階層を示すためのインデント文字列。
   * @returns 非同期処理の完了を示す Promise。
   *
   * @throws 権限不足やパスの消失によりディレクトリの読み込みに失敗した場合
   *         は、その階層をスキップして終了する。
   */
  async scanDirectory(
    rootPath: string,
    currentPath: string,
    treeLines: string[],
    files: DirToTextFileInfo[],
    prefix = "",
  ): Promise<void> {
    const entries: RuntimeDirEntry[] = [];

    // 現在位置の一覧を取得
    for await (const entry of this.runtime.readDir(currentPath)) {
      if (entry.isDirectory && DirToTextCommand.IGNORE_DIRS.has(entry.name)) {
        continue;
      }

      if (entry.isDirectory) {
        const childPath = path.join(currentPath, entry.name);
        if (await this.hasTargetFiles(childPath)) {
          entries.push(entry);
        }
      }
      else if (entry.isFile) {
        const ext = path.extname(entry.name).toLowerCase();
        if (entry.isFile && DirToTextCommand.TARGET_EXTS.has(ext)) {
          entries.push(entry);
        }
      }
      // シンボリックリンクは無視する
    }

    entries.sort((a, b) =>
      DirToTextCommand.I18N_COLLATOR.compare(a.name, b.name)
    );

    // 再帰的にディレクトリ構造を走査する
    for (let i = 0; i < entries.length; i++) {
      const entry = entries[i];
      const isLast = i === entries.length - 1;
      const fullPath = path.join(currentPath, entry.name);
      const line = `${prefix}${isLast ? "└── " : "├── "}${entry.name}`;
      treeLines.push(line);

      if (entry.isDirectory) {
        await this.scanDirectory(
          rootPath,
          fullPath,
          treeLines,
          files,
          prefix + (isLast ? "    " : "│   "),
        );
      }
      else {
        files.push({
          fullPath,
          relPath: path.relative(rootPath, fullPath),
        });
      }
    }
  }

  /**
   * 対象エントリーかを判定する。
   *
   * @param dirPath 走査対象のディレクトリーパス
   * @returns `dirPath`が対象ファイルなら true。
   *          `dirPath`が対象ファイルを含むディレクトリーでも true。
   */
  async hasTargetFiles(dirPath: string): Promise<boolean> {
    for await (const entry of this.runtime.readDir(dirPath)) {
      if (entry.isDirectory && DirToTextCommand.IGNORE_DIRS.has(entry.name)) {
        continue;
      }

      if (entry.isDirectory) {
        const childPath = path.join(dirPath, entry.name);
        if (await this.hasTargetFiles(childPath)) {
          return true;
        }
      }
      else if (entry.isFile) {
        const ext = path.extname(entry.name).toLowerCase();
        if (DirToTextCommand.TARGET_EXTS.has(ext)) {
          return true;
        }
      }
      // シンボリックリンクは無視
    }

    return false;
  }

  /**
   * 出力先に応じた Writer オブジェクトを生成します。
   *
   * @param outputPath - 出力先ファイルのパス。
   *                     省略した場合は標準出力(console.log)を使用する。
   * @returns `DirToTextWriter` オブジェクト。
   *          `using` 構文による自動リソース解放に対応する。
   *
   * @example
   * ```typescript
   * using writer = this.createWriter("output.txt");
   * writer("content");
   * ```
   */
  private createWriter(outputPath?: string): DirToTextWriter {
    if (outputPath) {
      return this.createFileWriter(outputPath);
    }

    return this.createConsoleWriter();
  }

  private createFileWriter(outputPath: string): DirToTextWriter {
    const encoder = new TextEncoder();
    const file = this.runtime.openSync(outputPath, {
      write: true,
      create: true,
      truncate: true,
    });

    const writer = ((content: string): void => {
      file.writeSync(encoder.encode(content + "\n"));
    }) as DirToTextWriter;

    writer.close = (): void => {
      file.close();
    };

    writer[Symbol.dispose] = (): void => {
      writer.close();
    };

    return writer;
  }

  private createConsoleWriter(): DirToTextWriter {
    const writer = ((content: string): void => {
      console.log(content);
    }) as DirToTextWriter;

    writer.close = (): void => {};
    writer[Symbol.dispose] = (): void => {};

    return writer;
  }
}

// ==================================================================
// メイン処理
// ==================================================================

/**
 * ツール全体のライフサイクルとコマンド実行を管理するメインクラス。
 *
 * 本プログラムはシンプルなコマンドラインプログラムなので、高度な
 * ハンドリングは必要ない。
 */
class Main {
  // NOTE: ここで SubCommandConstructor型 を指定することで
  // サブコマンドにstaticメンバーとコンストラクタを継承(強制)させられる
  // deno-fmt-ignore
  readonly COMMANDS: ReadonlyMap<string, SubCommandConstructor> =
    new Map<string, SubCommandConstructor>([
      // [ReNumberCommand.ID, ReNumberCommand],
      // [DownloadFilesCommand.ID, DownloadFilesCommand],
      // [ReSizeImagesCommand.ID, ReSizeImagesCommand],
      // [FileCompressorCommand.ID, FileCompressorCommand],
      [DirToTextCommand.ID, DirToTextCommand],
  ]);

  /**
   * ヘルプ情報を標準出力。
   *
   * @returns 常に0(正常終了コード)
   */
  private showHelp(): void {
    console.log(
      "Usage: deno run -A utils.ts [command] [options]",
      "\n\n  -h, --help      : Show help",
      "\n  -v, --version   : Show version",
      "\n  -e, --env       : Show environment info",
      "\n\nSubCommands:",
    );

    for (const cmd of this.COMMANDS.values()) {
      console.log(`  ${cmd.ID.padEnd(15)} : ${cmd.DESCRIPTION}`);
    }
  }

  /**
   * アプリケーションのメインエントリーポイント。
   *
   * 引数を解析し、適切なサブコマンドを呼び出す。
   *
   * @returns プロセスの終了コード
   */
  async exec(): Promise<number> {
    const args = parseArgs(
      nativeRuntime.getArgs(),
      {
        stopEarly: true, // 最初の引数をサブコマンドとして扱う
        boolean: ["help", "version", "env"],
        alias: { h: "help", v: "version", e: "env" },
      },
    );

    if (args.version) {
      console.log(`${PROG_VERSION}`);
      return 0;
    }
    if (args.env) {
      nativeRuntime.outputEnv();
      return 0;
    }

    const [subCommandName, ...remainingArgs] = args._.map(String);
    if (args.help || !subCommandName) {
      this.showHelp();
      return 0;
    }

    const command = this.COMMANDS.get(subCommandName);
    if (!command) {
      console.error(`Unknown command: ${subCommandName}`);
      return 1;
    }

    try {
      const cmd = new command();
      const result = await cmd.run(remainingArgs);

      if (result.message) {
        const kind = result.success ? "" : "[Error] ";
        console.log(`${kind}${result.message}`);
      }

      if (!result.success && result.error) {
        console.debug(result.error.stack);
      }

      return result.code;
    }
    // 想定外のエラー(権限不足やディスクフルなど)
    catch (error) {
      const e = toError(error);
      console.error(`[Fatal Error] ${command.ID}: ${e.message}`);
      console.debug(e.stack);

      return 2;
    }
  }
}

/**
 * 直接実行された場合のみアプリケーションを起動
 */
if (import.meta.main) {
  const exitCode = await new Main().exec();
  nativeRuntime.exit(exitCode);
}

// /**
//  * 唯一の公開シンボル(テスト用ゲートウェイ)
//  */
// export const __Test__ = {
//   safeFetch,
//   DirToTextCommand,
//   // テストで必要な内部定義をすべてここに集約
// } as const;