diff --git a/.github/agents/data-validator.agent.md b/.github/agents/data-validator.agent.md new file mode 100644 index 00000000..7879f023 --- /dev/null +++ b/.github/agents/data-validator.agent.md @@ -0,0 +1,15 @@ +--- +name: data-validator +description: A data validator who checks `data.json` for missing or malformed data +--- + +# Data Validation Agent + +You are a data validator who checks `data.json` for missing or malformed data + +## Data validation criteria + +When validating, always checks for: +- Empty author +- Year=0 +- Missing fields diff --git a/.github/agents/doc-writer.agent.md b/.github/agents/doc-writer.agent.md new file mode 100644 index 00000000..f58fa468 --- /dev/null +++ b/.github/agents/doc-writer.agent.md @@ -0,0 +1,23 @@ +--- +name: doc-writer +description: Generates or updates docstrings and README content +--- + +# Documentation Writing Agent + +You are a developer whose job is to create and update docstrings for functions and generates README content + +## Update Docstrings + +When updating docstrings, follows these patterns: +- Give a short description for the function +- Add description and type for parameters +- Add description and type for outputs +- Give an examples on how to use that function + +## Gerenate README + +- Start with a one-sentence summary +- Include usage examples +- Document parameters and return values +- Note any gotchas or limitations \ No newline at end of file diff --git a/.github/agents/documentor.agent.md b/.github/agents/documentor.agent.md new file mode 100644 index 00000000..9038940d --- /dev/null +++ b/.github/agents/documentor.agent.md @@ -0,0 +1,14 @@ +--- +name: documentor +description: Technical writer for clear and complete documentation +--- + +# Documentation Agent + +You are a technical writer who creates clear documentation. + +**Documentation standards:** +- Start with a one-sentence summary +- Include usage examples +- Document parameters and return values +- Note any gotchas or limitations \ No newline at end of file diff --git a/.github/agents/error-handler.agent.md b/.github/agents/error-handler.agent.md new file mode 100644 index 00000000..cde9d260 --- /dev/null +++ b/.github/agents/error-handler.agent.md @@ -0,0 +1,21 @@ +--- +name: error-handler +description: A senior developer reviews Python code for inconsistent error handling and suggests a unified approach +--- + +# Error Handling Agent + +You are a senior Python developer reviews code for inconsistent error handling and suggests a unified approach + +## Your expertise + +- Python 3.10+ features (dataclasses, type hints, match statements) +- PEP 8 style compliance +- Error handling patterns (try/except, custom exceptions) + +## When suggesting approach + +- Follows PEP 8 guidelines +- Uses consistent error handling patterns +- Provides clear and informative error messages +- No bare except clause \ No newline at end of file diff --git a/.github/agents/reviewer.agent.md b/.github/agents/reviewer.agent.md new file mode 100644 index 00000000..bfeaf58c --- /dev/null +++ b/.github/agents/reviewer.agent.md @@ -0,0 +1,18 @@ +--- +name: reviewer +description: Senior code reviewer focused on security and best practices +--- + +# Code Reviewer Agent + +You are a senior code reviewer focused on code quality. + +**Review priorities:** +1. Security vulnerabilities +2. Performance issues +3. Maintainability concerns +4. Best practice violations + +**Output format:** +Provide issues as a numbered list with severity tags: +[CRITICAL], [HIGH], [MEDIUM], [LOW] \ No newline at end of file diff --git a/.github/skills/book-summary/SKILL.md b/.github/skills/book-summary/SKILL.md new file mode 100644 index 00000000..b27cd691 --- /dev/null +++ b/.github/skills/book-summary/SKILL.md @@ -0,0 +1,14 @@ +--- +name: book-summary +description: Generates formatted markdown summary of book collection +--- + +# Book Summary Skill + +Generates a formatted markdown summary of a book collection, including titles, authors, and brief descriptions. + +## Output Format + +Follow this pattern: +- Use ✅/❌ for ready status +- Sort by year \ No newline at end of file diff --git a/.github/skills/code-tour/SKILL.md b/.github/skills/code-tour/SKILL.md new file mode 100644 index 00000000..2edf704d --- /dev/null +++ b/.github/skills/code-tour/SKILL.md @@ -0,0 +1,433 @@ +--- +name: code-tour +description: > + Use this skill to create CodeTour .tour files — persona-targeted, step-by-step walkthroughs + that link to real files and line numbers. Trigger for: "create a tour", "make a code tour", + "generate a tour", "onboarding tour", "tour for this PR", "tour for this bug", "RCA tour", + "architecture tour", "explain how X works", "vibe check", "PR review tour", + "contributor guide", "help someone ramp up", or any request for a structured walkthrough + through code. Supports 20 developer personas (new joiner, bug fixer, architect, PR reviewer, + vibecoder, security reviewer, and more), all CodeTour step types (file/line, selection, + pattern, uri, commands, view), and tour-level fields (ref, isPrimary, nextTour). + Works with any repository in any language. +--- + +# Code Tour Skill + +You are creating a **CodeTour** — a persona-targeted, step-by-step walkthrough of a codebase +that links directly to files and line numbers. CodeTour files live in `.tours/` and work with +the [VS Code CodeTour extension](https://github.com/microsoft/codetour). + +Two scripts are bundled in `scripts/`: + +- **`scripts/validate_tour.py`** — run after writing any tour. Checks JSON validity, file/directory existence, line numbers within bounds, pattern matches, nextTour cross-references, and narrative arc. Run it: `python ~/.agents/skills/code-tour/scripts/validate_tour.py .tours/.tour --repo-root .` +- **`scripts/generate_from_docs.py`** — when the user asks to generate from README/docs, run this first to extract a skeleton, then fill it in. Run it: `python ~/.agents/skills/code-tour/scripts/generate_from_docs.py --persona new-joiner --output .tours/skeleton.tour` + +Two reference files are bundled: + +- **`references/codetour-schema.json`** — the authoritative JSON schema. Read it to verify any field name or type. Every field you use must conform to it. +- **`references/examples.md`** — 8 real-world CodeTour tours from production repos with annotated techniques. Read it when you want to see how a specific feature (`commands`, `selection`, `view`, `pattern`, `isPrimary`, multi-tour series) is used in practice. + +### Real-world `.tour` files on GitHub + +These are confirmed production `.tour` files. Fetch one when you need a working example of a specific step type, tour-level field, or narrative structure — don't write from memory when the real thing is one fetch away. + +Find more with the GitHub code search: https://github.com/search?q=path%3A**%2F*.tour+&type=code + +#### By step type / technique demonstrated + +| What to study | File URL | +|---|---| +| `directory` + `file+line` (contributor onboarding) | https://github.com/coder/code-server/blob/main/.tours/contributing.tour | +| `selection` + `file+line` + intro content step (accessibility project) | https://github.com/a11yproject/a11yproject.com/blob/main/.tours/code-tour.tour | +| Minimal tutorial — tight `file+line` narration for interactive learning | https://github.com/lostintangent/rock-paper-scissors/blob/master/main.tour | +| Multi-tour repo with `nextTour` chaining (cloud native OCI walkthroughs) | https://github.com/lucasjellema/cloudnative-on-oci-2021/blob/main/.tours/introduction.tour | +| `isPrimary: true` (marks the onboarding entry point) | https://github.com/nickvdyck/webbundlr/blob/main/.tours/getting-started.tour | +| `pattern` instead of `line` (regex-anchored steps) | https://github.com/nickvdyck/webbundlr/blob/main/.tours/architecture.tour | + +**Raw content tip:** Prefix `raw.githubusercontent.com` and drop `/blob/` for raw JSON access. + +A great tour is not just annotated files. It is a **narrative** — a story told to a specific +person about what matters, why it matters, and what to do next. Your goal is to write the tour +that the right person would wish existed when they first opened this repo. + +**CRITICAL: Only create `.tour` JSON files. Never create, modify, or scaffold any other files.** + +--- + +## Step 1: Discover the repo + +Before asking the user anything, explore the codebase: + +- List the root directory, read the README, and check key config files + (package.json, pyproject.toml, go.mod, Cargo.toml, composer.json, etc.) +- Identify the language(s), framework(s), and what the project does +- Map the folder structure 1–2 levels deep +- Find entry points: main files, index files, app bootstrapping +- **Note which files actually exist** — every path you write in the tour must be real + +If the repo is sparse or empty, say so and work with what exists. + +**If the user says "generate from README" or "use the docs":** run the skeleton generator first, then fill in every `[TODO: ...]` by reading the actual files: + +```bash +python skills/code-tour/scripts/generate_from_docs.py \ + --persona new-joiner \ + --output .tours/skeleton.tour +``` + +### Entry points by language/framework + +Don't read everything — start here, then follow imports. + +| Stack | Entry points to read first | +|-------|---------------------------| +| **Node.js / TS** | `index.js/ts`, `server.js`, `app.js`, `src/main.ts`, `package.json` (scripts) | +| **Python** | `main.py`, `app.py`, `__main__.py`, `manage.py` (Django), `app/__init__.py` (Flask/FastAPI) | +| **Go** | `main.go`, `cmd//main.go`, `internal/` | +| **Rust** | `src/main.rs`, `src/lib.rs`, `Cargo.toml` | +| **Java / Kotlin** | `*Application.java`, `src/main/java/.../Main.java`, `build.gradle` | +| **Ruby** | `config/application.rb`, `config/routes.rb`, `app/controllers/application_controller.rb` | +| **PHP** | `index.php`, `public/index.php`, `bootstrap/app.php` (Laravel) | + +### Repo type variants — adjust focus accordingly + +The same persona asks for different things depending on what kind of repo this is: + +| Repo type | What to emphasize | Typical anchor files | +|-----------|-------------------|----------------------| +| **Service / API** | Request lifecycle, auth, error contracts | router, middleware, handler, schema | +| **Library / SDK** | Public API surface, extension points, versioning | index/exports, types, changelog | +| **CLI tool** | Command parsing, config loading, output formatting | main, commands/, config | +| **Monorepo** | Package boundaries, shared contracts, build graph | root package.json/pnpm-workspace, shared/, packages/ | +| **Framework** | Plugin system, lifecycle hooks, escape hatches | core/, plugins/, lifecycle | +| **Data pipeline** | Source → transform → sink, schema ownership | ingest/, transform/, schema/, dbt models | +| **Frontend app** | Component hierarchy, state management, routing | pages/, store/, router, api/ | + +For **monorepos**: identify the 2–3 packages most relevant to the persona's goal. Don't try to tour everything — open the tour with a step that explains how to navigate the workspace, then stay focused. + +### Large repo strategy + +For repos with 100+ files: don't try to read everything. + +1. Read entry points and the README first +2. Build a mental model of the top 5–7 modules +3. For the requested persona, identify the **2–3 modules that matter most** and read those deeply +4. For modules you're not covering, mention them in the intro step as "out of scope for this tour" +5. Use `directory` steps for areas you mapped but didn't read — they orient without requiring full knowledge + +A focused 10-step tour of the right files beats a scattered 25-step tour of everything. + +--- + +## Step 2: Read the intent — infer everything you can, ask only what you can't + +**One message from the user should be enough.** Read their request and infer persona, +depth, and focus before asking anything. + +### Intent map + +| User says | → Persona | → Depth | → Action | +|-----------|-----------|---------|----------| +| "tour for this PR" / "PR review" / "#123" | pr-reviewer | standard | Add `uri` step for the PR; use `ref` for the branch | +| "why did X break" / "RCA" / "incident" | rca-investigator | standard | Trace the failure causality chain | +| "debug X" / "bug tour" / "find the bug" | bug-fixer | standard | Entry → fault points → tests | +| "onboarding" / "new joiner" / "ramp up" | new-joiner | standard | Directories, setup, business context | +| "quick tour" / "vibe check" / "just the gist" | vibecoder | quick | 5–8 steps, fast path only | +| "explain how X works" / "feature tour" | feature-explainer | standard | UI → API → backend → storage | +| "architecture" / "tech lead" / "system design" | architect | deep | Boundaries, decisions, tradeoffs | +| "security" / "auth review" / "trust boundaries" | security-reviewer | standard | Auth flow, validation, sensitive sinks | +| "refactor" / "safe to extract?" | refactorer | standard | Seams, hidden deps, extraction order | +| "performance" / "bottlenecks" / "slow path" | performance-optimizer | standard | Hot path, N+1, I/O, caches | +| "contributor" / "open source onboarding" | external-contributor | quick | Safe areas, conventions, landmines | +| "concept" / "explain pattern X" | concept-learner | standard | Concept → implementation → rationale | +| "test coverage" / "where to add tests" | test-writer | standard | Contracts, seams, coverage gaps | +| "how do I call the API" | api-consumer | standard | Public surface, auth, error semantics | + +**Infer silently:** persona, depth, focus area, whether to add `uri`/`ref`, `isPrimary`. + +**Ask only if you genuinely can't infer:** +- "bug tour" but no bug described → ask for the bug description +- "feature tour" but no feature named → ask which feature +- "specific files" explicitly requested → honor them as required stops + +Never ask about `nextTour`, `commands`, `when`, or `stepMarker` unless the user mentioned them. + +### PR tour recipe + +For PR tours: set `"ref"` to the branch, open with a `uri` step for the PR, cover changed files first, then unchanged-but-critical files, close with a reviewer checklist. + +### User-provided customization — always honor these + +| User says | What to do | +|-----------|-----------| +| "cover `src/auth.ts` and `config/db.yml`" | Those files are required stops | +| "pin to the `v2.3.0` tag" / "this commit: abc123" | Set `"ref": "v2.3.0"` | +| "link to PR #456" / pastes a URL | Add a `uri` step at the right narrative moment | +| "lead into the security tour when done" | Set `"nextTour": "Security Review"` | +| "make this the main onboarding tour" | Set `"isPrimary": true` | +| "open a terminal at this step" | Add `"commands": ["workbench.action.terminal.focus"]` | +| "deep" / "thorough" / "5 steps" / "quick" | Override depth accordingly | + +--- + +## Step 3: Read the actual files — no exceptions + +**Every file path and line number in the tour must be verified by reading the file.** +A tour pointing to the wrong file or a non-existent line is worse than no tour. + +For every planned step: +1. Read the file +2. Find the exact line of the code you want to highlight +3. Understand it well enough to explain it to the target persona + +If a user-requested file doesn't exist, say so — don't silently substitute another. + +--- + +## Step 4: Write the tour + +Save to `.tours/-.tour`. Read `references/codetour-schema.json` for the +authoritative field list. Every field you use must appear in that schema. + +### Tour root + +```json +{ + "$schema": "https://aka.ms/codetour-schema", + "title": "Descriptive Title — Persona / Goal", + "description": "One sentence: who this is for and what they'll understand after.", + "ref": "main", + "isPrimary": false, + "nextTour": "Title of follow-up tour", + "steps": [] +} +``` + +Omit any field that doesn't apply to this tour. + +**`when`** — conditional display. A JavaScript expression evaluated at runtime. Only show this tour +if the condition is true. Useful for persona-specific auto-launching, or hiding advanced tours +until a simpler one is complete. +```json +{ "when": "workspaceFolders[0].name === 'api'" } +``` + +**`stepMarker`** — embed step anchors directly in source code comments. When set, CodeTour +looks for `// ` comments in files and uses them as step positions instead of +(or alongside) line numbers. Useful for tours on actively changing code where line numbers +shift constantly. Example: set `"stepMarker": "CT"` and put `// CT` in the source file. +Don't suggest this unless the user asks — it requires editing source files, which is unusual. + +--- + +### Step types — full reference + +All step types: **content** (intro/closing, max 2), **directory**, **file+line** (workhorse), **selection** (code block), **pattern** (regex match), **uri** (external link), **view** (focus VS Code panel), **commands** (run VS Code commands). + +> **Path rule:** `"file"` and `"directory"` must be relative to repo root. No absolute paths, no leading `./`. + +--- + +### When to use each step type + +| Situation | Step type | +|-----------|-----------| +| Tour intro or closing | content | +| "Here's what lives in this folder" | directory | +| One line tells the whole story | file + line | +| A function/class body is the point | selection | +| Line numbers shift, file is volatile | pattern | +| PR / issue / doc gives the "why" | uri | +| Reader should open terminal or explorer | view or commands | + +--- + +### Step count calibration + +Match steps to depth and persona. These are targets, not hard limits. + +| Depth | Total steps | Core path steps | Notes | +|-------|-------------|-----------------|-------| +| Quick | 5–8 | 3–5 | Vibecoder, fast explorer — cut ruthlessly | +| Standard | 9–13 | 6–9 | Most personas — breadth + enough detail | +| Deep | 14–18 | 10–13 | Architect, RCA — every tradeoff surfaced | + +Scale with repo size too. A 3-file CLI doesn't get 15 steps. A 200-file monolith shouldn't be squeezed into 5. + +| Repo size | Recommended standard depth | +|-----------|---------------------------| +| Tiny (< 20 files) | 5–8 steps | +| Small (20–80 files) | 8–11 steps | +| Medium (80–300 files) | 10–13 steps | +| Large (300+ files) | 12–15 steps (scoped to relevant subsystem) | + +--- + +### Writing excellent descriptions — the SMIG formula + +Every description should answer four questions in order. You don't need four paragraphs — but every description needs all four elements, even briefly. + +**S — Situation**: What is the reader looking at? One sentence grounding them in context. +**M — Mechanism**: How does this code work? What pattern, rule, or design is in play? +**I — Implication**: Why does this matter for *this persona's goal specifically*? +**G — Gotcha**: What would a smart person get wrong here? What's non-obvious, fragile, or surprising? + +Descriptions should tell the reader something they couldn't learn by reading the file themselves. Name the pattern, explain the design decision, flag failure modes, and cross-reference related context. + +--- + +## Narrative arc — every tour, every persona + +1. **Orientation** — **must be a `file` or `directory` step, never content-only.** + Use `"file": "README.md", "line": 1` or `"directory": "src"` and put your welcome text in the description. + A content-only first step (no `file`, `directory`, or `uri`) renders as a blank page in VS Code CodeTour — this is a known VS Code extension behaviour, not configurable. + +2. **High-level map** (1–3 directory or uri steps) — major modules and how they relate. + Not every folder — just what this persona needs to know. + +3. **Core path** (file/line, selection, pattern, uri steps) — the specific code that matters. + This is the heart of the tour. Read and narrate. Don't skim. + +4. **Closing** (content) — what the reader now understands, what they can do next, + 2–3 suggested follow-up tours. If `nextTour` is set, reference it by name here. + +### Closing steps + +Don't summarize — the reader just read it. Instead, tell them what they can now *do*, what to avoid, and suggest 2-3 follow-up tours. + +--- + +## The 20 personas + +| Persona | Goal | Must cover | Avoid | +|---------|------|------------|-------| +| **Vibecoder** | Get the vibe fast | Entry point, request flow, main modules. Max 8 steps. | Deep dives, edge cases | +| **New joiner** | Structured ramp-up | Directories, setup, business context, service boundaries. | Advanced internals | +| **Bug fixer** | Root cause fast | User action → trigger → fault points. Repro hints + test locations. | Architecture tours | +| **RCA investigator** | Why did it fail | Causality chain, side effects, race conditions, observability. | Happy path | +| **Feature explainer** | One feature end-to-end | UI → API → backend → storage. Feature flags, edge cases. | Unrelated features | +| **PR reviewer** | Review the change correctly | Change story, invariants, risky areas, reviewer checklist. URI step for PR. | Unrelated context | +| **Security reviewer** | Trust boundaries | Auth flow, input validation, secret handling, sensitive sinks. | Unrelated business logic | +| **Refactorer** | Safe restructuring | Seams, hidden deps, coupling hotspots, safe extraction order. | Feature explanations | +| **External contributor** | Contribute without breaking | Safe areas, code style, architecture landmines. | Deep internals | +| **Tech lead / architect** | Shape and rationale | Module boundaries, design tradeoffs, risk hotspots. | Line-by-line walkthroughs | + +--- + +## Designing a tour series + +When a codebase is complex enough that one tour can't cover it well, design a series. +The `nextTour` field chains them: when the reader finishes one tour, VS Code offers to +launch the next automatically. + +**Plan the series before writing any tour.** A good series has: +- A clear escalation path (broad → narrow, orientation → deep-dive) +- No duplicate steps between tours +- Each tour standalone enough to be useful on its own + +Set `nextTour` in each tour to the `title` of the next one (must match exactly). Each tour should be standalone enough to be useful on its own. + +--- + +## What CodeTour cannot do + +If asked for any of these, say clearly that it's not supported — do not suggest a workaround that doesn't exist: + +| Request | Reality | +|---|---| +| **Auto-advance to next step after X seconds** | Not supported. Navigation is always manual — the reader clicks Next. There is no timer, delay, or autoplay step mechanic in CodeTour. | +| **Embed a video or GIF in a step** | Not supported. Descriptions are Markdown text only. | +| **Run arbitrary shell commands** | Not supported. `commands` only executes VS Code commands (e.g. `workbench.action.terminal.focus`), not shell commands. | +| **Branch / conditional next step** | Not supported. Tours are linear. `when` controls whether a tour is shown, not which step follows which. | +| **Show a step without opening a file** | Partially — content-only steps work, but step 1 must have a `file` or `directory` anchor or VS Code shows a blank page. | + +--- + +## Anti-patterns + +| Anti-pattern | Fix | +|---|---| +| **File listing** — visiting files with "this file contains..." | Tell a story; each step should depend on the previous one | +| **Generic descriptions** | Name the specific pattern/gotcha unique to *this* codebase | +| **Line number guessing** | Never write a line number you didn't verify by reading the file | +| **Ignoring the persona** | Cut every step that doesn't serve their specific goal | +| **Hallucinated files** | If a file doesn't exist, skip the step | + +--- + +## Quality checklist — verify before writing the file + +- [ ] Every `file` path is **relative to the repo root** (no leading `/` or `./`) +- [ ] Every `file` path read and confirmed to exist +- [ ] Every `line` number verified by reading the file (not guessed) +- [ ] Every `directory` is **relative to the repo root** and confirmed to exist +- [ ] Every `pattern` regex would match a real line in the file +- [ ] Every `uri` is a complete, real URL (https://...) +- [ ] `ref` is a real branch/tag/commit if set +- [ ] `nextTour` exactly matches the `title` of another `.tour` file if set +- [ ] Only `.tour` JSON files created — no source code touched +- [ ] First step has a `file` or `directory` anchor (content-only first step = blank page in VS Code) +- [ ] Tour ends with a closing content step that tells the reader what they can *do* next +- [ ] Every description answers SMIG — Situation, Mechanism, Implication, Gotcha +- [ ] Persona's priorities drive step selection (cut everything that doesn't serve their goal) +- [ ] Step count matches requested depth and repo size (see calibration table) +- [ ] At most 2 content-only steps (intro + closing) +- [ ] All fields conform to `references/codetour-schema.json` + +--- + +## Step 5: Validate the tour + +**Always run the validator immediately after writing the tour file. Do not skip this step.** + +```bash +python ~/.agents/skills/code-tour/scripts/validate_tour.py .tours/.tour --repo-root . +``` + +The validator checks: +- JSON validity +- Every `file` path exists and every `line` is within file bounds +- Every `directory` exists +- Every `pattern` regex compiles and matches at least one line in the file +- Every `uri` starts with `https://` +- `nextTour` matches an existing tour title in `.tours/` +- Content-only step count (warns if > 2) +- Narrative arc (warns if no orientation or closing step) + +**Fix every error before proceeding.** Re-run until the validator reports ✓ or only warnings. Warnings are advisory — use your judgment. Do not show the user the tour until validation passes. + +**Common VS Code issues:** Content-only first step renders blank (anchor to file/directory instead). Absolute or `./`-prefixed paths silently fail. Out-of-bounds line numbers scroll nowhere. + +If you can't run scripts, manually verify: step 1 has `file`/`directory`, all paths exist, all line numbers are in bounds, `nextTour` matches exactly. + +**Autoplay:** `isPrimary: true` + `.vscode/settings.json` with `{ "codetour.promptForPrimaryTour": true }` prompts on repo open. Omit `ref` for tours that should appear on any branch. + +**Share:** For public repos, users can open tours at `https://vscode.dev/github.com//` with no install. + +--- + +## Step 6: Summarize + +After writing the tour, tell the user: +- File path (`.tours/.tour`) +- One-paragraph summary of what the tour covers and who it's for +- The `vscode.dev` URL if the repo is public (so they can share it immediately) +- 2–3 suggested follow-up tours (or the next tour in the series if one was planned) +- Any user-requested files that didn't exist (be explicit — don't quietly substitute) + +--- + +## File naming + +`-.tour` — kebab-case, communicates both: +``` +onboarding-new-joiner.tour +bug-fixer-payment-flow.tour +architect-overview.tour +vibecoder-quickstart.tour +pr-review-auth-refactor.tour +security-auth-boundaries.tour +concept-dependency-injection.tour +rca-login-outage.tour +``` \ No newline at end of file diff --git a/.github/skills/code-tour/references/codetour-schema.json b/.github/skills/code-tour/references/codetour-schema.json new file mode 100644 index 00000000..e4966b3e --- /dev/null +++ b/.github/skills/code-tour/references/codetour-schema.json @@ -0,0 +1,115 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Schema for CodeTour tour files", + "type": "object", + "required": ["title", "steps"], + "properties": { + "title": { + "type": "string", + "description": "Specifies the title of the code tour." + }, + "description": { + "type": "string", + "description": "Specifies an optional description for the code tour." + }, + "ref": { + "type": "string", + "description": "Indicates the git ref (branch/commit/tag) that this tour associate with." + }, + "isPrimary": { + "type": "boolean", + "description": "Specifies whether the tour represents the primary tour for this codebase." + }, + "nextTour": { + "type": "string", + "description": "Specifies the title of the tour that is meant to follow this tour." + }, + "stepMarker": { + "type": "string", + "description": "Specifies the marker that indicates a line of code represents a step for this tour." + }, + "when": { + "type": "string", + "description": "Specifies the condition (JavaScript expression) that must be met before this tour is shown." + }, + "steps": { + "type": "array", + "description": "Specifies the list of steps that are included in the code tour.", + "default": [], + "items": { + "type": "object", + "required": ["description"], + "properties": { + "title": { + "type": "string", + "description": "An optional title for the step." + }, + "description": { + "type": "string", + "description": "Description of the step. Supports markdown." + }, + "file": { + "type": "string", + "description": "File path (relative to the workspace root) that the step is associated with." + }, + "directory": { + "type": "string", + "description": "Directory path (relative to the workspace root) that the step is associated with." + }, + "uri": { + "type": "string", + "description": "Absolute URI (https://...) associated with the step. Use for PRs, issues, docs, ADRs." + }, + "line": { + "type": "number", + "description": "Line number (1-based) that the step is associated with." + }, + "pattern": { + "type": "string", + "description": "Regex to associate the step with a line by content instead of line number. Useful when line numbers shift frequently." + }, + "selection": { + "type": "object", + "required": ["start", "end"], + "description": "Text selection range associated with the step. Use when a block of code (not a single line) is the point.", + "properties": { + "start": { + "type": "object", + "required": ["line", "character"], + "properties": { + "line": { "type": "number", "description": "Line number (1-based) where the selection starts." }, + "character": { "type": "number", "description": "Column number (1-based) where the selection starts." } + } + }, + "end": { + "type": "object", + "required": ["line", "character"], + "properties": { + "line": { "type": "number", "description": "Line number (1-based) where the selection ends." }, + "character": { "type": "number", "description": "Column number (1-based) where the selection ends." } + } + } + } + }, + "view": { + "type": "string", + "description": "VS Code view ID to auto-focus when navigating to this step (e.g. 'terminal', 'explorer', 'problems', 'scm')." + }, + "commands": { + "type": "array", + "description": "VS Code command URIs to execute when this step is navigated to.", + "default": [], + "items": { "type": "string" }, + "examples": [ + ["editor.action.goToDeclaration"], + ["workbench.action.terminal.focus"], + ["editor.action.showHover"], + ["references-view.findReferences"], + ["workbench.action.tasks.runTask"] + ] + } + } + } + } + } +} diff --git a/.github/skills/code-tour/references/examples.md b/.github/skills/code-tour/references/examples.md new file mode 100644 index 00000000..186347bb --- /dev/null +++ b/.github/skills/code-tour/references/examples.md @@ -0,0 +1,195 @@ +# Real-World CodeTour Examples + +Reference this file when you want to see how real repos use CodeTour features. +Each example is sourced from a public GitHub repo with a direct link to the `.tour` file. + +--- + +## microsoft/codetour — Contributor orientation + +**Tour file:** https://github.com/microsoft/codetour/blob/main/.tours/intro.tour +**Persona:** New contributor +**Steps:** ~5 · **Depth:** Standard + +**What makes it good:** +- Intro step with an embedded SVG architecture diagram (raw GitHub URL inside the description) +- Rich markdown per step with emoji section headers (`### 🎥 Tour Player`) +- Inline cross-file links inside descriptions: `[Gutter decorator](./src/player/decorator.ts)` +- Uses the top-level `description` field as a subtitle for the tour itself + +**Technique to copy:** Embed images and cross-links in descriptions to make them self-contained. + +```json +{ + "file": "src/player/index.ts", + "line": 436, + "description": "### 🎥 Tour Player\n\nThe CodeTour player ...\n\n![Architecture](https://raw.githubusercontent.com/.../overview.svg)\n\nSee also: [Gutter decorator](./src/player/decorator.ts)" +} +``` + +--- + +## a11yproject/a11yproject.com — New contributor onboarding + +**Tour file:** https://github.com/a11yproject/a11yproject.com/blob/main/.tours/code-tour.tour +**Persona:** External contributor +**Steps:** 26 · **Depth:** Deep + +**What makes it good:** +- Almost entirely `directory` steps — orients to every `src/` subdirectory without getting lost in files +- Conversational, beginner-friendly tone throughout +- `selection` on the opening step to highlight the exact entry in `package.json` +- Closes with a genuine thank-you and call-to-action + +**Technique to copy:** Use directory steps as the skeleton of an onboarding tour — they teach structure without requiring the author to explain every file. + +```json +{ + "directory": "src/_data", + "description": "This folder contains the **data files** for the site. Think of them as a lightweight database — YAML files that power the resource listings, posts index, and nav." +} +``` + +--- + +## github/codespaces-codeql — The most technically complete example + +**Tour file:** https://github.com/github/codespaces-codeql/blob/main/.tours/codeql-tutorial.tour +**Persona:** Security engineer / concept learner +**Steps:** 12 · **Depth:** Standard + +**What makes it good:** +- `isPrimary: true` — auto-launches when the Codespace opens +- `commands` array to run real VS Code commands mid-tour: the tour literally executes `codeQL.runQuery` when the reader arrives at that step +- `view` property to switch the sidebar panel (`"view": "codeQLDatabases"`) +- `pattern` instead of `line` for resilient matching: `"pattern": "import tutorial.*"` +- `selection` to highlight the exact `select` clause in a query file + +**This is the canonical reference for `commands`, `view`, and `pattern`.** + +```json +{ + "file": "tutorial.ql", + "pattern": "import tutorial.*", + "view": "codeQLDatabases", + "commands": ["codeQL.setDefaultTourDatabase", "codeQL.runQuery"], + "title": "Run your first query", + "description": "Click the **▶ Run** button above. The results appear in the CodeQL Query Results panel." +} +``` + +--- + +## github/codespaces-learn-with-me — Minimal interactive tutorial + +**Tour file:** https://github.com/github/codespaces-learn-with-me/blob/main/.tours/main.tour +**Persona:** Total beginner +**Steps:** 4 · **Depth:** Quick + +**What makes it good:** +- Only 4 steps — proves that less is more for quick/vibecoder personas +- `isPrimary: true` for auto-launch +- Each step tells the reader to **do something** (edit a string, change a color) — not just read +- Ends with a tangible outcome: "your page is live" + +**Technique to copy:** For quick/vibecoder tours, cut mercilessly. Four steps that drive action beat twelve that explain everything. + +--- + +## blackgirlbytes/copilot-todo-list — 28-step interactive tutorial + +**Tour file:** https://github.com/blackgirlbytes/copilot-todo-list/blob/main/.tours/main.tour +**Persona:** Concept learner / hands-on tutorial +**Steps:** 28 · **Depth:** Deep + +**What makes it good:** +- Uses **content-only checkpoint steps** (no `file` key) as progress milestones: "Check out your page! 🎉" and "Try it out!" between coding tasks +- Terminal inline commands in descriptions: `>> npm install uuid; npm install styled-components` +- Each file step shows the exact code the user should accept, in a markdown code fence, so they know the expected output + +**Technique to copy:** Checkpoint steps (content-only, milestone title) break up long tours and give the reader a sense of progress. + +```json +{ + "title": "Check out your page! 🎉", + "description": "Open the **Simple Browser** tab to see your to-do list. You should see all three tasks rendering from your data array.\n\nOnce you're happy with it, continue to add interactivity." +} +``` + +--- + +## lucasjellema/cloudnative-on-oci-2021 — Multi-tour architecture series + +**Tour files:** +- https://github.com/lucasjellema/cloudnative-on-oci-2021/blob/main/.tours/function-tweet-retriever.tour +- https://github.com/lucasjellema/cloudnative-on-oci-2021/blob/main/.tours/oci-and-infrastructure-as-code.tour +- https://github.com/lucasjellema/cloudnative-on-oci-2021/blob/main/.tours/build-and-deployment-pipeline-function-tweet-retriever.tour + +**Persona:** Platform engineer / architect +**Steps:** 12 per tour · **Depth:** Standard + +**What makes it good:** +- Three separate tours for three separate concerns (function code, IaC, CI/CD pipeline) — each standalone but linked via `nextTour` +- `selection` coordinates used heavily in Terraform files where a block (not a single line) is the point +- Steps include markdown links to official OCI documentation inline +- Designed to be browsed via `vscode.dev/github.com/...` without cloning + +**Technique to copy:** For complex systems, write one tour per layer and chain them with `nextTour`. Don't try to cover infrastructure + application code + CI/CD in one tour. + +--- + +## SeleniumHQ/selenium — Monorepo build system onboarding + +**Tour files:** +- `.tours/bazel.tour` — Bazel workspace and build target orientation +- `.tours/building-and-testing-the-python-bindings.tour` — Python bindings BUILD.bazel walkthrough + +**Persona:** External contributor (build system focus) +**Steps:** ~10 per tour + +**What makes it good:** +- Targets a non-obvious entry point — not the product code but the build system +- Proves that "contributor onboarding" tours don't have to start with `main()` — they start with whatever is confusing about this specific repo +- Used in a large, mature OSS project at scale + +--- + +## Technique quick-reference + +| Feature | When to use | Real example | +|---------|-------------|-------------| +| `isPrimary: true` | Auto-launch tour when repo opens (Codespace, vscode.dev) | codespaces-learn-with-me, codespaces-codeql | +| `commands: [...]` | Run a VS Code command when reader arrives at this step | codespaces-codeql (`codeQL.runQuery`) | +| `view: "terminal"` | Switch VS Code sidebar/panel at this step | codespaces-codeql (`codeQLDatabases`) | +| `pattern: "regex"` | Match by line content, not number — use for volatile files | codespaces-codeql | +| `selection: {start, end}` | Highlight a block (function body, config section, type def) | a11yproject, oci-2021, codespaces-codeql | +| `directory: "path/"` | Orient to a folder without reading every file | a11yproject, codespaces-codeql | +| `uri: "https://..."` | Link to PR, issue, RFC, ADR, external doc | Any PR review tour | +| `nextTour: "Title"` | Chain tours in a series | oci-2021 (3-part series) | +| Checkpoint steps (content-only) | Progress milestones in long interactive tours | copilot-todo-list | +| `>> command` in description | Terminal inline command link in VS Code | copilot-todo-list | +| Embedded image in description | Architecture diagrams, screenshots | microsoft/codetour | + +--- + +## Discover more real tours on GitHub + +**Search all `.tour` files on GitHub:** +https://github.com/search?q=path%3A**%2F*.tour+&type=code + +This search returns every `.tour` file committed to a public GitHub repo. Use it to: +- Find tours for repos in the same language/framework as the one you're working on +- Study how other authors handle the same personas or step types +- Look up how a specific field (`commands`, `selection`, `pattern`) is used in the wild + +Filter by language or keyword to narrow results — e.g. add `language:TypeScript` or `fastapi` to the query. + +--- + +## Further reading + +- **DEV Community — "Onboard your codebase with CodeTour"**: https://dev.to/tobiastimm/onboard-your-codebase-with-codetour-2jc8 +- **Coder Blog — "Onboard to new projects faster with CodeTour"**: https://coder.com/blog/onboard-to-new-projects-faster-with-codetour +- **Microsoft Tech Community — Educator Developer Blog**: https://techcommunity.microsoft.com/blog/educatordeveloperblog/codetour-vscode-extension-allows-you-to-produce-interactive-guides-assessments-a/1274297 +- **AMIS Technology Blog — vscode.dev + CodeTour**: https://technology.amis.nl/software-development/visual-studio-code-the-code-tours-extension-for-in-context-and-interactive-readme/ +- **CodeTour GitHub Topics**: https://github.com/topics/codetour diff --git a/.github/skills/code-tour/scripts/generate_from_docs.py b/.github/skills/code-tour/scripts/generate_from_docs.py new file mode 100644 index 00000000..4c90c68e --- /dev/null +++ b/.github/skills/code-tour/scripts/generate_from_docs.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +""" +Generate a tour skeleton from repo documentation (README, CONTRIBUTING, docs/). + +Reads README.md (and optionally CONTRIBUTING.md, docs/) to extract: + - File and directory references + - Architecture / structure sections + - Setup instructions (becomes an orientation step) + - External links (becomes uri steps) + +Outputs a skeleton .tour JSON that the code-tour skill fills in with descriptions. +The skill reads this skeleton and enriches it — it does NOT replace the skill's judgment. + +Usage: + python generate_from_docs.py [--repo-root ] [--persona ] [--output ] + +Examples: + python generate_from_docs.py + python generate_from_docs.py --persona new-joiner --output .tours/from-readme.tour + python generate_from_docs.py --repo-root /path/to/repo --persona vibecoder +""" + +import json +import re +import sys +import os +from pathlib import Path +from typing import Optional + + +# ── Markdown extraction helpers ────────────────────────────────────────────── + +# Matches inline code that looks like a file/directory path +_CODE_PATH = re.compile(r"`([^`]{2,80})`") +# Matches headings +_HEADING = re.compile(r"^(#{1,3})\s+(.+)$", re.MULTILINE) +# Matches markdown links: [text](url) +_LINK = re.compile(r"\[([^\]]+)\]\((https?://[^)]+)\)") +# Patterns that suggest a path (contains / or . with extension) +_LOOKS_LIKE_PATH = re.compile(r"^\.?[\w\-]+(/[\w\-\.]+)+$|^\./|^[\w]+\.[a-z]{1,5}$") +# Architecture / structure section keywords +_STRUCT_KEYWORDS = re.compile( + r"\b(structure|architecture|layout|overview|directory|folder|module|component|" + r"design|system|organization|getting.started|quick.start|setup|installation)\b", + re.IGNORECASE, +) + + +def _extract_paths_from_text(text: str, repo_root: Path) -> list[str]: + """Extract inline code that looks like real file/directory paths.""" + candidates = _CODE_PATH.findall(text) + found = [] + for c in candidates: + c = c.strip().lstrip("./") + if not c: + continue + if not _LOOKS_LIKE_PATH.match(c) and "/" not in c and "." not in c: + continue + # check if path actually exists + full = repo_root / c + if full.exists(): + found.append(c) + return found + + +def _extract_external_links(text: str) -> list[tuple[str, str]]: + """Extract [label](url) pairs for URI steps.""" + links = _LINK.findall(text) + # filter out image links and very generic anchors + return [ + (label, url) + for label, url in links + if not url.endswith((".png", ".jpg", ".gif", ".svg")) + and label.lower() not in ("here", "this", "link", "click", "see") + ] + + +def _split_into_sections(text: str) -> list[tuple[str, str]]: + """Split markdown into (heading, body) pairs.""" + headings = list(_HEADING.finditer(text)) + sections = [] + for i, m in enumerate(headings): + heading = m.group(2).strip() + start = m.end() + end = headings[i + 1].start() if i + 1 < len(headings) else len(text) + body = text[start:end].strip() + sections.append((heading, body)) + return sections + + +def _is_structure_section(heading: str) -> bool: + return bool(_STRUCT_KEYWORDS.search(heading)) + + +# ── Step builders ───────────────────────────────────────────────────────────── + +def _make_content_step(title: str, hint: str) -> dict: + return { + "title": title, + "description": f"[TODO: {hint}]", + } + + +def _make_file_step(path: str, hint: str = "") -> dict: + step = { + "file": path, + "title": f"[TODO: title for {path}]", + "description": f"[TODO: {hint or 'explain this file for the persona'}]", + } + return step + + +def _make_dir_step(path: str, hint: str = "") -> dict: + return { + "directory": path, + "title": f"[TODO: title for {path}/]", + "description": f"[TODO: {hint or 'explain what lives here'}]", + } + + +def _make_uri_step(url: str, label: str) -> dict: + return { + "uri": url, + "title": label, + "description": "[TODO: explain why this link is relevant and what the reader should notice]", + } + + +# ── Core generator ──────────────────────────────────────────────────────────── + +def generate_skeleton(repo_root: str = ".", persona: str = "new-joiner") -> dict: + repo = Path(repo_root).resolve() + + # ── Read documentation files ───────────────────────────────────────── + doc_files = ["README.md", "readme.md", "Readme.md"] + extra_docs = ["CONTRIBUTING.md", "ARCHITECTURE.md", "docs/architecture.md", "docs/README.md"] + + readme_text = "" + for name in doc_files: + p = repo / name + if p.exists(): + readme_text = p.read_text(errors="replace") + break + + extra_texts = [] + for name in extra_docs: + p = repo / name + if p.exists(): + extra_texts.append((name, p.read_text(errors="replace"))) + + all_text = readme_text + "\n".join(t for _, t in extra_texts) + + # ── Collect steps ───────────────────────────────────────────────────── + steps = [] + seen_paths: set[str] = set() + + # 1. Intro step + steps.append( + _make_content_step( + "Welcome", + f"Introduce the repo: what it does, who this {persona} tour is for, what they'll understand after finishing.", + ) + ) + + # 2. Parse README sections + if readme_text: + sections = _split_into_sections(readme_text) + for heading, body in sections: + # structure / architecture sections → directory steps + if _is_structure_section(heading): + paths = _extract_paths_from_text(body, repo) + for p in paths: + if p in seen_paths: + continue + seen_paths.add(p) + full = repo / p + if full.is_dir(): + steps.append(_make_dir_step(p, f"mentioned under '{heading}' in README")) + elif full.is_file(): + steps.append(_make_file_step(p, f"mentioned under '{heading}' in README")) + + # 3. Scan all text for file/dir references not yet captured + all_paths = _extract_paths_from_text(all_text, repo) + for p in all_paths: + if p in seen_paths: + continue + seen_paths.add(p) + full = repo / p + if full.is_dir(): + steps.append(_make_dir_step(p)) + elif full.is_file(): + steps.append(_make_file_step(p)) + + # 4. If very few file steps found, fall back to top-level directory scan + file_and_dir_steps = [s for s in steps if "file" in s or "directory" in s] + if len(file_and_dir_steps) < 3: + # add top-level directories + for item in sorted(repo.iterdir()): + if item.name.startswith(".") or item.name in ("node_modules", "__pycache__", ".git"): + continue + rel = str(item.relative_to(repo)) + if rel in seen_paths: + continue + seen_paths.add(rel) + if item.is_dir(): + steps.append(_make_dir_step(rel, "top-level directory")) + elif item.is_file() and item.suffix in (".ts", ".js", ".py", ".go", ".rs", ".java", ".rb"): + steps.append(_make_file_step(rel, "top-level source file")) + + # 5. URI steps from external links in README + links = _extract_external_links(readme_text) + # Only include links that look like architecture / design references + for label, url in links[:3]: # cap at 3 to avoid noise + steps.append(_make_uri_step(url, label)) + + # 6. Closing step + steps.append( + _make_content_step( + "What to Explore Next", + "Summarize what the reader now understands. List 2–3 follow-up tours they should read next.", + ) + ) + + # Deduplicate steps by (file/directory/uri key) + seen_keys: set = set() + deduped = [] + for s in steps: + key = s.get("file") or s.get("directory") or s.get("uri") or s.get("title") + if key in seen_keys: + continue + seen_keys.add(key) + deduped.append(s) + + return { + "$schema": "https://aka.ms/codetour-schema", + "title": f"[TODO: descriptive title for {persona} tour]", + "description": f"[TODO: one sentence — who this is for and what they'll understand]", + "_skeleton_generated_by": "generate_from_docs.py", + "_instructions": ( + "This is a skeleton. Fill in every [TODO: ...] with real content. " + "Read each referenced file before writing its description. " + "Remove this _skeleton_generated_by and _instructions field before saving." + ), + "steps": deduped, + } + + +def main(): + args = sys.argv[1:] + if "--help" in args or "-h" in args: + print(__doc__) + sys.exit(0) + + repo_root = "." + persona = "new-joiner" + output: Optional[str] = None + + i = 0 + while i < len(args): + if args[i] == "--repo-root" and i + 1 < len(args): + repo_root = args[i + 1] + i += 2 + elif args[i] == "--persona" and i + 1 < len(args): + persona = args[i + 1] + i += 2 + elif args[i] == "--output" and i + 1 < len(args): + output = args[i + 1] + i += 2 + else: + i += 1 + + skeleton = generate_skeleton(repo_root, persona) + out_json = json.dumps(skeleton, indent=2) + + if output: + Path(output).parent.mkdir(parents=True, exist_ok=True) + Path(output).write_text(out_json) + print(f"✅ Skeleton written to {output}") + print(f" {len(skeleton['steps'])} steps generated from docs") + print(f" Fill in all [TODO: ...] entries before sharing") + else: + print(out_json) + + +if __name__ == "__main__": + main() diff --git a/.github/skills/code-tour/scripts/validate_tour.py b/.github/skills/code-tour/scripts/validate_tour.py new file mode 100644 index 00000000..605e1a2e --- /dev/null +++ b/.github/skills/code-tour/scripts/validate_tour.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +""" +CodeTour validator — bundled with the code-tour skill. + +Checks a .tour file for: + - Valid JSON + - Required fields (title, steps, description per step) + - File paths that actually exist in the repo + - Line numbers within file bounds + - Selection ranges within file bounds + - Directory paths that exist + - Pattern regexes that compile AND match at least one line + - URI format (must start with https://) + - nextTour matches an existing tour title in .tours/ + - Content-only step count (max 2 recommended) + - Narrative arc (first step should orient, last step should close) + +Usage: + python validate_tour.py [--repo-root ] + +Examples: + python validate_tour.py .tours/new-joiner.tour + python validate_tour.py .tours/new-joiner.tour --repo-root /path/to/repo +""" + +import json +import re +import sys +import os +from pathlib import Path + + +RESET = "\033[0m" +RED = "\033[31m" +YELLOW = "\033[33m" +GREEN = "\033[32m" +BOLD = "\033[1m" +DIM = "\033[2m" + + +def _line_count(path: Path) -> int: + try: + with open(path, errors="replace") as f: + return sum(1 for _ in f) + except Exception: + return 0 + + +def _file_content(path: Path) -> str: + try: + return path.read_text(errors="replace") + except Exception: + return "" + + +def validate_tour(tour_path: str, repo_root: str = ".") -> dict: + repo = Path(repo_root).resolve() + errors = [] + warnings = [] + info = [] + + # ── 1. JSON validity ──────────────────────────────────────────────────── + try: + with open(tour_path, errors="replace") as f: + tour = json.load(f) + except json.JSONDecodeError as e: + return { + "passed": False, + "errors": [f"Invalid JSON: {e}"], + "warnings": [], + "info": [], + "stats": {}, + } + except FileNotFoundError: + return { + "passed": False, + "errors": [f"File not found: {tour_path}"], + "warnings": [], + "info": [], + "stats": {}, + } + + # ── 2. Required top-level fields ──────────────────────────────────────── + if "title" not in tour: + errors.append("Missing required field: 'title'") + if "steps" not in tour: + errors.append("Missing required field: 'steps'") + return {"passed": False, "errors": errors, "warnings": warnings, "info": info, "stats": {}} + + steps = tour["steps"] + if not isinstance(steps, list): + errors.append("'steps' must be an array") + return {"passed": False, "errors": errors, "warnings": warnings, "info": info, "stats": {}} + + if len(steps) == 0: + errors.append("Tour has no steps") + return {"passed": False, "errors": errors, "warnings": warnings, "info": info, "stats": {}} + + # ── 3. Tour-level optional fields ─────────────────────────────────────── + if "nextTour" in tour: + tours_dir = Path(tour_path).parent + next_title = tour["nextTour"] + found_next = False + for tf in tours_dir.glob("*.tour"): + if tf.resolve() == Path(tour_path).resolve(): + continue + try: + other = json.loads(tf.read_text()) + if other.get("title") == next_title: + found_next = True + break + except Exception: + pass + if not found_next: + warnings.append( + f"nextTour '{next_title}' — no .tour file in .tours/ has a matching title" + ) + + # ── 4. Per-step validation ─────────────────────────────────────────────── + content_only_count = 0 + file_step_count = 0 + dir_step_count = 0 + uri_step_count = 0 + + for i, step in enumerate(steps): + label = f"Step {i + 1}" + if "title" in step: + label += f" — {step['title']!r}" + + # description required on every step + if "description" not in step: + errors.append(f"{label}: Missing required field 'description'") + + has_file = "file" in step + has_dir = "directory" in step + has_uri = "uri" in step + has_selection = "selection" in step + + if not has_file and not has_dir and not has_uri: + content_only_count += 1 + + # ── file ────────────────────────────────────────────────────────── + if has_file: + file_step_count += 1 + raw_path = step["file"] + + # must be relative — no leading slash, no ./ + if raw_path.startswith("/"): + errors.append(f"{label}: File path must be relative (no leading /): {raw_path!r}") + elif raw_path.startswith("./"): + warnings.append(f"{label}: File path should not start with './': {raw_path!r}") + + file_path = repo / raw_path + if not file_path.exists(): + errors.append(f"{label}: File does not exist: {raw_path!r}") + elif not file_path.is_file(): + errors.append(f"{label}: Path is not a file: {raw_path!r}") + else: + lc = _line_count(file_path) + + # line number + if "line" in step: + ln = step["line"] + if not isinstance(ln, int): + errors.append(f"{label}: 'line' must be an integer, got {ln!r}") + elif ln < 1: + errors.append(f"{label}: Line number must be >= 1, got {ln}") + elif ln > lc: + errors.append( + f"{label}: Line {ln} exceeds file length ({lc} lines): {raw_path!r}" + ) + + # selection + if has_selection: + sel = step["selection"] + start = sel.get("start", {}) + end = sel.get("end", {}) + s_line = start.get("line", 0) + e_line = end.get("line", 0) + if s_line > lc: + errors.append( + f"{label}: Selection start line {s_line} exceeds file length ({lc})" + ) + if e_line > lc: + errors.append( + f"{label}: Selection end line {e_line} exceeds file length ({lc})" + ) + if s_line > e_line: + errors.append( + f"{label}: Selection start ({s_line}) is after end ({e_line})" + ) + + # pattern + if "pattern" in step: + try: + compiled = re.compile(step["pattern"], re.MULTILINE) + content = _file_content(file_path) + if not compiled.search(content): + errors.append( + f"{label}: Pattern {step['pattern']!r} matches nothing in {raw_path!r}" + ) + except re.error as e: + errors.append(f"{label}: Invalid regex pattern: {e}") + + # ── directory ───────────────────────────────────────────────────── + if has_dir: + dir_step_count += 1 + raw_dir = step["directory"] + dir_path = repo / raw_dir + if not dir_path.exists(): + errors.append(f"{label}: Directory does not exist: {raw_dir!r}") + elif not dir_path.is_dir(): + errors.append(f"{label}: Path is not a directory: {raw_dir!r}") + + # ── uri ─────────────────────────────────────────────────────────── + if has_uri: + uri_step_count += 1 + uri = step["uri"] + if not uri.startswith("https://") and not uri.startswith("http://"): + warnings.append(f"{label}: URI should start with https://: {uri!r}") + + # ── commands ────────────────────────────────────────────────────── + if "commands" in step: + if not isinstance(step["commands"], list): + errors.append(f"{label}: 'commands' must be an array") + else: + for cmd in step["commands"]: + if not isinstance(cmd, str): + errors.append(f"{label}: Each command must be a string, got {cmd!r}") + + # ── 5. Content-only step count ────────────────────────────────────────── + if content_only_count > 2: + warnings.append( + f"{content_only_count} content-only steps (no file/dir/uri). " + f"Recommended max: 2 (intro + closing)." + ) + + # ── 6. Narrative arc checks ───────────────────────────────────────────── + first = steps[0] + last = steps[-1] + first_is_orient = "file" not in first and "directory" not in first and "uri" not in first + last_is_closing = "file" not in last and "directory" not in last and "uri" not in last + + if not first_is_orient and "directory" not in first: + info.append( + "First step is a file/uri step — consider starting with a content or directory " + "orientation step." + ) + if not last_is_closing: + info.append( + "Last step is not a content step — consider ending with a closing/summary step." + ) + + stats = { + "total_steps": len(steps), + "file_steps": file_step_count, + "directory_steps": dir_step_count, + "content_steps": content_only_count, + "uri_steps": uri_step_count, + } + + return { + "passed": len(errors) == 0, + "errors": errors, + "warnings": warnings, + "info": info, + "stats": stats, + } + + +def print_report(tour_path: str, result: dict) -> None: + title = f"{BOLD}{tour_path}{RESET}" + print(f"\n{title}") + print("─" * 60) + + stats = result.get("stats", {}) + if stats: + parts = [ + f"{stats.get('total_steps', 0)} steps", + f"{stats.get('file_steps', 0)} file", + f"{stats.get('directory_steps', 0)} dir", + f"{stats.get('content_steps', 0)} content", + f"{stats.get('uri_steps', 0)} uri", + ] + print(f"{DIM} {' · '.join(parts)}{RESET}") + + errors = result.get("errors", []) + warnings = result.get("warnings", []) + info = result.get("info", []) + + for e in errors: + print(f" {RED}✗ {e}{RESET}") + for w in warnings: + print(f" {YELLOW}⚠ {w}{RESET}") + for i in info: + print(f" {DIM}ℹ {i}{RESET}") + + if result["passed"] and not warnings: + print(f" {GREEN}✓ All checks passed{RESET}") + elif result["passed"]: + print(f" {GREEN}✓ Passed{RESET} {YELLOW}(with warnings){RESET}") + else: + print(f" {RED}✗ Failed — {len(errors)} error(s){RESET}") + + print() + + +def main(): + args = sys.argv[1:] + if not args or args[0] in ("-h", "--help"): + print(__doc__) + sys.exit(0) + + repo_root = "." + tour_files = [] + + i = 0 + while i < len(args): + if args[i] == "--repo-root" and i + 1 < len(args): + repo_root = args[i + 1] + i += 2 + else: + tour_files.append(args[i]) + i += 1 + + if not tour_files: + # validate all tours in .tours/ + tours_dir = Path(".tours") + if tours_dir.exists(): + tour_files = [str(p) for p in sorted(tours_dir.glob("*.tour"))] + if not tour_files: + print("No .tour files found. Pass a file path or run from a repo with a .tours/ directory.") + sys.exit(1) + + all_passed = True + for tf in tour_files: + result = validate_tour(tf, repo_root) + print_report(tf, result) + if not result["passed"]: + all_passed = False + + sys.exit(0 if all_passed else 1) + + +if __name__ == "__main__": + main() diff --git a/.github/skills/pr-review/SKILL.md b/.github/skills/pr-review/SKILL.md new file mode 100644 index 00000000..8c0f2ff6 --- /dev/null +++ b/.github/skills/pr-review/SKILL.md @@ -0,0 +1,37 @@ +--- +name: pr-review +description: Team-standard PR review checklist +--- + +# PR Review + +Review code changes against team standards: + +## Security Checklist +- [ ] No hardcoded secrets or API keys +- [ ] Input validation on all user data +- [ ] No bare except clauses +- [ ] No sensitive data in logs + +## Code Quality +- [ ] Functions under 50 lines +- [ ] No print statements in production code +- [ ] Type hints on public functions +- [ ] Context managers for file I/O +- [ ] No TODOs without issue references + +## Testing +- [ ] New code has tests +- [ ] Edge cases covered +- [ ] No skipped tests without explanation + +## Documentation +- [ ] API changes documented +- [ ] Breaking changes noted +- [ ] README updated if needed + +## Output Format +Provide results as: +- ✅ PASS: Items that look good +- ⚠️ WARN: Items that could be improved +- ❌ FAIL: Items that must be fixed before merge diff --git a/.github/skills/quick-review/SKILL.md b/.github/skills/quick-review/SKILL.md new file mode 100644 index 00000000..734883f1 --- /dev/null +++ b/.github/skills/quick-review/SKILL.md @@ -0,0 +1,21 @@ +--- +name: quick-review +description: Quick review code quality +--- + +# Quick review + +Quick review code changes for basic quality checks: + +## Code Quality + +- [ ] Type hints on functions +- [ ] No bare except clauses +- [ ] No unclear variable names + +## Output Format + +Provide results as: +- ✅ PASS: Items that look good +- ⚠️ WARN: Items that could be improved +- ❌ FAIL: Items that must be fixed before merge \ No newline at end of file diff --git a/.github/skills/security-audit/SKILL.md b/.github/skills/security-audit/SKILL.md new file mode 100644 index 00000000..3fbadd7b --- /dev/null +++ b/.github/skills/security-audit/SKILL.md @@ -0,0 +1,38 @@ +--- +name: security-audit +description: Security-focused code review checking OWASP (Open Web Application Security Project) Top 10 vulnerabilities +--- + +# Security Audit + +Perform a security audit checking for: + +## Injection Vulnerabilities +- SQL injection (string concatenation in queries) +- Command injection (unsanitized shell commands) +- LDAP injection +- XPath injection + +## Authentication Issues +- Hardcoded credentials +- Weak password requirements +- Missing rate limiting +- Session management flaws + +## Sensitive Data +- Plaintext passwords +- API keys in code +- Logging sensitive information +- Missing encryption + +## Access Control +- Missing authorization checks +- Insecure direct object references +- Path traversal vulnerabilities + +## Output +For each issue found, provide: +1. File and line number +2. Vulnerability type +3. Severity (CRITICAL/HIGH/MEDIUM/LOW) +4. Recommended fix \ No newline at end of file diff --git a/.github/uvs.csv b/.github/uvs.csv index d7840e32..91d4c74f 100644 --- a/.github/uvs.csv +++ b/.github/uvs.csv @@ -67,10 +67,3 @@ "05/08",1034 "05/09",455 "05/10",481 -"05/11",923 -"05/12",728 -"05/13",816 -"05/14",803 -"05/15",754 -"05/16",464 -"05/17",440 diff --git a/.github/views.csv b/.github/views.csv index 5860cd6d..62a5d69f 100644 --- a/.github/views.csv +++ b/.github/views.csv @@ -66,10 +66,3 @@ "05/08",2393 "05/09",1097 "05/10",1003 -"05/11",1832 -"05/12",1461 -"05/13",1710 -"05/14",1721 -"05/15",1538 -"05/16",1138 -"05/17",1093 diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..ab1483ab --- /dev/null +++ b/.mcp.json @@ -0,0 +1,16 @@ +{ + "mcpServers": { + "filesystem": { + "type": "local", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "."], + "tools": ["*"] + }, + "context7": { + "type": "local", + "command": "npx", + "args": ["-y", "@upstash/context7-mcp"], + "tools": ["*"] + } + } +} \ No newline at end of file diff --git a/01-setup-and-first-steps/copilot_programmatic_mode.sh b/01-setup-and-first-steps/copilot_programmatic_mode.sh new file mode 100755 index 00000000..88553363 --- /dev/null +++ b/01-setup-and-first-steps/copilot_programmatic_mode.sh @@ -0,0 +1,5 @@ +# Review all Python files in the book app +for file in samples/book-app-project/*.py; do + echo "Reviewing $file..." + copilot --allow-all -p "Quick code quality review of @$file - critical issues only" +done diff --git a/current-session.html b/current-session.html new file mode 100644 index 00000000..4bcf8308 --- /dev/null +++ b/current-session.html @@ -0,0 +1,1105 @@ + + + + + +Find all TODO comments in @samples/book-app-project/**/*.py + + + + +
+ + +
+ + +
+
+ + + \ No newline at end of file diff --git a/current-session.md b/current-session.md new file mode 100644 index 00000000..3e6d4185 --- /dev/null +++ b/current-session.md @@ -0,0 +1,292 @@ +# 🤖 Copilot CLI Session + +> [!NOTE] +> - **Session ID:** `18742761-232c-41e2-94a7-712d293f50d3` +> - **Started:** 5/12/2026, 9:44:38 AM +> - **Duration:** 415m 17s +> - **Exported:** 5/12/2026, 4:39:56 PM + +--- + +⏱️ 251m 24s + +### ℹ️ Info + +Undid to previous snapshot. 3 file(s) restored. + +--- + +⏱️ 251m 39s + +### ℹ️ Info + +Nothing to rewind yet. Start a conversation first. + +--- + +⏱️ 255m 2s + +### ℹ️ Info + +Nothing to rewind yet. Start a conversation first. + +--- + +⏱️ 264m 0s + +### ℹ️ Info + +Context Usage + +○ ○ ○ ○ ○ ○ ○ · · · gpt-5.4 · 22k/304k tokens (7%) +· · · · · · · · · · +· · · · · · · · · · ○ System/Tools: 21.7k (7%) +· · · · · · · · · · ◉ Messages: 0 (0%) +· · · · · · · · · · · Free Space: 236.7k (78%) +· · · · · · · · · · ◎ Buffer: 45.6k (15%) +· · · · · · · · · · +· · · · · · · · · · +· · · · · ◎ ◎ ◎ ◎ ◎ +◎ ◎ ◎ ◎ ◎ ◎ ◎ ◎ ◎ ◎ + +--- + +⏱️ 292m 20s + +### 👤 User + +Find all TODO comments in @samples/book-app-project/**/*.py + +--- + +⏱️ 292m 25s + +### 💬 Copilot + +Searching the Python sample for `TODO` comments now. + +--- + +⏱️ 292m 27s + +### ✅ `rg` + +**TODO** + +"TODO" in **/*.py (samples/book-app-project) + +``` +No matches found. +``` + + +--- + +⏱️ 292m 28s + +### 💬 Copilot + +**No `TODO` comments found** in `samples/book-app-project/**/*.py`. + +--- + +⏱️ 412m 50s + +### 👤 User + +Find all fucntions in @samples/book-app-project/**/*.py + +--- + +⏱️ 413m 6s + +### 💬 Copilot + +Scanning the Python sample for function definitions now. + +--- + +⏱️ 413m 16s + +### ✅ `rg` + +**^def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(** + +"^def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(" in **/*.py (samples/book-app-project) + +
+20 matches + +``` +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_books.py:13:def use_temp_data_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_books.py:20:def test_add_book() -> None: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_books.py:31:def test_mark_book_as_read() -> None: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_books.py:39:def test_mark_book_as_read_invalid() -> None: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_books.py:44:def test_remove_book() -> None: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_books.py:52:def test_remove_book_invalid() -> None: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_books.py:59:def collection_with_books() -> BookCollection: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/book_app.py:6:def show_books(books: list[Book]) -> None: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/book_app.py:21:def handle_list(collection: BookCollection) -> int: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/book_app.py:27:def handle_add(collection: BookCollection) -> int: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/book_app.py:44:def handle_remove(collection: BookCollection) -> int: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/book_app.py:60:def handle_mark_read(collection: BookCollection) -> int: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/book_app.py:76:def handle_find(collection: BookCollection) -> int: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/book_app.py:90:def show_help() -> None: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/book_app.py:115:def main(argv: list[str] | None = None) -> int: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_book_app.py:14:def use_temp_data_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/utils.py:4:def print_menu() -> None: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/utils.py:13:def get_user_choice() -> str: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/utils.py:32:def get_book_details() -> tuple[str, str, int]: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/utils.py:65:def print_books(books: list[Book]) -> None: +``` + +
+ + +--- + +⏱️ 413m 28s + +### 💬 Copilot + +I found top-level definitions; checking for any class methods too. + +--- + +⏱️ 413m 31s + +### ✅ `rg` + +**^[[:space:]]*def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(** + +"^[[:space:]]*def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(" in **/*.py (samples/book-app-project) + +
+47 matches + +``` +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_utils.py:14: def test_retries_after_empty_input( +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_utils.py:28: def test_retries_after_non_numeric_input( +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_utils.py:42: def test_retries_after_out_of_range_number( +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_utils.py:60: def test_retries_after_empty_title( +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_utils.py:74: def test_defaults_invalid_year_to_zero( +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/utils.py:4:def print_menu() -> None: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/utils.py:13:def get_user_choice() -> str: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/utils.py:32:def get_book_details() -> tuple[str, str, int]: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/utils.py:65:def print_books(books: list[Book]) -> None: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_book_app.py:14:def use_temp_data_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_book_app.py:33: def test_invalid_input( +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_book_app.py:53: def test_missing_title( +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_book_app.py:67: def test_book_not_found( +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_book_app.py:85: def test_missing_title( +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_book_app.py:99: def test_book_not_found( +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_book_app.py:113: def test_success( +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_book_app.py:133: def test_missing_author( +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_book_app.py:151: def test_unknown_command(self, capsys: pytest.CaptureFixture[str]) -> None: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_book_app.py:158: def test_collection_init_failure( +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_book_app.py:163: def raise_os_error() -> None: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/book_app.py:6:def show_books(books: list[Book]) -> None: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/book_app.py:21:def handle_list(collection: BookCollection) -> int: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/book_app.py:27:def handle_add(collection: BookCollection) -> int: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/book_app.py:44:def handle_remove(collection: BookCollection) -> int: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/book_app.py:60:def handle_mark_read(collection: BookCollection) -> int: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/book_app.py:76:def handle_find(collection: BookCollection) -> int: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/book_app.py:90:def show_help() -> None: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/book_app.py:115:def main(argv: list[str] | None = None) -> int: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_books.py:13:def use_temp_data_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_books.py:20:def test_add_book() -> None: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_books.py:31:def test_mark_book_as_read() -> None: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_books.py:39:def test_mark_book_as_read_invalid() -> None: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_books.py:44:def test_remove_book() -> None: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_books.py:52:def test_remove_book_invalid() -> None: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_books.py:59:def collection_with_books() -> BookCollection: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_books.py:78: def test_returns_books_in_inclusive_range( +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_books.py:97: def test_returns_empty_list_when_no_books_match( +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/books.py:17: def __init__(self) -> None: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/books.py:21: def load_books(self) -> None: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/books.py:33: def save_books(self) -> None: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/books.py:38: def add_book(self, title: str, author: str, year: int) -> Book: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/books.py:54: def list_books(self) -> List[Book]: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/books.py:57: def list_by_year(self, start: int, end: int) -> List[Book]: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/books.py:61: def find_book_by_title(self, title: str) -> Optional[Book]: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/books.py:67: def mark_as_read(self, title: str) -> bool: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/books.py:75: def remove_book(self, title: str) -> bool: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/books.py:84: def find_by_author(self, author: str) -> List[Book]: +``` + +
+ + +--- + +⏱️ 413m 31s + +### ✅ `rg` + +**^class\s+** + +"^class\s+" in **/*.py (samples/book-app-project) + +
+10 matches + +``` +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_utils.py:11:class TestGetUserChoice: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_utils.py:57:class TestGetBookDetails: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_book_app.py:21:class TestHandleAdd: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_book_app.py:50:class TestHandleRemove: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_book_app.py:82:class TestHandleMarkRead: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_book_app.py:130:class TestHandleFind: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_book_app.py:148:class TestMain: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/tests/test_books.py:67:class TestListByYear: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/books.py:9:class Book: +/home/jack/Projects/copilot-cli-for-beginners/samples/book-app-project/books.py:16:class BookCollection: +``` + +
+ + +--- + +⏱️ 413m 39s + +### 💬 Copilot + +**Function definitions found** in `samples/book-app-project/**/*.py`: + +| File | Functions | +|---|---| +| `samples/book-app-project/book_app.py` | `show_books`, `handle_list`, `handle_add`, `handle_remove`, `handle_mark_read`, `handle_find`, `show_help`, `main` | +| `samples/book-app-project/utils.py` | `print_menu`, `get_user_choice`, `get_book_details`, `print_books` | +| `samples/book-app-project/books.py` | `__init__`, `load_books`, `save_books`, `add_book`, `list_books`, `list_by_year`, `find_book_by_title`, `mark_as_read`, `remove_book`, `find_by_author` | +| `samples/book-app-project/tests/test_books.py` | `use_temp_data_file`, `test_add_book`, `test_mark_book_as_read`, `test_mark_book_as_read_invalid`, `test_remove_book`, `test_remove_book_invalid`, `collection_with_books`, `test_returns_books_in_inclusive_range`, `test_returns_empty_list_when_no_books_match` | +| `samples/book-app-project/tests/test_book_app.py` | `use_temp_data_file`, `test_invalid_input`, `test_missing_title`, `test_book_not_found`, `test_missing_title`, `test_book_not_found`, `test_success`, `test_missing_author`, `test_unknown_command`, `test_collection_init_failure`, `raise_os_error` | +| `samples/book-app-project/tests/test_utils.py` | `test_retries_after_empty_input`, `test_retries_after_non_numeric_input`, `test_retries_after_out_of_range_number`, `test_retries_after_empty_title`, `test_defaults_invalid_year_to_zero` | + +**Note:** this includes top-level functions, class methods, and test methods. + +--- + +⏱️ 414m 52s + +### ℹ️ Info + +Activity · last 180 days · 61 messages + + Nov Dec Jan Feb Mar Apr May  + ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■  +M ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■  + ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■  +W ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■  + ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■  +F ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■  + ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■  + Less ■ ■ ■ ■ ■  More + +Changes +0 -0 +Requests 2 Premium (6h 54m 52s) +Tokens ↑ 106.4k • ↓ 1.1k • 101.8k (cached) • 313 (reasoning) + +--- + +Generated by [GitHub Copilot CLI](https://github.com/features/copilot/cli) diff --git a/samples/book-app-project-cs/BookApp.csproj.lscache b/samples/book-app-project-cs/BookApp.csproj.lscache new file mode 100644 index 00000000..88d4a0c6 --- /dev/null +++ b/samples/book-app-project-cs/BookApp.csproj.lscache @@ -0,0 +1,71 @@ +version=1 + +# This file caches language service data to improve the performance of C# Dev Kit. +# It is not intended for manual editing. It can safely be deleted and will be +# regenerated automatically. For more information, see https://aka.ms/lscache +# +# To control where cache files are stored, use the following VS Code setting: +# "dotnet.projectsystem.cacheInProjectFolder": true + +[project] +language=C# +primary +lastDtbSucceeded + +[properties] +AssemblyName=BookApp +CommandLineArgsForDesignTimeEvaluation=-langversion:13.0 -define:TRACE +CompilerGeneratedFilesOutputPath= +MaxSupportedLangVersion=13.0 +ProjectAssetsFile=obj/project.assets.json +RootNamespace=BookApp +RunAnalyzers= +RunAnalyzersDuringLiveAnalysis= +SolutionPath=../../copilot-cli-for-beginners.sln +TargetFrameworkIdentifier=.NETCoreApp +TargetPath=bin/Debug/net10.0/BookApp.dll +TargetRefPath=obj/Debug/net10.0/ref/BookApp.dll +TemporaryDependencyNodeTargetIdentifier=net10.0 + +[commandLineArguments] +/noconfig +/unsafe- +/checked- +/nowarn:1701,1702,1701,1702 +/fullpaths +/nostdlib+ +/errorreport:prompt +/warn:1 +/define:TRACE;DEBUG;NET;NET10_0;NETCOREAPP;NET5_0_OR_GREATER;NET6_0_OR_GREATER;NET7_0_OR_GREATER;NET8_0_OR_GREATER;NET9_0_OR_GREATER;NETCOREAPP1_0_OR_GREATER;NETCOREAPP1_1_OR_GREATER;NETCOREAPP2_0_OR_GREATER;NETCOREAPP2_1_OR_GREATER;NETCOREAPP2_2_OR_GREATER;NETCOREAPP3_0_OR_GREATER;NETCOREAPP3_1_OR_GREATER +/highentropyva+ +/nullable:enable +/debug+ +/debug:portable +/filealign:512 +/optimize- +/out:obj/Debug/net10.0/BookApp.dll +/refout:obj/Debug/net10.0/refint/BookApp.dll +/target:exe +/warnaserror- +/utf8output +/deterministic+ +/langversion:13.0 +/warnaserror+:NU1605,SYSLIB0011 + +[sourceFiles] +Models/Book.cs +obj/Debug/net10.0/ + .NETCoreApp,Version=v10.0.AssemblyAttributes.cs + BookApp.AssemblyInfo.cs + BookApp.GlobalUsings.g.cs +Program.cs +Services/BookCollection.cs + +[analyzerReferences] +../../../../../../usr/lib/dotnet/sdk/9.0.203/Sdks/Microsoft.NET.Sdk/analyzers/ + Microsoft.CodeAnalysis.CSharp.NetAnalyzers.dll + Microsoft.CodeAnalysis.NetAnalyzers.dll + +[analyzerConfigFiles] +../../../../../../usr/lib/dotnet/sdk/9.0.203/Sdks/Microsoft.NET.Sdk/codestyle/cs/build/config/analysislevelstyle_default.globalconfig +obj/Debug/net10.0/BookApp.GeneratedMSBuildEditorConfig.editorconfig diff --git a/samples/book-app-project-cs/Tests/BookApp.Tests.csproj.lscache b/samples/book-app-project-cs/Tests/BookApp.Tests.csproj.lscache new file mode 100644 index 00000000..95270779 --- /dev/null +++ b/samples/book-app-project-cs/Tests/BookApp.Tests.csproj.lscache @@ -0,0 +1,72 @@ +version=1 + +# This file caches language service data to improve the performance of C# Dev Kit. +# It is not intended for manual editing. It can safely be deleted and will be +# regenerated automatically. For more information, see https://aka.ms/lscache +# +# To control where cache files are stored, use the following VS Code setting: +# "dotnet.projectsystem.cacheInProjectFolder": true + +[project] +language=C# +primary +lastDtbSucceeded + +[properties] +AssemblyName=BookApp.Tests +CommandLineArgsForDesignTimeEvaluation=-langversion:13.0 -define:TRACE +CompilerGeneratedFilesOutputPath= +MaxSupportedLangVersion=13.0 +ProjectAssetsFile=obj/project.assets.json +RootNamespace=BookApp.Tests +RunAnalyzers= +RunAnalyzersDuringLiveAnalysis= +SolutionPath=../../../copilot-cli-for-beginners.sln +TargetFrameworkIdentifier=.NETCoreApp +TargetPath=bin/Debug/net10.0/BookApp.Tests.dll +TargetRefPath=obj/Debug/net10.0/ref/BookApp.Tests.dll +TemporaryDependencyNodeTargetIdentifier=net10.0 + +[commandLineArguments] +/noconfig +/unsafe- +/checked- +/nowarn:1701,1702,1701,1702 +/fullpaths +/nostdlib+ +/errorreport:prompt +/warn:1 +/define:TRACE;DEBUG;NET;NET10_0;NETCOREAPP;NET5_0_OR_GREATER;NET6_0_OR_GREATER;NET7_0_OR_GREATER;NET8_0_OR_GREATER;NET9_0_OR_GREATER;NETCOREAPP1_0_OR_GREATER;NETCOREAPP1_1_OR_GREATER;NETCOREAPP2_0_OR_GREATER;NETCOREAPP2_1_OR_GREATER;NETCOREAPP2_2_OR_GREATER;NETCOREAPP3_0_OR_GREATER;NETCOREAPP3_1_OR_GREATER +/highentropyva+ +/nullable:enable +/debug+ +/debug:portable +/filealign:512 +/optimize- +/out:obj/Debug/net10.0/BookApp.Tests.dll +/refout:obj/Debug/net10.0/refint/BookApp.Tests.dll +/target:library +/warnaserror- +/utf8output +/deterministic+ +/langversion:13.0 +/warnaserror+:NU1605,SYSLIB0011 + +[sourceFiles] +BookCollectionTests.cs +obj/Debug/net10.0/ + .NETCoreApp,Version=v10.0.AssemblyAttributes.cs + BookApp.Tests.AssemblyInfo.cs + BookApp.Tests.GlobalUsings.g.cs + +[metadataReferences] +../obj/Debug/net10.0/ref/BookApp.dll + +[analyzerReferences] +../../../../../../../usr/lib/dotnet/sdk/9.0.203/Sdks/Microsoft.NET.Sdk/analyzers/ + Microsoft.CodeAnalysis.CSharp.NetAnalyzers.dll + Microsoft.CodeAnalysis.NetAnalyzers.dll + +[analyzerConfigFiles] +../../../../../../../usr/lib/dotnet/sdk/9.0.203/Sdks/Microsoft.NET.Sdk/codestyle/cs/build/config/analysislevelstyle_default.globalconfig +obj/Debug/net10.0/BookApp.Tests.GeneratedMSBuildEditorConfig.editorconfig diff --git a/samples/book-app-project/README.md b/samples/book-app-project/README.md index d3dd580a..aebfa2b1 100644 --- a/samples/book-app-project/README.md +++ b/samples/book-app-project/README.md @@ -30,6 +30,7 @@ It can add, remove, and list books. Also mark them as read. ```bash python book_app.py list python book_app.py add +python book_app.py mark-read python book_app.py find python book_app.py remove python book_app.py help diff --git a/samples/book-app-project/book_app.py b/samples/book-app-project/book_app.py index f0100c2d..8fd778b9 100644 --- a/samples/book-app-project/book_app.py +++ b/samples/book-app-project/book_app.py @@ -1,98 +1,133 @@ import sys -from books import BookCollection - - -# Global collection instance -collection = BookCollection() - - -def show_books(books): - """Display books in a user-friendly format.""" - if not books: - print("No books found.") - return - - print("\nYour Book Collection:\n") - - for index, book in enumerate(books, start=1): - status = "✓" if book.read else " " - print(f"{index}. [{status}] {book.title} by {book.author} ({book.year})") +from collections.abc import Callable - print() +from books import BookCollection +from utils import display_books, display_help, parse_publication_year -def handle_list(): +def handle_list(collection: BookCollection) -> int: books = collection.list_books() - show_books(books) + display_books(books) + return 0 -def handle_add(): +def handle_add(collection: BookCollection) -> int: print("\nAdd a New Book\n") title = input("Title: ").strip() author = input("Author: ").strip() year_str = input("Year: ").strip() + year, year_error = parse_publication_year(year_str) + if year_error is not None or year is None: + print(f"\nError: {year_error}\n") + return 1 + try: - year = int(year_str) if year_str else 0 collection.add_book(title, author, year) print("\nBook added successfully.\n") + return 0 except ValueError as e: print(f"\nError: {e}\n") + return 1 -def handle_remove(): +def handle_remove(collection: BookCollection) -> int: print("\nRemove a Book\n") title = input("Enter the title of the book to remove: ").strip() - collection.remove_book(title) + try: + result = collection.remove_book(title) + except ValueError as e: + print(f"\nError: {e}\n") + return 1 + + if result.success: + print(f"\n{result.message}\n") + return 0 + + print(f"\nError: {result.message}\n") + return 1 - print("\nBook removed if it existed.\n") +def handle_mark_read(collection: BookCollection) -> int: + print("\nMark a Book as Read\n") -def handle_find(): + title = input("Enter the title of the book to mark as read: ").strip() + if not title: + print("\nError: Title cannot be empty.\n") + return 1 + + if collection.mark_as_read(title): + print("\nBook marked as read.\n") + return 0 + + print("\nError: Book not found.\n") + return 1 + + +def handle_find(collection: BookCollection) -> int: print("\nFind Books by Author\n") author = input("Author name: ").strip() + if not author: + print("\nError: Author cannot be empty.\n") + return 1 + books = collection.find_by_author(author) - show_books(books) + display_books(books) + return 0 + + +CollectionCommandHandler = Callable[[BookCollection], int] +CommandHandler = Callable[[], int] -def show_help(): - print(""" -Book Collection Helper +def handle_help() -> int: + display_help() + return 0 -Commands: - list - Show all books - add - Add a new book - remove - Remove a book by title - find - Find books by author - help - Show this help message -""") +def create_collection_command(handler: CollectionCommandHandler) -> CommandHandler: + def command() -> int: + try: + collection = BookCollection() + except (OSError, ValueError) as e: + print(f"Error: {e}") + return 1 -def main(): - if len(sys.argv) < 2: - show_help() - return + return handler(collection) - command = sys.argv[1].lower() + return command - if command == "list": - handle_list() - elif command == "add": - handle_add() - elif command == "remove": - handle_remove() - elif command == "find": - handle_find() - elif command == "help": - show_help() - else: + +COMMAND_HANDLERS: dict[str, CommandHandler] = { + "list": create_collection_command(handle_list), + "add": create_collection_command(handle_add), + "mark-read": create_collection_command(handle_mark_read), + "remove": create_collection_command(handle_remove), + "find": create_collection_command(handle_find), + "help": handle_help, +} + + +def main(argv: list[str] | None = None) -> int: + args = argv if argv is not None else sys.argv[1:] + + if not args: + return handle_help() + + command = args[0].lower() + + handler = COMMAND_HANDLERS.get(command) + if handler is None: print("Unknown command.\n") - show_help() + display_help() + return 1 + + return handler() if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/samples/book-app-project/books.py b/samples/book-app-project/books.py index 2110689f..f64499a6 100644 --- a/samples/book-app-project/books.py +++ b/samples/book-app-project/books.py @@ -1,56 +1,368 @@ +from contextlib import contextmanager +from datetime import date import json +import os +from pathlib import Path +import tempfile from dataclasses import dataclass, asdict -from typing import List, Optional +from typing import IO, Iterator, List, Optional DATA_FILE = "data.json" +def _normalize_required_text(value: str, field_name: str) -> str: + """Trim and validate a required text value. + + Args: + value (str): The user-provided text to normalize. + field_name (str): The field name used in validation error messages. + + Returns: + str: The trimmed text value. + + Raises: + ValueError: If the trimmed value is empty. + + Examples: + >>> _normalize_required_text(" Dune ", "Title") + 'Dune' + >>> _normalize_required_text(" ", "Author") + Traceback (most recent call last): + ... + ValueError: Author cannot be empty. + """ + normalized_value = value.strip() + if not normalized_value: + raise ValueError(f"{field_name} cannot be empty.") + return normalized_value + + +def _validate_publication_year(year: int) -> int: + """Validate that a publication year is realistic for the sample app. + + Args: + year (int): The publication year to validate. + + Returns: + int: The validated year when it is within the accepted range. + + Raises: + ValueError: If ``year`` is negative or later than the current year. + + Examples: + >>> _validate_publication_year(1965) + 1965 + >>> _validate_publication_year(-1) + Traceback (most recent call last): + ... + ValueError: Year cannot be negative. + """ + if year < 0: + raise ValueError("Year cannot be negative.") + + current_year = date.today().year + if year > current_year: + raise ValueError(f"Year cannot be in the future. Please enter a year up to {current_year}.") + + return year + + @dataclass class Book: + """Represent a single book in the collection. + + Attributes: + title (str): The book title. + author (str): The author name. + year (int): The publication year. + read (bool): Whether the book has been marked as read. + + Examples: + >>> Book(title="Dune", author="Frank Herbert", year=1965) + Book(title='Dune', author='Frank Herbert', year=1965, read=False) + """ title: str author: str year: int read: bool = False +@dataclass(frozen=True) +class BookOperationResult: + """Describe the outcome of an operation on a book. + + Attributes: + success (bool): Whether the operation completed successfully. + message (str): A user-friendly summary of the result. + + Examples: + >>> BookOperationResult(success=True, message="Removed the book.") + BookOperationResult(success=True, message='Removed the book.') + """ + + success: bool + message: str + + class BookCollection: - def __init__(self): + """Manage a collection of books stored in a JSON file.""" + + def __init__(self) -> None: + """Create a collection and load any saved books from disk. + + Returns: + None: This initializer sets up the in-memory collection. + + Examples: + >>> collection = BookCollection() + >>> isinstance(collection.books, list) + True + """ self.books: List[Book] = [] self.load_books() - def load_books(self): - """Load books from the JSON file if it exists.""" + @contextmanager + def _open_data_file(self, mode: str) -> Iterator[IO[str]]: + """Open the JSON data file using a shared context manager. + + Args: + mode (str): The file mode to pass to ``open()``, such as ``"r"`` or + ``"w"``. + + Yields: + IO[str]: An open text file handle for the collection data file. + + Examples: + >>> collection = BookCollection() + >>> with collection._open_data_file("r") as data_file: + ... hasattr(data_file, "read") + True + """ + with open(DATA_FILE, mode) as data_file: + yield data_file + + def _quarantine_corrupted_file(self) -> Path: + """Rename an unreadable data file to a safe backup path. + + Returns: + Path: The new path of the quarantined file. + + Raises: + OSError: If the file cannot be renamed. + + Examples: + >>> collection = BookCollection() + >>> backup_path = Path("data.corrupted.json") + >>> isinstance(backup_path, Path) + True + """ + data_path = Path(DATA_FILE) + backup_path = data_path.with_name(f"{data_path.stem}.corrupted{data_path.suffix}") + counter = 1 + + while backup_path.exists(): + backup_path = data_path.with_name( + f"{data_path.stem}.corrupted-{counter}{data_path.suffix}" + ) + counter += 1 + + os.replace(data_path, backup_path) + return backup_path + + def _load_book_data(self) -> List[Book]: + """Validate and convert raw JSON entries into ``Book`` objects. + + Returns: + List[Book]: The validated books loaded from the JSON file. + + Raises: + ValueError: If the JSON structure or any book entry is invalid. + + Examples: + >>> collection = BookCollection() + >>> isinstance(collection._load_book_data(), list) + True + """ + with self._open_data_file("r") as data_file: + data = json.load(data_file) + + if not isinstance(data, list): + raise ValueError("Expected a list of books.") + + loaded_books: List[Book] = [] + for index, book_data in enumerate(data, start=1): + if not isinstance(book_data, dict): + raise ValueError(f"Book entry #{index} must be an object.") + + try: + loaded_books.append(Book(**book_data)) + except TypeError as exc: + raise ValueError(f"Book entry #{index} has invalid fields.") from exc + + return loaded_books + + def load_books(self) -> None: + """Load books from the JSON data file into the collection. + + If the file does not exist or contains invalid JSON, the collection + starts empty. + + Returns: + None: The loaded books are assigned to ``self.books``. + + Examples: + >>> collection = BookCollection() + >>> collection.load_books() + """ try: - with open(DATA_FILE, "r") as f: - data = json.load(f) - self.books = [Book(**b) for b in data] + self.books = self._load_book_data() except FileNotFoundError: self.books = [] - except json.JSONDecodeError: - print("Warning: data.json is corrupted. Starting with empty collection.") + except (json.JSONDecodeError, ValueError) as exc: self.books = [] + backup_path = self._quarantine_corrupted_file() + print( + "Warning: data.json is corrupted or invalid. " + f"The unreadable file was moved to {backup_path.name}. " + "Starting with empty collection." + ) + + def save_books(self) -> None: + """Write the current book collection to the JSON data file. + + Returns: + None: The method persists the current collection to disk. - def save_books(self): - """Save the current book collection to JSON.""" - with open(DATA_FILE, "w") as f: - json.dump([asdict(b) for b in self.books], f, indent=2) + Raises: + OSError: If the data file cannot be written. + TypeError: If a book entry cannot be serialized to JSON. + + Examples: + >>> collection = BookCollection() + >>> collection.save_books() + """ + data_path = Path(DATA_FILE) + data_path.parent.mkdir(parents=True, exist_ok=True) + + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=data_path.parent, + delete=False, + ) as temp_file: + json.dump([asdict(b) for b in self.books], temp_file, indent=2) + temp_file.write("\n") + temp_file_path = Path(temp_file.name) + + os.replace(temp_file_path, data_path) def add_book(self, title: str, author: str, year: int) -> Book: - book = Book(title=title, author=author, year=year) + """Add a new book to the collection and save the updated data. + + Args: + title (str): The title of the book to add. + author (str): The author of the book to add. + year (int): The publication year of the book. + + Returns: + Book: The newly created ``Book`` instance. + + Raises: + ValueError: If ``title`` or ``author`` is empty after trimming, or + if ``year`` is negative. + OSError: If the updated collection cannot be written to disk. + TypeError: If the updated collection cannot be serialized to JSON. + + Examples: + >>> collection = BookCollection() + >>> book = collection.add_book("Dune", "Frank Herbert", 1965) + >>> book.title + 'Dune' + """ + normalized_title = _normalize_required_text(title, "Title") + normalized_author = _normalize_required_text(author, "Author") + + validated_year = _validate_publication_year(year) + + book = Book(title=normalized_title, author=normalized_author, year=validated_year) self.books.append(book) self.save_books() return book def list_books(self) -> List[Book]: + """Return all books currently stored in the collection. + + Returns: + List[Book]: The in-memory list of books. + + Examples: + >>> collection = BookCollection() + >>> books = collection.list_books() + >>> isinstance(books, list) + True + """ return self.books + def list_by_year(self, start: int, end: int) -> List[Book]: + """Return books published within an inclusive year range. + + Args: + start (int): The earliest publication year to include. + end (int): The latest publication year to include. + + Returns: + List[Book]: Books whose publication year falls between ``start`` + and ``end``, inclusive. + + Examples: + >>> collection = BookCollection() + >>> collection.list_by_year(2000, 2020) + [] + """ + return [book for book in self.books if start <= book.year <= end] + def find_book_by_title(self, title: str) -> Optional[Book]: + """Find the first book whose title matches case-insensitively. + + Args: + title (str): The title to search for. + + Returns: + Optional[Book]: The matching book if found; otherwise, ``None``. + + Examples: + >>> collection = BookCollection() + >>> collection.find_book_by_title("Dune") is None + True + """ + normalized_title = title.strip() + if not normalized_title: + return None + + normalized_query = normalized_title.casefold() for book in self.books: - if book.title.lower() == title.lower(): + if book.title.casefold() == normalized_query: return book return None def mark_as_read(self, title: str) -> bool: + """Mark a book as read when a matching title is found. + + Args: + title (str): The title of the book to mark as read. + + Returns: + bool: ``True`` if a matching book was updated; otherwise, ``False``. + + Raises: + OSError: If the updated collection cannot be written to disk. + TypeError: If the updated collection cannot be serialized to JSON. + + Examples: + >>> collection = BookCollection() + >>> collection.mark_as_read("Dune") + False + """ book = self.find_book_by_title(title) if book: book.read = True @@ -58,15 +370,67 @@ def mark_as_read(self, title: str) -> bool: return True return False - def remove_book(self, title: str) -> bool: - """Remove a book by title.""" - book = self.find_book_by_title(title) + def remove_book(self, title: str) -> BookOperationResult: + """Remove the first book whose title matches case-insensitively. + + Args: + title (str): The title of the book to remove. + + Returns: + BookOperationResult: Describes whether a book was removed and why. + + Raises: + ValueError: If ``title`` is empty after trimming. + OSError: If the updated collection cannot be written to disk. + TypeError: If the updated collection cannot be serialized to JSON. + + Examples: + >>> collection = BookCollection() + >>> collection.remove_book("Dune").success + False + """ + normalized_title = _normalize_required_text(title, "Title") + book = self.find_book_by_title(normalized_title) if book: self.books.remove(book) self.save_books() - return True - return False + return BookOperationResult( + success=True, + message=f'Removed "{book.title}" from the collection.', + ) + + partial_matches = [ + candidate.title + for candidate in self.books + if normalized_title.casefold() in candidate.title.casefold() + ] + if partial_matches: + suggestions = ", ".join(f'"{title}"' for title in partial_matches) + return BookOperationResult( + success=False, + message=( + f'No exact match found for "{normalized_title}". ' + f"Try one of these full titles: {suggestions}." + ), + ) + + return BookOperationResult( + success=False, + message=f'Book "{normalized_title}" was not found in the collection.', + ) def find_by_author(self, author: str) -> List[Book]: - """Find all books by a given author.""" + """Find all books written by a given author. + + Args: + author (str): The author name to match case-insensitively. + + Returns: + List[Book]: All books whose author matches the supplied name. + + Examples: + >>> collection = BookCollection() + >>> collection.find_by_author("Frank Herbert") + [] + """ return [b for b in self.books if b.author.lower() == author.lower()] diff --git a/samples/book-app-project/data.json b/samples/book-app-project/data.json index a9f23f68..940c9474 100644 --- a/samples/book-app-project/data.json +++ b/samples/book-app-project/data.json @@ -28,5 +28,11 @@ "author": "", "year": 0, "read": false + }, + { + "title": "Test", + "author": "Someone", + "year": 0, + "read": false } ] diff --git a/samples/book-app-project/nested/books.json b/samples/book-app-project/nested/books.json new file mode 100644 index 00000000..562202bb --- /dev/null +++ b/samples/book-app-project/nested/books.json @@ -0,0 +1,8 @@ +[ + { + "title": "Dune", + "author": "Frank Herbert", + "year": 1965, + "read": false + } +] diff --git a/samples/book-app-project/tests/test_book_app.py b/samples/book-app-project/tests/test_book_app.py new file mode 100644 index 00000000..e85e7c0a --- /dev/null +++ b/samples/book-app-project/tests/test_book_app.py @@ -0,0 +1,397 @@ +import os +import sys +from datetime import date +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import pytest + +import book_app +import books + + +@pytest.fixture(autouse=True) +def use_temp_data_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Use a temporary data file for each test.""" + temp_file = tmp_path / "data.json" + temp_file.write_text("[]") + monkeypatch.setattr(books, "DATA_FILE", str(temp_file)) + + +@pytest.fixture +def set_input(monkeypatch: pytest.MonkeyPatch): + """Provide a helper for mocking sequential input responses.""" + + def _set_input(responses: list[str]) -> None: + remaining_responses = responses.copy() + monkeypatch.setattr("builtins.input", lambda _: remaining_responses.pop(0)) + + return _set_input + + +class TestHandleAdd: + """Tests for handle_add.""" + + @pytest.mark.parametrize( + ("responses", "expected_book"), + [ + ([" Dune ", " Frank Herbert ", "1965"], ("Dune", "Frank Herbert", 1965)), + (["Neuromancer", "William Gibson", "0"], ("Neuromancer", "William Gibson", 0)), + ( + ["Snow Crash", "Neal Stephenson", str(date.today().year)], + ("Snow Crash", "Neal Stephenson", date.today().year), + ), + ], + ) + def test_normalizes_text_and_allows_valid_years( + self, + responses: list[str], + expected_book: tuple[str, str, int], + set_input, + capsys: pytest.CaptureFixture[str], + ) -> None: + collection = books.BookCollection() + set_input(responses) + + result = book_app.handle_add(collection) + + captured = capsys.readouterr() + saved_book = collection.list_books()[0] + assert result == 0 + assert "Book added successfully." in captured.out + assert (saved_book.title, saved_book.author, saved_book.year) == expected_book + + @pytest.mark.parametrize( + ("responses", "expected_message"), + [ + (["", "Frank Herbert", "1965"], "Error: Title cannot be empty."), + (["Dune", "", "1965"], "Error: Author cannot be empty."), + (["Dune", "Frank Herbert", ""], "Error: Year cannot be empty. Please enter a publication year."), + (["Dune", "Frank Herbert", "invalid"], "Error: Year must be a whole number."), + (["Dune", "Frank Herbert", "-1"], "Error: Year cannot be negative."), + ( + ["Dune", "Frank Herbert", str(date.today().year + 1)], + f"Error: Year cannot be in the future. Please enter a year up to {date.today().year}.", + ), + ], + ) + def test_invalid_input( + self, + responses: list[str], + expected_message: str, + set_input, + capsys: pytest.CaptureFixture[str], + ) -> None: + collection = books.BookCollection() + set_input(responses) + + result = book_app.handle_add(collection) + + captured = capsys.readouterr() + assert result == 1 + assert expected_message in captured.out + + +class TestHandleList: + """Tests for handle_list.""" + + def test_shows_empty_state( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + collection = books.BookCollection() + + result = book_app.handle_list(collection) + + captured = capsys.readouterr() + assert result == 0 + assert "No books found." in captured.out + + def test_displays_books_with_shared_format( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + collection = books.BookCollection() + collection.add_book("Dune", "Frank Herbert", 1965) + collection.add_book("1984", "George Orwell", 1949) + collection.mark_as_read("1984") + + result = book_app.handle_list(collection) + + captured = capsys.readouterr() + assert result == 0 + assert "Your Book Collection:" in captured.out + assert "1. [ ] Dune by Frank Herbert (1965)" in captured.out + assert "2. [✓] 1984 by George Orwell (1949)" in captured.out + + +class TestHandleRemove: + """Tests for handle_remove.""" + + @pytest.mark.parametrize("title", ["", " "]) + def test_missing_title( + self, + title: str, + set_input, + capsys: pytest.CaptureFixture[str], + ) -> None: + collection = books.BookCollection() + set_input([title]) + + result = book_app.handle_remove(collection) + + captured = capsys.readouterr() + assert result == 1 + assert "Error: Title cannot be empty." in captured.out + + def test_book_not_found( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + collection = books.BookCollection() + monkeypatch.setattr("builtins.input", lambda _: "Missing Book") + + result = book_app.handle_remove(collection) + + captured = capsys.readouterr() + assert result == 1 + assert 'Error: Book "Missing Book" was not found in the collection.' in captured.out + + def test_partial_title_shows_suggestion( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + collection = books.BookCollection() + collection.add_book("Dune Messiah", "Frank Herbert", 1969) + monkeypatch.setattr("builtins.input", lambda _: "Dune") + + result = book_app.handle_remove(collection) + + captured = capsys.readouterr() + assert result == 1 + assert ( + 'Error: No exact match found for "Dune". Try one of these full titles: ' + '"Dune Messiah".' + ) in captured.out + + +class TestHandleMarkRead: + """Tests for handle_mark_read.""" + + @pytest.mark.parametrize("title", ["", " "]) + def test_missing_title( + self, + title: str, + set_input, + capsys: pytest.CaptureFixture[str], + ) -> None: + collection = books.BookCollection() + set_input([title]) + + result = book_app.handle_mark_read(collection) + + captured = capsys.readouterr() + assert result == 1 + assert "Error: Title cannot be empty." in captured.out + + def test_book_not_found( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + collection = books.BookCollection() + monkeypatch.setattr("builtins.input", lambda _: "Missing Book") + + result = book_app.handle_mark_read(collection) + + captured = capsys.readouterr() + assert result == 1 + assert "Error: Book not found." in captured.out + + def test_success( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + collection = books.BookCollection() + collection.add_book("Dune", "Frank Herbert", 1965) + monkeypatch.setattr("builtins.input", lambda _: "Dune") + + result = book_app.handle_mark_read(collection) + + captured = capsys.readouterr() + assert result == 0 + assert "Book marked as read." in captured.out + assert collection.find_book_by_title("Dune").read is True + + +class TestHandleFind: + """Tests for handle_find.""" + + @pytest.mark.parametrize("author", ["", " "]) + def test_missing_author( + self, + author: str, + set_input, + capsys: pytest.CaptureFixture[str], + ) -> None: + collection = books.BookCollection() + set_input([author]) + + result = book_app.handle_find(collection) + + captured = capsys.readouterr() + assert result == 1 + assert "Error: Author cannot be empty." in captured.out + + def test_displays_matching_books_with_shared_format( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + collection = books.BookCollection() + collection.add_book("Dune", "Frank Herbert", 1965) + collection.add_book("Children of Dune", "Frank Herbert", 1976) + monkeypatch.setattr("builtins.input", lambda _: "Frank Herbert") + + result = book_app.handle_find(collection) + + captured = capsys.readouterr() + assert result == 0 + assert "Your Book Collection:" in captured.out + assert "1. [ ] Dune by Frank Herbert (1965)" in captured.out + assert "2. [ ] Children of Dune by Frank Herbert (1976)" in captured.out + + def test_allows_whitespace_around_author_name( + self, + set_input, + capsys: pytest.CaptureFixture[str], + ) -> None: + collection = books.BookCollection() + collection.add_book("Dune", "Frank Herbert", 1965) + set_input([" Frank Herbert "]) + + result = book_app.handle_find(collection) + + captured = capsys.readouterr() + assert result == 0 + assert "1. [ ] Dune by Frank Herbert (1965)" in captured.out + + +class TestCreateCollectionCommand: + """Tests for create_collection_command.""" + + def test_returns_handler_result(self) -> None: + expected_collection = books.BookCollection() + + def handler(collection: books.BookCollection) -> int: + assert collection is expected_collection + return 7 + + command = book_app.create_collection_command(handler) + + original_collection = book_app.BookCollection + book_app.BookCollection = lambda: expected_collection + try: + result = command() + finally: + book_app.BookCollection = original_collection + + assert result == 7 + + @pytest.mark.parametrize( + ("exception_type", "message"), + [ + (OSError, "cannot open data"), + (ValueError, "invalid book data"), + ], + ) + def test_handles_collection_initialization_errors( + self, + exception_type: type[Exception], + message: str, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + def raise_error() -> None: + raise exception_type(message) + + monkeypatch.setattr(book_app, "BookCollection", raise_error) + command = book_app.create_collection_command(lambda _: 0) + + result = command() + + captured = capsys.readouterr() + assert result == 1 + assert f"Error: {message}" in captured.out + + +class TestMain: + """Tests for main.""" + + def test_no_args_shows_help(self, capsys: pytest.CaptureFixture[str]) -> None: + result = book_app.main([]) + + captured = capsys.readouterr() + assert result == 0 + assert "Book Collection Helper" in captured.out + + def test_command_lookup_is_case_insensitive( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + calls: list[str] = [] + + def fake_help() -> int: + calls.append("help") + return 0 + + monkeypatch.setitem(book_app.COMMAND_HANDLERS, "help", fake_help) + + result = book_app.main(["HeLp"]) + + assert result == 0 + assert calls == ["help"] + + def test_help_command_does_not_initialize_collection( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + def raise_os_error() -> None: + raise OSError("should not be called") + + monkeypatch.setattr(book_app, "BookCollection", raise_os_error) + + result = book_app.main(["help"]) + + captured = capsys.readouterr() + assert result == 0 + assert "Book Collection Helper" in captured.out + assert "should not be called" not in captured.out + + def test_unknown_command(self, capsys: pytest.CaptureFixture[str]) -> None: + result = book_app.main(["unknown"]) + + captured = capsys.readouterr() + assert result == 1 + assert "Unknown command." in captured.out + + def test_collection_init_failure( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + def raise_os_error() -> None: + raise OSError("cannot read data file") + + monkeypatch.setattr(book_app, "BookCollection", raise_os_error) + + result = book_app.main(["list"]) + + captured = capsys.readouterr() + assert result == 1 + assert "Error: cannot read data file" in captured.out diff --git a/samples/book-app-project/tests/test_books.py b/samples/book-app-project/tests/test_books.py index 061149c5..f2de9502 100644 --- a/samples/book-app-project/tests/test_books.py +++ b/samples/book-app-project/tests/test_books.py @@ -1,53 +1,674 @@ -import sys +import json import os +import sys +import threading +from dataclasses import asdict +from datetime import date +from pathlib import Path + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import pytest + import books -from books import BookCollection +from books import Book, BookCollection -@pytest.fixture(autouse=True) -def use_temp_data_file(tmp_path, monkeypatch): - """Use a temporary data file for each test.""" +@pytest.fixture +def temp_data_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Provide a temporary data file path for each test.""" temp_file = tmp_path / "data.json" - temp_file.write_text("[]") monkeypatch.setattr(books, "DATA_FILE", str(temp_file)) + return temp_file + + +@pytest.fixture +def empty_data_file(temp_data_file: Path) -> Path: + """Create an empty JSON collection file.""" + temp_data_file.write_text("[]") + return temp_data_file + + +@pytest.fixture +def empty_collection(empty_data_file: Path) -> BookCollection: + """Provide a collection backed by an empty data file.""" + return BookCollection() + + +@pytest.fixture +def collection_with_books(empty_collection: BookCollection) -> BookCollection: + """Provide a collection populated with sample books.""" + empty_collection.add_book("The Hobbit", "J.R.R. Tolkien", 1937) + empty_collection.add_book("Dune", "Frank Herbert", 1965) + empty_collection.add_book("Neuromancer", "William Gibson", 1984) + return empty_collection + + +def make_collection_without_init() -> BookCollection: + """Create a collection instance without triggering load_books.""" + collection = object.__new__(BookCollection) + collection.books = [] + return collection + + +class TestNormalizeRequiredText: + """Tests for _normalize_required_text.""" + + @pytest.mark.parametrize( + ("value", "field_name", "expected"), + [ + ("Dune", "Title", "Dune"), + (" Frank Herbert ", "Author", "Frank Herbert"), + ("\nFoundation\t", "Title", "Foundation"), + ], + ) + def test_trims_and_returns_text( + self, + value: str, + field_name: str, + expected: str, + ) -> None: + assert books._normalize_required_text(value, field_name) == expected + + @pytest.mark.parametrize("value", ["", " ", "\n\t "]) + def test_rejects_empty_text(self, value: str) -> None: + with pytest.raises(ValueError, match="Title cannot be empty."): + books._normalize_required_text(value, "Title") + + def test_raises_attribute_error_for_none(self) -> None: + with pytest.raises(AttributeError): + books._normalize_required_text(None, "Title") # type: ignore[arg-type] + + +class TestBookDataclass: + """Tests for Book.""" + + def test_defaults_read_to_false(self) -> None: + assert Book(title="Dune", author="Frank Herbert", year=1965) == Book( + title="Dune", + author="Frank Herbert", + year=1965, + read=False, + ) + + def test_asdict_includes_all_fields(self) -> None: + assert asdict(Book(title="Dune", author="Frank Herbert", year=1965, read=True)) == { + "title": "Dune", + "author": "Frank Herbert", + "year": 1965, + "read": True, + } + + +class TestInitAndLoading: + """Tests for __init__ and load_books.""" + + def test_init_loads_existing_books(self, temp_data_file: Path) -> None: + temp_data_file.write_text( + '[{"title": "Dune", "author": "Frank Herbert", "year": 1965, "read": true}]' + ) + + collection = BookCollection() + + assert collection.books == [ + Book(title="Dune", author="Frank Herbert", year=1965, read=True) + ] + + def test_load_books_starts_empty_when_file_is_missing(self, temp_data_file: Path) -> None: + collection = BookCollection() + assert collection.books == [] + + @pytest.mark.parametrize( + ("contents", "expected_backup_name"), + [ + ("{not valid json", "data.corrupted.json"), + ('{"title": "Dune"}', "data.corrupted.json"), + ('[{"title": "Dune", "year": 1965}]', "data.corrupted.json"), + ('["Dune"]', "data.corrupted.json"), + ], + ) + def test_load_books_quarantines_invalid_data( + self, + temp_data_file: Path, + capsys: pytest.CaptureFixture[str], + contents: str, + expected_backup_name: str, + ) -> None: + temp_data_file.write_text(contents) + + collection = BookCollection() + + captured = capsys.readouterr() + backup_path = temp_data_file.with_name(expected_backup_name) + assert collection.books == [] + assert temp_data_file.exists() is False + assert backup_path.read_text() == contents + assert "Warning: data.json is corrupted or invalid." in captured.out + assert expected_backup_name in captured.out + + def test_load_books_uses_incremented_quarantine_name_when_backup_exists( + self, + temp_data_file: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + temp_data_file.write_text("{not valid json") + temp_data_file.with_name("data.corrupted.json").write_text("older backup") + + collection = BookCollection() + + captured = capsys.readouterr() + assert collection.books == [] + assert temp_data_file.with_name("data.corrupted.json").read_text() == "older backup" + assert temp_data_file.with_name("data.corrupted-1.json").read_text() == "{not valid json" + assert "data.corrupted-1.json" in captured.out + + def test_load_books_replaces_existing_in_memory_books(self, empty_collection: BookCollection) -> None: + empty_collection.books = [Book(title="Old", author="Author", year=1900)] + data_path = Path(books.DATA_FILE) + data_path.write_text( + '[{"title": "Dune", "author": "Frank Herbert", "year": 1965, "read": false}]' + ) + + empty_collection.load_books() + + assert empty_collection.books == [ + Book(title="Dune", author="Frank Herbert", year=1965, read=False) + ] + + +class TestInternalHelpers: + """Tests for internal file and data loading helpers.""" + + def test_open_data_file_reads_contents(self, temp_data_file: Path) -> None: + temp_data_file.write_text("hello") + collection = make_collection_without_init() + + with collection._open_data_file("r") as data_file: + assert data_file.read() == "hello" + + def test_open_data_file_writes_contents(self, temp_data_file: Path) -> None: + collection = make_collection_without_init() + + with collection._open_data_file("w") as data_file: + data_file.write("hello") + + assert temp_data_file.read_text() == "hello" + + def test_quarantine_corrupted_file_moves_data_file(self, temp_data_file: Path) -> None: + temp_data_file.write_text("broken") + collection = make_collection_without_init() + + backup_path = collection._quarantine_corrupted_file() + + assert temp_data_file.exists() is False + assert backup_path.name == "data.corrupted.json" + assert backup_path.read_text() == "broken" + + def test_quarantine_corrupted_file_skips_existing_backup(self, temp_data_file: Path) -> None: + temp_data_file.write_text("broken") + temp_data_file.with_name("data.corrupted.json").write_text("older backup") + collection = make_collection_without_init() + + backup_path = collection._quarantine_corrupted_file() + + assert backup_path.name == "data.corrupted-1.json" + assert backup_path.read_text() == "broken" + + def test_load_book_data_returns_book_instances(self, temp_data_file: Path) -> None: + temp_data_file.write_text( + '[{"title": "Dune", "author": "Frank Herbert", "year": 1965, "read": true}]' + ) + collection = make_collection_without_init() + + assert collection._load_book_data() == [ + Book(title="Dune", author="Frank Herbert", year=1965, read=True) + ] + + @pytest.mark.parametrize( + ("contents", "expected_message"), + [ + ('{"title": "Dune"}', "Expected a list of books."), + ('["Dune"]', "Book entry #1 must be an object."), + ('[{"title": "Dune", "year": 1965}]', "Book entry #1 has invalid fields."), + ( + '[{"title": "Dune", "author": "Frank Herbert", "year": 1965, "read": false, "genre": "Sci-Fi"}]', + "Book entry #1 has invalid fields.", + ), + ( + '[{"title": "Dune", "author": "Frank Herbert", "year": 1965}, {"title": "1984"}]', + "Book entry #2 has invalid fields.", + ), + ], + ) + def test_load_book_data_rejects_invalid_shapes( + self, + temp_data_file: Path, + contents: str, + expected_message: str, + ) -> None: + temp_data_file.write_text(contents) + collection = make_collection_without_init() + + with pytest.raises(ValueError, match=expected_message): + collection._load_book_data() + + +class TestSaveBooks: + """Tests for save_books.""" + + def test_save_books_writes_expected_json(self, empty_collection: BookCollection, temp_data_file: Path) -> None: + empty_collection.books = [ + Book(title="Dune", author="Frank Herbert", year=1965, read=False), + Book(title="1984", author="George Orwell", year=1949, read=True), + ] + + empty_collection.save_books() + + assert temp_data_file.read_text().strip() == ( + "[\n" + " {\n" + ' "title": "Dune",\n' + ' "author": "Frank Herbert",\n' + ' "year": 1965,\n' + ' "read": false\n' + " },\n" + " {\n" + ' "title": "1984",\n' + ' "author": "George Orwell",\n' + ' "year": 1949,\n' + ' "read": true\n' + " }\n" + "]" + ) + + def test_save_books_creates_missing_parent_directory(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + data_file = tmp_path / "nested" / "books.json" + monkeypatch.setattr(books, "DATA_FILE", str(data_file)) + collection = make_collection_without_init() + collection.books = [Book(title="Dune", author="Frank Herbert", year=1965)] + + collection.save_books() + + assert data_file.exists() + assert json.loads(data_file.read_text()) == [ + {"title": "Dune", "author": "Frank Herbert", "year": 1965, "read": False} + ] + + def test_save_books_overwrites_previous_contents( + self, + empty_collection: BookCollection, + temp_data_file: Path, + ) -> None: + temp_data_file.write_text("stale data") + empty_collection.books = [Book(title="Dune", author="Frank Herbert", year=1965)] + + empty_collection.save_books() + + assert json.loads(temp_data_file.read_text()) == [ + {"title": "Dune", "author": "Frank Herbert", "year": 1965, "read": False} + ] + + def test_save_books_propagates_permission_error_from_replace( + self, + empty_collection: BookCollection, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + empty_collection.books = [Book(title="Dune", author="Frank Herbert", year=1965)] + + def raise_permission_error(source: Path, destination: Path) -> None: + raise PermissionError("permission denied") + + monkeypatch.setattr(books.os, "replace", raise_permission_error) + + with pytest.raises(PermissionError, match="permission denied"): + empty_collection.save_books() + + +class TestAddBook: + """Tests for add_book.""" + + def test_add_book_returns_new_book_and_updates_collection( + self, + empty_collection: BookCollection, + ) -> None: + book = empty_collection.add_book("1984", "George Orwell", 1949) + + assert book == Book(title="1984", author="George Orwell", year=1949, read=False) + assert empty_collection.books == [book] + + def test_add_book_strips_surrounding_whitespace(self, empty_collection: BookCollection) -> None: + book = empty_collection.add_book(" Dune ", " Frank Herbert ", 1965) + + assert asdict(book) == { + "title": "Dune", + "author": "Frank Herbert", + "year": 1965, + "read": False, + } + + @pytest.mark.parametrize("title", ["", " ", "\n\t"]) + def test_add_book_rejects_empty_title(self, empty_collection: BookCollection, title: str) -> None: + with pytest.raises(ValueError, match="Title cannot be empty."): + empty_collection.add_book(title, "Frank Herbert", 1965) + + @pytest.mark.parametrize("author", ["", " ", "\n\t"]) + def test_add_book_rejects_empty_author(self, empty_collection: BookCollection, author: str) -> None: + with pytest.raises(ValueError, match="Author cannot be empty."): + empty_collection.add_book("Dune", author, 1965) + + @pytest.mark.parametrize("year", [-1, -1965]) + def test_add_book_rejects_negative_year(self, empty_collection: BookCollection, year: int) -> None: + with pytest.raises(ValueError, match="Year cannot be negative."): + empty_collection.add_book("Dune", "Frank Herbert", year) + + def test_add_book_rejects_future_year(self, empty_collection: BookCollection) -> None: + future_year = date.today().year + 1 + + with pytest.raises( + ValueError, + match=rf"Year cannot be in the future\. Please enter a year up to {date.today().year}\.", + ): + empty_collection.add_book("Dune", "Frank Herbert", future_year) + + def test_add_book_persists_normalized_values( + self, + empty_collection: BookCollection, + ) -> None: + added_book = empty_collection.add_book(" Dune ", " Frank Herbert ", 1965) + reloaded_collection = BookCollection() + + assert reloaded_collection.books == [added_book] + + def test_add_book_allows_duplicate_title_and_author(self, empty_collection: BookCollection) -> None: + first_book = empty_collection.add_book("Dune", "Frank Herbert", 1965) + second_book = empty_collection.add_book("Dune", "Frank Herbert", 1965) + + assert empty_collection.books == [first_book, second_book] + + +class TestListBooks: + """Tests for list_books.""" + + def test_returns_internal_list(self, collection_with_books: BookCollection) -> None: + assert collection_with_books.list_books() is collection_with_books.books + + def test_returns_empty_list_for_empty_collection(self, empty_collection: BookCollection) -> None: + assert empty_collection.list_books() == [] + + +class TestListByYear: + """Tests for list_by_year.""" + + @pytest.mark.parametrize( + ("start", "end", "expected_titles"), + [ + (1937, 1937, ["The Hobbit"]), + (1937, 1965, ["The Hobbit", "Dune"]), + (1965, 1984, ["Dune", "Neuromancer"]), + (-10, 1937, ["The Hobbit"]), + ], + ) + def test_returns_books_in_inclusive_range( + self, + collection_with_books: BookCollection, + start: int, + end: int, + expected_titles: list[str], + ) -> None: + result = collection_with_books.list_by_year(start, end) + + assert [book.title for book in result] == expected_titles + + @pytest.mark.parametrize(("start", "end"), [(1900, 1901), (1985, 1990), (2000, 1990)]) + def test_returns_empty_list_when_no_books_match( + self, + collection_with_books: BookCollection, + start: int, + end: int, + ) -> None: + assert collection_with_books.list_by_year(start, end) == [] + + +class TestFindBookByTitle: + """Tests for find_book_by_title.""" + + def test_matches_title_case_insensitively(self, collection_with_books: BookCollection) -> None: + result = collection_with_books.find_book_by_title("dUnE") + + assert result == Book(title="Dune", author="Frank Herbert", year=1965, read=False) + + def test_returns_first_match_when_titles_are_duplicated(self, empty_collection: BookCollection) -> None: + first_book = empty_collection.add_book("Dune", "Frank Herbert", 1965) + empty_collection.add_book("Dune", "Someone Else", 2021) + + assert empty_collection.find_book_by_title("dune") is first_book + + def test_returns_none_when_title_is_not_found(self, collection_with_books: BookCollection) -> None: + assert collection_with_books.find_book_by_title("Foundation") is None + + @pytest.mark.parametrize("title", ["Dune", "", " "]) + def test_returns_none_when_collection_is_empty( + self, + empty_collection: BookCollection, + title: str, + ) -> None: + assert empty_collection.find_book_by_title(title) is None + + +class TestMarkAsRead: + """Tests for mark_as_read.""" + + def test_marks_book_case_insensitively_and_persists(self, empty_collection: BookCollection) -> None: + empty_collection.add_book("Dune", "Frank Herbert", 1965) + + result = empty_collection.mark_as_read("dUnE") + reloaded_collection = BookCollection() + + assert result is True + assert reloaded_collection.find_book_by_title("Dune").read is True + + def test_returns_false_without_saving_when_title_is_not_found( + self, + empty_collection: BookCollection, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + save_books_called = False + + def fake_save_books() -> None: + nonlocal save_books_called + save_books_called = True + + monkeypatch.setattr(empty_collection, "save_books", fake_save_books) + + result = empty_collection.mark_as_read("Missing Book") + + assert result is False + assert save_books_called is False + + def test_returns_false_for_empty_collection(self, empty_collection: BookCollection) -> None: + assert empty_collection.mark_as_read("Dune") is False + + def test_only_marks_first_matching_title(self, empty_collection: BookCollection) -> None: + first_book = empty_collection.add_book("Dune", "Frank Herbert", 1965) + second_book = empty_collection.add_book("Dune", "Brian Herbert", 2001) + + assert empty_collection.mark_as_read("Dune") is True + assert first_book.read is True + assert second_book.read is False + + +class TestRemoveBook: + """Tests for remove_book.""" + + def test_removes_book_that_exists_and_persists(self, empty_collection: BookCollection) -> None: + empty_collection.add_book("Dune", "Frank Herbert", 1965) + + result = empty_collection.remove_book("Dune") + reloaded_collection = BookCollection() + + assert result.success is True + assert result.message == 'Removed "Dune" from the collection.' + assert reloaded_collection.books == [] + + def test_matches_title_case_insensitively(self, empty_collection: BookCollection) -> None: + empty_collection.add_book("Dune", "Frank Herbert", 1965) + + result = empty_collection.remove_book("dUnE") + reloaded_collection = BookCollection() + + assert result.success is True + assert result.message == 'Removed "Dune" from the collection.' + assert reloaded_collection.books == [] + + def test_returns_feedback_when_book_does_not_exist( + self, + empty_collection: BookCollection, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + save_books_called = False + + def fake_save_books() -> None: + nonlocal save_books_called + save_books_called = True + + monkeypatch.setattr(empty_collection, "save_books", fake_save_books) + + result = empty_collection.remove_book("Missing Book") + + assert result.success is False + assert result.message == 'Book "Missing Book" was not found in the collection.' + assert save_books_called is False + + def test_rejects_empty_title(self, empty_collection: BookCollection) -> None: + with pytest.raises(ValueError, match="Title cannot be empty."): + empty_collection.remove_book(" ") + + def test_returns_feedback_when_collection_is_empty(self, empty_collection: BookCollection) -> None: + result = empty_collection.remove_book("Dune") + + assert result.success is False + assert result.message == 'Book "Dune" was not found in the collection.' + + def test_only_removes_first_matching_title(self, empty_collection: BookCollection) -> None: + first_book = empty_collection.add_book("Dune", "Frank Herbert", 1965) + second_book = empty_collection.add_book("Dune", "Brian Herbert", 2001) + + result = empty_collection.remove_book("Dune") + + assert result.success is True + assert empty_collection.books == [second_book] + assert first_book not in empty_collection.books + + def test_does_not_remove_book_by_partial_title_match(self, empty_collection: BookCollection) -> None: + empty_collection.add_book("The Hobbit", "J.R.R. Tolkien", 1937) + + result = empty_collection.remove_book("Hob") + + assert result.success is False + assert result.message == ( + 'No exact match found for "Hob". Try one of these full titles: "The Hobbit".' + ) + assert [book.title for book in empty_collection.books] == ["The Hobbit"] + + def test_ignores_whitespace_around_title(self, empty_collection: BookCollection) -> None: + empty_collection.add_book("Dune", "Frank Herbert", 1965) + + result = empty_collection.remove_book(" dune ") + + assert result.success is True + assert empty_collection.books == [] + + +class TestFindByAuthor: + """Tests for find_by_author.""" + + def test_matches_author_case_insensitively(self, collection_with_books: BookCollection) -> None: + result = collection_with_books.find_by_author("frank herbert") + + assert result == [Book(title="Dune", author="Frank Herbert", year=1965, read=False)] + + def test_returns_all_matching_books(self, empty_collection: BookCollection) -> None: + empty_collection.add_book("Dune", "Frank Herbert", 1965) + empty_collection.add_book("Children of Dune", "Frank Herbert", 1976) + + assert [book.title for book in empty_collection.find_by_author("Frank Herbert")] == [ + "Dune", + "Children of Dune", + ] + + @pytest.mark.parametrize( + ("stored_author", "searched_author"), + [ + ("Jean-Paul Sartre", "jean-paul sartre"), + ("Mary Wollstonecraft Shelley", "mary wollstonecraft shelley"), + ("Gabriel Garcia Marquez", ""), + ("Gabriel Garcia Marquez", " "), + ("Gabriel Garcia Marquez", "gabriel garcia marquez"), + ("Gabriel García Márquez", "gabriel garcía márquez"), + ], + ) + def test_handles_author_name_edge_cases( + self, + empty_collection: BookCollection, + stored_author: str, + searched_author: str, + ) -> None: + empty_collection.add_book("Sample Book", stored_author, 1965) + + result = empty_collection.find_by_author(searched_author) + + expected = [] if searched_author.strip() == "" else [empty_collection.books[0]] + assert result == expected + + def test_returns_empty_list_when_author_has_no_matches( + self, + collection_with_books: BookCollection, + ) -> None: + assert collection_with_books.find_by_author("Isaac Asimov") == [] + + @pytest.mark.parametrize("author", ["Frank Herbert", "", " "]) + def test_returns_empty_list_for_empty_collection( + self, + empty_collection: BookCollection, + author: str, + ) -> None: + assert empty_collection.find_by_author(author) == [] + + +class TestConcurrentAccess: + """Tests for concurrent access patterns.""" + + def test_separate_collections_can_overwrite_each_others_changes( + self, + temp_data_file: Path, + ) -> None: + first_collection = BookCollection() + second_collection = BookCollection() + barrier = threading.Barrier(2) + + def add_book_after_sync( + collection: BookCollection, + title: str, + author: str, + year: int, + ) -> None: + barrier.wait() + collection.add_book(title, author, year) + + first_thread = threading.Thread( + target=add_book_after_sync, + args=(first_collection, "Dune", "Frank Herbert", 1965), + ) + second_thread = threading.Thread( + target=add_book_after_sync, + args=(second_collection, "Neuromancer", "William Gibson", 1984), + ) + first_thread.start() + second_thread.start() + first_thread.join() + second_thread.join() -def test_add_book(): - collection = BookCollection() - initial_count = len(collection.books) - collection.add_book("1984", "George Orwell", 1949) - assert len(collection.books) == initial_count + 1 - book = collection.find_book_by_title("1984") - assert book is not None - assert book.author == "George Orwell" - assert book.year == 1949 - assert book.read is False - -def test_mark_book_as_read(): - collection = BookCollection() - collection.add_book("Dune", "Frank Herbert", 1965) - result = collection.mark_as_read("Dune") - assert result is True - book = collection.find_book_by_title("Dune") - assert book.read is True - -def test_mark_book_as_read_invalid(): - collection = BookCollection() - result = collection.mark_as_read("Nonexistent Book") - assert result is False - -def test_remove_book(): - collection = BookCollection() - collection.add_book("The Hobbit", "J.R.R. Tolkien", 1937) - result = collection.remove_book("The Hobbit") - assert result is True - book = collection.find_book_by_title("The Hobbit") - assert book is None - -def test_remove_book_invalid(): - collection = BookCollection() - result = collection.remove_book("Nonexistent Book") - assert result is False + saved_books = json.loads(temp_data_file.read_text()) + assert len(saved_books) == 1 + assert saved_books[0]["title"] in {"Dune", "Neuromancer"} diff --git a/samples/book-app-project/tests/test_utils.py b/samples/book-app-project/tests/test_utils.py new file mode 100644 index 00000000..58ce5118 --- /dev/null +++ b/samples/book-app-project/tests/test_utils.py @@ -0,0 +1,290 @@ +import os +import sys +from datetime import date + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import pytest + +from books import Book +import utils + + +@pytest.fixture +def mock_input(monkeypatch: pytest.MonkeyPatch): + """Mock interactive input with a predefined sequence of responses.""" + + def _mock_input(responses: list[str]) -> None: + remaining_responses = responses.copy() + monkeypatch.setattr("builtins.input", lambda _: remaining_responses.pop(0)) + + return _mock_input + + +class TestValidateUserChoice: + """Tests for validate_user_choice.""" + + @pytest.mark.parametrize( + ("choice", "expected_message"), + [ + ("", "Choice cannot be empty. Please enter a number from 1 to 5."), + ("abc", "Invalid choice. Please enter a number from 1 to 5."), + ("9", "Choice must be between 1 and 5."), + ("3", None), + ], + ) + def test_returns_expected_validation_message( + self, + choice: str, + expected_message: str | None, + ) -> None: + assert utils.validate_user_choice(choice) == expected_message + + +class TestGetUserChoice: + """Tests for get_user_choice.""" + + def test_retries_after_empty_input( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + responses = ["", "2"] + monkeypatch.setattr("builtins.input", lambda _: responses.pop(0)) + + result = utils.get_user_choice() + + captured = capsys.readouterr() + assert result == "2" + assert "Choice cannot be empty. Please enter a number from 1 to 5." in captured.out + + def test_retries_after_non_numeric_input( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + responses = ["abc", "4"] + monkeypatch.setattr("builtins.input", lambda _: responses.pop(0)) + + result = utils.get_user_choice() + + captured = capsys.readouterr() + assert result == "4" + assert "Invalid choice. Please enter a number from 1 to 5." in captured.out + + def test_retries_after_out_of_range_number( + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + responses = ["9", "5"] + monkeypatch.setattr("builtins.input", lambda _: responses.pop(0)) + + result = utils.get_user_choice() + + captured = capsys.readouterr() + assert result == "5" + assert "Choice must be between 1 and 5." in captured.out + + +class TestGetBookDetails: + """Tests for get_book_details.""" + + def test_retries_after_empty_title( + self, + mock_input, + capsys: pytest.CaptureFixture[str], + ) -> None: + mock_input(["", "Dune", "Frank Herbert", "1965"]) + + result = utils.get_book_details() + + captured = capsys.readouterr() + assert result == ("Dune", "Frank Herbert", 1965) + assert "Title cannot be empty. Please enter a book title." in captured.out + + @pytest.mark.parametrize( + ("invalid_year", "expected_message"), + [ + ("invalid", "Year must be a whole number."), + ("19.65", "Year must be a whole number."), + ("1965a", "Year must be a whole number."), + ("", "Year cannot be empty. Please enter a publication year."), + ("-1", "Year cannot be negative."), + ( + str(date.today().year + 1), + f"Year cannot be in the future. Please enter a year up to {date.today().year}.", + ), + ], + ) + def test_reprompts_after_invalid_year_input( + self, + mock_input, + capsys: pytest.CaptureFixture[str], + invalid_year: str, + expected_message: str, + ) -> None: + mock_input(["Dune", "Frank Herbert", invalid_year, "1965"]) + + result = utils.get_book_details() + + captured = capsys.readouterr() + assert result == ("Dune", "Frank Herbert", 1965) + assert expected_message in captured.out + + def test_returns_details_for_valid_input(self, mock_input) -> None: + mock_input(["The Hobbit", "J.R.R. Tolkien", "1937"]) + + result = utils.get_book_details() + + assert result == ("The Hobbit", "J.R.R. Tolkien", 1937) + + def test_trims_surrounding_whitespace_from_inputs( + self, + mock_input, + capsys: pytest.CaptureFixture[str], + ) -> None: + mock_input([" Dune ", " Frank Herbert ", " 1965 "]) + + result = utils.get_book_details() + + captured = capsys.readouterr() + assert result == ("Dune", "Frank Herbert", 1965) + assert captured.out == "" + + def test_reprompts_when_title_is_only_whitespace( + self, + mock_input, + capsys: pytest.CaptureFixture[str], + ) -> None: + mock_input([" ", "Dune", "Frank Herbert", "1965"]) + + result = utils.get_book_details() + + captured = capsys.readouterr() + assert result == ("Dune", "Frank Herbert", 1965) + assert "Title cannot be empty. Please enter a book title." in captured.out + + def test_accepts_very_long_title(self, mock_input) -> None: + long_title = "A" * 500 + mock_input([long_title, "Frank Herbert", "1965"]) + + result = utils.get_book_details() + + assert result == (long_title, "Frank Herbert", 1965) + + @pytest.mark.parametrize( + "author_name", + [ + "Gabriel Garcia Marquez", + "Mary Shelley-Wollstonecraft", + "O'Connor, Flannery", + "N. K. Jemisin", + ], + ) + def test_accepts_special_characters_in_author_names( + self, + mock_input, + author_name: str, + ) -> None: + mock_input(["Dune", author_name, "1965"]) + + result = utils.get_book_details() + + assert result == ("Dune", author_name, 1965) + + +class TestParsePublicationYear: + """Tests for parse_publication_year.""" + + @pytest.mark.parametrize( + ("year_input", "expected_result"), + [ + ("1965", (1965, None)), + (" 1965 ", (1965, None)), + ("", (None, "Year cannot be empty. Please enter a publication year.")), + ("invalid", (None, "Year must be a whole number.")), + ("-1", (None, "Year cannot be negative.")), + ( + str(date.today().year + 1), + ( + None, + f"Year cannot be in the future. Please enter a year up to {date.today().year}.", + ), + ), + ], + ) + def test_returns_parsed_year_and_optional_message( + self, + year_input: str, + expected_result: tuple[int | None, str | None], + ) -> None: + assert utils.parse_publication_year(year_input) == expected_result + + +class TestFormatBooks: + """Tests for format_books.""" + + def test_formats_empty_state(self) -> None: + assert utils.format_books([]) == "No books found." + + def test_formats_books_with_read_status(self) -> None: + books = [ + Book(title="Dune", author="Frank Herbert", year=1965, read=False), + Book(title="1984", author="George Orwell", year=1949, read=True), + ] + + result = utils.format_books(books) + + assert "Your Book Collection:" in result + assert "1. [ ] Dune by Frank Herbert (1965)" in result + assert "2. [✓] 1984 by George Orwell (1949)" in result + + +class TestPrintBooks: + """Tests for print_books.""" + + def test_prints_empty_state(self, capsys: pytest.CaptureFixture[str]) -> None: + utils.print_books([]) + + captured = capsys.readouterr() + assert "No books found." in captured.out + + def test_prints_books_with_read_status( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + books = [ + Book(title="Dune", author="Frank Herbert", year=1965, read=False), + Book(title="1984", author="George Orwell", year=1949, read=True), + ] + + utils.print_books(books) + + captured = capsys.readouterr() + assert "Your Book Collection:" in captured.out + assert "1. [ ] Dune by Frank Herbert (1965)" in captured.out + assert "2. [✓] 1984 by George Orwell (1949)" in captured.out + + +class TestPrintHelp: + """Tests for print_help.""" + + def test_prints_command_help(self, capsys: pytest.CaptureFixture[str]) -> None: + utils.print_help() + + captured = capsys.readouterr() + assert "Book Collection Helper" in captured.out + assert "list - Show all books" in captured.out + assert "help - Show this help message" in captured.out + + +class TestFormatHelp: + """Tests for format_help.""" + + def test_returns_command_help_text(self) -> None: + result = utils.format_help() + + assert "Book Collection Helper" in result + assert "list - Show all books" in result + assert "help - Show this help message" in result diff --git a/samples/book-app-project/utils.py b/samples/book-app-project/utils.py index 4151dcda..512f7d32 100644 --- a/samples/book-app-project/utils.py +++ b/samples/book-app-project/utils.py @@ -1,36 +1,166 @@ -def print_menu(): - print("\n📚 Book Collection App") - print("1. Add a book") - print("2. List books") - print("3. Mark book as read") - print("4. Remove a book") - print("5. Exit") +from collections.abc import Sequence +from datetime import date +from typing import Final, Literal, TypeAlias, cast +from books import Book -def get_user_choice() -> str: - return input("Choose an option (1-5): ").strip() +MenuChoice: TypeAlias = Literal["1", "2", "3", "4", "5"] +BookDetails: TypeAlias = tuple[str, str, int] -def get_book_details(): - title = input("Enter book title: ").strip() - author = input("Enter author: ").strip() +VALID_CHOICES: Final[set[MenuChoice]] = {"1", "2", "3", "4", "5"} +COMMAND_HELP_TEXT: Final[str] = """ +Book Collection Helper + +Commands: + list - Show all books + add - Add a new book + mark-read - Mark a book as read + remove - Remove a book by title + find - Find books by author + help - Show this help message +""" +MENU_TEXT: Final[str] = """ +📚 Book Collection App +1. Add a book +2. List books +3. Mark book as read +4. Remove a book +5. Exit +""" + + +def format_menu() -> str: + return MENU_TEXT + + +def display_menu() -> None: + print(format_menu()) + + +def print_menu() -> None: + display_menu() + + +def format_help() -> str: + return COMMAND_HELP_TEXT + + +def display_help() -> None: + print(format_help()) + + +def print_help() -> None: + display_help() + + +def validate_user_choice(choice: str) -> str | None: + if not choice: + return "Choice cannot be empty. Please enter a number from 1 to 5." + + if not choice.isdigit(): + return "Invalid choice. Please enter a number from 1 to 5." + + if choice not in VALID_CHOICES: + return "Choice must be between 1 and 5." + + return None + + +def get_user_choice() -> MenuChoice: + while True: + choice = input("Choose an option (1-5): ").strip() + error_message = validate_user_choice(choice) + if error_message is not None: + print(error_message) + continue + + return cast(MenuChoice, choice) + + +def validate_title(title: str) -> str | None: + if title: + return None + + return "Title cannot be empty. Please enter a book title." + + +def current_calendar_year() -> int: + return date.today().year + + +def parse_publication_year(year_input: str) -> tuple[int | None, str | None]: + normalized_year = year_input.strip() + if not normalized_year: + return None, "Year cannot be empty. Please enter a publication year." - year_input = input("Enter publication year: ").strip() try: - year = int(year_input) + year = int(normalized_year) except ValueError: - print("Invalid year. Defaulting to 0.") - year = 0 + return None, "Year must be a whole number." + + if year < 0: + return None, "Year cannot be negative." + + max_year = current_calendar_year() + if year > max_year: + return None, f"Year cannot be in the future. Please enter a year up to {max_year}." + + return year, None + + +def get_book_details() -> BookDetails: + """Prompt the user for book details and return them as a tuple. + + Parameters: + None. The function reads all values interactively from standard input. + + Returns: + tuple[str, str, int]: A tuple containing: + - title: The non-empty book title entered by the user. + - author: The author name entered by the user. + - year: The publication year as an integer after validation. + """ + while True: + title = input("Enter book title: ").strip() + error_message = validate_title(title) + if error_message is None: + break + + print(error_message) + + author = input("Enter author: ").strip() + + while True: + year_input = input("Enter publication year: ").strip() + year, error_message = parse_publication_year(year_input) + if error_message is None and year is not None: + break + + print(error_message) return title, author, year -def print_books(books): +def format_books(books: Sequence[Book]) -> str: + """Build the book list display text in a consistent, user-friendly format.""" if not books: - print("No books in your collection.") - return + return "No books found." + + book_lines = ["Your Book Collection:", ""] - print("\nYour Books:") for index, book in enumerate(books, start=1): - status = "✅ Read" if book.read else "📖 Unread" - print(f"{index}. {book.title} by {book.author} ({book.year}) - {status}") + status = "✓" if book.read else " " + book_lines.append( + f"{index}. [{status}] {book.title} by {book.author} ({book.year})", + ) + + return "\n".join(book_lines) + + +def display_books(books: Sequence[Book]) -> None: + print(format_books(books)) + + +def print_books(books: Sequence[Book]) -> None: + display_books(books) diff --git a/test-mcp.file b/test-mcp.file new file mode 100644 index 00000000..e69de29b