[
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    branches: [master]\n  pull_request:\n\nenv:\n  CARGO_TERM_COLOR: always\n\njobs:\n  check:\n    name: Check compilation\n    strategy:\n      matrix:\n        os: [ubuntu-latest, macos-latest]\n\n    runs-on: ${{ matrix.os }}\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Install Rust\n        uses: dtolnay/rust-toolchain@stable\n      - name: Cache Rust dependencies\n        uses: actions/cache@v4\n        with:\n          path: |\n            ~/.cargo/registry/index/\n            ~/.cargo/registry/cache/\n            ~/.cargo/git/db/\n            target/\n          key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}\n          restore-keys: |\n            ${{ runner.os }}-cargo-\n\n      - name: Check\n        run: cargo check\n\n  fmt:\n    name: Check formatting\n    needs: check\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Install Rust\n        uses: dtolnay/rust-toolchain@stable\n\n      - name: Install rustfmt\n        run: rustup component add rustfmt\n\n      - name: Check formatting\n        run: cargo fmt -- --check\n\n  test:\n    name: Run tests (${{ matrix.name }})\n    strategy:\n      matrix:\n        include:\n          - name: ubuntu\n            os: ubuntu-latest\n          - name: macos\n            os: macos-latest\n          - name: musl\n            os: ubuntu-latest\n            target: x86_64-unknown-linux-musl\n\n    runs-on: ${{ matrix.os }}\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Install Rust\n        uses: dtolnay/rust-toolchain@stable\n        with:\n          targets: ${{ matrix.target || '' }}\n\n      - name: Install musl tools\n        if: matrix.target == 'x86_64-unknown-linux-musl'\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y musl-tools\n\n      - name: Cache Rust dependencies\n        uses: actions/cache@v4\n        with:\n          path: |\n            ~/.cargo/registry/index/\n            ~/.cargo/registry/cache/\n            ~/.cargo/git/db/\n            target/\n          key: ${{ matrix.name }}-cargo-${{ hashFiles('**/Cargo.lock') }}\n          restore-keys: |\n            ${{ matrix.name }}-cargo-\n\n      - name: Disable agent tests\n        run: echo 'test-agents = []' > test-agents.toml\n\n      - name: Test\n        run: cargo test ${{ matrix.target && format('--target {0}', matrix.target) || '' }}\n"
  },
  {
    "path": ".gitignore",
    "content": "/target\n.claude/"
  },
  {
    "path": ".skill-tree.toml",
    "content": "[github]\nowner   = \"rust-lang\"\nproject = 42\n\n[[field]]\ndisplay-name = \"status\"\ngithub-name  = \"Status\"\n\n[[field]]\ndisplay-name = \"priority\"\ngithub-name  = \"Priority\"\n\n[[field]]\ndisplay-name = \"assignee\"\ngithub-name  = \"Assignee\"\n\n[colors]\ngithub-name = \"Status\"\n\n[colors.values]\n\"In Progress\" = \"#4a90d9\"\n\"Blocked\"     = \"#e05252\"\n\"Complete\"    = \"#57a85a\"\n\"Not Started\" = \"#888888\""
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Code of Conduct\n\n## Conduct\n\n- We are committed to providing a friendly, safe and welcoming environment for all, regardless of level of experience, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, nationality, or other similar characteristic.\n- Please avoid using overtly sexual aliases or other nicknames that might detract from a friendly, safe and welcoming environment for all.\n- Please be kind and courteous. There's no need to be mean or rude.\n- Respect that people have differences of opinion and that every design or implementation choice carries a trade-off and numerous costs. There is seldom a right answer.\n- Please keep unstructured critique to a minimum. If you have solid ideas you want to experiment with, make a fork and see how it works.\n- We will exclude you from interaction if you insult, demean or harass anyone. That is not welcome behavior. We interpret the term \"harassment\" as including the definition in the [Citizen Code of Conduct](https://github.com/stumpsyn/policies/blob/master/citizen_code_of_conduct.md); if you have any lack of clarity about what might be included in that concept, please read their definition. In particular, we don't tolerate behavior that excludes people in socially marginalized groups.\n- Private harassment is also unacceptable. No matter who you are, if you feel you have been or are being harassed or made uncomfortable by a community member, please contact one of the project maintainers immediately. Whether you're a regular contributor or a newcomer, we care about making this community a safe place for you and we've got your back.\n- Likewise any spamming, trolling, flaming, baiting or other attention-stealing behavior is not welcome.\n\n## Reporting\n\nIf you need to report conduct issues, please reach out [Niko Matsakis](mailto:rust@nikomatsakis.com)\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[package]\nname = \"skill-tree\"\nversion = \"3.2.1\"\nauthors = [\"Niko Matsakis <niko@alum.mit.edu>\"]\nedition = \"2024\"\ndescription = \"generate graphviz files to show roadmaps\"\nlicense = \"MIT\"\nrepository = \"https://github.com/nikomatsakis/skill-tree\"\nhomepage = \"https://github.com/nikomatsakis/skill-tree\"\n\n\n[dependencies]\nrand = \"0.10.1\"\nreqwest = { version = \"0.13.3\", features = [\"json\"] }\nserde = { version = \"1.0.228\", features = [\"derive\"] }\nserde_json = \"1.0.149\"\nthiserror = \"2.0.18\"\ntokio = { version = \"1.52.2\", features = [\"time\"] }\ntoml = \"1.1.2\"\n\n\n[workspace]\nmembers = [\"./\", \"./skill-tree-testlib\"]\n\n[dev-dependencies]\nindoc = \"2.0.7\"\nskill-tree-testlib = { path = \"./skill-tree-testlib\" }\ntempfile = \"3.27.0\"\ntokio = { version = \"1.52.2\", features = [\"macros\", \"rt\"] }\n"
  },
  {
    "path": "README.md",
    "content": "# skill-tree\n\nskill-tree fetches a GitHub Project and renders it as a directed dependency graph.\n\n## What is a skill tree?\n\nA \"skill tree\" is a way to map out the roadmap for a project. The term is\nborrowed from video games, but it was first applied to project planning in\nthis [blog post about WebAssembly's post-MVP future][wasm] — at least, that\nwas the first time it was used that way.\n\n[wasm]: https://hacks.mozilla.org/2018/10/webassemblys-post-mvp-future/\n\nThe idea: work items have dependencies, just like skills in a game. You cannot\nunlock the next thing until the current thing is done. Mapping those\ndependencies visually shows you the shape of a roadmap at a glance.\n\n## How it works\n\nskill-tree reads a GitHub Project — issues, their blocking relationships, and\ntheir custom field values — and renders the result as a Graphviz DOT file or\nSVG. Each node is a GitHub issue. Each edge is a blocking relationship. Node\ncolor is driven by a custom field in GitHub Projects.\n\nGitHub is the source of truth. There is no separate file to maintain.\n\n## Usage\n\n```bash\n# Render the dependency graph as SVG\nskill-tree render --format svg --output graph.svg\n\n# List open issues with no incoming blocking edges\nskill-tree unblocked\n\n# Check for cycles and dangling references\nskill-tree validate\n```\n\n## Configuration\n\nCreate a `.skill-tree.toml` in your project root:\n\n```toml\n[github]\nowner   = \"rust-lang\"\nproject = 42\n\n[[field]]\ndisplay-name = \"status\"\ngithub-name  = \"Status\"\n\n[colors]\ngithub-name = \"Status\"\n\n[colors.values]\n\"In Progress\" = \"#4a90d9\"\n\"Blocked\"     = \"#e05252\"\n\"Complete\"    = \"#57a85a\"\n\"Not Started\" = \"#888888\"\n```\n\n`owner` is the GitHub organization or user that owns the project.\n`project` is the project number from the GitHub Projects URL.\n\nskill-tree fetches all custom fields GitHub returns automatically. `[[field]]`\nentries are display declarations only — they give a field a friendly\n`display-name` for CLI output. Fields not declared in `[[field]]` are still\nfetched and stored on each node.\n\n`[colors]` specifies which GitHub field drives node color (`github-name`)\nand maps that field's option values to hex colors (`[colors.values]`).\nThe entire section is optional — if omitted, all nodes render gray.\n\n## Installation\n\n```bash\ncargo install skill-tree\n```\n\nRendering SVG requires Graphviz:\n\n```bash\n# macOS\nbrew install graphviz\n\n# Ubuntu\napt install graphviz\n```\n\n## Authentication\n\nskill-tree reads your GitHub token from the `GITHUB_TOKEN` environment\nvariable:\n\n```bash\nexport GITHUB_TOKEN=<your token>\n```\n\nThe token requires `read:project` and `repo` scopes.\n\n## Documentation\n\nFor architecture, design decisions, and contribution guide, see the\n[skill-tree design book](https://nikomatsakis.github.io/skill-tree/).\n\n## Status\n\n⚠️ **Early development** — expect frequent changes.\n\n## Community\n\nskill-tree is open source. We welcome contributors and maintain a\n[code of conduct](./CODE_OF_CONDUCT.md)."
  },
  {
    "path": "RELEASES.md",
    "content": "# 1.3.2\n\n* Remove outdated dependencies\n\n# 1.3.0\n\n* Fix bug around underline\n* Add ability to have `href` on tree nodes\n\n# 1.2.0 and before\n\nI wasn't taking notes =)\n"
  },
  {
    "path": "book.toml",
    "content": "[book]\nauthors = [\"Niko Matsakis\", \"James Muriuki\"]\nlanguage = \"en\"\nsrc = \"md\"\ntitle = \"skill-tree\"\n\n[preprocessor.mermaid]\ncommand = \"mdbook-mermaid\"\n\n[output]\n\n[output.html]\nadditional-js = [\"mermaid.min.js\", \"mermaid-init.js\"]\nadditional-css = [\"theme/custom.css\"]\nfold.enable = true\nfold.level = 0\ngit-repository-url = \"https://github.com/nikomatsakis/skill-tree\"\nedit-url-template = \"https://github.com/nikomatsakis/skill-tree/edit/main/md/{path}\""
  },
  {
    "path": "md/contributing/01-module-structure.md",
    "content": "# Key modules\n\nskill-tree is organized as a three-stage pipeline: fetch data from GitHub, model it as a graph, render it as output.\n\n## `config.rs` — configuration\n\nReads `.skill-tree.toml` and provides the `SkillTree` application context. Two constructors:\n- `SkillTree::from_dir()` — load from a directory (production and tests)\n- `SkillTree::from_path()` — load from an explicit file path\n\nProvides query methods to access config data:\n- `color_for_value()` — map a field option to a hex color\n- `field_by_display_name()` — look up a field by its display name\n- `color_field_github_name()` — which field drives node color\n\n## `error/` — error types\n\nAll errors in skill-tree organized by origin:\n\n- `error/github.rs` — GitHub API errors (`GitHubError`, `NetworkErrorKind`, `ErrorContext`)\n- `error/config.rs` — configuration file errors (`ConfigError`)\n\nEach error type implements `.exit_code()` to map to process exit codes (1, 3, or 4).\n\n## `github/` — GitHub GraphQL client\n\nThe only module that talks to GitHub. Implements the fetch stage of the pipeline.\n\n- `github/mod.rs` — `GitHubClient` with retry, rate limit, and timeout handling\n- `github/projects.rs` — fetch GitHub Projects V2 items (stub)\n- `github/issues.rs` — fetch issues and blocking relationships (stub)\n\nThe client automatically retries transient errors (exponential backoff), waits on rate limits, and fails if the operation exceeds the configured timeout. All errors carry `ErrorContext` (query name, owner, project) for debugging.\n\n## `graph/` — graph model\n\nThe platform-agnostic data model: nodes (issues), edges (blocking relationships), and algorithms.\n\n- `graph/mod.rs` — `Graph`, `Node`, `Edge` types\n- `graph/validate.rs` — cycle detection and dangling edge detection\n\n## `render/` — rendering\n\nTurns a `Graph` into Graphviz DOT format and optionally renders it to SVG using the system `dot` binary.\n\n- `render/mod.rs` — `render()` function, DOT generation"
  },
  {
    "path": "md/contributing/02-important-flows.md",
    "content": "# Important flows\n\nskill-tree is a three-stage pipeline. These are the major paths through the code.\n\n## `render` command\n\nFetch GitHub Project → model as graph → render as DOT/SVG.\n\n**Flow:**\n1. Load config from `.skill-tree.toml`\n2. Construct GitHub client (read token from `--token` or `GITHUB_TOKEN` env var)\n3. Fetch project items and issues from GitHub GraphQL API\n4. Build graph: create nodes for each issue, edges for blocking relationships\n5. Render graph to Graphviz DOT format\n6. If `--format svg` specified, invoke system `dot` binary to render SVG\n7. Write output to file or stdout\n\n## `validate` command\n\nLoad graph → check for cycles and dangling edges.\n\n**Flow:**\n1. Load config from `.skill-tree.toml`\n2. Fetch and build graph (same as render command, steps 2-4)\n3. Run cycle detection: depth-first search from each unvisited node\n4. Check for dangling edges: edges that reference issues not in the project\n5. Exit with code 0 if valid, 2 if cycles found, 3 if GitHub error, 4 if config error\n\n## `unblocked` command\n\nLoad graph → find issues with no incoming blocking edges.\n\n**Flow:**\n1. Load config from `.skill-tree.toml`\n2. Fetch and build graph (same as render command, steps 2-4)\n3. Filter to issues with no incoming edges\n4. Sort by issue number for deterministic output\n5. Print each unblocked issue (or JSON if `--json` specified)"
  },
  {
    "path": "md/contributing/03-common-issues.md",
    "content": "# Common issues\n\n## Known v1 limitations\n\n### Pagination not yet implemented\n\nThe GitHub client has the structure for automatic pagination (following `hasNextPage` cursors), but the actual implementation is stubbed. For projects with more than ~100 items, fetching will be incomplete.\n\n**Status:** Ready to implement after `projects.rs` is written. The client already handles the retry/rate limit/timeout logic that pagination needs.\n\n### PR node support deferred\n\nPull requests are not included in the graph. Only issues and their blocking relationships are fetched.\n\n**Why:** PRs don't form dependency nodes in the skill tree model. A PR either resolves an issue (removes it) or doesn't. Blocking relationships are only tracked between issues.\n\n**Status:** Deferred to v2 if users request it.\n\n### Edge source: GitHub blocking only\n\nThe only blocking relationships fetched are those tracked natively in GitHub Projects (the \"blocks\" field). Relationships encoded in issue bodies (e.g., \"blocked by #123\") are not parsed.\n\n**Why:** Text parsing is fragile and loses the explicit structure GitHub provides. Native blocking is more reliable.\n\n**Status:** v2 may add opt-in body parsing if requested.\n\n### Single color field in v1\n\nOnly one GitHub field can drive node color. Multiple color rules (e.g., Status drives fill, Priority drives border) are deferred.\n\n**Why:** Keeping v1 simple. The infrastructure is designed to support multiple rules in v2.\n\n**Status:** v2 will add `[[color-rule]]` with `attribute` field.\n\n### Deterministic output only by issue number\n\nNode and edge order in DOT output is deterministic (sorted by issue number) but not configurable. Custom sort orders are deferred.\n\n**Status:** v2 may add sort options.\n\n## Potential gotchas for contributors\n\n### `ErrorContext` is metadata, not an error source\n\nThe `ErrorContext` struct carries debugging information (query name, owner, project) but is not part of the error chain. Don't use `#[source]` on ErrorContext fields.\n\n### Config filename has a hyphen\n\nThe config file is `.skill-tree.toml` (hyphen), not `.skill_tree.toml` (underscore). This matters in tests and error messages.\n\n### GitHub returns 200 for GraphQL errors\n\nWhen GitHub encounters a GraphQL error, it returns HTTP 200 with an `errors` field in the JSON body. Always check `errors` even on successful HTTP status.\n\n### Network errors during JSON parsing\n\nWhen `response.json()` fails (bad JSON from GitHub), it returns a `reqwest::Error`, not `serde_json::Error`. Classify it as a network error, not a JSON error."
  },
  {
    "path": "md/contributing/04-running-tests.md",
    "content": "# Running tests\n\n## Unit tests\n\n```bash\ncargo test\n```\n\nRuns all unit tests inside the `skill-tree` crate. No network access required.\nNo `GITHUB_TOKEN` required.\n\n## Integration tests\n\n```bash\ncargo test --test integration\n```\n\nRuns the integration test suite in `tests/integration.rs`. Uses\n`skill-tree-testlib` fixture builders exclusively. No network access required.\nNo `GITHUB_TOKEN` required.\n\n## All tests\n\n```bash\ncargo test --workspace\n```\n\nRuns unit tests and integration tests across all crates in the workspace.\n\n## Checking DOT output determinism\n\n```bash\ncargo test dot_output_is_valid_digraph\ncargo test dot_output_contains_all_nodes\n```\n\nThese tests assert byte-level properties of the DOT output. If you change\nanything in `render/mod.rs`, run these first."
  },
  {
    "path": "md/design/01-config.md",
    "content": "# Configuration\n\nskill-tree is configured via a `.skill-tree.toml` file in the current\ndirectory. This file tells skill-tree which GitHub Project to read and\nhow to display what it finds.\n\nConfiguration is read once at startup. Changes to `.skill-tree.toml` take\neffect on the next invocation.\n\n## Field auto-discovery\n\nskill-tree fetches **all** custom fields GitHub returns for every project\nitem, regardless of what is declared in `[[field]]`. You do not need to\ndeclare a field to have it fetched.\n\n`[[field]]` entries are display declarations only — they give a field a\nfriendly `display-name` for CLI output. Fields not declared in `[[field]]`\nare still fetched and stored on each node. Adding a new `[[field]]` entry\nor a new value in `[colors.values]` later does not require changing what\ngets fetched.\n\n## File format\n\n```toml\n[github]\nowner   = \"rust-lang\"\nproject = 42\n\n[[field]]\ndisplay-name = \"status\"\ngithub-name  = \"Status\"\n\n[[field]]\ndisplay-name = \"priority\"\ngithub-name  = \"Priority\"\n\n[colors]\ngithub-name = \"Status\"\n\n[colors.values]\n\"In Progress\" = \"#4a90d9\"\n\"Blocked\"     = \"#e05252\"\n\"Complete\"    = \"#57a85a\"\n\"Not Started\" = \"#888888\"\n```\n\n## Sections\n\n### `[github]`\n\nIdentifies the GitHub Project to fetch data from.\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `owner` | string | yes | GitHub organization or user that owns the project |\n| `project` | integer | yes | Project number from the GitHub Projects URL |\n\nFor `github.com/orgs/rust-lang/projects/42`, `owner` is `\"rust-lang\"`\nand `project` is `42`. For a user project at\n`github.com/users/your-username/projects/1`, `owner` is `\"your-username\"`.\n\n### `[[field]]`\n\nGives a GitHub Projects custom field a friendly display name for CLI\noutput. Optional — skill-tree fetches all fields regardless.\n\n| Field | Type | Description |\n|---|---|---|\n| `display-name` | string | How skill-tree refers to this field in CLI output |\n| `github-name` | string | Exact field name as it appears in GitHub Projects |\n\n`github-name` is case-sensitive and must match the field name in GitHub\nProjects character for character. Unknown keys are rejected at parse time.\n\n### `[colors]`\n\nControls node color in the rendered graph. The entire section is optional.\nIf omitted, all nodes render with the default gray.\n\n| Field | Type | Description |\n|---|---|---|\n| `github-name` | string | Which GitHub field drives node color |\n| `values` | table | Maps field option values to hex colors |\n\n`github-name` does not need to match a declared `[[field]]` entry —\nit refers directly to the GitHub field name. The keys in `[colors.values]`\nmust match the option names in that field's single-select options in GitHub\nProjects exactly, including case and spacing.\n\nNodes whose field value does not appear in `[colors.values]` render with\nthe default gray (`#dddddd`).\n\n## The `SkillTree` application context\n\nThe parsed `Config` is wrapped in a `SkillTree` struct that also carries\nthe directory containing the config file. The rest of the pipeline takes\n`&SkillTree` rather than `&Config` directly — this keeps configuration\nthreading explicit and avoids global state. Constructors:\n\n- `SkillTree::from_dir(dir)` — load from a directory (production and tests)\n- `SkillTree::from_path(path)` — load from an explicit file path\n\n## Validation\n\nAfter parsing, skill-tree runs validation on the config and fails with\nexit code 4 if any value in `[colors.values]` is not a valid hex color\n(`#rgb` or `#rrggbb`).\n\nOther failures happen at parse time, not validation time:\n\n- Missing `[github]` or its required keys\n- A `[[field]]` entry with unknown keys\n- Type mismatches on any field\n\n## Example: minimal config\n\nThe smallest valid config — no field declarations, no colors:\n\n```toml\n[github]\nowner   = \"your-org\"\nproject = 1\n```\n\nskill-tree fetches all fields from the board and renders nodes in the\ndefault gray. Add `[colors]` when you are ready to add color.\n\n## Example: colors only, no field declarations\n\n```toml\n[github]\nowner   = \"rust-lang\"\nproject = 42\n\n[colors]\ngithub-name = \"Status\"\n\n[colors.values]\n\"In Progress\" = \"#4a90d9\"\n\"Blocked\"     = \"#e05252\"\n\"Complete\"    = \"#57a85a\"\n```\n\nNo `[[field]]` declarations needed. skill-tree fetches the Status field\nautomatically along with everything else on the board.\n\n## Example: full config with display names\n\n```toml\n[github]\nowner   = \"rust-lang\"\nproject = 42\n\n[[field]]\ndisplay-name = \"status\"\ngithub-name  = \"Status\"\n\n[[field]]\ndisplay-name = \"priority\"\ngithub-name  = \"Priority\"\n\n[[field]]\ndisplay-name = \"assignee\"\ngithub-name  = \"Assignee\"\n\n[colors]\ngithub-name = \"Status\"\n\n[colors.values]\n\"In Progress\" = \"#4a90d9\"\n\"Blocked\"     = \"#e05252\"\n\"Complete\"    = \"#57a85a\"\n\"Not Started\" = \"#888888\"\n```\n\n## Common pitfalls\n\n- **Case mismatch on `github-name`.** `\"status\"` and `\"Status\"` are different\n  fields. The value must match GitHub's field header character for character.\n- **Forgetting the `#` on a hex color.** `\"4a90d9\"` is rejected. The leading\n  `#` is required.\n- **Quoting numeric values.** `project = \"42\"` is rejected — the field is\n  an integer, not a string.\n- **Mixing case in `[colors.values]` keys.** `\"in progress\"` does not match\n  `\"In Progress\"`. Match GitHub's option names exactly."
  },
  {
    "path": "md/design/02-github-client.md",
    "content": "# GitHub GraphQL Client\n\nThe `github` module owns all communication with the GitHub API. Other modules\nimport typed structs from `github/projects.rs` and `github/issues.rs` and never\nconstruct URLs, never handle HTTP errors, never parse JSON directly.\n\nThis module is the exclusive gateway to GitHub. Everything flows through it.\n\n## Responsibilities\n\nThree things, three things only:\n\n- **Authentication** — read the token from CLI or environment, fail fast if missing\n- **Transport** — send GraphQL requests over HTTP, handle retries and rate limits\n- **Error translation** — turn network failures and GitHub errors into structured Rust types\n\nThe actual GraphQL queries (fields, shapes, variables) live in `projects.rs` and\n`issues.rs`. Those modules call back into this module for transport.\n\n## Public API\n\n```rust\npub struct GitHubClient { ... }\n\nimpl GitHubClient {\n    /// Construct a client. Reads the token from the parameter or the\n    /// `GITHUB_TOKEN` env var. Synchronous; does no I/O.\n    pub fn new(token: Option<String>, timeout: Duration) -> Result<Self, GitHubError>;\n\n    /// Send a single GraphQL request. Handles retries and rate limits.\n    /// Returns the typed `data` field of the response.\n    ///\n    /// Pagination is the caller's responsibility — see the Pagination section.\n    pub async fn query<V: Serialize, T: DeserializeOwned>(\n        &self,\n        query: &str,\n        variables: V,\n    ) -> Result<T, GitHubError>;\n}\n```\n\nCallers construct the client once at startup. The client:\n- Owns the HTTP connection pool\n- Stores the authentication token\n- Stores the timeout duration\n- Implements retry logic and rate limit backoff\n\nErrors do not carry caller context (which project, which query name).\nCallers wrap errors at the call site if they need it; this keeps the\ntransport layer focused on transport.\n\n## Authentication\n\nThe token comes from two sources in order of priority:\n\n1. `--token` CLI flag — explicit, takes precedence\n2. `GITHUB_TOKEN` environment variable — standard convention\n\nIf neither is present, `GitHubClient::new()` returns `GitHubError::MissingToken`\nbefore any network I/O occurs. The error message tells the user how to set the\ntoken.\n\nRequired scopes:\n- `read:project` — read GitHub Projects V2 data\n- `repo` — read issue content and blocking relationships on private repositories\n\nFor fully public repositories `public_repo` is sufficient.\n\n## Transport\n\nThe client uses `reqwest` for HTTP and `tokio` for async runtime. Every GraphQL\nquery:\n\n1. Serialize variables to JSON\n2. POST to `https://api.github.com/graphql` with the Authorization header\n3. Parse the response JSON\n4. Check HTTP status (4xx/5xx is an error)\n5. Check for `errors` field in the response (non-empty is an error)\n6. Return `data` on success\n\nThe timeout applies to the entire request including retry backoff. If the request\nplus retries exceed the timeout, the client returns `GitHubError::Timeout`.\n\n## Retry strategy\n\nTransient errors are retried with exponential backoff and jitter:\n\n- Network failures (timeout, connection refused, DNS, TLS)\n- HTTP 5xx (GitHub service errors)\n- HTTP 429 (rate limited; see [Rate limiting](#rate-limiting) for the policy)\n\nRetry policy:\n- Up to 3 attempts\n- Exponential backoff: ~1s, ~2s, ~4s between attempts\n- Jitter: ±20% to avoid thundering herd\n- Does not exceed the overall request timeout\n\nNon-transient errors (4xx except 429, GraphQL validation errors, auth failures)\nfail immediately without retry.\n\n## Rate limiting\n\nGitHub allows 5000 requests per hour for authenticated tokens. When the client\nhits the rate limit (HTTP 429):\n\n1. Parse the `X-RateLimit-Reset` header to get the Unix timestamp when the limit resets\n2. Calculate seconds to wait\n3. Log a message: `\"Rate limit exceeded, waiting N seconds before retry\"`\n4. Sleep until the reset time\n5. Retry the request\n\nThe client only sleeps if the wait fits within the *remaining* request\ntimeout. If the reset is further away than the time we have left, it\nreturns `GitHubError::RateLimited { retry_after }` immediately so the\ncaller can decide whether to wait or fail. There is no fixed 60-second\nthreshold — the budget is the timeout.\n\n## Pagination\n\nGitHub's GraphQL API uses cursor-based pagination. The transport does **not**\nhide pagination — it sends one request and returns one response. Pagination\nloops live in the caller (`projects.rs`, `issues.rs`) where the query and\nresponse shape are known.\n\nRationale: making pagination transparent in `query()` requires the transport\nto know where, in an arbitrary `T`, the `pageInfo` and node list live. That\neither forces a `Paginated` trait on every response type or hides query-shape\nknowledge inside the transport. Both are worse than a small explicit loop in\nthe caller, which already owns the query.\n\nThe transport provides two reusable types so callers don't redefine them:\n\n```rust\n/// Page metadata returned by every GitHub GraphQL connection.\n#[derive(Debug, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct PageInfo {\n    pub has_next_page: bool,\n    pub end_cursor: Option<String>,\n}\n\n/// A connection: a list of `nodes` plus `pageInfo`. Embed in your response\n/// type to use the standard pagination loop.\n#[derive(Debug, Deserialize)]\npub struct Connection<T> {\n    pub nodes: Vec<T>,\n    #[serde(rename = \"pageInfo\")]\n    pub page_info: PageInfo,\n}\n```\n\nA caller-side pagination loop looks like this:\n\n```rust\nlet mut all = Vec::new();\nlet mut cursor: Option<String> = None;\nloop {\n    let resp: MyResponse = client\n        .query(QUERY, MyVars { after: cursor.clone(), .. })\n        .await?;\n    let conn = resp.repository.issues; // Connection<Issue>\n    all.extend(conn.nodes);\n    if !conn.page_info.has_next_page { break; }\n    cursor = conn.page_info.end_cursor;\n}\n```\n\nThe query must declare `first: N` for page size and `after: $after` for the\ncursor variable. Beyond that, it's the caller's GraphQL.\n\nA generic `paginate(...)` helper in the transport is intentionally not\nprovided yet — there are only two callers, and the loop pattern is short.\nAdd a helper if a third caller appears.\n\n## Timeout configuration\n\nUsers set a global timeout via:\n\n```bash\nskill-tree render --timeout 60\n```\n\nOr environment:\n\n```bash\nexport GITHUB_TIMEOUT=60\n```\n\nDefault is 30 seconds if neither is set. The timeout applies to the entire\nrequest including retries and rate limit waiting. If the operation exceeds\nthe timeout, the client returns `GitHubError::Timeout`.\n\n## Error types\n\n```rust\n#[derive(Debug, thiserror::Error)]\npub enum GitHubError {\n    /// No token in --token or GITHUB_TOKEN environment variable.\n    MissingToken,\n\n    /// HTTP client could not be constructed (TLS backend, proxy config, etc.).\n    ClientInit(String),\n\n    /// Network-level failure with a sub-category.\n    Network { kind: NetworkErrorKind, message: String },\n\n    /// HTTP response with error status code (4xx or 5xx).\n    HttpError { status: u16, body: String },\n\n    /// GraphQL response contained errors in the `errors` field.\n    GraphQLError(String),\n\n    /// GitHub returned a body we could not interpret: malformed JSON,\n    /// or a well-formed envelope with neither `data` nor `errors`.\n    InvalidResponse(String),\n\n    /// Rate limit exceeded; see Rate limiting for when the client waits\n    /// vs. surfaces this to the caller.\n    RateLimited { retry_after: u64 },\n\n    /// Overall budget (timeout) exceeded across attempts.\n    Timeout(u64),\n}\n\npub enum NetworkErrorKind { Timeout, Connection, Other(String) }\n```\n\nExit codes (via `GitHubError::exit_code()`):\n- 1 — `InvalidResponse` (malformed upstream body; likely a regression)\n- 3 — `Network`, `HttpError`, `GraphQLError`, `RateLimited`, `Timeout`\n- 4 — `MissingToken`, `ClientInit` (configuration / environment)\n\nErrors do not carry a `context` field. Callers that want to attach which\nproject or query failed wrap the error at their call site.\n\n## Module structure\n\n```\ngithub/\n  mod.rs           — GitHubClient, transport, auth, retry logic\n  projects.rs      — ProjectV2 types and query builders\n  issues.rs        — Issue types, sub-issues, blocking relationships\n```\n\n`projects.rs` and `issues.rs` define the GraphQL queries as `const` strings and\nprovide typed response structs. They call `client.query()` for transport.\n\n## Example usage\n\n```rust\n// Construct the client once at startup. Synchronous, no I/O.\nlet client = GitHubClient::new(token_from_cli, Duration::from_secs(30))?;\n\n// Single request — no pagination loop:\nlet project: ProjectMeta = client.query(\n    FETCH_PROJECT_META_QUERY,\n    FetchProjectMetaVars { owner: \"rust-lang\", project: 42 },\n).await?;\n\n// Paginated fetch — loop lives here in the caller:\nlet mut all = Vec::new();\nlet mut cursor: Option<String> = None;\nloop {\n    let resp: FetchIssuesResponse = client.query(\n        FETCH_ISSUES_QUERY,\n        FetchIssuesVars { owner: \"rust-lang\", project: 42, after: cursor.clone() },\n    ).await?;\n    all.extend(resp.repository.issues.nodes);\n    if !resp.repository.issues.page_info.has_next_page { break; }\n    cursor = resp.repository.issues.page_info.end_cursor;\n}\n\n// The client handles, on each call to query():\n// - Rate limits (waits and retries when budget allows)\n// - Transient errors (retries with backoff)\n// - Overall request timeout\n```\n\n## What we are not doing (v2 scope)\n\n- GitHub Enterprise Server (always public github.com)\n- GitHub App authentication (token only)\n- Per-request timeout override (global timeout only)\n- Automatic exponential backoff tuning (fixed schedule)\n- Connection pool size configuration"
  },
  {
    "path": "md/introduction.md",
    "content": "# skill-tree\n\nskill-tree fetches a GitHub Project and renders it as a directed dependency graph.\n\nGiven a `.skill-tree.toml` pointing at a GitHub Project, skill-tree reads every\nissue on the board, extracts blocking relationships and sub-issue hierarchy from\nGitHub's native features, and produces a Graphviz DOT file or SVG where each\nnode is a GitHub issue and each edge is a blocking relationship.\n\n```bash\nskill-tree render --format svg --output graph.svg\nskill-tree unblocked\nskill-tree validate\n```\n\n- For installation, see [Installing skill-tree](./guide/install.md).\n- For configuration, see [Configuration](./guide/configuration.md).\n- For subcommand reference, see [Subcommands](./guide/subcommands.md).\n\n## How it works\n\n```mermaid\nflowchart LR\n    A[\".skill-tree.toml\"] --> B\n\n    subgraph B[\"skill-tree\"]\n        direction LR\n        C[\"fetch\"] --> D[\"model\"] --> E[\"render\"]\n    end\n\n    B --> F[\"graph.svg\"]\n```\n\nThe pipeline has three stages. **Fetch** reads project items, status field\nvalues, sub-issues, and blocking relationships from the GitHub GraphQL API.\n**Model** builds a directed graph of nodes and edges and validates it for\ncycles and dangling references. **Render** writes a deterministic DOT file\nand optionally pipes it through the system `dot` binary to produce an SVG\nwith clickable nodes.\n\n## What the output looks like\n\n```mermaid\ngraph LR\n    D[\"#8: RFC approved\"] --> A[\"#12: Parser rewrite\"]\n    A --> B[\"#34: New syntax support\"]\n    A --> C[\"#35: Error messages\"]\n    E[\"#41: Test harness\"]\n\n    style D fill:#57a85a,color:#fff\n    style A fill:#4a90d9,color:#fff\n    style B fill:#e05252,color:#fff\n    style C fill:#e05252,color:#fff\n    style E fill:#888888,color:#fff\n```\n\nNode color is driven by the value of a single-select custom field in GitHub\nProjects. Colors are configured in `.skill-tree.toml`. Every node in the SVG\nlinks to its GitHub issue."
  },
  {
    "path": "md/summary.md",
    "content": "# Summary\n\n- [Introduction](./introduction.md)\n\n# User's guide\n\n- [Installing skill-tree](./guide/install.md)\n- [Configuration](./guide/configuration.md)\n- [Subcommands](./guide/subcommands.md)\n\n# Design\n\n- [Architecture](./design/architecture.md)\n- [GitHub as source of truth](./design/github-source-of-truth.md)\n- [Edge convention](./design/edge-convention.md)\n- [Node model](./design/node-model.md)\n- [Roadmap](./design/roadmap.md)\n\n# Contribution guide\n\n- [Key modules](./contributing/module-structure.md)\n- [Important flows](./contributing/important-flows.md)\n- [Running tests](./contributing/running-tests.md)\n- [Common issues](./contributing/common-issues.md)\n- [Governance](./contributing/governance.md)"
  },
  {
    "path": "md/welcome.md",
    "content": "# Contributing to skill-tree\n\nWelcome! This section is for people who want to work on skill-tree itself. If you're a user of skill-tree, see the [getting started guide](../introduction.md) instead.\n\n## Building and testing\n\nskill-tree is a standard Cargo project with a library and a binary:\n\n```bash\ncargo check              # type-check\ncargo test               # run the test suite\ncargo run -- render      # run locally: render command\n```\n\nTests use snapshot assertions via the `expect-test` crate. If a snapshot changes, run with `UPDATE_EXPECT=1` to update it:\n\n```bash\nUPDATE_EXPECT=1 cargo test\n```\n\n## Development setup\n\nYou'll need:\n\n- Rust (latest stable)\n- `graphviz` package (for the `dot` binary, used by render tests)\n\nOn macOS:\n```bash\nbrew install graphviz\n```\n\nOn Ubuntu:\n```bash\napt install graphviz\n```\n\n## Logging and debugging\n\nskill-tree uses `eprintln!` for diagnostic output during development. Errors use the `thiserror` crate for structured error types.\n\nTo debug a command:\n```bash\nRUST_BACKTRACE=1 cargo run -- render --verbose\n```\n\nFor unit tests:\n```bash\ncargo test -- --nocapture\n```\n\n## Code style\n\nFollow Niko's patterns from the codebase:\n\n- Comments explain the \"why\", not the \"what\"\n- Code is self-documenting; avoid redundant comments\n- Each error type knows its own exit code\n- Use `match` statements instead of `matches!` when binding multiple variables\n- Tests are organized by concern (config tests in config.rs, GitHub client tests in github/mod.rs)\n\n## What to read next\n\n- [Key modules](./01-module-structure.md) — the major pieces of skill-tree\n- [Important flows](./02-important-flows.md) — how each command works\n- [Common issues](./03-common-issues.md) — known limitations and gotchas\n- [Design docs](../design/) — architecture decisions and design philosophy\n\n## Getting started on a task\n\n1. Pick an issue or identify something to improve\n2. Read the relevant design doc (e.g., [GitHub client design](../design/02-github-client.md))\n3. Check the [key modules](./01-module-structure.md) to understand the code structure\n4. Write a failing test first, then implement\n5. Run `cargo test` to make sure everything passes\n6. Open a PR with a clear description of what changed and why\n\n## What we're building\n\nskill-tree turns a GitHub Project into a visual dependency graph. It's a tool for thinking about project roadmaps—seeing at a glance which issues are blocking others, which are ready to start, and how the work flows together.\n\nThe codebase is intentionally kept small and focused. We ship features that work well, not everything that could be imagined."
  },
  {
    "path": "mermaid-init.js",
    "content": "// This Source Code Form is subject to the terms of the Mozilla Public\n// License, v. 2.0. If a copy of the MPL was not distributed with this\n// file, You can obtain one at https://mozilla.org/MPL/2.0/.\n\n(() => {\n    const darkThemes = ['ayu', 'navy', 'coal'];\n    const lightThemes = ['light', 'rust'];\n\n    const classList = document.getElementsByTagName('html')[0].classList;\n\n    let lastThemeWasLight = true;\n    for (const cssClass of classList) {\n        if (darkThemes.includes(cssClass)) {\n            lastThemeWasLight = false;\n            break;\n        }\n    }\n\n    const theme = lastThemeWasLight ? 'default' : 'dark';\n    mermaid.initialize({ startOnLoad: true, theme });\n\n    // Simplest way to make mermaid re-render the diagrams in the new theme is via refreshing the page\n\n    for (const darkTheme of darkThemes) {\n        document.getElementById(darkTheme).addEventListener('click', () => {\n            if (lastThemeWasLight) {\n                window.location.reload();\n            }\n        });\n    }\n\n    for (const lightTheme of lightThemes) {\n        document.getElementById(lightTheme).addEventListener('click', () => {\n            if (!lastThemeWasLight) {\n                window.location.reload();\n            }\n        });\n    }\n})();\n"
  },
  {
    "path": "skill-tree-testlib/Cargo.toml",
    "content": "[package]\nname = \"skill-tree-testlib\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\nserde_json = \"1.0.149\"\nskill-tree = { path = \"..\" }\nwiremock = \"0.6\"\n"
  },
  {
    "path": "skill-tree-testlib/src/github.rs",
    "content": "//! Mock GitHub GraphQL endpoint for integration tests.\n//!\n//! `MockGitHub` wraps a `wiremock::MockServer` configured to look like\n//! `https://api.github.com/graphql`, plus response builders for the\n//! shapes `GitHubClient` needs to handle: 200-with-data, 5xx, 429 with\n//! `X-RateLimit-Reset`, GraphQL `errors` envelopes, and malformed bodies.\n//!\n//! Tests only import this module — they should not pull in `wiremock`\n//! directly. `Mock` is re-exported so callers can chain `.expect(N)` /\n//! `.up_to_n_times(N)` and call `.mount(&gh.server).await` without a\n//! `wiremock` dependency line.\n\nuse std::time::{Duration, SystemTime, UNIX_EPOCH};\n\nuse serde_json::Value;\nuse skill_tree::github::GitHubClient;\nuse wiremock::matchers::{header, method, path};\nuse wiremock::{Mock, MockBuilder, MockServer, ResponseTemplate};\n\npub use wiremock::Mock as MockHandle;\n\n/// A wiremock server preconfigured to look like GitHub's GraphQL endpoint.\npub struct MockGitHub {\n    pub server: MockServer,\n}\n\nimpl MockGitHub {\n    pub async fn start() -> Self {\n        Self {\n            server: MockServer::start().await,\n        }\n    }\n\n    /// A `GitHubClient` pointed at this mock with the given timeout.\n    /// The token is a non-empty placeholder so `with_endpoint` does not\n    /// fall back to `GITHUB_TOKEN`.\n    pub fn client(&self, timeout: Duration) -> GitHubClient {\n        GitHubClient::with_endpoint(\n            format!(\"{}/graphql\", self.server.uri()),\n            Some(\"test-token\".into()),\n            timeout,\n        )\n        .expect(\"token is supplied directly\")\n    }\n\n    /// `POST /graphql` matcher base. Useful when a test needs an extra\n    /// matcher like a header check.\n    pub fn matcher(&self) -> MockBuilder {\n        Mock::given(method(\"POST\")).and(path(\"/graphql\"))\n    }\n\n    /// 200 response wrapping `body` in a GraphQL `data` envelope.\n    pub fn ok_data(&self, body: Value) -> MockHandle {\n        self.matcher().respond_with(\n            ResponseTemplate::new(200).set_body_json(serde_json::json!({ \"data\": body })),\n        )\n    }\n\n    /// Like `ok_data`, but the mock only matches requests carrying every\n    /// `(name, value)` header pair. Used to assert that the client sent\n    /// the headers it was supposed to.\n    pub fn ok_data_with_headers(&self, body: Value, headers: &[(&str, &str)]) -> MockHandle {\n        let mut builder = self.matcher();\n        for (name, value) in headers {\n            builder = builder.and(header(*name, *value));\n        }\n        builder.respond_with(\n            ResponseTemplate::new(200).set_body_json(serde_json::json!({ \"data\": body })),\n        )\n    }\n\n    /// 200 response with a non-empty GraphQL `errors` array.\n    pub fn graphql_error(&self, message: &str) -> MockHandle {\n        self.matcher()\n            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({\n                \"data\": null,\n                \"errors\": [{ \"message\": message }],\n            })))\n    }\n\n    /// 200 response with neither `data` nor `errors` — exercises\n    /// `GitHubError::InvalidResponse`.\n    pub fn empty_envelope(&self) -> MockHandle {\n        self.matcher()\n            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))\n    }\n\n    /// Response with the given HTTP status and an empty body.\n    pub fn status(&self, status: u16) -> MockHandle {\n        self.matcher().respond_with(ResponseTemplate::new(status))\n    }\n\n    /// Response with the given HTTP status and a string body.\n    pub fn status_with_body(&self, status: u16, body: &str) -> MockHandle {\n        self.matcher()\n            .respond_with(ResponseTemplate::new(status).set_body_string(body))\n    }\n\n    /// 429 response with `X-RateLimit-Reset` set to `now + secs_until_reset`,\n    /// matching the format GitHub returns.\n    pub fn rate_limited(&self, secs_until_reset: u64) -> MockHandle {\n        let reset = SystemTime::now()\n            .duration_since(UNIX_EPOCH)\n            .expect(\"clock is past UNIX_EPOCH\")\n            .as_secs()\n            + secs_until_reset;\n        self.matcher().respond_with(\n            ResponseTemplate::new(429)\n                .insert_header(\"X-RateLimit-Reset\", reset.to_string().as_str()),\n        )\n    }\n}\n"
  },
  {
    "path": "skill-tree-testlib/src/lib.rs",
    "content": "//! Test infrastructure for skill-tree integration tests.\n//! Always imported as a dev-dependency.\n\npub mod github;\n\npub use github::MockGitHub;\n"
  },
  {
    "path": "src/cli/mod.rs",
    "content": "//! CLI argument definitions using clap.\n//! Declares the three subcommands: render, unblocked, validate.\n//!\nmod render;\nmod unblocked;\nmod validate;\n"
  },
  {
    "path": "src/cli/render.rs",
    "content": "//! Implementation of the `skill-tree render` subcommand.\n//! Runs the full fetch → model → render pipeline and writes DOT or SVG.\n"
  },
  {
    "path": "src/cli/unblocked.rs",
    "content": "//! Implementation of the `skill-tree unblocked` subcommand.\n//! Prints all open issues with no incoming blocking edges.\n"
  },
  {
    "path": "src/cli/validate.rs",
    "content": "//! Implementation of the `skill-tree validate` subcommand.\n//! Checks for cycles and dangling edges. Produces no rendered output.\n"
  },
  {
    "path": "src/config.rs",
    "content": "//! Reads and validates .skill-tree.toml.\n//!\n//! Two types carry configuration through the application:\n//!\n//! - [`Config`] -- the raw parsed TOML. Just data.\n//! - [`SkillTree`] -- the application context. Wraps `Config` with\n//!   resolved paths and provides the methods the rest of the pipeline calls.\n//!\n//! ## Field auto-discovery.\n//!\n//! skill-tree fetches ALL custom fields GitHub returns for every project item.\n//! `[[field]]` entries are display declarations only -- they give a field a\n//! friendly `display-name` for CLI output.\n//! Fields not declared in `[[field]]` are still fetched and stored on each node.\n\nuse crate::error::config::ConfigError;\nuse serde::{Deserialize, Serialize};\nuse std::{\n    collections::HashMap,\n    fs,\n    path::{Path, PathBuf},\n};\n\ntype Fallible<T> = Result<T, ConfigError>;\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct Config {\n    pub github: GithubConfig,\n    #[serde(default, rename = \"field\")]\n    pub fields: Vec<FieldConfig>,\n    #[serde(default)]\n    pub colors: ColorsConfig,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct GithubConfig {\n    /// GitHub organization or user that owns the project.\n    ///\n    /// For `github.com/orgs/rust-lang/projects/42` -> `rust-lang`.\n    pub owner: String,\n\n    /// Project number from the GitHub Projects URL.\n    ///\n    /// For `github.com/orgs/rust-lang/projects/42` -> `42`.\n    pub project: u64,\n}\n\n/// Declares one GitHub Project custom field that skill-tree should read.\n#[derive(Debug, Deserialize, Serialize, Clone)]\n#[serde(deny_unknown_fields)]\npub struct FieldConfig {\n    #[serde(rename = \"display-name\")]\n    pub display_name: String,\n\n    /// Exact field name as it appears in GitHub Projects.\n    ///\n    /// Case-sensitive. Must match the field header in GitHub Projects.\n    #[serde(rename = \"github-name\")]\n    pub github_name: String,\n}\n\n/// Controls node color in the rendered graph.\n#[derive(Debug, Deserialize, Serialize, Clone, Default)]\npub struct ColorsConfig {\n    /// Which GitHub field drives node color.\n    #[serde(rename = \"github-name\", default)]\n    pub github_name: String,\n\n    /// Maps field option values to hex colors.\n    ///\n    /// Keys are the option names from the GitHub Projects single-select field.\n    /// Nodes whose value is not in this map render with the default gray.\n    #[serde(default)]\n    pub values: HashMap<String, String>,\n}\n\n#[derive(Debug, Clone)]\npub struct SkillTree {\n    /// The parsed configuration.\n    pub config: Config,\n\n    /// Directory containing the config file. Used to resolve relative paths.\n    config_dir: PathBuf,\n}\n\nimpl SkillTree {\n    /// The default filename skill-tree looks for.\n    pub const CONFIG_FILENAME: &'static str = \".skill-tree.toml\";\n\n    /// Load config from `.skill-tree.toml` in `dir`.\n    ///\n    /// If the file does not exist, return an error\n    pub fn from_dir(dir: impl AsRef<Path>) -> Fallible<Self> {\n        let dir = dir.as_ref();\n        Self::from_path(dir.join(Self::CONFIG_FILENAME))\n    }\n\n    /// Load config from an explicit file path.\n    pub fn from_path(path: impl AsRef<Path>) -> Fallible<Self> {\n        let path = path.as_ref();\n\n        let content = fs::read_to_string(path).map_err(|source| ConfigError::Io {\n            path: path.to_owned(),\n            source,\n        })?;\n\n        let config: Config = toml::from_str(&content).map_err(|source| ConfigError::Parse {\n            path: path.to_owned(),\n            source,\n        })?;\n\n        config.validate()?;\n\n        let config_dir = path.parent().unwrap_or(Path::new(\".\")).to_path_buf();\n\n        Ok(Self { config, config_dir })\n    }\n\n    /// Directory containing the config file.\n    pub fn config_dir(&self) -> &Path {\n        &self.config_dir\n    }\n\n    /// Return the hex color for a field option value.\n    ///\n    /// Returns `None` if no color is configured for this value -- the\n    /// renderer falls back to the default gray.\n    pub fn color_for_value(&self, value: &str) -> Option<&str> {\n        self.config.colors.values.get(value).map(String::as_str)\n    }\n\n    /// Returns the `github_name` of the field that drives node color.\n    pub fn color_field_github_name(&self) -> &str {\n        &self.config.colors.github_name\n    }\n\n    /// Look up fields by its `display-name`.\n    ///\n    /// Returns `None` if no field with the given display name is found.\n    pub fn field_by_display_name(&self, display_name: &str) -> Option<&FieldConfig> {\n        self.config\n            .fields\n            .iter()\n            .find(|fconf| fconf.display_name == display_name)\n    }\n}\n\nimpl Config {\n    fn validate(&self) -> Fallible<()> {\n        for (key, value) in &self.colors.values {\n            if !is_valid_hex_color(value) {\n                return Err(ConfigError::InvalidColor {\n                    key: key.clone(),\n                    value: value.clone(),\n                });\n            }\n        }\n\n        Ok(())\n    }\n}\n\nfn is_valid_hex_color(color: &str) -> bool {\n    let Some(hex) = color.strip_prefix('#') else {\n        return false;\n    };\n\n    matches!(hex.len(), 3 | 6) && hex.chars().all(|hc| hc.is_ascii_hexdigit())\n}\n\n// ---------------------------------------------------------------------------\n// Tests\n// ---------------------------------------------------------------------------\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use indoc::indoc;\n    use tempfile::tempdir;\n\n    fn parse(toml: &str) -> Config {\n        toml::from_str(toml).expect(\"test TOML should be valid\")\n    }\n\n    fn valid_toml() -> &'static str {\n        indoc! {\"\n            [github]\n            owner   = \\\"rust-lang\\\"\n            project = 42\n\n            [[field]]\n            display-name = \\\"status\\\"\n            github-name = \\\"Status\\\"\n\n            [[field]]\n            display-name = \\\"priority\\\"\n            github-name = \\\"Priority\\\"\n\n            [colors]\n            github-name = \\\"Status\\\"\n\n            [colors.values]\n            \\\"In Progress\\\" = \\\"#4a90d9\\\"\n            \\\"Blocked\\\" = \\\"#e05252\\\"\n            \\\"Complete\\\" = \\\"#57a85a\\\"\n        \"}\n    }\n\n    fn minimal_toml() -> &'static str {\n        indoc! {\"\n            [github]\n            owner   = \\\"nikomatsakis\\\"\n            project = 1\n        \"}\n    }\n\n    #[test]\n    fn parses_github_section() {\n        let config = parse(valid_toml());\n        assert_eq!(config.github.owner, \"rust-lang\");\n        assert_eq!(config.github.project, 42);\n    }\n\n    #[test]\n    fn parses_multiple_fields() {\n        let config = parse(valid_toml());\n        assert_eq!(config.fields.len(), 2);\n        assert_eq!(config.fields[0].display_name, \"status\");\n        assert_eq!(config.fields[0].github_name, \"Status\");\n        assert_eq!(config.fields[1].display_name, \"priority\");\n        assert_eq!(config.fields[1].github_name, \"Priority\");\n    }\n\n    #[test]\n    fn parses_colors_section() {\n        let config = parse(valid_toml());\n        assert_eq!(config.colors.github_name, \"Status\");\n        assert_eq!(\n            config.colors.values.get(\"In Progress\").map(String::as_str),\n            Some(\"#4a90d9\")\n        );\n    }\n\n    #[test]\n    fn minimal_config_is_valid() {\n        // No [[field]] and no [colors] -- both are optional after\n        // introducing field auto-discovery.\n        let config = parse(minimal_toml());\n        assert!(config.validate().is_ok());\n        assert!(config.fields.is_empty());\n        assert!(config.colors.github_name.is_empty());\n    }\n\n    #[test]\n    fn config_without_fields_is_valid() {\n        // [[field]] is optional -- skill-tree fetches all fields regardless.\n        let config = parse(indoc! {\"\n            [github]\n            owner   = \\\"rust-lang\\\"\n            project = 42\n\n            [colors]\n            github-name = \\\"Status\\\"\n        \"});\n        assert!(config.validate().is_ok());\n    }\n\n    #[test]\n    fn validation_passes_on_valid_config() {\n        let config = parse(valid_toml());\n        assert!(config.validate().is_ok());\n    }\n\n    #[test]\n    fn validation_fails_on_invalid_hex_color() {\n        let config = parse(indoc! {\"\n            [github]\n            owner   = \\\"rust-lang\\\"\n            project = 42\n\n            [[field]]\n            display-name = \\\"status\\\"\n            github-name  = \\\"Status\\\"\n\n            [colors]\n            github-name = \\\"Status\\\"\n\n            [colors.values]\n            \\\"In Progress\\\" = \\\"blue\\\"\n        \"});\n        assert!(matches!(\n            config.validate(),\n            Err(ConfigError::InvalidColor { .. })\n        ));\n    }\n\n    #[test]\n    fn from_dir_loads_config_file() {\n        let tmp = tempdir().unwrap();\n        fs::write(tmp.path().join(\".skill-tree.toml\"), valid_toml()).unwrap();\n\n        let st = SkillTree::from_dir(tmp.path()).unwrap();\n        assert_eq!(st.config.github.owner, \"rust-lang\");\n        assert_eq!(st.config_dir(), tmp.path());\n    }\n\n    #[test]\n    fn from_dir_fails_when_file_missing() {\n        let tmp = tempdir().unwrap();\n        assert!(matches!(\n            SkillTree::from_dir(tmp.path()),\n            Err(ConfigError::Io { .. })\n        ));\n    }\n\n    #[test]\n    fn color_for_value_returns_hex() {\n        let tmp = tempdir().unwrap();\n        fs::write(tmp.path().join(\".skill-tree.toml\"), valid_toml()).unwrap();\n        let st = SkillTree::from_dir(tmp.path()).unwrap();\n\n        assert_eq!(st.color_for_value(\"In Progress\"), Some(\"#4a90d9\"));\n        assert_eq!(st.color_for_value(\"Unknown\"), None);\n    }\n\n    #[test]\n    fn color_for_value_returns_none_when_colors_not_configured() {\n        let tmp = tempdir().unwrap();\n        fs::write(tmp.path().join(\".skill-tree.toml\"), minimal_toml()).unwrap();\n        let st = SkillTree::from_dir(tmp.path()).unwrap();\n\n        assert_eq!(st.color_for_value(\"In Progress\"), None);\n    }\n\n    #[test]\n    fn field_by_display_name_finds_declared_field() {\n        let tmp = tempdir().unwrap();\n        fs::write(tmp.path().join(\".skill-tree.toml\"), valid_toml()).unwrap();\n        let st = SkillTree::from_dir(tmp.path()).unwrap();\n\n        let field = st.field_by_display_name(\"status\").unwrap();\n        assert_eq!(field.github_name, \"Status\");\n        assert!(st.field_by_display_name(\"nonexistent\").is_none());\n    }\n\n    #[test]\n    fn deny_unknown_fields_on_field_config() {\n        let result: Result<Config, _> = toml::from_str(indoc! {\"\n            [github]\n            owner   = \\\"rust-lang\\\"\n            project = 42\n\n            [[field]]\n            display-name = \\\"status\\\"\n            github-name  = \\\"Status\\\"\n            unknown-key  = \\\"oops\\\"\n\n            [colors]\n            github-name = \\\"Status\\\"\n        \"});\n        assert!(result.is_err());\n    }\n\n    #[test]\n    fn hex_color_validation() {\n        assert!(is_valid_hex_color(\"#4a90d9\"));\n        assert!(is_valid_hex_color(\"#fff\"));\n        assert!(is_valid_hex_color(\"#FFF\"));\n        assert!(is_valid_hex_color(\"#AABBCC\"));\n        assert!(!is_valid_hex_color(\"blue\"));\n        assert!(!is_valid_hex_color(\"#12345\"));\n        assert!(!is_valid_hex_color(\"#gggggg\"));\n        assert!(!is_valid_hex_color(\"\"));\n        assert!(!is_valid_hex_color(\"#\"));\n    }\n}\n"
  },
  {
    "path": "src/error/config.rs",
    "content": "//! Configuration file errors.\n\nuse std::path::PathBuf;\n\n/// Error returned when loading or validating a config file.\n#[derive(Debug, thiserror::Error)]\npub enum ConfigError {\n    /// I/O error reading the config file.\n    #[error(\"failed to read config file {path}: {source}\")]\n    Io {\n        /// Path to the config file that failed to read.\n        path: PathBuf,\n        /// The underlying I/O error.\n        #[source]\n        source: std::io::Error,\n    },\n\n    /// TOML parsing error.\n    #[error(\"failed to parse config file {path}: {source}\")]\n    Parse {\n        /// Path to the config file that failed to parse.\n        path: PathBuf,\n        /// The underlying TOML parse error.\n        #[source]\n        source: toml::de::Error,\n    },\n\n    /// Invalid hex color in the config.\n    #[error(\"invalid hex color in [colors.values]: {key} = {value}\")]\n    InvalidColor {\n        /// The key that had the invalid color.\n        key: String,\n        /// The invalid color value.\n        value: String,\n    },\n}\n\nimpl ConfigError {\n    /// Return the process exit code for this error.\n    pub fn exit_code(&self) -> u8 {\n        4 // config error\n    }\n}\n"
  },
  {
    "path": "src/error/github.rs",
    "content": "//! GitHub API error types.\n//!\n//! All failures from GitHub requests are translated into structured\n//! `GitHubError` variants. The transport layer does not know about\n//! higher-level concerns like which project or owner triggered a call;\n//! callers that want that context should wrap these errors at their\n//! call site.\n\nuse std::fmt;\n\n/// Error returned by the GitHub GraphQL client.\n#[derive(Debug, thiserror::Error)]\npub enum GitHubError {\n    /// No token found in --token flag or GITHUB_TOKEN environment variable.\n    #[error(\"no GitHub token found. Set GITHUB_TOKEN or use --token flag\")]\n    MissingToken,\n\n    /// HTTP client could not be constructed (TLS backend, proxy config, etc.).\n    #[error(\"failed to initialize HTTP client: {0}\")]\n    ClientInit(String),\n\n    /// Network-level failure: timeout, DNS, TLS, connection refused, etc.\n    #[error(\"network error ({kind}): {message}\")]\n    Network {\n        /// Category of network failure.\n        kind: NetworkErrorKind,\n        /// Human-readable description.\n        message: String,\n    },\n\n    /// HTTP response with error status code (4xx or 5xx).\n    #[error(\"HTTP {status}: {body}\")]\n    HttpError {\n        /// HTTP status code.\n        status: u16,\n        /// Full response body.\n        body: String,\n    },\n\n    /// GraphQL response contained errors in the `errors` field.\n    #[error(\"GraphQL error: {0}\")]\n    GraphQLError(String),\n\n    /// GitHub returned a body we could not interpret: malformed JSON, or a\n    /// well-formed envelope with neither `data` nor `errors`.\n    #[error(\"invalid response body: {0}\")]\n    InvalidResponse(String),\n\n    /// GitHub rate limit exceeded. Caller should wait before retrying.\n    #[error(\"rate limit exceeded, retry after {retry_after}s\")]\n    RateLimited {\n        /// Seconds to wait before retrying.\n        retry_after: u64,\n    },\n\n    /// Request exceeded the configured timeout.\n    #[error(\"request timeout after {0}s\")]\n    Timeout(u64),\n}\n\n/// Category of network-level failure.\n#[derive(Debug, Clone)]\npub enum NetworkErrorKind {\n    /// Request timeout (socket, DNS, or connection timeout).\n    Timeout,\n    /// Connection refused, reset, or closed unexpectedly.\n    Connection,\n    /// Other network error not categorized above.\n    Other(String),\n}\n\nimpl fmt::Display for NetworkErrorKind {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        match self {\n            NetworkErrorKind::Timeout => write!(f, \"timeout\"),\n            NetworkErrorKind::Connection => write!(f, \"connection refused\"),\n            NetworkErrorKind::Other(s) => write!(f, \"{s}\"),\n        }\n    }\n}\n\nimpl GitHubError {\n    /// Return the process exit code for this error.\n    ///\n    /// - 1: malformed response (likely a bug or upstream regression)\n    /// - 3: GitHub API errors (network, HTTP, GraphQL, rate limit, timeout)\n    /// - 4: configuration errors (missing token, client init failure)\n    pub fn exit_code(&self) -> u8 {\n        match self {\n            GitHubError::MissingToken | GitHubError::ClientInit(_) => 4,\n            GitHubError::Network { .. }\n            | GitHubError::HttpError { .. }\n            | GitHubError::GraphQLError(_)\n            | GitHubError::RateLimited { .. }\n            | GitHubError::Timeout(_) => 3,\n            GitHubError::InvalidResponse(_) => 1,\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn missing_token_exit_code() {\n        assert_eq!(GitHubError::MissingToken.exit_code(), 4);\n    }\n\n    #[test]\n    fn client_init_exit_code() {\n        assert_eq!(GitHubError::ClientInit(\"tls\".into()).exit_code(), 4);\n    }\n\n    #[test]\n    fn network_error_exit_code() {\n        let err = GitHubError::Network {\n            kind: NetworkErrorKind::Timeout,\n            message: \"timeout waiting for response\".to_string(),\n        };\n        assert_eq!(err.exit_code(), 3);\n    }\n\n    #[test]\n    fn http_error_exit_code() {\n        let err = GitHubError::HttpError {\n            status: 500,\n            body: \"Internal Server Error\".to_string(),\n        };\n        assert_eq!(err.exit_code(), 3);\n    }\n\n    #[test]\n    fn graphql_error_exit_code() {\n        let err = GitHubError::GraphQLError(\"Field not found\".to_string());\n        assert_eq!(err.exit_code(), 3);\n    }\n\n    #[test]\n    fn rate_limited_exit_code() {\n        let err = GitHubError::RateLimited { retry_after: 3600 };\n        assert_eq!(err.exit_code(), 3);\n    }\n\n    #[test]\n    fn timeout_exit_code() {\n        let err = GitHubError::Timeout(30);\n        assert_eq!(err.exit_code(), 3);\n    }\n\n    #[test]\n    fn invalid_response_exit_code() {\n        let err = GitHubError::InvalidResponse(\"no data, no errors\".into());\n        assert_eq!(err.exit_code(), 1);\n    }\n\n    #[test]\n    fn network_error_kind_display() {\n        assert_eq!(NetworkErrorKind::Timeout.to_string(), \"timeout\");\n        assert_eq!(\n            NetworkErrorKind::Connection.to_string(),\n            \"connection refused\"\n        );\n        assert_eq!(\n            NetworkErrorKind::Other(\"custom error\".to_string()).to_string(),\n            \"custom error\"\n        );\n    }\n}\n"
  },
  {
    "path": "src/error.rs",
    "content": "//! Error types for skill-tree.\n//!\n//! All errors in skill-tree are organized into modules by origin:\n//! - [`github`] — GitHub API errors\n//! - [`config`] — configuration file errors\n//!\n//! Each error type implements `.exit_code()` to map to the appropriate\n//! process exit code (1, 3, or 4).\n\npub mod config;\npub mod github;\n\npub use config::ConfigError;\npub use github::{GitHubError, NetworkErrorKind};\n"
  },
  {
    "path": "src/github/issues.rs",
    "content": "//! Issue relationships: sub-issues and blocking dependencies.\n//!\n//! Placeholder: GraphQL queries and typed response structs land in a\n//! follow-up.\n"
  },
  {
    "path": "src/github/mod.rs",
    "content": "//! GitHub GraphQL API client.\n//!\n//! This module is the only place in skill-tree that talks to GitHub.\n//! Everything else works with the typed structs from [`projects`] and [`issues`].\n\npub mod issues;\npub mod projects;\n\nuse crate::error::{GitHubError, NetworkErrorKind};\nuse reqwest::Client;\nuse serde::de::DeserializeOwned;\nuse serde::{Deserialize, Serialize};\nuse std::time::{Duration, Instant};\n\n// ---------------------------------------------------------------------------\n// GraphQL primitives\n// ---------------------------------------------------------------------------\n\n#[derive(Debug, Serialize)]\npub(crate) struct GraphQLRequest<'a, V: Serialize> {\n    pub query: &'a str,\n    pub variables: V,\n}\n\n#[derive(Debug, Deserialize)]\npub(crate) struct GraphQLResponse<T> {\n    pub data: Option<T>,\n    pub errors: Option<Vec<GraphQLErrorResponse>>,\n}\n\n#[derive(Debug, Deserialize)]\npub(crate) struct GraphQLErrorResponse {\n    pub message: String,\n}\n\n// ---------------------------------------------------------------------------\n// Pagination types\n// ---------------------------------------------------------------------------\n//\n// GitHub's GraphQL API uses cursor-based pagination on every list (\"connection\").\n// The transport does not paginate — callers loop, using `Connection<T>` in\n// their response types and reading `page_info` to drive the loop.\n\n/// Page metadata returned by every GitHub GraphQL connection.\n#[derive(Debug, Clone, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct PageInfo {\n    pub has_next_page: bool,\n    pub end_cursor: Option<String>,\n}\n\n/// A list of `nodes` plus its `page_info`. Embed in your response struct\n/// to get the standard pagination shape.\n#[derive(Debug, Clone, Deserialize)]\npub struct Connection<T> {\n    pub nodes: Vec<T>,\n    #[serde(rename = \"pageInfo\")]\n    pub page_info: PageInfo,\n}\n\n// ---------------------------------------------------------------------------\n// Client\n// ---------------------------------------------------------------------------\n\n/// A configured GitHub GraphQL client with built-in retry and rate limit handling.\n///\n/// Handles network errors, transient failures, rate limiting, and timeouts.\n/// Pass `&GitHubClient` to [`projects`] and [`issues`] functions.\npub struct GitHubClient {\n    client: Client,\n    endpoint: String,\n    token: String,\n    timeout: Duration,\n}\n\nimpl GitHubClient {\n    const DEFAULT_ENDPOINT: &'static str = \"https://api.github.com/graphql\";\n    const API_VERSION: &'static str = \"2022-11-28\";\n\n    /// Maximum number of HTTP requests per `query()` call: one initial\n    /// attempt plus `MAX_ATTEMPTS - 1` retries.\n    const MAX_ATTEMPTS: u32 = 3;\n\n    /// Wait used when GitHub returns a 429 with no parseable\n    /// `X-RateLimit-Reset` header. One minute is GitHub's documented\n    /// minimum reset window for secondary rate limits.\n    const RATE_LIMIT_FALLBACK_SECS: u64 = 60;\n\n    /// Create a new client targeting `https://api.github.com/graphql`,\n    /// reading the token from the parameter or the `GITHUB_TOKEN` env var.\n    ///\n    /// Fails immediately with [`GitHubError::MissingToken`] if neither is present,\n    /// before any network I/O occurs.\n    pub fn new(token: Option<String>, timeout: Duration) -> Result<Self, GitHubError> {\n        Self::with_endpoint(Self::DEFAULT_ENDPOINT.to_string(), token, timeout)\n    }\n\n    /// Like [`Self::new`] but targets the supplied GraphQL endpoint URL.\n    /// Used by integration tests against a mock server; also the foundation\n    /// for any future GitHub Enterprise support.\n    pub fn with_endpoint(\n        endpoint: String,\n        token: Option<String>,\n        timeout: Duration,\n    ) -> Result<Self, GitHubError> {\n        let token = token\n            .or_else(|| std::env::var(\"GITHUB_TOKEN\").ok())\n            .ok_or(GitHubError::MissingToken)?;\n\n        // Per-request timeouts are set in `query_once` from the *remaining*\n        // budget, so a single hung request can't consume the whole timeout.\n        let client = Client::builder()\n            .user_agent(\"skill-tree\")\n            .build()\n            .map_err(|e| GitHubError::ClientInit(e.to_string()))?;\n\n        Ok(Self {\n            client,\n            endpoint,\n            token,\n            timeout,\n        })\n    }\n\n    /// Send a GraphQL query with automatic retry and rate limit handling.\n    ///\n    /// Makes one initial HTTP request plus up to 2 retries on transient\n    /// failures, with exponential backoff between attempts. Detects rate\n    /// limits and waits before retrying when the timeout budget allows.\n    /// Fails with [`GitHubError::Timeout`] if the entire operation exceeds\n    /// the configured timeout.\n    pub async fn query<V, T>(&self, query: &str, variables: V) -> Result<T, GitHubError>\n    where\n        V: Serialize,\n        T: DeserializeOwned,\n    {\n        let start = Instant::now();\n\n        for attempt in 1..=Self::MAX_ATTEMPTS {\n            if start.elapsed() >= self.timeout {\n                return Err(GitHubError::Timeout(self.timeout.as_secs()));\n            }\n\n            let err = match self.query_once(query, &variables, start).await {\n                Ok(response) => return Ok(response),\n                Err(err) => err,\n            };\n\n            // Last attempt: surface whatever we got, no more retries.\n            if attempt == Self::MAX_ATTEMPTS {\n                return Err(err);\n            }\n\n            // Rate limit: wait if the remaining budget covers it, else fail now.\n            if let GitHubError::RateLimited { retry_after } = &err {\n                let wait_secs = *retry_after;\n                let remaining = self\n                    .timeout\n                    .as_secs()\n                    .saturating_sub(start.elapsed().as_secs());\n\n                if remaining > wait_secs {\n                    eprintln!(\"Rate limited, waiting {wait_secs} seconds...\");\n                    tokio::time::sleep(Duration::from_secs(wait_secs)).await;\n                    continue;\n                }\n                return Err(err);\n            }\n\n            // Transient: back off and retry.\n            if Self::is_transient(&err) {\n                let backoff = Self::backoff_duration(attempt);\n                eprintln!(\n                    \"Transient error (attempt {}/{}), retrying in {:?}...\",\n                    attempt,\n                    Self::MAX_ATTEMPTS,\n                    backoff\n                );\n                tokio::time::sleep(backoff).await;\n                continue;\n            }\n\n            // Non-transient: fail fast.\n            return Err(err);\n        }\n\n        // Loop body always returns or `continue`s on attempts < MAX_ATTEMPTS,\n        // and always returns on attempt == MAX_ATTEMPTS.\n        unreachable!(\"retry loop exited without returning\")\n    }\n\n    /// Send a single GraphQL request without retry logic. The per-request\n    /// timeout is the *remaining* budget so a single hung request cannot\n    /// consume the whole `query()`-level timeout.\n    async fn query_once<V, T>(\n        &self,\n        query: &str,\n        variables: &V,\n        start: Instant,\n    ) -> Result<T, GitHubError>\n    where\n        V: Serialize,\n        T: DeserializeOwned,\n    {\n        let request = GraphQLRequest { query, variables };\n        let remaining = self.timeout.saturating_sub(start.elapsed());\n\n        let response = self\n            .client\n            .post(&self.endpoint)\n            .bearer_auth(&self.token)\n            .header(\"X-GitHub-Api-Version\", Self::API_VERSION)\n            .timeout(remaining)\n            .json(&request)\n            .send()\n            .await\n            .map_err(Self::classify_reqwest_error)?;\n\n        let status = response.status();\n        if !status.is_success() {\n            if status.as_u16() == 429 {\n                let retry_after = response\n                    .headers()\n                    .get(\"X-RateLimit-Reset\")\n                    .and_then(|h| h.to_str().ok())\n                    .and_then(|s| s.parse::<u64>().ok())\n                    .and_then(|reset_time| {\n                        let now = std::time::SystemTime::now()\n                            .duration_since(std::time::UNIX_EPOCH)\n                            .ok()?\n                            .as_secs();\n                        Some(reset_time.saturating_sub(now))\n                    });\n\n                return Err(GitHubError::RateLimited {\n                    retry_after: retry_after.unwrap_or(Self::RATE_LIMIT_FALLBACK_SECS),\n                });\n            }\n\n            let body = response\n                .text()\n                .await\n                .unwrap_or_else(|e| format!(\"<failed to read body: {e}>\"));\n            return Err(GitHubError::HttpError {\n                status: status.as_u16(),\n                body,\n            });\n        }\n\n        let body: GraphQLResponse<T> = response\n            .json()\n            .await\n            .map_err(Self::classify_reqwest_error)?;\n\n        // GitHub's GraphQL spec says `errors` must contain at least one entry\n        // when present. Treat an empty array the same as no errors so we don't\n        // surface a useless `GraphQLError(\"\")`.\n        if let Some(errors) = body.errors.filter(|e| !e.is_empty()) {\n            let message = errors\n                .into_iter()\n                .map(|e| e.message)\n                .collect::<Vec<_>>()\n                .join(\"; \");\n            return Err(GitHubError::GraphQLError(message));\n        }\n\n        body.data.ok_or_else(|| {\n            GitHubError::InvalidResponse(\n                \"GraphQL response had neither `data` nor `errors`\".to_string(),\n            )\n        })\n    }\n\n    /// Classify a reqwest error. JSON decode failures are reported as\n    /// `InvalidResponse`; everything else is a `Network` error.\n    fn classify_reqwest_error(err: reqwest::Error) -> GitHubError {\n        if err.is_decode() {\n            return GitHubError::InvalidResponse(err.to_string());\n        }\n\n        let kind = if err.is_timeout() {\n            NetworkErrorKind::Timeout\n        } else if err.is_connect() {\n            NetworkErrorKind::Connection\n        } else {\n            NetworkErrorKind::Other(err.to_string())\n        };\n\n        GitHubError::Network {\n            kind,\n            message: err.to_string(),\n        }\n    }\n\n    /// Check if an error is transient and worth retrying.\n    fn is_transient(err: &GitHubError) -> bool {\n        match err {\n            GitHubError::Network { .. } => true,\n            GitHubError::HttpError { status, .. } => *status >= 500,\n            _ => false,\n        }\n    }\n\n    /// Delay before retry, with ±20% jitter to avoid thundering herd.\n    /// Called after a failed `attempt` when more retries remain, so for\n    /// `MAX_ATTEMPTS = 3` the inputs are 1 (~1s) and 2 (~2s).\n    fn backoff_duration(attempt: u32) -> Duration {\n        let base_millis = 1000_u64 * 2_u64.pow(attempt - 1);\n        let jitter_pct = rand::random::<u64>() % 21; // 0..=20\n        let signed = if rand::random::<bool>() {\n            base_millis + base_millis * jitter_pct / 100\n        } else {\n            base_millis - base_millis * jitter_pct / 100\n        };\n        Duration::from_millis(signed)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[derive(Debug, Deserialize)]\n    struct Issue {\n        number: u64,\n    }\n\n    #[test]\n    fn connection_deserializes_from_github_shape() {\n        let json = r#\"{\n            \"nodes\": [{\"number\": 1}, {\"number\": 2}],\n            \"pageInfo\": {\n                \"hasNextPage\": true,\n                \"endCursor\": \"Y3Vyc29yOjEw\"\n            }\n        }\"#;\n\n        let conn: Connection<Issue> = serde_json::from_str(json).unwrap();\n        assert_eq!(conn.nodes.len(), 2);\n        assert_eq!(conn.nodes[0].number, 1);\n        assert!(conn.page_info.has_next_page);\n        assert_eq!(conn.page_info.end_cursor.as_deref(), Some(\"Y3Vyc29yOjEw\"));\n    }\n\n    #[test]\n    fn page_info_handles_null_end_cursor_on_last_page() {\n        let json = r#\"{\"hasNextPage\": false, \"endCursor\": null}\"#;\n        let info: PageInfo = serde_json::from_str(json).unwrap();\n        assert!(!info.has_next_page);\n        assert!(info.end_cursor.is_none());\n    }\n}\n"
  },
  {
    "path": "src/github/projects.rs",
    "content": "//! GitHub Projects V2 queries.\n//!\n//! Placeholder: GraphQL queries and typed response structs land in a\n//! follow-up. Pagination is the caller's responsibility — see the loop\n//! pattern documented in `md/design/02-github-client.md`.\n"
  },
  {
    "path": "src/graph/mod.rs",
    "content": "//! Graph validation: cycle detection via DFS, dangling edge checks,\n//! and orphaned node warnings. Reports precise error paths.\n\nmod validate;\n"
  },
  {
    "path": "src/graph/validate.rs",
    "content": "//! Graph validation: cycle detection via DFS, dangling edge checks,\n//! and orphaned node warnings. Reports precise error paths.\n"
  },
  {
    "path": "src/lib.rs",
    "content": "//! # skill-tree\n//!\n//! skill-tree turns a GitHub Project into a visual dependency graph.\n//!\n//! ## Architecture\n//!\n//! The codebase is organized as a three-stage pipeline:\n//!\n//! ```text\n//! GitHub API  ──►  graph::Graph  ──►  rendered output\n//!  (fetch)          (model)              (render)\n//! ```\n//!\n//! Each stage has its own module:\n//!\n//! - [`config`]  — reads `.skill-tree.toml`; drives all three stages\n//! - [`github`]  — fetches data from the GitHub GraphQL API\n//! - [`graph`]   — the platform-agnostic data model (nodes + edges)\n//! - [`render`]  — turns a [`graph`] into Graphviz DOT / SVG\n//!\n\npub mod config;\npub mod error;\npub mod github;\npub mod graph;\npub mod render;\n"
  },
  {
    "path": "src/main.rs",
    "content": "//! skill-tree binary entry point.\n//! Parses CLI arguments and dispatches to render, unblocked, or validate.\n\nuse skill_tree::config::SkillTree;\n\nfn main() {\n    println!(\"Hello world!\");\n\n    let config = SkillTree::from_dir(\".\").unwrap();\n\n    println!(\"{:#?}\", config);\n}\n"
  },
  {
    "path": "src/render/mod.rs",
    "content": "//! Renders a Graph as Graphviz DOT or SVG.\n//! DOT output is deterministic. SVG is produced via the system dot binary.\n//! Every node is a clickable link to its GitHub issue.\n//!\n"
  },
  {
    "path": "tests/github_client.rs",
    "content": "//! Integration tests for `GitHubClient` against a mock GraphQL endpoint.\n//!\n//! Only imports the public API of `skill_tree` and the test infrastructure\n//! exposed by `skill_tree_testlib`. The wiremock plumbing lives in the\n//! testlib so individual tests stay focused on the scenario.\n\nuse std::time::Duration;\n\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse skill_tree::error::GitHubError;\nuse skill_tree_testlib::MockGitHub;\n\n#[derive(Serialize)]\nstruct EmptyVars {}\n\n#[derive(Debug, Deserialize, PartialEq)]\nstruct Hello {\n    hello: String,\n}\n\n#[tokio::test]\nasync fn retries_5xx_then_succeeds_and_returns_data() {\n    let gh = MockGitHub::start().await;\n    gh.status(503).up_to_n_times(1).mount(&gh.server).await;\n    gh.ok_data(json!({ \"hello\": \"world\" }))\n        .mount(&gh.server)\n        .await;\n\n    let client = gh.client(Duration::from_secs(10));\n    let resp: Hello = client\n        .query(\"query Q { hello }\", EmptyVars {})\n        .await\n        .unwrap();\n    assert_eq!(\n        resp,\n        Hello {\n            hello: \"world\".into()\n        }\n    );\n}\n\n#[tokio::test]\nasync fn gives_up_after_max_retries_returning_last_real_error() {\n    let gh = MockGitHub::start().await;\n    gh.status_with_body(500, \"boom\")\n        .expect(3) // MAX_ATTEMPTS\n        .mount(&gh.server)\n        .await;\n\n    let client = gh.client(Duration::from_secs(30));\n    let err = client\n        .query::<_, Hello>(\"query Q { hello }\", EmptyVars {})\n        .await\n        .unwrap_err();\n\n    match err {\n        GitHubError::HttpError { status, body } => {\n            assert_eq!(status, 500);\n            assert_eq!(body, \"boom\");\n        }\n        other => panic!(\"expected HttpError(500), got {other:?}\"),\n    }\n}\n\n#[tokio::test]\nasync fn rate_limit_within_budget_waits_and_retries() {\n    let gh = MockGitHub::start().await;\n    gh.rate_limited(1).up_to_n_times(1).mount(&gh.server).await;\n    gh.ok_data(json!({ \"hello\": \"world\" }))\n        .mount(&gh.server)\n        .await;\n\n    let client = gh.client(Duration::from_secs(10));\n    let resp: Hello = client\n        .query(\"query Q { hello }\", EmptyVars {})\n        .await\n        .unwrap();\n    assert_eq!(\n        resp,\n        Hello {\n            hello: \"world\".into()\n        }\n    );\n}\n\n#[tokio::test]\nasync fn rate_limit_outside_budget_surfaces_to_caller() {\n    let gh = MockGitHub::start().await;\n    gh.rate_limited(60).mount(&gh.server).await;\n\n    // Tight timeout so a 60s wait is outside the budget.\n    let client = gh.client(Duration::from_secs(2));\n    let err = client\n        .query::<_, Hello>(\"query Q { hello }\", EmptyVars {})\n        .await\n        .unwrap_err();\n\n    match err {\n        GitHubError::RateLimited { retry_after } => {\n            assert!(retry_after >= 58, \"expected ~60s, got {retry_after}\");\n        }\n        other => panic!(\"expected RateLimited, got {other:?}\"),\n    }\n}\n\n#[tokio::test]\nasync fn graphql_errors_are_returned_without_retry() {\n    let gh = MockGitHub::start().await;\n    gh.graphql_error(\"Field 'oops' not found\")\n        .expect(1) // not retried\n        .mount(&gh.server)\n        .await;\n\n    let client = gh.client(Duration::from_secs(10));\n    let err = client\n        .query::<_, Hello>(\"query Q { oops }\", EmptyVars {})\n        .await\n        .unwrap_err();\n\n    match err {\n        GitHubError::GraphQLError(msg) => assert!(msg.contains(\"oops\")),\n        other => panic!(\"expected GraphQLError, got {other:?}\"),\n    }\n}\n\n#[tokio::test]\nasync fn invalid_response_when_envelope_has_neither_data_nor_errors() {\n    let gh = MockGitHub::start().await;\n    gh.empty_envelope()\n        .expect(1) // not retried\n        .mount(&gh.server)\n        .await;\n\n    let client = gh.client(Duration::from_secs(10));\n    let err = client\n        .query::<_, Hello>(\"query Q { hello }\", EmptyVars {})\n        .await\n        .unwrap_err();\n\n    assert!(\n        matches!(err, GitHubError::InvalidResponse(_)),\n        \"got {err:?}\"\n    );\n}\n\n#[tokio::test]\nasync fn sends_api_version_and_authorization_headers() {\n    let gh = MockGitHub::start().await;\n    gh.ok_data_with_headers(\n        json!({ \"hello\": \"world\" }),\n        &[\n            (\"X-GitHub-Api-Version\", \"2022-11-28\"),\n            (\"Authorization\", \"Bearer test-token\"),\n        ],\n    )\n    .expect(1)\n    .mount(&gh.server)\n    .await;\n\n    let client = gh.client(Duration::from_secs(10));\n    let _: Hello = client\n        .query(\"query Q { hello }\", EmptyVars {})\n        .await\n        .unwrap();\n    // Mock's `.expect(1)` is verified on drop — if headers were wrong, no\n    // mock would have matched and the assertion would fail there.\n}\n"
  },
  {
    "path": "tests/integration.rs",
    "content": "\n"
  }
]