diff --git a/README.md b/README.md index 6c0b599..29fcdf2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ # Client.Tools This repository contains tools shipped by the NuGet Client team to help developers effectively use the latest NuGet features. + +| Tool | Description | +| --- | --- | +| [dotnet-package-skills](dotnet-package-skills/README.md) | Copies agent skills bundled in NuGet packages into a repository's skills directory. | diff --git a/dotnet-package-skills/.gitignore b/dotnet-package-skills/.gitignore new file mode 100644 index 0000000..2220015 --- /dev/null +++ b/dotnet-package-skills/.gitignore @@ -0,0 +1,7 @@ +bin/ +obj/ +artifacts/ +node_modules/ +*.user +.DS_Store +.agents/ diff --git a/dotnet-package-skills/CONTRIBUTING.md b/dotnet-package-skills/CONTRIBUTING.md new file mode 100644 index 0000000..85ba9af --- /dev/null +++ b/dotnet-package-skills/CONTRIBUTING.md @@ -0,0 +1,424 @@ +# How to contribute + +Thank you for your help. This tool is small and simple on purpose. Keep changes small and simple +too. + +## Set up your computer + +Install the .NET 10 SDK that this folder's `global.json` selects. Install the .NET 8 runtime too. +Install PowerShell 7. The tool and its C# test suite target both net8.0 and net10.0. The CI system +runs tests on Windows only. + +```powershell +git clone https://github.com/NuGet/Client.Tools.git +Set-Location .\Client.Tools\dotnet-package-skills +dotnet restore .\DotnetPackageSkills.slnx --configfile .\NuGet.config +dotnet build .\DotnetPackageSkills.slnx -c Release --no-restore +dotnet test .\tests\DotnetPackageSkills.Tests.csproj -c Release --no-build --no-restore +``` + +Try your build against a real repository without installing it: + +```bash +dotnet run --project src -f net10.0 -- list --target /path/to/YourApp.sln +``` + +Pack and verify your build without replacing a globally installed tool: + +```powershell +dotnet pack .\src\DotnetPackageSkills.csproj -c Release --no-build --no-restore -o .\artifacts\packages +pwsh -NoProfile -File ..\eng\pipelines\dotnet-package-skills\Verify-Package.ps1 ` + -PackagePath .\artifacts\packages\dotnet-package-skills.0.1.0-dev.nupkg ` + -ExpectedVersion 0.1.0-dev ` + -BuildOutputPath .\src\bin\Release +``` + +## Origin + +This folder came from +[`kartheekp-ms/dotnet-package-skills` at `59d3bc0d4fc80188d33bcc257d2a83c99a4cbfa3`](https://github.com/kartheekp-ms/dotnet-package-skills/tree/59d3bc0d4fc80188d33bcc257d2a83c99a4cbfa3). +We copied the source files at that commit. We did not copy or change the original repository's +history. The source repository is unchanged. + +The source README and package metadata name the MIT license. Client.Tools keeps its own root MIT +license file. We left out the Python and Node.js terminal regression tests on purpose. The C# +picker tests and the interactive tool behavior stay the same. Build configuration and pipeline +integration apply only to this tool. This lets a maintainer remove the tool on its own, for +example when its function moves into the .NET SDK. + +## Layout + +``` +src/ +├── Program.cs CLI surface: commands, options, exit codes +├── SkillInstallService.cs Orchestration. This is the only file that puts the steps in order. +├── Cli/OutputWriter.cs Writes reports for people to read +├── Cli/SkillPicker.cs The --interactive picker. It shows one page per screen. +├── Cli/ITerminal.cs An interface for console access, so tests can replace the console +├── Cli/InteractiveSkills.cs Picker-only metadata and selection mapping +├── Infrastructure/ Process execution and the dotnet CLI wrapper +├── NuGet/ Target detection, package listing, cache path resolution +└── Skills/ Discovery, copying, version-change removal, the install manifest + +tests/ xunit tests. Application tests use in-process fakes. +``` + +## Rules that must stay true + +Read this list before you change this tool. Each rule protects something that is not obvious +from the code alone. Do not change a rule without a discussion first. + +**Copy files from the global packages folder. Never move them.** NuGet validates this folder +during restore. It is a content-addressable cache, and every project on the machine shares it. +If you move a file out of the cache, restore may treat the cached package as damaged. Moving a +file also removes the skill from every other repository that uses that package. + +**Use the manifest to decide what to remove. Never scan the destination folder to decide.** +`.dotnet-package-skills.json` records, for each package ID, the one installed version and the +names of the skill folders it owns. `install` acts only on those names when a package changes +version. `uninstall` acts only on those names too. Users keep their own hand-written skills in +the same folder. Deleting one of those skills by mistake is a serious failure. + +**An unreadable manifest stops every operation that needs ownership information.** Do not treat +a damaged or unreadable manifest as an empty manifest. An empty manifest means the existing +folders belong to the user. A damaged manifest means the tool does not know who owns the +folders. `install` and `uninstall` must fail before they change anything. They must keep the +file in place so the user can repair it or restore it. `list` can still run, because `list` does +not read ownership information and does not write anything. + +An existing manifest must have these parts. It must have a format `version` property, and the +value must be a whole number that this version of the tool supports. It must have a `packages` +object. Each package ID in that object must be valid under NuGet's own rule. `PackageCoordinate.IsValidId` +checks this rule, and the rule allows letters outside ASCII. Each package needs a version that is +not empty. JSON property names must be unique when you ignore letter case. Destination names +claimed by different packages must be unique when you ignore letter case. When the manifest has +a newer format version than the tool supports, the tool asks the user to update the tool. A +pre-release manifest has an `installed` array instead of a `packages` object. The tool refuses +this old format. It does not convert the file. `SetSkills` refuses any ID that the reader would +refuse. This way, the tool never writes a manifest that it cannot later read. + +In v1, use ordinary manifest files, and update them in place. The tool does not create symbolic +links. Linked or redirected manifests fall outside the v1 safety guarantees. Do not replace an +existing manifest with a new file merely to handle links. That action can change file ownership +or access rights on Unix systems. + +**The manifest is a public contract.** Reports have no machine-readable form. Scripts read the +manifest instead, and teams commit the manifest to source control. Its shape follows +`dotnet-tools.json`. It has a format `version` property and a `packages` object. The object key +is the lowercase package ID. A change that an older version of the tool would misread needs a new +format `version` number. This way, an older tool refuses the file instead of misreading it. + +Write the manifest the same way on every platform. Use UTF-8 encoding without a byte order mark. +Use LF line endings, with a final newline at the end of the file. List packages and skills in a +fixed order. Escape values the same way on `net8.0` and `net10.0`. .NET 8 has no +`JsonWriterOptions.NewLine` property, so the writer replaces its CRLF output with LF after it +serializes the file. A byte that depends on the platform creates needless differences in a pull +request. + +**When no skill is tracked, the manifest and the folder both disappear.** When the last entry +leaves the manifest, `install` and `uninstall` both delete `.dotnet-package-skills.json`. They +also delete the destination folder, but only if the folder is empty. This way, a repository where +no package ships a skill never grows a stray `.agents/skills/` folder. The tool removes the +folder only when the folder is truly empty. Hand-written skills in that folder keep it from being +deleted. + +**Descriptions are read-only text for display. They are not a requirement for installation.** +Skill discovery still identifies a skill by its folder structure alone. Only the interactive +pickers read the top-level YAML `description` property in `SKILL.md`. They read it with a +bounded frontmatter reader and a standard YAML parser. Never interpret the Markdown body of +`SKILL.md`. Never run any part of its metadata as code. Never invent a description. Never +rewrite the file. + +When a skill has no description, the picker shows an explicit placeholder text. When the +metadata is unreadable or invalid, the picker shows a visible warning, but it still shows the +skill. This rule replaces an earlier rule that forbade reading frontmatter at all. The new rule +lets users make an informed choice. Reports and the manifest never include descriptions. + +**Treat skill names from packages as untrusted input.** A skill name becomes a path segment +inside the user's repository. `SkillDiscovery.IsSafeSkillName` is the one gate that both +discovery and the manifest use. Keep this gate strict. Reject a name that ends in a dot or a +space, on every platform. Windows can resolve such a name to a different folder, or to the +destination folder itself. Before you change any file, resolve every affected skill path as a +direct child of the destination folder. When the tool removes a skill, it must not walk up the +folder tree and delete a parent folder. + +**`install` removes a skill only when its package changes to a version that drops the skill.** A +noninteractive install offers the packages that it resolved and found in the cache. A tracked +skill is removed only when all of these conditions are true. First, the tool offers its package +at a different normalized version. Second, the new version does not ship that skill. Third, no +ownership conflict protects the skill. + +When an ownership conflict does protect the skill, `install` stops instead of removing it. +Removing the skill would hand its folder name to a different package. Relabeling the skill would +record the old version's files under the new version's name, and a later run would then never +remove those old files. A package that left the project is never a reason to delete its skills. +A package reference can disappear for a moment, for example during a refactor, so `install` +reports these skills as unreferenced instead of deleting them. `uninstall --stale` removes them +when the user asks for that. + +A version that is missing from the cache never causes a removal. When a target install is +missing a resolved package, the install stops before it writes anything, including during a +preview. An incomplete cache must never look like permission to delete skills. + +**`install -i` only adds skills. It never refreshes or removes a skill.** The checklist lists +only skills that would install cleanly and that are not already tracked. Nothing starts checked. +The installer receives an empty map of offered packages, so it cannot refresh or remove anything. + +Every check that could stop the run happens before the checklist opens. The checks look for +three problems: two versions of one package, a missing package or a stale skill when you use a +target, and a named package tracked at a different version when you use `--package`. If the tool +let you add a skill next to a stale skill, the manifest would disagree with the project. If the +tool let you add a skill next to another version of the same package, one package would have two +versions in the manifest. The tool stops before the checklist opens to prevent both problems. + +**`uninstall --stale` reads package references. It does not read packages.** This command needs +a solution or project. It compares the manifest with the target's direct package references from +`dotnet list package`. It never asks where the NuGet cache is. A missing or partial cache cannot +change what counts as stale, because the command never looks at the cache. A skill is stale when +no referenced package matches both its ID and its installed version. This rule also works +correctly when a target resolves a package to two versions. `--stale` cannot combine with +`--package`. `uninstall` accepts `--target` only together with `--stale`. + +**The picker shows one page at a time. This design choice is deliberate.** A solution can +reference many packages that ship skills. `SkillPicker` renders a frame that fits inside the +window, and it redraws that frame in place. The list can never scroll off the top of the screen +unread. We designed the picker this way to prevent the worst failure: a user who approves skills +that they never saw. Page size follows the rendered height of the content, including descriptions +and keyboard help. Page size does not follow a fixed count of items. The picker itself has no +access to the file system. `InteractiveSkills` supplies package descriptions for `install`, and it +supplies installed-file descriptions for `uninstall`. + +**The tool measures the frame from its content. The window bounds the frame's size.** A skill's +name and description share one row. The text ` - ` follows the authored name directly. The +layout does not use a padded name column. Do not append package or version metadata to a name. +Do not strip the package prefix from a name. Each skill's wrapped description text lines up with +the start of the skill's own text. The description uses the rest of the row's width. The +description does not use an indent as wide as the name. Measure every line, including every +wrapped footer line, before you assign whole skill entries to pages. A user must be able to +scroll an oversized description. The tool must never truncate a description silently. The layout +must reflow when the window resizes, and it must keep the current focus and the current +selections. A checkbox change or a cursor move must never shift a page boundary. A partial last +page ends at its actual content. It never shows a run of blank rows. + +**Keyboard hints follow the style of Aspire's checklist.** The primary hint reads +`(Press to select, to accept)`. It appears below the list. Use the same +angle-bracket key notation for paging, select-all, clear-all, cancel, and description scrolling. +Start every keyboard-help line with the word `Press`. Wrap the help text instead of cutting off +any key name. Show the paging and scrolling hints only when the user can actually use paging or +scrolling. + +**Focus and checked state are the only visual cues.** The focused skill's text turns blue, +including all of its wrapped description lines. A checked item shows a blue uppercase `X` in +both pickers. Selection never changes the color of a name or a bracket. Neither picker marks what +a check mark does with a separate cue. Each picker does only one thing, so its title and its +summary state that action. The uninstall summary also states how many skills will be removed. A +terminal without color shows `>` for focus and `[X]` for a checked item. It shows no other +marker and no color legend. The tool respects the `NO_COLOR` setting. A separate status column +would take space away from the descriptions, so the tool does not use one. + +**A check mark means install in one picker. It means remove in the other picker.** Neither +picker starts with any item checked. If a user presses enter without checking anything, both +pickers change nothing. `PickerMode` carries this difference, and the difference shows only in +the summary text. The code that calls the picker supplies the title text. The uninstall list +comes from the manifest, so the picker never offers a hand-written skill for deletion. + +**The picker takes control of the terminal, so it must give control back.** `Choose` hides the +cursor. It reads Ctrl+C as ordinary input instead of letting the runtime handle it. It restores +the cursor and the input mode inside a `finally` block. This matters because of Ctrl+C: if the +runtime handled Ctrl+C directly, it would end the process in the middle of a frame, the restore +code would never run, and the user would be left typing into a terminal with no visible cursor. +The code reads Ctrl+C as a key instead, and it cancels through the same path as the `esc` key. +The code tests for the Ctrl modifier before it checks the key itself, because a plain `c` key +clears the current selection. + +**An interactive choice applies only to the ownership state that was in effect when the user +made it.** The tool rechecks its ownership snapshot before it applies an interactive choice. When +ownership changed at the same time, the tool rejects the stale choice. It does not apply that +choice to a different package's files. + +Hold the destination lock from the moment the tool loads ownership information through the +moment it writes the final manifest. Both installation and removal take part in this lock. This +way, no other run of the tool can change ownership between the check and the change. Resolve +destination aliases to one canonical path before the tool chooses which lock to use. + +**A resize while a frame is on screen makes that frame invalid.** Read the new width and the new +height together. Restart the redraw when the viewport size changes. Clear cells in place instead +of scrolling blank lines past them. If the code scrolls blank lines instead, old picker frames +build up in the terminal's history, and they can wrap incorrectly when the host resizes again. +Keep the prior scrollback content, the current focus, and the current selections. Never swallow a +rendering failure that is not related to the resize. + +The picker owns an alternate screen for as long as it runs. Clearing only the current viewport +cannot erase old rows, because the host may have already moved those rows into its normal +history. Restore the original screen and the original output mode on every managed exit. Then +write the final report on the normal screen, not on the alternate screen. Clear the alternate +viewport and move the cursor to its home position before the first frame, and do the same thing +again after every resize. When the terminal enters the alternate screen, it can keep the shell's +old cursor position. If the code then reserves space with blank lines, those lines leave a gap +above a compact checklist. Never clear the normal screen to remove that gap. + +Every render also places the cursor directly below the last line that it drew. The cursor does +not go to the bottom of the layout's maximum possible height. `SkillPickerTests` checks this +exact placement for short pages. A managed exit restores the original shell cursor position on +its own, separately, when it leaves the alternate screen. + +**The picker's own chrome uses only ASCII characters. Author-written text can use any +language.** Keep every control hint and every marker in ASCII, so that an older console encoding +does not lose or corrupt them. Display Unicode descriptions without splitting a text element in +the middle. Measure terminal cells for this purpose, not UTF-16 code units. Remove any unsafe +terminal control sequence from text that a package author supplied. Send all color output +through `ITerminal`. The picker uses UTF-8 without a byte order mark while it prompts the user. +Restore the original text encoding and the original terminal styling when the picker exits, and +also when it fails. This way, ordinary command output keeps behaving the way it always did. + +**A report written for people must not let metadata run as a terminal command.** Sanitize every +untrusted display field with `TerminalText.Sanitize`. This includes package and version metadata, +file paths, reasons for a skip, and operational errors. Framework parser diagnostics and +suggestions use a separate output path. Sanitize that path too, including any write that is split +into parts. Keep multiline error guidance readable. + +Never sanitize an argument before the tool validates it. Never save a sanitized value as a +stored value. A canonical identity, such as a package ID, must stay intact and unmodified. + +**Reports exist for people to read. The tool has no JSON report.** The manifest is the one +machine-readable record. Exit codes carry success or failure. Do not add a `--json` report back +without a new discussion of that decision. `--package` accepts several values, so the parser +hands it any unknown option that follows it. This can happen when an old script still has a +`--json` flag left in it. The validator for `--package` reports a value that starts with `-` as +an unrecognized argument. It does not report that value as a malformed package coordinate. + +**`--package` refuses a floating version and refuses a version range.** Resolving a range means +choosing one version from it. The only correct answer to that choice comes from a project's own +restore step. `PackageCoordinate.Parse` is the one gate that enforces this rule. + +**The tool scans only direct dependencies.** Application code depends on its direct package +references. It does not depend on implementation details that come in through a transitive +reference. Do not add a `--include-transitive` option. Do not parse `transitivePackages`. Both +changes need a new product discussion first. + +**The authored skill folder name becomes the destination folder name.** A package skill at +`skills/contoso.widgets-widget-usage/` lands at `/contoso.widgets-widget-usage/`. +The package ID and the package version stay in the manifest as metadata. They do not become part +of the destination path. A skill must be an immediate subfolder that contains `SKILL.md`. A lone +`skills/SKILL.md` file, with no subfolder, is not supported. This is a deliberate choice. + +**When names collide, the tool warns and skips. It never overwrites a file silently.** The tool +compares destination names without regard to letter case. Package enumeration and skill discovery +both stay deterministic, so the same package always wins a collision, every time you run the +tool. An existing untracked destination folder belongs to the user. The tool must not touch it. +No install mode can transfer an already-tracked destination from one owner package to another +package. A conflicting path that the tool skips is also protected from removal when the owner's +version changes, in the same way that it is protected from copying. A version refresh of the +same package remains allowed. The tool never relabels a protected skill under a version that does +not ship it. That case stops the install instead, as an earlier rule in this list describes. + +Keep every discovery candidate available internally until the tool knows ownership at install +time. Prefer the current owner's candidate when more than one package offers the same name. +`list` stays a discovery report that does not depend on the destination folder's current state. + +Package authors can avoid collisions by prefixing their skill folders with their lowercased +package ID. The tool does not enforce this naming convention. + +In v1, the tool matches names case-insensitively, as a logical rule. It does not reconcile two +folders that differ only in physical case on a case-sensitive file system. Package authors must +keep their folder casing stable across versions. A case-only rename, or two physical folders +that differ only in case on a case-sensitive file system, both fall outside the v1 ownership +guarantees. Do not promise safe migration for these cases. Do not add special reconciliation +logic for them without a new discussion of this scope. + +**A package filter must never broaden a destructive operation.** An explicitly blank filter +value is an error. Only a completely absent `--package` option means all packages. Interactive +uninstall and noninteractive uninstall both use the same normalized version matcher. + +**The tool allows one version of each package, or it does not install at all.** The manifest +records exactly one version for each package. The resolved packages, or the `--package` +coordinates, can sometimes include two normalized versions of one package ID. When that happens, +every install mode stops before it changes anything, and it asks the user to align the versions. +We expect repositories to use NuGet Central Package Management for this. `PackageLister.Parse` +keeps each distinct `(id, version)` pair separate, so this check can see both versions. `list` +still shows both versions too. + +**Write every error message as guidance, not as a description of the failure alone.** Throw +`PackageSkillsException` with a message that tells the user what to do next. `Program.cs` prints +this message without a stack trace. If a message would leave a user stuck with no next step, add +more words to it. A command shown inside a message must work exactly as printed, when the user +copies and pastes it. Build that command with `SkillInstallService.UninstallCommand`. This +method repeats the run's `--target` value and its non-default `--destination` value. + +## Tests + +Application unit tests run offline. They never run the `dotnet` command. Any test code that +needs the CLI goes through `IProcessRunner`. `SkillInstallServiceTests` fakes this interface. See +`FakeDotnet` in that file for the pattern to follow. Use `TempDirectory` for any test that +touches the file system. `TempDirectory` cleans up its own files afterward. + +The interactive picker goes through `ITerminal`. `FakeTerminal` drives this interface from a +scripted sequence of keys, and it reads the result back as a screen buffer. `FakeTerminal` models +a buffer instead of joining writes end to end. The picker redraws its frame in place, so joining +every write together would stack frames on top of each other. A real user sees only one page at +a time, and the test model must match that. + +### Checks for the pipeline and the package + +`PipelineVersionTests` calls the tool's PowerShell version calculator. These tests cover PR +versions, preview versions, manual versions, and stable release versions. They also cover +invalid identifiers and invalid release requests. These tests need `pwsh` on the PATH. They need +no network access and no signing credentials. + +A separate package verifier installs the produced `.nupkg` file for each target framework. It +checks the package version, the package payload, and the install, list, and uninstall behavior, +all inside temporary directories. Official builds also require a valid package signature and +valid assembly signatures. + +Keep all pipeline logic that is specific to this tool under `eng\pipelines\dotnet-package-skills`, +at the root of the repository. See its [guide](../eng/pipelines/dotnet-package-skills/README.md) +for version numbering, official signing setup, and the checklist for retiring this tool. + +### Name unit tests + +Name each test as a sentence that describes the behavior. Do not name a test after the method +under test: + +```csharp +[Fact] +public void Install_skips_a_later_skill_when_destination_names_collide() +``` + +Every new behavior needs a test. Every bug fix needs a test that fails without the fix. The +`.slnx` preference bug shipped with a test like this, and that test is the reason the bug has +stayed fixed since then. + +## Style + +`TreatWarningsAsErrors` is on. A build must produce no warnings. Beyond that rule, match the +style of the surrounding code. Write a comment to explain why the code does something. Do not +write a comment that only restates what the code does. If you find a comment that only restates +the code, delete it. + +## Compatibility + +- The tool targets `net8.0` and `net10.0`. Do not drop `net8.0` without a discussion first. Many + teams still run the `net8.0` long-term support release. +- `dotnet list package --format json` needs SDK 7.0.200 or later. This is the lowest SDK version + that the tool can inspect, and the error message states this requirement when the installed SDK + does not meet it. +- **The tool never restores a project.** It runs `dotnet list package` without a `--no-restore` + flag, and it never runs `dotnet restore` on its own. The .NET 10 SDK restores the project during + this listing step, when the project needs it. An earlier SDK instead reports that the target + needs to be restored first. When the listing fails, the command stops. It shows what the SDK + reported, including the JSON `problems` array when the SDK provides one, so the customer can + restore the target, or fix the target, and run the command again. `PackageListerTests` checks + two things together: the exact arguments that the tool passes, and the fact that the tool never + attempts a restore. +- The output shape of `dotnet nuget locals` has changed across SDK versions. The parser reads the + key from the `global-packages:` label in that output. It does not read the key by line + position. Keep the parser written this way. + +## Pull requests + +- Make one change in each pull request. +- Make sure `dotnet build` and `dotnet test` both pass. +- Update the README when you change the CLI surface. +- State what you tested your change against. For example, write "Ran `install` on a solution + with 40 packages. Two of those packages ship skills." This kind of statement is worth more than + a description of the code diff. diff --git a/dotnet-package-skills/Directory.Build.props b/dotnet-package-skills/Directory.Build.props new file mode 100644 index 0000000..1f409fc --- /dev/null +++ b/dotnet-package-skills/Directory.Build.props @@ -0,0 +1,13 @@ + + + + latest + enable + enable + true + true + true + true + + + diff --git a/dotnet-package-skills/DotnetPackageSkills.slnx b/dotnet-package-skills/DotnetPackageSkills.slnx new file mode 100644 index 0000000..1bdf0b9 --- /dev/null +++ b/dotnet-package-skills/DotnetPackageSkills.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/dotnet-package-skills/NuGet.config b/dotnet-package-skills/NuGet.config new file mode 100644 index 0000000..bff9791 --- /dev/null +++ b/dotnet-package-skills/NuGet.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/dotnet-package-skills/README.md b/dotnet-package-skills/README.md new file mode 100644 index 0000000..af74ac4 --- /dev/null +++ b/dotnet-package-skills/README.md @@ -0,0 +1,616 @@ +# dotnet-package-skills + +This tool copies agent skills from inside NuGet packages into a folder that your coding agent +reads. + +## The problem + +Package authors know their own libraries best. Some package authors now ship an **agent skill** +inside the package. A skill is a set of instructions that covers the conventions, the pitfalls, +and the correct usage patterns for that library. The package stores each skill at +`skills/-/SKILL.md`. + +Restore extracts the package into the **NuGet global packages folder**. This folder is +`~/.nuget/packages` by default. It sits outside your repository, and every project on the +machine shares it. A coding agent scans a skills folder only *inside* the working repository. +Because of this, the skill sits correctly on disk, but the agent cannot see it. + +This tool closes that gap. + +``` +~/.nuget/packages/mockly/1.10.0/skills/mockly-usage/SKILL.md ← where restore puts it + ↓ +.agents/skills/mockly-usage/SKILL.md ← where your agent looks +``` + +## Install + +```bash +dotnet tool install --global dotnet-package-skills +``` + +## Use + +Run this command from your repository root: + +```bash +dotnet-package-skills install +``` + +This single command does the whole job. It finds your solution or project. It lists that +project's packages. It locates each direct dependency in the NuGet cache. It copies any bundled +skills into `.agents/skills/`. + +Run the command again after you add or upgrade a package. The command refreshes the skills of +the packages it finds. When a package moves to a new version, the command removes the skills +that version no longer ships. The command never removes a skill just because a package left the +project. Instead, it lists that skill, and you remove it later with +`dotnet-package-skills uninstall --stale`. + +### Commands + +| Command | What it does | +| --- | --- | +| `install` | Copies bundled skills into the destination. Add `--interactive` to choose which new skills to add. | +| `list` | Shows which packages ship skills. It copies nothing. | +| `uninstall` | Removes skills that this tool copied in. Add `--stale` to remove only the skills whose package the project no longer references. Add `--interactive` to pick them yourself. | + +### Choose what to read skills from + +There are three ways to tell the tool which packages to read skills from. + +```bash +dotnet-package-skills install # auto-detect solution or project +dotnet-package-skills install --target src/MyApp.slnx # a specific solution or project +dotnet-package-skills install --package Mockly@1.10.0 # exact packages, no project needed +``` + +`--package` is repeatable. It needs an **exact version**. The tool refuses `Mockly@1.*` and +`Mockly@[1.0,2.0)`. Resolving a range means picking one version from it, and the only correct +answer to "which version" comes from a project's own restore step. `--target` gives you that +answer. If the tool guessed a version instead, it could copy skills that describe a release you +do not actually reference. + +`--target` and `--package` cannot combine, because both options answer the same question. + +When you name packages explicitly, the tool touches only the packages you name. It leaves every +other installed skill alone. A target describes the project's complete set of packages. Because +of this, a target install can also tell you which installed skills belong to a package that the +project no longer references. When a package that the target resolves is missing from the NuGet +cache, `install` stops before it changes anything. Restore the project first, and then try again. + +The tool expects one version of each package. +[Central Package Management](https://learn.microsoft.com/nuget/consume-packages/central-package-management) +gives a repository that guarantee. When the target resolves a package to two versions, or when +`--package` names a package twice at two versions, `install` stops without changing anything. It +names the versions that you need to align. + +### Keep skills in step with the project + +When a package moves to a new version, `install` copies that version's skills over the old +copies. It removes any skill that the new version no longer ships. The manifest records one +version for each package, so the manifest always states which release the installed guidance +describes. + +When a package leaves the project, `install` keeps its skills and lists them: + +``` +2 installed skills belong to a package that the target no longer references: + fabrikam.testing-fakes (fabrikam.testing 1.4.0) + fabrikam.testing-fixtures (fabrikam.testing 1.4.0) +Run 'dotnet-package-skills uninstall --stale' to remove them. +``` + +The suggested command repeats the `--target` and `--destination` that you passed, so you can run +it exactly as printed. Every command that the tool's errors suggest works the same way. + +A package reference can disappear for a moment, for example halfway through a refactor. Because +of this, removing skills is always a command that you run on purpose. `uninstall --stale` removes +every **stale** skill. A stale skill is one whose package the target no longer references, or +whose package the target references at a different version. Preview this removal with +`--dry-run`, or pick among the stale skills yourself with `--interactive`: + +```bash +dotnet-package-skills uninstall --stale --dry-run +dotnet-package-skills uninstall --stale +``` + +`--stale` reads the project's package references, so it needs a solution or project. The tool +uses the one in the current directory, or the one you pass with `--target`. A skill that you +added with `install --package`, for a package outside the project, also counts as stale. + +### Choose which skills to install + +By default, `install` copies every skill that it finds. Add `--interactive` to choose which new +skills to add: + +```bash +dotnet-package-skills install --interactive # everything the project references +dotnet-package-skills install --package Mockly@1.10.0 --interactive # just one package's skills +``` + +`--interactive` combines with `--target` and `--package`. You can narrow the list to a single +package first, and then pick among the skills that package ships. Use this method when one +package bundles a dozen skills together. + +``` +Which skills should be installed? (MyApp.slnx) +Installed skills aren't listed. + +> [X] contoso.widgets-widget-usage - Correct usage patterns for the + Contoso.Widgets library, including lifetime rules and the batching API. + Use whenever code creates, configures, or disposes a Widget. + [ ] fabrikam.testing-fakes - Create fakes and verify their calls in unit + tests. + [ ] fabrikam.testing-fixtures - Share expensive setup across tests with + fixtures. + +1 of 3 selected +(Press to select, to accept) +(Press / to move, / for first/last) +(Press to select all, to clear all, // to cancel) +Blue X: selected +``` + +The checklist lists only skills that are not already installed. Nothing starts checked. When you +accept, the tool adds exactly the skills that you checked. An interactive install never refreshes +a skill and never removes a skill. Run `install` without `--interactive` to refresh a skill. Run +`uninstall` to remove a skill. When the packages ship only skills that are already installed, the +command prints `Nothing new to install.` and does not open the checklist. A skill that the tool +cannot add, for example because its name is already taken by another package or by a folder you +wrote yourself, is not listed. The report names these skills under its skipped warning instead. + +Adding a skill makes sense only when the installed skills already match the packages. Because of +this, an interactive install stops before the checklist opens, and changes nothing, in these +cases: + +- The target resolves a package to more than one version, or `--package` names a package more + than once. +- With a target, a package that the target resolves is missing from the NuGet cache. +- With a target, an installed skill is stale. Run `dotnet-package-skills uninstall --stale` + first. +- With `--package`, a named package is installed at a different version. Run + `dotnet-package-skills uninstall --package ` first. You can also run + `install --package @` without `--interactive` to move the package to its new + version. + +Each description follows the authored skill name right after ` - `. There is no padded column and +no package or version suffix. The tool keeps any package prefix in an authored name. A +continuation line flows beneath the skill's own text, using the available width, instead of +leaving a name-sized gap. The focused skill's text turns blue, including every wrapped +description line. A checked item shows a blue `X`. Every other skill name and description keeps +its normal color. The summary counts only the checked skills. There is no separate status +column. When you set `NO_COLOR`, or when your terminal has no color support, `>` marks the +focused skill and `[X]` marks each checked skill, with no other symbol beside them. + +The keyboard hints appear below the list, in the style of Aspire's +`(Press to select, to accept)` message. Every keyboard-help line starts with the +word `Press`. This applies to movement, paging, select-all, clear-all, cancel, and description +scrolling. The tool leaves out a control that would do nothing. These keys work: + +| Key | Does | +| --- | --- | +| `up` / `down` | Moves to the next or previous skill, and wraps around at either end | +| `left` / `right`, `pgup` / `pgdn` | Moves to the previous or next page | +| `home` / `end` | Jumps to the first or last skill | +| `space` | Toggles the highlighted skill | +| `a` / `c` | Selects all skills, or clears all skills, across every page | +| `ctrl+up` / `ctrl+down` | Scrolls a description when one skill is taller than a page | +| `enter` | Confirms the selection | +| `esc` / `q` / `ctrl+c` | Cancels, and changes nothing | + +The tool measures a page in rendered lines, including wrapped descriptions and keyboard hints. It +does not measure a page by a fixed number of skills. Each ordinary skill stays together on one +page. A user can scroll a description that is too long for one page, without changing the +selection. When you resize the terminal, the tool reflows the page in place. It keeps the +highlighted skill and the checked items, even during a redraw. The tool does not push old picker +frames into your scrollback. When you scroll an oversized description, the skill's own row stays +visible while its continuation lines scroll. A short list, or a partial final page, does not +leave a screenful of blank rows. A single page shows no page counter. The note under the title +gives way first when the window is too small to fit it. This way, a small window still shows the +checklist instead of refusing to open. + +The live picker uses a temporary terminal screen. It starts at the top of that screen, regardless +of where the shell's cursor was before you ran the command. A host-driven reflow cannot leave +duplicate copies of the checklist in your normal scrollback. When you accept, cancel, or hit a +handled failure, the tool restores the previous shell screen. It writes the final report there, +not next to an old checklist. + +Descriptions come from the top-level YAML `description` property in each package's `SKILL.md` +file. A missing description shows the text `No description provided.` Unreadable or malformed +metadata shows an explicit description warning, but it does not hide the skill or block its +selection. Only the interactive checklists read this metadata. Reports and the ownership +manifest never include a description. + +`--interactive` needs a terminal. Pair it with `--dry-run` to see what a selection would change, +before you commit to that change. + +### Choose what to remove + +`uninstall` also takes `--interactive`. It lists only what this tool installed. It never lists a +skill that you wrote yourself, because it reads the manifest instead of scanning the folder: + +```bash +dotnet-package-skills uninstall --interactive +``` + +``` +Which skills should be uninstalled? + +> [X] contoso.widgets-widget-testing - Testing patterns for code that uses + Contoso.Widgets. Use when writing unit or integration tests involving + widgets. + [ ] contoso.widgets-widget-usage - Correct usage patterns for the + Contoso.Widgets library, including lifetime rules and the batching API. + Use whenever code creates, configures, or disposes a Widget. + +1 of 2 selected; 1 to remove +(Press to select, to accept) +(Press / to move, / for first/last) +(Press to select all, to clear all, // to cancel) +Blue X: selected +``` + +Nothing starts checked, so a mistaken enter removes nothing. A checked row looks the same as it +does in the install checklist. Each checklist does only one thing, so its title and its summary +state what a check mark does. Narrow the list first with `--package` when you care about only one +package. Narrow it with `--stale` to see only the skills that no longer match the project: + +``` +Which skills should be uninstalled? +Only skills that don't match the target are listed. + +> [X] fabrikam.testing-fakes - Create fakes and verify their calls in unit + tests. + [ ] fabrikam.testing-fixtures - Share expensive setup across tests with + fixtures. + +1 of 2 selected; 1 to remove +``` + +Add `--dry-run` to see the outcome without it happening. Descriptions come from the installed +copies, not from the NuGet cache. A missing or damaged `SKILL.md` does not block removal of a +manifest-owned skill. Package matching ignores letter case. Both modes normalize version filters, +so `1.10` matches `1.10.0`. A blank, missing, or repeated `--package` value on `uninstall` is an +error. It never broadens the command to an unfiltered uninstall. `--stale` and `--package` cannot +combine. + +### Options + +| Option | Applies to | Description | +| --- | --- | --- | +| `-t, --target ` | install, list | Solution or project to inspect. Defaults to searching the current directory. | +| `-p, --package ` | install, list | Take skills from an exact package instead of a project. Repeatable. Refuses a floating version. | +| `-d, --destination ` | install, list | Where the tool copies skills to. Default `.agents/skills`. | +| `-d, --destination ` | uninstall | Where the tool removes skills from. Must match the destination you installed to. | +| `--global-packages ` | install, list | Overrides the NuGet global packages folder. | +| `-i, --interactive` | install | Lets you choose which new skills to add, with descriptions and pagination. Lists only skills that are not installed. Combines with `--target` or `--package`. | +| `-i, --interactive` | uninstall | Lets you choose which installed skills to remove, with descriptions and pagination. Lists only skills that this tool installed. | +| `-p, --package ` | uninstall | Removes only this package's skills. Removes whichever version is installed, or only the version you name. | +| `--stale` | uninstall | Removes only stale skills: a skill whose package the target no longer references, or whose package the target references at a different version. Needs a solution or project. Cannot combine with `--package`. | +| `-t, --target ` | uninstall | With `--stale`, the solution or project to compare against. Defaults to searching the current directory. | +| `--dry-run` | install, uninstall | Reports what would change, and writes nothing. | + +### Target another agent's folder + +`.agents/skills` is the vendor-neutral default destination. Point `--destination` anywhere else: + +```bash +dotnet-package-skills install --destination .claude/skills +dotnet-package-skills install --destination .codex/skills +``` + +`uninstall` takes the same option, and it needs that option. `uninstall` looks only where you +point it. To remove what you put in `.claude/skills`, you must name that folder again. + +```bash +dotnet-package-skills uninstall --destination .claude/skills +``` + +### Scripts and CI + +The tool writes reports for people to read. The manifest is its only machine-readable output. In +a script, rely on the exit code. The code is `0` when the command succeeded. The code is `1` when +the command stopped, and the reason appears on stderr. A command that stops changes nothing. An +argument error also prints help text on stdout. + +A job that keeps a committed skills folder in step with the project can run these two commands: + +```bash +dotnet-package-skills install +dotnet-package-skills uninstall --stale +``` + +To see what the tool installed, read `.agents/skills/.dotnet-package-skills.json`. [What you +get](#what-you-get) describes this file. `--interactive` needs a terminal, so leave it out of a +script. + +A report and a diagnostic message are both written for people, including an argument-validation +error and a parser suggestion. The tool removes terminal escape sequences and unsafe control +characters from metadata, paths, and diagnostic text before it shows them. This change affects +only the display. The tool still validates arguments exactly as you supplied them, and a stored +identity stays unchanged. + +## What you get + +Each authored skill folder lands directly under the destination: + +``` +.agents/skills/ +├── .dotnet-package-skills.json # what this tool copied in; do not hand-edit +├── contoso.widgets-widget-usage/ +│ ├── SKILL.md +│ └── references/ +│ └── batching.md +└── contoso.widgets-widget-testing/ + └── SKILL.md +``` + +The tool keeps the skill folder's name from the package. The package ID and version stay in the +install manifest, for attribution and for uninstall filtering, but the tool does not add them to +the path. + +The manifest follows the shape of the .NET local tool manifest, `dotnet-tools.json`. It has a +format `version`, and then one entry for each package, keyed by the lowercase package ID. Each +entry names the one version that its skills came from, and the skill folders it owns: + +```json +{ + "version": 1, + "packages": { + "contoso.widgets": { + "version": "2.3.0", + "skills": [ + "contoso.widgets-widget-testing", + "contoso.widgets-widget-usage" + ] + } + } +} +``` + +You can safely commit this file. The tool writes it the same way on every platform. It uses +UTF-8 without a byte order mark, LF line endings, and a stable order for its entries. Because of +this, a Windows checkout and a Linux checkout produce the same bytes. The tool ignores a property +that it does not recognize. It refuses a manifest with a newer format `version`, and it asks you +to update the tool instead. + +We recommend that a package author prefix every skill folder with the package's lowercased ID, as the +example above shows. This convention keeps names globally unique when skills from many packages +share one destination. The tool documents this convention. It does not enforce it. An existing +safe name still works. + +### Name collisions + +The tool compares destination names without regard to letter case. When two package skills +choose the same name, the tool copies the first one in a fixed package order. It skips a later +collision and shows a warning. An existing destination folder that this tool does not track +belongs to the user. The tool skips that folder too, and never overwrites it. + +The same protection applies to a name that a different package already owns. Every install mode +warns about this and keeps the current owner. It does not transfer the name automatically. +Uninstall the old skill explicitly before you install its replacement. Upgrading the same package +still works as expected. When both the current owner and another package offer the same name, +installation prefers the owner's candidate. This way, the conflict does not block a legitimate +refresh. + +One combination stops `install` instead of skipping a file. This happens when the owner's package +moves to a version that no longer ships the skill, while a different package ships a skill with +that same name. Removing the old copy would hand the name to the other package. Keeping the old +copy would record it under the new version's number, which would be wrong. For both reasons, +`install` changes nothing in this case. It suggests `uninstall --package ` for the owner. +After you run that command, `install` copies both packages' current skills. + +V1 does not reconcile two folders that differ only in physical case on a case-sensitive file +system. Keep an authored skill folder's casing stable across versions. Avoid folders such as +`guide` and `GUIDE` in the same destination. A case-only rename, or a collision between those +physical variants, can leave an untracked old copy behind, or it can overwrite a handwritten +variant. Both outcomes fall outside the v1 guarantees. + +A refresh of a tracked skill replaces its entire folder. This includes any local edits and any +files that you added. Keep hand-written guidance in a separate, untracked skill folder instead. + +### Package versions + +The destination holds skills from one version of each package, and the manifest records that one +version. +[NuGet Central Package Management](https://learn.microsoft.com/nuget/consume-packages/central-package-management) +keeps the projects in a repository on one version of each package. We recommend it for this +reason. When a target resolves a package to more than one version, `install` stops without +changing anything, and it names the versions that you need to align. `--package` stops the same +way when you name one package at two versions. `list` still shows every version that it finds. + +### Commit or ignore this folder + +Both choices are reasonable. Commit the folder so the whole team and your CI system get the +skills without running anything. Or add the folder to `.gitignore` and let each machine refresh +it on its own. Pick one choice, and state that choice in your own contributing guide. + +## For package authors: ship a skill + +Put each skill under `skills/-/`. Give it its own `SKILL.md` file, plus +any supporting files it needs. Prefix the folder with your lowercased package ID. This keeps your +skills from colliding with another package's skills on the consumer's machine. + +```xml + + + + +``` + +Every skill must have its own immediate subfolder under `skills/`. The tool does not discover a +lone `skills/SKILL.md` file. + +Give each skill a useful `description` in its YAML frontmatter, so a customer can decide whether +they need it: + +```yaml +--- +name: contoso.widgets-widget-usage +description: > + Correct usage patterns for Contoso.Widgets, including lifetime rules and batching. + Use when creating, configuring, or disposing a Widget. +--- +``` + +The tool supports a plain description, a quoted description, a literal (`|`) description, and a +folded (`>`) description. The interactive picker reads only bounded frontmatter. It never +interprets the Markdown instructions in the file, and it never rewrites the file. Description +metadata exists to inform the user. It is not an additional requirement for installation. +Frontmatter is limited to 65,536 decoded characters and 32 collection levels. The description +reader does not support an explicit YAML tag, anchor, or alias. These produce a visible metadata +warning instead of blocking installation. + +## How it works + +1. `dotnet list package --format json` finds the resolved direct packages. The tool + never restores a project on its own. The .NET 10 SDK restores the project during this step, + when it needs to. An earlier SDK instead says that the target needs to be restored first. When + this step fails, the tool shows what it reported, so you can restore or fix the target and run + the tool again. +2. `dotnet nuget locals global-packages --list` finds where restore extracted those packages. + `NUGET_PACKAGES` and `--global-packages` both take precedence over this step, in that order. +3. `install` stops without changing anything when a package resolves to more than one version, or + when a package that the target resolves is missing from the cache. +4. For each package, the tool looks in `///skills/`. +5. The tool copies each `skills//` folder to `//`. It skips a collision + and shows a warning instead. For a package that moved to a new version, the tool removes the + skills that the new version no longer ships. +6. The tool records what it copied in `/.dotnet-package-skills.json`. + +`uninstall --stale` needs only step 1. It compares the manifest with the target's package +references, and it never looks in the NuGet cache for skills. + +The tool never reads or interprets anything inside a skill. The package author decides what a +skill contains. This tool only places that content where an agent will look for it. + +### The tool only copies skills + +The global packages folder is NuGet's content-addressable cache. NuGet validates this folder +during restore, and every project on the machine shares it. If you move a file out of this +folder, restore may treat the cached package as damaged. Moving a file would also remove the +skill from every other repository that uses that package. + +### Removal is manifest-driven + +`.dotnet-package-skills.json` records what the tool copied in. `install` removes only the paths +listed there, when a package moves to a new version. `uninstall` removes only those listed paths +too. Neither command scans arbitrary folders. Keep hand-written guidance in a separate, untracked +folder. This folder is still subject to the v1 case-variant and linked-manifest limitations that +this document describes. + +When that manifest exists but the tool cannot read it, `install` and `uninstall` both stop +without changing anything. They keep the file in place so you can repair it. Resolve any merge +conflict in the file, or restore it from source control, before you try again. If you cannot +recover the file, move the whole destination folder aside before you install again. The tool will +not guess which existing folders it owns. + +The tool also refuses a manifest in three other cases. It refuses a manifest that names a newer +format version. Update the tool instead. It refuses a manifest that a pre-release build of this +tool wrote. Move the skills folder aside and install again instead. It refuses a manifest where a +package is missing its version, where a package ID is invalid, or where a skill is claimed twice. + +A skill name must identify a single folder directly inside the destination. The tool rejects a +name that ends in a dot or a space, including the name `...`, because Windows can resolve such a +name to a different folder or to the destination folder itself. A manifest that contains such a +name blocks both install and uninstall, including interactive mode and dry-run mode, before the +tool changes any skill file or manifest byte. + +By default, the tool creates an ordinary manifest file, and it updates an existing manifest in +place. V1 does not support a symbolic link or another kind of redirected manifest. The tool does +not create such a link, and it does not protect a link's target. An ordinary file system +operation can follow a link, including a link that already exists in a checked-out repository. +Use a regular manifest file in your skills destination. A customer who provides a link is +responsible for that link's effects. + +The tool serializes concurrent operations on the same destination. It rejects an interactive +choice if ownership changed before the tool could apply that choice. + +## A note on trust + +A bundled skill is a set of instructions that a third party wrote. Your agent will follow those +instructions, so a bundled skill is part of your software supply chain. This tool only copies +skills from a package that your project already depends on, and it prints every skill that it +copies, so you can review them. Treat a new skill the way you would treat any new dependency. + +## Common problems + +**"No bundled skills found"** This is the common and correct outcome. Most packages do not ship +skills. + +**"'dotnet list ... package' failed"** The tool reads the target's packages with `dotnet list +package`, and it shows what that command reported. For example, a restore may have failed, or an +earlier SDK may say that the target needs restoring. The tool never restores a project on its +own. Resolve what the tool reports, for example by running `dotnet restore`, and run the tool +again. + +**"resolved packages are missing from"** the NuGet cache. Run `dotnet restore` for the target and +try again. This message also appears when your packages come from a NuGet *fallback folder*, +which is common in a container or on a hosted build agent. Point `--global-packages` at that +folder. + +**"resolve to more than one version"** Projects in the target reference different versions of +one package. Align those versions, for example with Central Package Management, and try again. + +**"installed skills don't match the target"** This message comes from `install --interactive`. +Some installed skills are stale. Preview them with +`dotnet-package-skills uninstall --stale --dry-run`. Remove them with `uninstall --stale`, and +try again. + +**"is already installed, and an interactive install only adds skills"** `install --interactive +--package` named a package that is installed at a different version. Run `install --package` +without `--interactive` to move the package to the new version, or run `uninstall --package ` +first. + +**"Could not read the install manifest"** The manifest has a merge conflict, or someone edited it +into a shape that the tool cannot trust. See +[Removal is manifest-driven](#removal-is-manifest-driven). + +**"Unrecognized option '--format'"** Your SDK is older than 7.0.200. Upgrade it. + +**The tool reads the wrong global packages folder.** `nuget.config` discovery walks up from the +current directory. Run the tool from your repository root instead, or pass `--global-packages` +explicitly. + +**A solution filter (`.slnf`) is rejected.** Not every SDK accepts a solution filter with +`dotnet list package`. Pass the underlying `.sln` file instead, or run the tool once for each +project with `--target`. + +## Build from source + +Run these commands from the `dotnet-package-skills` folder in a Client.Tools checkout. This way, +`global.json` selects the pinned .NET SDK. Install the .NET 8 runtime as well as .NET 10. Install +PowerShell 7 too, for the C# pipeline-version tests. The CI system currently validates these +commands on Windows only. + +```powershell +Set-Location .\dotnet-package-skills +dotnet restore .\DotnetPackageSkills.slnx --configfile .\NuGet.config +dotnet build .\DotnetPackageSkills.slnx -c Release --no-restore +dotnet test .\tests\DotnetPackageSkills.Tests.csproj -c Release --no-build --no-restore +dotnet pack .\src\DotnetPackageSkills.csproj -c Release --no-build --no-restore -o .\artifacts\packages +pwsh -NoProfile -File ..\eng\pipelines\dotnet-package-skills\Verify-Package.ps1 ` + -PackagePath .\artifacts\packages\dotnet-package-skills.0.1.0-dev.nupkg ` + -ExpectedVersion 0.1.0-dev ` + -BuildOutputPath .\src\bin\Release +``` + +This verification command installs the exact local package into a temporary tool path, once for +.NET 8 and once for .NET 10. It runs the package's non-interactive commands, and then it removes +its own temporary files. It does not replace a tool that you installed globally, and it does not +change your installed skills. + +A local build uses the version `0.1.0-dev`. A PR build uses the version +`0.1.0-pr..`. An ordinary official build uses the version +`0.1.0-preview.`. Only an explicit manual official release run produces the stable base +version. A build does not publish its artifacts to a NuGet feed automatically. See the +[pipeline and release guide](../eng/pipelines/dotnet-package-skills/README.md) for more +information. + +## License + +MIT diff --git a/dotnet-package-skills/global.json b/dotnet-package-skills/global.json new file mode 100644 index 0000000..3dbe096 --- /dev/null +++ b/dotnet-package-skills/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.400", + "rollForward": "latestPatch", + "allowPrerelease": false + } +} diff --git a/dotnet-package-skills/src/Cli/CommandLineDiagnostics.cs b/dotnet-package-skills/src/Cli/CommandLineDiagnostics.cs new file mode 100644 index 0000000..5267761 --- /dev/null +++ b/dotnet-package-skills/src/Cli/CommandLineDiagnostics.cs @@ -0,0 +1,47 @@ +using System.CommandLine; + +namespace DotnetPackageSkills.Cli; + +/// Sanitizes framework diagnostics without changing arguments or application console output. +internal static class CommandLineDiagnostics +{ + public static int Invoke(ParseResult result, TextWriter output, TextWriter error) + { + // Suggestions use Output, not Error. Buffer both through the whole invocation so + // split escape sequences, surrogate pairs, and Flush calls cannot bypass sanitizing. + using var capturedOutput = new StringWriter(output.FormatProvider) { NewLine = output.NewLine }; + using var capturedError = new StringWriter(error.FormatProvider) { NewLine = error.NewLine }; + + try + { + return result.Invoke(new InvocationConfiguration + { + Output = capturedOutput, + Error = capturedError, + }); + } + finally + { + Write(error, capturedError.ToString()); + Write(output, capturedOutput.ToString()); + } + } + + private static void Write(TextWriter writer, string text) + { + if (text.Length == 0) + { + return; + } + + var clean = TerminalText.Sanitize(text, multiline: true, trim: false); + + if (text.EndsWith('\n') && !clean.EndsWith('\n')) + { + // An unterminated control string can also consume the framework's final newline. + clean += "\n"; + } + + writer.Write(clean.Replace("\n", writer.NewLine, StringComparison.Ordinal)); + } +} diff --git a/dotnet-package-skills/src/Cli/ConsoleViewport.cs b/dotnet-package-skills/src/Cli/ConsoleViewport.cs new file mode 100644 index 0000000..7f8980c --- /dev/null +++ b/dotnet-package-skills/src/Cli/ConsoleViewport.cs @@ -0,0 +1,132 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace DotnetPackageSkills.Cli; + +internal static class ConsoleViewport +{ + public static (int Width, int Height) Size() + { + if (!OperatingSystem.IsWindows()) + { + return (Console.WindowWidth, Console.WindowHeight); + } + + var buffer = ReadBuffer(); + return (buffer.Window.Right - buffer.Window.Left + 1, buffer.Window.Bottom - buffer.Window.Top + 1); + } + + public static void SetCursorPosition(int left, int top) + { + if (!OperatingSystem.IsWindows()) + { + Console.SetCursorPosition(left, top); + return; + } + + var buffer = ReadBuffer(); + var position = new Coordinate + { + X = (short)Math.Clamp(buffer.Window.Left + left, buffer.Window.Left, buffer.Window.Right), + Y = (short)Math.Clamp(buffer.Window.Top + top, buffer.Window.Top, buffer.Window.Bottom), + }; + if (!SetConsoleCursorPosition(GetStdHandle(-11), position)) + { + throw ConsoleError("Could not position the interactive terminal cursor."); + } + } + + public static void Clear() + { + if (OperatingSystem.IsWindows()) + { + ClearWindows(); + } + else + { + Console.Write("\x1b[2J\x1b[H"); + } + } + + [SupportedOSPlatform("windows")] + private static void ClearWindows() + { + var output = GetStdHandle(-11); + var buffer = ReadBuffer(); + + // Clear cells in place. Printing blank lines instead pushes stale picker frames + // into scrollback, where the terminal can rewrap them independently after a resize. + var width = (uint)(buffer.Window.Right - buffer.Window.Left + 1); + for (var row = (int)buffer.Window.Top; row <= buffer.Window.Bottom; row++) + { + var position = new Coordinate { X = buffer.Window.Left, Y = (short)row }; + if (!FillConsoleOutputCharacter(output, ' ', width, position, out _) || + !FillConsoleOutputAttribute(output, buffer.Attributes, width, position, out _)) + { + throw ConsoleError("Could not clear the interactive terminal viewport."); + } + } + } + + [SupportedOSPlatform("windows")] + private static ScreenBufferInfo ReadBuffer() + { + if (!GetConsoleScreenBufferInfo(GetStdHandle(-11), out var buffer)) + { + throw ConsoleError("Could not read the interactive terminal viewport."); + } + + return buffer; + } + + private static IOException ConsoleError(string message) => + new(message, new Win32Exception(Marshal.GetLastPInvokeError())); + + [StructLayout(LayoutKind.Sequential)] + private struct Coordinate + { + public short X; + public short Y; + } + + [StructLayout(LayoutKind.Sequential)] + private struct WindowRectangle + { + public short Left; + public short Top; + public short Right; + public short Bottom; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ScreenBufferInfo + { + public Coordinate Size; + public Coordinate Cursor; + public ushort Attributes; + public WindowRectangle Window; + public Coordinate MaximumWindowSize; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern nint GetStdHandle(int handle); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetConsoleScreenBufferInfo(nint output, out ScreenBufferInfo info); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetConsoleCursorPosition(nint output, Coordinate position); + + [DllImport("kernel32.dll", EntryPoint = "FillConsoleOutputCharacterW", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool FillConsoleOutputCharacter( + nint output, char character, uint length, Coordinate position, out uint written); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool FillConsoleOutputAttribute( + nint output, ushort attributes, uint length, Coordinate position, out uint written); +} diff --git a/dotnet-package-skills/src/Cli/ITerminal.cs b/dotnet-package-skills/src/Cli/ITerminal.cs new file mode 100644 index 0000000..f5a4461 --- /dev/null +++ b/dotnet-package-skills/src/Cli/ITerminal.cs @@ -0,0 +1,281 @@ +using System.Diagnostics; +using System.Text; + +namespace DotnetPackageSkills.Cli; + +internal enum TerminalStyle +{ + Default, + Focus, + Selected, + Muted, +} + +internal readonly record struct TerminalState( + bool CursorVisible, + bool TreatControlCAsInput, + ConsoleColor? Foreground, + ConsoleColor? Background, + TerminalStyle Style, + Encoding OutputEncoding); + +/// The console operations the interactive picker needs, in viewport coordinates. +internal interface ITerminal +{ + bool IsRedirected { get; } + + bool SupportsColor { get; } + + int WindowHeight { get; } + + int WindowWidth { get; } + + (int Width, int Height) GetWindowSize() => (WindowWidth, WindowHeight); + + int CursorTop { get; } + + bool CursorVisible { set; } + + bool TreatControlCAsInput { set; } + + TerminalState CaptureState(); + + void RestoreState(TerminalState state); + + /// Uses lossless Unicode output for this interaction; RestoreState restores the encoding. + void UseUtf8Output(); + + IDisposable EnterInteractiveScreen(); + + void SetStyle(TerminalStyle style); + + void ResetStyle(); + + void SetCursorPosition(int left, int top); + + void Write(string text); + + void WriteLine(string text = ""); + + /// Starts a fresh viewport for the initial frame or a resize, without discarding scrollback. + void ClearViewport(); + + /// Waits at most the timeout; false lets the picker observe an idle resize. + bool TryReadKey(TimeSpan timeout, out ConsoleKeyInfo key); + + ConsoleKeyInfo ReadKey(); +} + +/// An over the real console. +internal sealed class SystemTerminal : ITerminal +{ + private const int FallbackHeight = 24; + private const int FallbackWidth = 80; + private TerminalStyle _style; + private TerminalStyle? _appliedStyle; + + public bool IsRedirected => Console.IsInputRedirected || Console.IsOutputRedirected; + + public bool SupportsColor => CanUseColor( + IsRedirected, + Environment.GetEnvironmentVariable("NO_COLOR"), + Environment.GetEnvironmentVariable("TERM"), + OperatingSystem.IsWindows()); + + public int WindowHeight => GetWindowSize().Height; + + public int WindowWidth => GetWindowSize().Width; + + public (int Width, int Height) GetWindowSize() + { + var (width, height) = Read(ConsoleViewport.Size, (FallbackWidth, FallbackHeight)); + return (width > 0 ? width : FallbackWidth, height > 0 ? height : FallbackHeight); + } + + public int CursorTop => Math.Clamp( + Read(static () => Console.CursorTop, 0) - Read(static () => Console.WindowTop, 0), + 0, + WindowHeight - 1); + + public bool CursorVisible + { + set => Ignoring(() => Console.CursorVisible = value); + } + + public bool TreatControlCAsInput + { + set => Ignoring(() => Console.TreatControlCAsInput = value); + } + + public TerminalState CaptureState() => new( + Read(static () => OperatingSystem.IsWindows() ? Console.CursorVisible : true, true), + Read(static () => Console.TreatControlCAsInput, false), + ReadColor(static () => Console.ForegroundColor), + ReadColor(static () => Console.BackgroundColor), + _style, + Console.OutputEncoding); + + public void UseUtf8Output() => Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + + public IDisposable EnterInteractiveScreen() => InteractiveScreen.Enter(); + + public void RestoreState(TerminalState state) + { + try + { + ResetStyle(); + if (SupportsColor) + { + if (state.Foreground is { } foreground) + { + Ignoring(() => Console.ForegroundColor = foreground); + } + + if (state.Background is { } background) + { + Ignoring(() => Console.BackgroundColor = background); + } + } + + _style = state.Style; + _appliedStyle = null; + } + finally + { + try + { + CursorVisible = state.CursorVisible; + } + finally + { + try + { + TreatControlCAsInput = state.TreatControlCAsInput; + } + finally + { + Console.OutputEncoding = state.OutputEncoding; + } + } + } + } + + public void SetStyle(TerminalStyle style) + { + var supportsColor = SupportsColor; + _style = supportsColor ? style : TerminalStyle.Default; + if (!supportsColor || _appliedStyle == style) + { + return; + } + + Ignoring(Console.ResetColor); + var color = style switch + { + TerminalStyle.Focus => ConsoleColor.Blue, + TerminalStyle.Selected => ConsoleColor.Blue, + TerminalStyle.Muted => ConsoleColor.DarkGray, + _ => (ConsoleColor?)null, + }; + + if (color is { } foreground) + { + Ignoring(() => Console.ForegroundColor = foreground); + } + + _appliedStyle = style; + } + + public void ResetStyle() => SetStyle(TerminalStyle.Default); + + public void SetCursorPosition(int left, int top) => ConsoleViewport.SetCursorPosition(left, top); + + public void Write(string text) => Console.Write(text); + + public void WriteLine(string text = "") => Console.WriteLine(text); + + public void ClearViewport() + { + ConsoleViewport.Clear(); + SetCursorPosition(0, 0); + } + + public ConsoleKeyInfo ReadKey() => Console.ReadKey(intercept: true); + + public bool TryReadKey(TimeSpan timeout, out ConsoleKeyInfo key) + { + ArgumentOutOfRangeException.ThrowIfLessThan(timeout, TimeSpan.Zero); + var started = Stopwatch.GetTimestamp(); + while (true) + { + if (Console.KeyAvailable) + { + key = ReadKey(); + return true; + } + + var remaining = timeout - Stopwatch.GetElapsedTime(started); + if (remaining <= TimeSpan.Zero) + { + key = default; + return false; + } + + // Keep key latency low without spinning, including the final fractional millisecond. + Thread.Sleep((int)Math.Clamp(Math.Ceiling(remaining.TotalMilliseconds), 1, 25)); + } + } + + internal static bool CanUseColor(bool redirected, string? noColor, string? term, bool windows) + { + if (redirected || noColor is not null) + { + return false; + } + + var capability = term?.ToLowerInvariant(); + if (capability is "dumb" or "unknown" or "vt100" or "vt102" or "vt220") + { + return false; + } + + return windows || + capability is "linux" or "ansi" or "cygwin" || + capability is not null && + (capability.Contains("color", StringComparison.Ordinal) || + capability.StartsWith("xterm", StringComparison.Ordinal) || + capability.StartsWith("screen", StringComparison.Ordinal) || + capability.StartsWith("tmux", StringComparison.Ordinal) || + capability.StartsWith("rxvt", StringComparison.Ordinal)); + } + + private static ConsoleColor? ReadColor(Func read) + { + var color = Read(read, (ConsoleColor)(-1)); + return (int)color is >= 0 and <= 15 ? color : null; + } + + private static T Read(Func read, T fallback) + { + try + { + return read(); + } + catch (Exception ex) when (ex is IOException or PlatformNotSupportedException or InvalidOperationException) + { + return fallback; + } + } + + private static void Ignoring(Action action) + { + try + { + action(); + } + catch (Exception ex) when (ex is IOException or PlatformNotSupportedException or + ArgumentOutOfRangeException or InvalidOperationException) + { + } + } +} diff --git a/dotnet-package-skills/src/Cli/InteractiveScreen.cs b/dotnet-package-skills/src/Cli/InteractiveScreen.cs new file mode 100644 index 0000000..afa9cbf --- /dev/null +++ b/dotnet-package-skills/src/Cli/InteractiveScreen.cs @@ -0,0 +1,87 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace DotnetPackageSkills.Cli; + +/// Keeps transient picker frames out of the shell's reflowable scrollback. +internal sealed class InteractiveScreen : IDisposable +{ + private const uint EnableProcessedOutput = 0x0001; + private const uint EnableVirtualTerminalProcessing = 0x0004; + private readonly nint _output; + private readonly uint? _originalMode; + private bool _disposed; + + private InteractiveScreen(nint output, uint? originalMode) + { + _output = output; + _originalMode = originalMode; + } + + public static InteractiveScreen Enter() + { + nint output = nint.Zero; + uint? originalMode = null; + if (OperatingSystem.IsWindows()) + { + output = GetStdHandle(-11); + if (!GetConsoleMode(output, out var mode) || + !SetConsoleMode(output, mode | EnableProcessedOutput | EnableVirtualTerminalProcessing)) + { + throw new PackageSkillsException( + "This terminal cannot open an interactive screen. Use a terminal with virtual-terminal support " + + "or run the command without --interactive.", + new Win32Exception(Marshal.GetLastPInvokeError())); + } + + originalMode = mode; + } + + var screen = new InteractiveScreen(output, originalMode); + try + { + Console.Write("\x1b[?1049h"); + Console.Out.Flush(); + return screen; + } + catch + { + screen.Dispose(); + throw; + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + try + { + Console.Write("\x1b[?1049l"); + Console.Out.Flush(); + } + finally + { + if (_originalMode is { } mode && !SetConsoleMode(_output, mode)) + { + throw new IOException("Could not restore the terminal output mode.", + new Win32Exception(Marshal.GetLastPInvokeError())); + } + } + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern nint GetStdHandle(int handle); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetConsoleMode(nint handle, out uint mode); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetConsoleMode(nint handle, uint mode); +} diff --git a/dotnet-package-skills/src/Cli/InteractiveSkills.cs b/dotnet-package-skills/src/Cli/InteractiveSkills.cs new file mode 100644 index 0000000..d5cd537 --- /dev/null +++ b/dotnet-package-skills/src/Cli/InteractiveSkills.cs @@ -0,0 +1,73 @@ +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Cli; + +internal sealed record UninstallChoice( + IReadOnlyCollection Selected, + IReadOnlyCollection ExpectedInstalled); + +internal static class InteractiveSkills +{ + /// + /// Checklist items for install, which only adds. Installed skills are never offered, so + /// nothing on the list can refresh, replace or remove a skill the user already has. + /// + public static IReadOnlyList ForInstall( + IReadOnlyList candidates, + IReadOnlyCollection installed) + { + var tracked = installed.Select(entry => entry.Skill).ToHashSet(StringComparer.OrdinalIgnoreCase); + + return + [ + .. candidates + .Where(skill => !tracked.Contains(skill.RelativePath)) + .Select(skill => Describe(skill.RelativePath, skill.PackageId, skill.PackageVersion, skill.SourcePath)) + .OrderBy(item => item.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(item => item.Name, StringComparer.Ordinal), + ]; + } + + public static IReadOnlyList ForUninstall( + IReadOnlyList skills, + string destination) => + [ + .. skills.Select(skill => Describe( + skill.Skill, + skill.Package, + skill.Version, + Path.Combine(destination, skill.Skill))), + ]; + + /// The checked skills that were actually shown. Nothing else is installed or changed. + public static SkillChoice InstallChoice( + IReadOnlyList candidates, + IReadOnlyCollection installed, + IReadOnlyList shown, + IReadOnlySet selected) => + new( + [ + .. candidates.Where(skill => selected.Contains(skill.RelativePath) && + shown.Any(item => + item.Name.Equals(skill.RelativePath, StringComparison.OrdinalIgnoreCase) && + item.Package.Equals(skill.PackageId, StringComparison.OrdinalIgnoreCase))), + ]) + { + ExpectedInstalled = installed, + }; + + private static SkillPickerItem Describe( + string name, + string package, + string version, + string skillDirectory) + { + var metadata = SkillDescriptionReader.Read(skillDirectory); + return new SkillPickerItem( + name, + package, + version, + metadata.Description, + metadata.Warning); + } +} diff --git a/dotnet-package-skills/src/Cli/OutputWriter.cs b/dotnet-package-skills/src/Cli/OutputWriter.cs new file mode 100644 index 0000000..97ca208 --- /dev/null +++ b/dotnet-package-skills/src/Cli/OutputWriter.cs @@ -0,0 +1,203 @@ +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Cli; + +/// Renders results for people. There is no machine-readable report. +public sealed class OutputWriter(TextWriter output, TextWriter? errorOutput = null) +{ + /// + /// False for list, which discovers without writing, so the report says "Found" + /// rather than claiming files were placed. + /// + public void WriteInstallReport(InstallResult result, bool copied) + { + WriteContext(result); + + var verb = copied + ? result.DryRun ? "Would copy" : "Copied" + // list discovers without writing, and it always runs as a dry run, so asking + // DryRun first would make this branch unreachable and claim a copy was pending. + : "Found"; + + if (result.Skills.Count > 0) + { + output.WriteLine($"{verb} {Count(result.Skills.Count, "skill")}:"); + + foreach (var skill in result.Skills) + { + output.WriteLine($" {Describe(skill.RelativePath, skill.PackageId, skill.PackageVersion)}"); + } + } + else if (result.NothingNewToInstall) + { + // An interactive install lists only skills that are not installed. With nothing left + // to list there was no checklist, and the skipped section below says why when some + // skills could not be offered. + output.WriteLine(result.Skipped.Count == 0 + ? "Nothing new to install. Every skill that these packages ship is already installed." + : "Nothing new to install."); + } + else if (result.SkillsDiscovered > 0) + { + // Packages did ship skills; none of them ended up installed, because they were + // deselected or skipped. Saying nobody ships a skill here would be a lie, and the + // sections below already explain what happened to each one. + output.WriteLine($"{verb} no skills."); + } + else + { + // Packages missing from the cache look exactly like packages without skills, so + // claim nothing about why the list is empty. + output.WriteLine("No bundled skills found."); + } + + if (result.Removed.Count > 0) + { + output.WriteLine(); + output.WriteLine( + $"{(result.DryRun ? "Would remove" : "Removed")} {Count(result.Removed.Count, "skill")}:"); + + foreach (var entry in result.Removed) + { + output.WriteLine($" {Describe(entry.Skill, entry.Package, entry.Version)}"); + } + } + + WriteUnreferenced(result); + WriteSkipped(result); + + if (result.Skills.Count > 0 && copied && !result.DryRun) + { + output.WriteLine(); + + // One line, however long. Any break we choose is a guess at the reader's width, + // and the terminal already knows theirs. + output.WriteLine( + "These skills are instructions written by the package authors, " + + "and your coding agent will follow them. Review them before relying on them."); + } + } + + private void WriteContext(InstallResult result) + { + output.WriteLine($"Target: {TerminalText.Sanitize(result.Target ?? "(packages named on the command line)")}"); + output.WriteLine($"NuGet cache: {TerminalText.Sanitize(result.GlobalPackagesFolder)}"); + output.WriteLine($"Destination: {TerminalText.Sanitize(result.Destination)}"); + + var scope = result.Target is null ? "named explicitly" : "direct"; + + output.WriteLine($"Scanned {Count(result.PackagesScanned, "package")} ({scope})."); + output.WriteLine(); + } + + /// + /// Install never removes a skill because its package left the project, so say which ones + /// stayed and which command removes them. + /// + private void WriteUnreferenced(InstallResult result) + { + if (result.Unreferenced.Count == 0) + { + return; + } + + var one = result.Unreferenced.Count == 1; + var packages = result.Unreferenced + .Select(entry => entry.Package) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count() == 1 + ? "a package" + : "packages"; + + output.WriteLine(); + output.WriteLine( + $"{Count(result.Unreferenced.Count, "installed skill")} {(one ? "belongs" : "belong")} to " + + $"{packages} that the target no longer references:"); + + foreach (var entry in result.Unreferenced) + { + output.WriteLine($" {Describe(entry.Skill, entry.Package, entry.Version)}"); + } + + output.WriteLine( + $"Run '{TerminalText.Sanitize(result.StaleCommand)}' to remove {(one ? "it" : "them")}."); + } + + private void WriteSkipped(InstallResult result) + { + if (result.Skipped.Count == 0) + { + return; + } + + output.WriteLine(); + output.WriteLine($"Warning: skipped {Count(result.Skipped.Count, "colliding skill")}:"); + + foreach (var skill in result.Skipped) + { + output.WriteLine($" {Describe(skill.RelativePath, skill.PackageId, skill.PackageVersion)}"); + output.WriteLine($" {TerminalText.Sanitize(skill.Reason)}"); + } + } + + /// + /// The solution or project that uninstall --stale compared against, or null for a + /// plain uninstall. + /// + public void WriteUninstallReport( + IReadOnlyList removed, + string destination, + bool dryRun, + string? target = null) + { + if (target is not null) + { + output.WriteLine($"Target: {TerminalText.Sanitize(target)}"); + } + + output.WriteLine($"Destination: {TerminalText.Sanitize(destination)}"); + output.WriteLine(); + + if (removed.Count == 0) + { + output.WriteLine(target is null + ? "Nothing to remove. No skills installed by this tool were found there." + : "Nothing to remove. No stale skills were found."); + return; + } + + output.WriteLine($"{(dryRun ? "Would remove" : "Removed")} {Count(removed.Count, "skill")}:"); + + foreach (var entry in removed) + { + output.WriteLine($" {Describe(entry.Skill, entry.Package, entry.Version)}"); + } + } + + public void WriteError(string message) + { + var text = TerminalText.Sanitize(message, multiline: true).Replace("\n", Environment.NewLine); + (errorOutput ?? Console.Error).WriteLine($"error: {text}"); + } + + /// + /// Reported when the user leaves the interactive picker without confirming. Nothing failed, + /// so this is a statement of fact rather than an error. + /// + public void WriteCancelled() + { + output.WriteLine("Cancelled. Nothing was copied or removed."); + } + + private static string Count(int value, string noun) => $"{value} {noun}{(value == 1 ? string.Empty : "s")}"; + + /// One skill on one line: the folder name, then who it came from. + /// + /// This used to be two lines, with "from Package Version" indented underneath. That doubled + /// the length of every report to carry a word — "from" — that the brackets say for free, and + /// twelve skills read far more easily as twelve lines than as twenty-four. + /// Sanitize fields separately so an unterminated control in one cannot hide the next. + /// + private static string Describe(string skill, string package, string version) => + $"{TerminalText.Sanitize(skill)} ({TerminalText.Sanitize(package)} {TerminalText.Sanitize(version)})"; +} diff --git a/dotnet-package-skills/src/Cli/PickerLayout.cs b/dotnet-package-skills/src/Cli/PickerLayout.cs new file mode 100644 index 0000000..e752e5d --- /dev/null +++ b/dotnet-package-skills/src/Cli/PickerLayout.cs @@ -0,0 +1,296 @@ +namespace DotnetPackageSkills.Cli; + +/// A selection-independent layout, rebuilt only when the viewport changes. +internal sealed class PickerLayout +{ + internal const string PrimaryHelp = "(Press to select, to accept)"; + + internal sealed record Entry(string Label, int DescriptionColumn, IReadOnlyList Description) + { + public int Height => Description.Count; + } + + internal sealed record Page(int First, int Count, int VisibleRows, bool Scrollable); + + private readonly string _title; + private readonly string? _note; + private readonly int _headerRows; + private readonly int _footerRows; + private readonly int[] _itemPages; + + private PickerLayout( + int windowWidth, + int windowHeight, + int width, + bool supportsColor, + string title, + string? note, + IReadOnlyList entries, + IReadOnlyList pages, + IReadOnlyList help, + int headerRows, + int footerRows) + { + WindowWidth = windowWidth; + WindowHeight = windowHeight; + Width = width; + SupportsColor = supportsColor; + _title = title; + _note = note; + Entries = entries; + Pages = pages; + Help = help; + _headerRows = headerRows; + _footerRows = footerRows; + _itemPages = new int[entries.Count]; + for (var page = 0; page < pages.Count; page++) + { + Array.Fill(_itemPages, page, pages[page].First, pages[page].Count); + } + } + + public int WindowWidth { get; } + + public int WindowHeight { get; } + + public int Width { get; } + + public bool SupportsColor { get; } + + /// + /// Where a wrapped description continues: past the cursor, the checkbox, and a space. Both + /// pickers draw a row the same way, with or without color. + /// + public int ContinuationColumn => RowPrefix; + + private const int RowPrefix = 6; + + public IReadOnlyList Entries { get; } + + public IReadOnlyList Pages { get; } + + public IReadOnlyList Help { get; } + + public int MaxFrameHeight => Pages.Max(page => + _headerRows + 2 + _footerRows + page.VisibleRows + + (page.Scrollable ? ScrollHelpRows(Entries[page.First].Height, Width) : 0)); + + public int PageIndexFor(int item) => _itemPages[item]; + + public int MaxScroll(int item) + { + var page = Pages[PageIndexFor(item)]; + return page.Scrollable ? Entries[item].Height - page.VisibleRows : 0; + } + + public IReadOnlyList Header(int page) => Header(_title, _note, page + 1, Pages.Count, Width); + + public static PickerLayout For( + IReadOnlyList items, + string title, + PickerMode mode, + int windowWidth, + int windowHeight, + bool supportsColor, + string? note = null) + { + if (note is null) + { + return Build(items, title, mode, windowWidth, windowHeight, supportsColor, note: null); + } + + try + { + return Build(items, title, mode, windowWidth, windowHeight, supportsColor, note); + } + catch (PackageSkillsException) + { + // The note only explains what the list leaves out. A window without room for it + // keeps the checklist and loses the note, rather than refusing to open. + return Build(items, title, mode, windowWidth, windowHeight, supportsColor, note: null); + } + } + + private static PickerLayout Build( + IReadOnlyList items, + string title, + PickerMode mode, + int windowWidth, + int windowHeight, + bool supportsColor, + string? note) + { + var labels = items.Select(item => TerminalText.Sanitize(item.Name)).ToArray(); + var descriptions = items.Select(DescriptionFor).ToArray(); + var cleanTitle = TerminalText.Sanitize(title); + var cleanNote = note is null ? null : TerminalText.Sanitize(note); + var longestName = labels.Max(TerminalText.Width); + var longestDescription = descriptions.Max(description => description.Split('\n').Max(TerminalText.Width)); + var prefix = RowPrefix; + var summaryBounds = new[] { Summary(items.Count, items.Count, mode) }; + var helpWidths = HelpFor(items.Count, items.Count, supportsColor).Select(TerminalText.Width); + var naturalWidth = new[] + { + labels.Select((label, index) => prefix + TerminalText.Width(label) + 3 + + descriptions[index].Split('\n').Max(TerminalText.Width)).Max(), + TerminalText.Width(cleanTitle) + (items.Count > 1 ? 2 + Counter(items.Count, items.Count).Length : 0), + cleanNote is null ? 0 : TerminalText.Width(cleanNote), + summaryBounds.Max(TerminalText.Width), + helpWidths.Max(), + }.Max(); + + // Leave a column and a row untouched so neither padding nor a final newline can + // force an automatic wrap in the middle of a frame. + var width = Math.Min(naturalWidth, windowWidth - 1); + var available = width - prefix - 3; + var widestGrapheme = descriptions.SelectMany(TerminalText.Elements).Max(TerminalText.CellWidth); + if (available < 1 + widestGrapheme) + { + throw TooSmall(windowWidth, windowHeight, $"at least {prefix + 3 + widestGrapheme + 2} columns"); + } + + // When both columns want more than the window, give the description at least half + // the remaining cells. Short descriptions give that space back to a long name. + var descriptionReserve = Math.Max(widestGrapheme, Math.Min(longestDescription, available / 2)); + var nameWidth = Math.Min(longestName, available - descriptionReserve); + var entries = labels.Select((label, index) => + { + var displayName = TerminalText.Clip(label, nameWidth); + var descriptionColumn = prefix + TerminalText.Width(displayName) + 3; + return new Entry( + displayName, + descriptionColumn, + TerminalText.Wrap(descriptions[index], width - descriptionColumn, width - prefix)); + }).ToArray(); + + var pageCount = 1; + while (true) + { + var headerRows = Header(cleanTitle, cleanNote, pageCount, pageCount, width).Count; + var help = HelpFor(items.Count, pageCount, supportsColor) + .SelectMany(line => TerminalText.Wrap(line, width)).ToArray(); + var footerRows = summaryBounds.Max(summary => TerminalText.Wrap(summary, width).Count) + help.Length; + var budget = windowHeight - 1 - headerRows - 2 - footerRows; + if (budget < 1) + { + throw TooSmall(windowWidth, windowHeight, $"at least {windowHeight + 1 - budget} rows at this width"); + } + + var pages = Paginate(entries, budget, width, windowWidth, windowHeight); + if (pages.Count == pageCount) + { + return new PickerLayout( + windowWidth, windowHeight, width, supportsColor, cleanTitle, cleanNote, + entries, pages, help, headerRows, footerRows); + } + + // Only paging chrome and counter digit growth can shrink the row budget. + // Iterating to a fixed point avoids guessing how many rows that chrome uses. + pageCount = pages.Count; + } + } + + /// + /// Nothing on an install list is installed, so every tick is one install and the count needs + /// no second number. Every tick on an uninstall list is one removal, which is worth saying. + /// The widest summary is the one with every row ticked, so that is what layout measures. + /// + public static string Summary(int selected, int total, PickerMode mode) => + mode == PickerMode.Uninstall + ? $"{selected} of {total} selected; {selected} to remove" + : $"{selected} of {total} selected"; + + public static string ScrollHelp(int first, int last, int total) => + $"(Press / to scroll description: {first}-{last}/{total})"; + + private static int ScrollHelpRows(int lines, int width) => + TerminalText.Wrap(ScrollHelp(lines, lines, lines), width).Count; + + private static List Paginate( + IReadOnlyList entries, + int budget, + int width, + int windowWidth, + int windowHeight) + { + var pages = new List(); + for (var first = 0; first < entries.Count;) + { + if (entries[first].Height > budget) + { + var visible = budget - ScrollHelpRows(entries[first].Height, width); + // Keep the skill row visible alongside at least one scrolling continuation. + if (visible < 2) + { + throw TooSmall(windowWidth, windowHeight, $"at least {windowHeight + 2 - visible} rows at this width"); + } + + pages.Add(new Page(first++, 1, visible, Scrollable: true)); + continue; + } + + var rows = 0; + var end = first; + while (end < entries.Count && rows + entries[end].Height <= budget) + { + rows += entries[end++].Height; + } + + pages.Add(new Page(first, end - first, rows, Scrollable: false)); + first = end; + } + + return pages; + } + + private static string DescriptionFor(SkillPickerItem item) + { + if (!string.IsNullOrWhiteSpace(item.DescriptionWarning)) + { + var warning = TerminalText.Sanitize(item.DescriptionWarning); + return $"Description unavailable: {(TerminalText.Width(warning) > 0 ? warning : "unreadable metadata.")}"; + } + + var description = TerminalText.Sanitize(item.Description, multiline: true); + return TerminalText.Width(description) > 0 ? description : "No description provided."; + } + + private static IReadOnlyList Header(string title, string? note, int page, int pages, int width) + { + var text = pages > 1 ? $"{title} {Counter(page, pages)}" : title; + IReadOnlyList lines = TerminalText.Width(text) <= width ? [text] : TerminalText.Wrap(text, width); + return note is null ? lines : [.. lines, .. TerminalText.Wrap(note, width)]; + } + + private static string Counter(int page, int pages) => $"page {page} of {pages}"; + + private static IEnumerable HelpFor(int items, int pages, bool supportsColor) + { + yield return PrimaryHelp; + if (items > 1) + { + yield return "(Press / to move, / for first/last)"; + } + + if (pages > 1) + { + yield return "(Press /, / to change page)"; + } + + yield return items > 1 + ? "(Press to select all, to clear all, // to cancel)" + : "(Press // to cancel)"; + + // Each checklist does one thing, so a tick needs no cue for what it does: the title and + // the summary say that. The legend only explains the color, so without color it goes. + if (supportsColor) + { + yield return "Blue X: selected"; + } + } + + private static PackageSkillsException TooSmall(int width, int height, string minimum) => new( + $"The terminal is too small for the interactive checklist ({width}x{height}). " + + $"Enlarge the window to {minimum}, or use the command without --interactive " + + "and with --package to limit the operation."); +} diff --git a/dotnet-package-skills/src/Cli/SkillPicker.cs b/dotnet-package-skills/src/Cli/SkillPicker.cs new file mode 100644 index 0000000..4dffe99 --- /dev/null +++ b/dotnet-package-skills/src/Cli/SkillPicker.cs @@ -0,0 +1,379 @@ +namespace DotnetPackageSkills.Cli; + +internal enum PickerMode +{ + Install, + Uninstall, +} + +/// Picker metadata supplied by the caller, never loaded by the UI. +internal sealed record SkillPickerItem( + string Name, + string Package, + string Version, + string? Description = null, + string? DescriptionWarning = null); + +/// +/// A paged checklist. Nothing starts ticked: a tick means install in install mode and remove in +/// uninstall mode, so accepting without a choice changes nothing either way. +/// +internal sealed class SkillPicker(ITerminal terminal) +{ + private static readonly TimeSpan InputPollInterval = TimeSpan.FromMilliseconds(100); + + /// An optional line shown under the title, such as what the list leaves out. + public IReadOnlySet? Choose( + IReadOnlyList items, + string title, + PickerMode mode = PickerMode.Install, + string? note = null) + { + if (items.Count == 0) + { + return new HashSet(StringComparer.OrdinalIgnoreCase); + } + + if (terminal.IsRedirected) + { + throw new PackageSkillsException( + mode == PickerMode.Install + ? "--interactive needs a terminal, but input or output is redirected. " + + "Drop --interactive to install every discovered skill, or name the ones you want " + + "with --package." + : "--interactive needs a terminal, but input or output is redirected. " + + "Drop --interactive to remove every skill that the command matches, and add " + + "--dry-run to see which ones first."); + } + + var selected = new HashSet(); + var layout = Measure(); + var cursor = 0; + int? pageOffset = null; + var scroll = new int[items.Count]; + var frameTop = 0; + var height = 0; + var frameStarted = false; + var resetViewport = true; + var original = terminal.CaptureState(); + IDisposable? screen = null; + + try + { + terminal.UseUtf8Output(); + screen = terminal.EnterInteractiveScreen(); + terminal.CursorVisible = false; + terminal.TreatControlCAsInput = true; + terminal.ResetStyle(); + frameStarted = true; + + while (true) + { + Reflow(); + DrawFrame(); + ConsoleKeyInfo key; + while (!terminal.TryReadKey(InputPollInterval, out key)) + { + if (Reflow()) + { + DrawFrame(); + } + } + + // A key can arrive after a resize. Page keys must use the new boundaries, + // and accept/cancel must not leave the old, differently sized frame behind. + if (Reflow()) + { + DrawFrame(); + } + + if ((key.Modifiers & ConsoleModifiers.Control) != 0) + { + if (key.Key == ConsoleKey.C) + { + return null; + } + + if (key.Key is ConsoleKey.UpArrow or ConsoleKey.DownArrow) + { + scroll[cursor] = Math.Clamp( + scroll[cursor] + (key.Key == ConsoleKey.UpArrow ? -1 : 1), + 0, + layout.MaxScroll(cursor)); + continue; + } + } + + switch (key.Key) + { + case ConsoleKey.UpArrow: + cursor = (cursor - 1 + items.Count) % items.Count; + pageOffset = null; + break; + case ConsoleKey.DownArrow: + cursor = (cursor + 1) % items.Count; + pageOffset = null; + break; + case ConsoleKey.LeftArrow or ConsoleKey.PageUp: + MovePage(-1); + break; + case ConsoleKey.RightArrow or ConsoleKey.PageDown: + MovePage(1); + break; + case ConsoleKey.Home: + cursor = 0; + pageOffset = null; + break; + case ConsoleKey.End: + cursor = items.Count - 1; + pageOffset = null; + break; + case ConsoleKey.Spacebar: + if (!selected.Add(cursor)) + { + selected.Remove(cursor); + } + + break; + case ConsoleKey.A: + selected.UnionWith(Enumerable.Range(0, items.Count)); + break; + case ConsoleKey.C: + selected.Clear(); + break; + case ConsoleKey.Enter: + return items.Where((_, index) => selected.Contains(index)) + .Select(item => item.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); + case ConsoleKey.Escape or ConsoleKey.Q: + return null; + } + } + } + finally + { + try + { + try + { + terminal.ResetStyle(); + } + finally + { + if (frameStarted) + { + var bottom = Math.Clamp(frameTop + height, 0, terminal.WindowHeight - 1); + terminal.SetCursorPosition(0, bottom); + if (bottom < terminal.WindowHeight - 1) + { + terminal.WriteLine(); + } + } + } + } + finally + { + try + { + screen?.Dispose(); + } + finally + { + terminal.RestoreState(original); + } + } + } + + PickerLayout Measure() + { + var size = terminal.GetWindowSize(); + return PickerLayout.For(items, title, mode, size.Width, size.Height, terminal.SupportsColor, note); + } + + void DrawFrame() + { + while (true) + { + Reflow(); + var previousHeight = height; + try + { + if (resetViewport) + { + terminal.ResetStyle(); + terminal.ClearViewport(); + frameTop = 0; + height = 0; + resetViewport = false; + } + + Render(items, selected, cursor, scroll, layout, frameTop, mode, ref height); + EnsureViewport(layout); + return; + } + catch (Exception ex) when (ex is ViewportChangedException || + (ex is IOException or ArgumentOutOfRangeException or InvalidOperationException) && ViewportChanged(layout)) + { + height = Math.Max(previousHeight, height); + resetViewport = true; + } + catch + { + // A failed redraw can leave the lower part of the previous frame intact. + height = Math.Max(previousHeight, height); + throw; + } + } + } + + bool Reflow() + { + if (!ViewportChanged(layout)) + { + return false; + } + + layout = Measure(); + resetViewport = true; + pageOffset = null; + for (var item = 0; item < scroll.Length; item++) + { + scroll[item] = Math.Min(scroll[item], layout.MaxScroll(item)); + } + + return true; + } + + void MovePage(int direction) + { + var page = layout.PageIndexFor(cursor); + var target = Math.Clamp(page + direction, 0, layout.Pages.Count - 1); + if (target == page) + { + return; + } + + pageOffset ??= cursor - layout.Pages[page].First; + var next = layout.Pages[target]; + cursor = next.First + Math.Min(pageOffset.Value, next.Count - 1); + } + } + + private void Render( + IReadOnlyList items, + HashSet selected, + int cursor, + int[] scroll, + PickerLayout layout, + int frameTop, + PickerMode mode, + ref int height) + { + var previousHeight = height; + height = 0; + var pageIndex = layout.PageIndexFor(cursor); + var page = layout.Pages[pageIndex]; + foreach (var line in layout.Header(pageIndex)) + { + WriteRow(layout, frameTop, ref height, new Span(line)); + } + + WriteRow(layout, frameTop, ref height); + for (var index = page.First; index < page.First + page.Count; index++) + { + var entry = layout.Entries[index]; + var isSelected = selected.Contains(index); + var rowStyle = index == cursor ? TerminalStyle.Focus : TerminalStyle.Default; + var offset = page.Scrollable ? scroll[index] : 0; + var rows = page.Scrollable ? page.VisibleRows : entry.Height; + // Continuations are wider than the space after the name, so scroll them below + // the fixed skill row rather than placing one into its narrower first-line slot. + WriteRow( + layout, frameTop, ref height, + new Span(index == cursor ? ">" : " ", rowStyle), + new Span(" ", rowStyle), + new Span("[", rowStyle), + new Span(isSelected ? "X" : " ", isSelected ? TerminalStyle.Selected : rowStyle), + new Span("]", rowStyle), + new Span($" {entry.Label}", rowStyle), + new Span($" - {entry.Description[0]}", rowStyle)); + for (var line = 1; line < rows; line++) + { + WriteRow(layout, frameTop, ref height, + new Span(new string(' ', layout.ContinuationColumn) + entry.Description[offset + line], rowStyle)); + } + } + + WriteRow(layout, frameTop, ref height); + foreach (var line in TerminalText.Wrap( + PickerLayout.Summary(selected.Count, items.Count, mode), layout.Width)) + { + WriteRow(layout, frameTop, ref height, new Span(line)); + } + + foreach (var line in layout.Help) + { + WriteRow(layout, frameTop, ref height, new Span(line, TerminalStyle.Muted)); + } + + if (page.Scrollable) + { + foreach (var line in TerminalText.Wrap( + PickerLayout.ScrollHelp(scroll[cursor] + 2, scroll[cursor] + page.VisibleRows, + layout.Entries[cursor].Height), + layout.Width)) + { + WriteRow(layout, frameTop, ref height, new Span(line, TerminalStyle.Muted)); + } + } + + // Erase old content, but park at the actual footer, not at the end of the erased + // rectangle. A short final page should not strand the eventual shell prompt. + var erased = height; + while (erased < previousHeight) + { + WriteRow(layout, frameTop, ref erased); + } + + terminal.SetCursorPosition(0, frameTop + height); + } + + private void WriteRow(PickerLayout layout, int frameTop, ref int height, params Span[] spans) + { + EnsureViewport(layout); + var row = height++; + terminal.SetCursorPosition(0, frameTop + row); + var cells = 0; + foreach (var span in spans) + { + terminal.SetStyle(layout.SupportsColor ? span.Style : TerminalStyle.Default); + EnsureViewport(layout); + terminal.Write(span.Text); + EnsureViewport(layout); + cells += TerminalText.Width(span.Text); + } + + terminal.ResetStyle(); + EnsureViewport(layout); + terminal.Write(new string(' ', layout.Width - cells)); + EnsureViewport(layout); + } + + private bool ViewportChanged(PickerLayout layout) + { + var size = terminal.GetWindowSize(); + return layout.WindowWidth != size.Width || layout.WindowHeight != size.Height || + layout.SupportsColor != terminal.SupportsColor; + } + + private void EnsureViewport(PickerLayout layout) + { + if (ViewportChanged(layout)) + { + throw new ViewportChangedException(); + } + } + + private readonly record struct Span(string Text, TerminalStyle Style = TerminalStyle.Default); + + private sealed class ViewportChangedException : Exception; +} diff --git a/dotnet-package-skills/src/Cli/TerminalText.cs b/dotnet-package-skills/src/Cli/TerminalText.cs new file mode 100644 index 0000000..ab12e14 --- /dev/null +++ b/dotnet-package-skills/src/Cli/TerminalText.cs @@ -0,0 +1,256 @@ +using System.Globalization; +using System.Text; + +namespace DotnetPackageSkills.Cli; + +/// Plain terminal text, measured and split at grapheme and display-cell boundaries. +internal static class TerminalText +{ + public static string Sanitize(string? text, bool multiline = false, bool trim = true) + { + if (string.IsNullOrEmpty(text)) + { + return string.Empty; + } + + var clean = new StringBuilder(text.Length); + for (var index = 0; index < text.Length;) + { + var character = text[index]; + if (character is '\x1b' or '\x9b' or '\x9d' or '\x90' or '\x98' or '\x9e' or '\x9f') + { + index = SkipEscape(text, index); + continue; + } + + if (character is '\r' or '\n' or '\u2028' or '\u2029') + { + clean.Append(multiline ? '\n' : ' '); + index += character == '\r' && index + 1 < text.Length && text[index + 1] == '\n' ? 2 : 1; + continue; + } + + if (character == '\t') + { + clean.Append(' '); + index++; + continue; + } + + var status = Rune.DecodeFromUtf16(text.AsSpan(index), out var rune, out var consumed); + if (status != System.Buffers.OperationStatus.Done) + { + rune = Rune.ReplacementChar; + consumed = 1; + } + + index += consumed; + var category = Rune.GetUnicodeCategory(rune); + if (category == UnicodeCategory.Control || + category == UnicodeCategory.Format && rune.Value is not (0x200c or 0x200d or >= 0xe0020 and <= 0xe007f)) + { + continue; + } + + clean.Append(Rune.IsWhiteSpace(rune) ? " " : rune.ToString()); + } + + var result = clean.ToString(); + return trim ? result.Trim() : result; + } + + public static int Width(string text) => Elements(text).Sum(element => CellWidth(element)); + + public static IEnumerable Elements(string text) + { + var elements = StringInfo.GetTextElementEnumerator(text); + while (elements.MoveNext()) + { + yield return elements.GetTextElement(); + } + } + + public static int CellWidth(string element) + { + var width = 0; + var emojiPresentation = false; + foreach (var rune in element.EnumerateRunes()) + { + emojiPresentation |= rune.Value is 0xfe0f or 0x20e3; + if (Rune.GetUnicodeCategory(rune) is UnicodeCategory.NonSpacingMark or + UnicodeCategory.SpacingCombiningMark or UnicodeCategory.EnclosingMark or + UnicodeCategory.Format or UnicodeCategory.Control) + { + continue; + } + + width = Math.Max(width, IsWide(rune.Value) ? 2 : 1); + } + + return width > 0 && emojiPresentation ? 2 : width; + } + + public static string PadRight(string text, int width) => + text + new string(' ', Math.Max(0, width - Width(text))); + + public static string Clip(string text, int width) + { + if (width <= 0) + { + return string.Empty; + } + + if (Width(text) <= width) + { + return text; + } + + var suffix = new string('.', Math.Min(3, width)); + var clipped = new StringBuilder(); + var available = width - suffix.Length; + foreach (var element in Elements(text)) + { + var cells = CellWidth(element); + if (cells > available) + { + break; + } + + clipped.Append(element); + available -= cells; + } + + return clipped.Append(suffix).ToString(); + } + + public static IReadOnlyList Wrap(string text, int width) => Wrap(text, width, width); + + public static IReadOnlyList Wrap(string text, int firstLineWidth, int continuationWidth) + { + ArgumentOutOfRangeException.ThrowIfLessThan(firstLineWidth, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(continuationWidth, 1); + var width = firstLineWidth; + var lines = new List(); + foreach (var paragraph in text.Split('\n')) + { + var line = new StringBuilder(); + var cells = 0; + foreach (var word in paragraph.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + var wordWidth = Width(word); + if (cells > 0 && cells + 1 + wordWidth <= width) + { + line.Append(' ').Append(word); + cells += 1 + wordWidth; + continue; + } + + if (cells > 0) + { + lines.Add(line.ToString()); + line.Clear(); + cells = 0; + width = continuationWidth; + } + + foreach (var element in Elements(word)) + { + var elementWidth = CellWidth(element); + if (cells > 0 && cells + elementWidth > width) + { + lines.Add(line.ToString()); + line.Clear(); + cells = 0; + width = continuationWidth; + } + + if (elementWidth > width) + { + throw new ArgumentException("The column is narrower than a single display grapheme.", nameof(width)); + } + + line.Append(element); + cells += elementWidth; + } + } + + lines.Add(line.ToString()); + width = continuationWidth; + } + + return lines; + } + + private static int SkipEscape(string text, int index) + { + var kind = text[index++]; + if (kind == '\x1b') + { + if (index == text.Length) + { + return index; + } + + kind = text[index++]; + } + + if (kind is '[' or '\x9b') + { + while (index < text.Length) + { + if (text[index++] is >= '\x40' and <= '\x7e') + { + break; + } + } + } + else if (kind is ']' or 'P' or 'X' or '^' or '_' or '\x9d' or '\x90' or '\x98' or '\x9e' or '\x9f') + { + while (index < text.Length) + { + if (text[index++] is '\a' or '\x9c') + { + break; + } + + if (text[index - 1] == '\x1b' && index < text.Length && text[index] == '\\') + { + return index + 1; + } + } + } + else if (kind is >= '\x20' and <= '\x2f') + { + while (index < text.Length && text[index] is >= '\x20' and <= '\x2f') + { + index++; + } + + if (index < text.Length && text[index] is >= '\x30' and <= '\x7e') + { + index++; + } + } + + return index; + } + + private static bool IsWide(int value) => + value != 0x303f && value is >= 0x1100 and <= 0x115f or + 0x231a or 0x231b or 0x2329 or 0x232a or + >= 0x23e9 and <= 0x23ec or 0x23f0 or 0x23f3 or 0x25fd or 0x25fe or + 0x2614 or 0x2615 or >= 0x2648 and <= 0x2653 or 0x267f or 0x2693 or + 0x26a1 or 0x26aa or 0x26ab or 0x26bd or 0x26be or 0x26c4 or 0x26c5 or + 0x26ce or 0x26d4 or 0x26ea or 0x26f2 or 0x26f3 or 0x26f5 or 0x26fa or + 0x26fd or 0x2705 or 0x270a or 0x270b or 0x2728 or 0x274c or 0x274e or + >= 0x2753 and <= 0x2755 or 0x2757 or >= 0x2795 and <= 0x2797 or + 0x27b0 or 0x27bf or 0x2b1b or 0x2b1c or 0x2b50 or 0x2b55 or + >= 0x2e80 and <= 0xa4cf or >= 0xac00 and <= 0xd7a3 or + >= 0xf900 and <= 0xfaff or >= 0xfe10 and <= 0xfe19 or + >= 0xfe30 and <= 0xfe6f or >= 0xff00 and <= 0xff60 or + >= 0xffe0 and <= 0xffe6 or >= 0x16fe0 and <= 0x18dff or + >= 0x1aff0 and <= 0x1b2ff or 0x1f004 or 0x1f0cf or 0x1f18e or + >= 0x1f191 and <= 0x1f19a or >= 0x1f1e6 and <= 0x1f1ff or + >= 0x1f200 and <= 0x1f251 or >= 0x1f300 and <= 0x1faff or + >= 0x20000 and <= 0x3fffd; +} diff --git a/dotnet-package-skills/src/DotnetPackageSkills.csproj b/dotnet-package-skills/src/DotnetPackageSkills.csproj new file mode 100644 index 0000000..9ca917e --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills.csproj @@ -0,0 +1,44 @@ + + + + Exe + net8.0;net10.0 + dotnet-package-skills + DotnetPackageSkills + + + Major + + + + true + + dotnet-package-skills + dotnet-package-skills + 0.1.0 + dev + $(DotnetPackageSkillsVersion) + dotnet-package-skills contributors + Copies agent skills bundled inside NuGet packages out of the global packages folder and into a repository's skills directory, where coding agents can actually find them. + dotnet-tool;nuget;ai;agent;skills;claude;copilot + README.md + MIT + https://github.com/NuGet/Client.Tools/tree/main/dotnet-package-skills + https://github.com/NuGet/Client.Tools + git + true + + + + + + + + + + + + + diff --git a/dotnet-package-skills/src/Infrastructure/DotnetCli.cs b/dotnet-package-skills/src/Infrastructure/DotnetCli.cs new file mode 100644 index 0000000..bc0167c --- /dev/null +++ b/dotnet-package-skills/src/Infrastructure/DotnetCli.cs @@ -0,0 +1,20 @@ +namespace DotnetPackageSkills.Infrastructure; + +/// Invokes the dotnet CLI. +public sealed class DotnetCli(IProcessRunner runner) +{ + /// + /// The dotnet host to invoke. DOTNET_HOST_PATH is set by the SDK when the tool + /// runs inside a build or from another dotnet command, and points at the exact + /// host in use — preferring it avoids picking a different dotnet off PATH. + /// + private static string Executable => + Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") is { Length: > 0 } host && File.Exists(host) + ? host + : "dotnet"; + + public ProcessResult Run(params string[] arguments) => runner.Run(Executable, arguments); + + public ProcessResult Run(IReadOnlyList arguments, string? workingDirectory) => + runner.Run(Executable, arguments, workingDirectory); +} diff --git a/dotnet-package-skills/src/Infrastructure/ProcessRunner.cs b/dotnet-package-skills/src/Infrastructure/ProcessRunner.cs new file mode 100644 index 0000000..0bb5ca1 --- /dev/null +++ b/dotnet-package-skills/src/Infrastructure/ProcessRunner.cs @@ -0,0 +1,95 @@ +using System.Diagnostics; + +namespace DotnetPackageSkills.Infrastructure; + +/// Result of running an external process to completion. +public sealed record ProcessResult(int ExitCode, string StandardOutput, string StandardError) +{ + /// + /// Diagnostics text for error messages: tools write failures to stderr, but the + /// dotnet CLI frequently reports MSBuild and NuGet errors on stdout instead. + /// + public string Diagnostics => + string.IsNullOrWhiteSpace(StandardError) ? StandardOutput.Trim() : StandardError.Trim(); +} + +/// Runs external processes. Abstracted so command logic is testable without spawning dotnet. +public interface IProcessRunner +{ + ProcessResult Run(string fileName, IReadOnlyList arguments, string? workingDirectory = null); +} + +/// Thrown when a process cannot be started or does not finish in time. +public sealed class ProcessExecutionException(string message, Exception? inner = null) + : Exception(message, inner); + +public sealed class ProcessRunner(TimeSpan? timeout = null) : IProcessRunner +{ + private readonly TimeSpan _timeout = timeout ?? TimeSpan.FromMinutes(5); + + public ProcessResult Run(string fileName, IReadOnlyList arguments, string? workingDirectory = null) + { + var startInfo = new ProcessStartInfo + { + FileName = fileName, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + if (!string.IsNullOrEmpty(workingDirectory)) + { + startInfo.WorkingDirectory = workingDirectory; + } + + using var process = new Process { StartInfo = startInfo }; + + try + { + process.Start(); + } + catch (Exception ex) + { + throw new ProcessExecutionException( + $"Could not start '{fileName}'. Make sure the .NET SDK is installed and on PATH " + + "(https://dotnet.microsoft.com/download).", ex); + } + + // Read both streams concurrently before waiting. Draining one to completion + // first deadlocks as soon as the other fills its pipe buffer, which dotnet + // restore output does routinely. + var standardOutput = process.StandardOutput.ReadToEndAsync(); + var standardError = process.StandardError.ReadToEndAsync(); + + if (!process.WaitForExit((int)_timeout.TotalMilliseconds)) + { + TryKill(process); + throw new ProcessExecutionException( + $"'{fileName} {string.Join(' ', arguments)}' did not finish within {_timeout.TotalSeconds:0} seconds."); + } + + // The overload that takes a timeout does not wait for the async output + // readers to drain, so the parameterless call is needed for complete output. + process.WaitForExit(); + + return new ProcessResult(process.ExitCode, standardOutput.Result, standardError.Result); + } + + private static void TryKill(Process process) + { + try + { + process.Kill(entireProcessTree: true); + } + catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException or System.ComponentModel.Win32Exception) + { + // The process already exited or cannot be killed; nothing useful to do. + } + } +} diff --git a/dotnet-package-skills/src/NuGet/GlobalPackagesLocator.cs b/dotnet-package-skills/src/NuGet/GlobalPackagesLocator.cs new file mode 100644 index 0000000..e991b24 --- /dev/null +++ b/dotnet-package-skills/src/NuGet/GlobalPackagesLocator.cs @@ -0,0 +1,99 @@ +using DotnetPackageSkills.Infrastructure; + +namespace DotnetPackageSkills.NuGet; + +/// Locates the NuGet global packages folder, where restore extracts packages. +public sealed class GlobalPackagesLocator(DotnetCli dotnet) +{ + private const string Label = "global-packages:"; + + /// + /// Resolves the folder, honouring an explicit override, then NUGET_PACKAGES, then + /// whatever the CLI reports (which is the only thing that accounts for a + /// globalPackagesFolder set in a nuget.config). + /// + /// + /// Where to ask from. This matters: nuget.config discovery walks up from the current + /// directory, so asking from outside the repo silently ignores a repo-level config. + /// + public string Locate(string? overridePath, string workingDirectory) + { + if (!string.IsNullOrWhiteSpace(overridePath)) + { + var resolved = Path.GetFullPath(overridePath, workingDirectory); + return Directory.Exists(resolved) + ? resolved + : throw new PackageSkillsException($"--global-packages does not exist: {resolved}"); + } + + if (Environment.GetEnvironmentVariable("NUGET_PACKAGES") is { Length: > 0 } fromEnvironment && + Directory.Exists(fromEnvironment)) + { + return Path.GetFullPath(fromEnvironment); + } + + return FromCli(workingDirectory); + } + + private string FromCli(string workingDirectory) + { + var result = dotnet.Run(["nuget", "locals", "global-packages", "--list"], workingDirectory); + + if (result.ExitCode != 0) + { + throw new PackageSkillsException( + $""" + Could not determine the NuGet global packages folder. + 'dotnet nuget locals global-packages --list' failed with exit code {result.ExitCode}: + {result.Diagnostics} + """); + } + + var path = ParseListOutput(result.StandardOutput); + + if (path is null) + { + throw new PackageSkillsException( + $""" + Could not find the global packages path in the output of 'dotnet nuget locals global-packages --list': + {result.StandardOutput.Trim()} + """); + } + + if (!Directory.Exists(path)) + { + throw new PackageSkillsException( + $""" + NuGet reports its global packages folder as '{path}', but that directory does not exist. + Restore the project first — restore is what creates it. + """); + } + + return path; + } + + /// + /// Extracts the path from CLI output. The shape has drifted across SDK versions + /// ("global-packages: /path" today, "info : global-packages: /path" on older ones), + /// so this keys off the label rather than the line's position or prefix. + /// + internal static string? ParseListOutput(string output) + { + foreach (var line in output.Split('\n')) + { + var index = line.IndexOf(Label, StringComparison.OrdinalIgnoreCase); + if (index < 0) + { + continue; + } + + var value = line[(index + Label.Length)..].Trim(); + if (value.Length > 0) + { + return Path.GetFullPath(value); + } + } + + return null; + } +} diff --git a/dotnet-package-skills/src/NuGet/PackageCoordinate.cs b/dotnet-package-skills/src/NuGet/PackageCoordinate.cs new file mode 100644 index 0000000..91767ce --- /dev/null +++ b/dotnet-package-skills/src/NuGet/PackageCoordinate.cs @@ -0,0 +1,104 @@ +using System.Text.RegularExpressions; + +namespace DotnetPackageSkills.NuGet; + +/// An exact package identity, written as Id@Version on the command line. +public sealed partial record PackageCoordinate(string Id, string Version) +{ + public const char Separator = '@'; + + /// + /// Parses Id@Version, rejecting anything that is not a single concrete version. + /// + /// + /// Version ranges and floating versions are refused rather than resolved. Resolving one + /// means picking a version, and the only correct answer to "which version" comes from a + /// project's restore — which is what --target is for. Guessing here would copy + /// skills that describe a version the user does not actually reference. + /// + public static PackageCoordinate Parse(string value) + { + var input = value?.Trim() ?? string.Empty; + + if (input.Length == 0) + { + throw new PackageSkillsException("--package needs a value in the form Id@Version, for example Mockly@1.10.0."); + } + + var separator = input.IndexOf(Separator); + + if (separator < 0) + { + throw new PackageSkillsException( + $"'{input}' is missing a version. Write --package as Id@Version, for example {input}@1.10.0. " + + "To take versions from a project instead, use --target."); + } + + if (input.IndexOf(Separator, separator + 1) >= 0) + { + throw new PackageSkillsException($"'{input}' has more than one '{Separator}'. Expected Id@Version."); + } + + var id = input[..separator].Trim(); + var version = input[(separator + 1)..].Trim(); + + if (id.Length == 0) + { + throw new PackageSkillsException($"'{input}' is missing a package id before the '{Separator}'."); + } + + ValidateId(id); + + if (version.Length == 0) + { + throw new PackageSkillsException($"'{input}' is missing a version after the '{Separator}'."); + } + + if (IsFloatingOrRange(version)) + { + throw new PackageSkillsException( + $""" + '{version}' is a floating version or a version range, and this tool needs an exact version. + Write it out, for example --package {id}@1.10.0. + To let restore choose the version, point at a project or solution with --target instead. + """); + } + + if (!ExactVersionPattern().IsMatch(version)) + { + throw new PackageSkillsException( + $"'{version}' is not a version this tool recognises. Expected something like 1.10.0 or 2.0.0-beta.1."); + } + + return new PackageCoordinate(id, version); + } + + internal static void ValidateId(string id) + { + if (!IsValidId(id)) + { + throw new PackageSkillsException( + $"'{id}' is not a valid package id. Ids are letters, digits and '_', joined by single '.' or '-' characters."); + } + } + + /// + /// NuGet's own rule for package ids, so any id that restore accepts is accepted here too, + /// including letters outside ASCII. + /// + internal static bool IsValidId(string id) => PackageIdPattern().IsMatch(id); + + /// Wildcards and NuGet interval notation: 4.*, [1.0,2.0), (,3.0]. + private static readonly char[] RangeCharacters = ['*', '[', ']', '(', ')', ',']; + + private static bool IsFloatingOrRange(string version) => version.IndexOfAny(RangeCharacters) >= 0; + + public override string ToString() => $"{Id}{Separator}{Version}"; + + // NuGet's PackageIdValidator pattern, with \z so a trailing newline can't end a match. + [GeneratedRegex(@"^\w+([.-]\w+)*\z", RegexOptions.CultureInvariant)] + private static partial Regex PackageIdPattern(); + + [GeneratedRegex(@"^\d+(\.\d+){0,3}(-[0-9A-Za-z][0-9A-Za-z.-]*)?(\+[0-9A-Za-z][0-9A-Za-z.-]*)?$")] + private static partial Regex ExactVersionPattern(); +} diff --git a/dotnet-package-skills/src/NuGet/PackageLister.cs b/dotnet-package-skills/src/NuGet/PackageLister.cs new file mode 100644 index 0000000..2a73fd5 --- /dev/null +++ b/dotnet-package-skills/src/NuGet/PackageLister.cs @@ -0,0 +1,214 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using DotnetPackageSkills.Infrastructure; + +namespace DotnetPackageSkills.NuGet; + +/// A package the target resolves to, after de-duplication across projects and frameworks. +public sealed record PackageReferenceInfo(string Id, string Version); + +/// +/// Lists the packages a solution or project resolves to, by way of +/// dotnet list <target> package --format json. +/// +public sealed class PackageLister(DotnetCli dotnet) +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + /// + /// Runs dotnet list package as it is. Whether it restores is the SDK's call: the .NET 10 + /// SDK restores when it needs to, and earlier SDKs say the target has to be restored first. + /// + /// + /// This tool never restores. A failure is reported with what the SDK said, so the customer can + /// restore or fix whatever else it names, and then run the command again. + /// + public IReadOnlyList List(string target) + { + // The target goes *before* the `package` verb: `dotnet list package`. + var arguments = new List { "list", target, "package", "--format", "json" }; + var result = dotnet.Run(arguments, workingDirectory: Path.GetDirectoryName(target)); + + if (result.ExitCode != 0) + { + throw new PackageSkillsException( + $""" + 'dotnet list "{target}" package' failed with exit code {result.ExitCode}: + {ReportedProblems(result.StandardOutput) ?? result.Diagnostics} + + Resolve what it reports, for example by restoring the target, and then run this command again. + """); + } + + return Parse(result.StandardOutput); + } + + /// + /// The problems that a JSON listing reports, one per line, or null when there are none. The + /// .NET 10 SDK reports a failed restore this way, on standard output. + /// + private static string? ReportedProblems(string output) + { + var start = output.IndexOf('{'); + if (start < 0) + { + return null; + } + + try + { + using var document = JsonDocument.Parse(output[start..]); + if (document.RootElement.ValueKind != JsonValueKind.Object || + !document.RootElement.TryGetProperty("problems", out var problems) || + problems.ValueKind != JsonValueKind.Array) + { + return null; + } + + var lines = problems.EnumerateArray() + .Where(problem => problem.ValueKind == JsonValueKind.Object) + .Select(problem => (Level: Text(problem, "level"), Text: Text(problem, "text"))) + .Where(problem => !string.IsNullOrWhiteSpace(problem.Text)) + .Select(problem => string.IsNullOrWhiteSpace(problem.Level) + ? problem.Text! + : $"{problem.Level}: {problem.Text}") + .ToList(); + + return lines.Count == 0 ? null : string.Join(Environment.NewLine, lines); + } + catch (JsonException) + { + return null; + } + + static string? Text(JsonElement problem, string name) => + problem.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + } + + internal static IReadOnlyList Parse(string json) + { + var report = Deserialize(json); + + // Key on (id, version) because each resolved version has its own folder in the global + // packages cache. Keeping all versions also lets skill discovery report name collisions. + var found = new Dictionary<(string Id, string Version), PackageReferenceInfo>(); + + foreach (var framework in report.Projects?.SelectMany(p => p.Frameworks ?? []) ?? []) + { + foreach (var entry in framework.TopLevelPackages ?? []) + { + var id = entry.Id?.Trim(); + + // The resolved version is what exists on disk: it is the concrete value behind a + // floating version or a version managed through Central Package Management. + var version = Coalesce(entry.ResolvedVersion, entry.RequestedVersion); + + if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(version)) + { + continue; + } + + var key = (id.ToLowerInvariant(), version.ToLowerInvariant()); + found[key] = new PackageReferenceInfo(id, version); + } + } + + return [.. found.Values.OrderBy(p => p.Id, StringComparer.OrdinalIgnoreCase).ThenBy(p => p.Version, StringComparer.Ordinal)]; + + static string? Coalesce(string? first, string? second) => + string.IsNullOrWhiteSpace(first) ? second?.Trim() : first.Trim(); + } + + private static ListPackageReport Deserialize(string json) + { + // MSBuild sometimes writes warnings ahead of the payload, so fall back to the + // first '{' rather than assuming the whole stream is JSON. + foreach (var candidate in Candidates(json)) + { + try + { + var report = JsonSerializer.Deserialize(candidate, JsonOptions); + if (report is not null) + { + return report; + } + } + catch (JsonException) + { + // Try the next candidate. + } + } + + throw new PackageSkillsException( + $""" + Could not parse the output of 'dotnet list package --format json'. + + If the error above mentions an unrecognized '--format' option, the installed SDK predates 7.0.200 and needs upgrading. + Raw output: + {json.Trim()} + """); + + static IEnumerable Candidates(string text) + { + var trimmed = text.Trim(); + if (trimmed.Length == 0) + { + yield break; + } + + yield return trimmed; + + var start = trimmed.IndexOf('{'); + if (start > 0) + { + yield return trimmed[start..]; + } + } + } + + private sealed class ListPackageReport + { + [JsonPropertyName("version")] + public int Version { get; set; } + + [JsonPropertyName("projects")] + public List? Projects { get; set; } + } + + private sealed class ListPackageProject + { + [JsonPropertyName("path")] + public string? Path { get; set; } + + [JsonPropertyName("frameworks")] + public List? Frameworks { get; set; } + } + + private sealed class ListPackageFramework + { + [JsonPropertyName("framework")] + public string? Framework { get; set; } + + [JsonPropertyName("topLevelPackages")] + public List? TopLevelPackages { get; set; } + } + + private sealed class ListPackageEntry + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("requestedVersion")] + public string? RequestedVersion { get; set; } + + [JsonPropertyName("resolvedVersion")] + public string? ResolvedVersion { get; set; } + } +} diff --git a/dotnet-package-skills/src/NuGet/PackagePathResolver.cs b/dotnet-package-skills/src/NuGet/PackagePathResolver.cs new file mode 100644 index 0000000..e0a826e --- /dev/null +++ b/dotnet-package-skills/src/NuGet/PackagePathResolver.cs @@ -0,0 +1,101 @@ +namespace DotnetPackageSkills.NuGet; + +/// +/// Maps a package id and version to its folder inside the global packages cache. +/// +/// +/// Restore extracts each package to <global-packages>/<id>/<version>/ +/// with both segments lowercased and the version normalized. This mirrors NuGet's own +/// normalization rules; where they are ambiguous, a directory scan settles it, so a +/// mismatch degrades into a slower lookup rather than a missed package. +/// +public static class PackagePathResolver +{ + /// Returns the extracted package folder, or null when it is not on disk. + public static string? Resolve(string globalPackagesFolder, string packageId, string version) + { + var packageDirectory = Path.Combine(globalPackagesFolder, packageId.ToLowerInvariant()); + + if (!Directory.Exists(packageDirectory)) + { + return null; + } + + var normalized = NormalizeVersion(version); + + var candidate = Path.Combine(packageDirectory, normalized); + if (Directory.Exists(candidate)) + { + return candidate; + } + + // Fall back to a case-insensitive scan. NuGet's normalization has corner cases + // (SemVer 2 build metadata, unusual padding) that are not worth reimplementing + // exactly, and the directory itself is the authoritative answer. + foreach (var directory in Directory.EnumerateDirectories(packageDirectory)) + { + var name = Path.GetFileName(directory); + if (name.Equals(normalized, StringComparison.OrdinalIgnoreCase) || + name.Equals(version, StringComparison.OrdinalIgnoreCase)) + { + return directory; + } + } + + return null; + } + + /// + /// Normalizes a version the way NuGet does for folder names: lowercased, build + /// metadata dropped, padded to three parts, and a fourth part dropped when zero. + /// So 1.2 becomes 1.2.0 and 1.2.3.0 becomes 1.2.3. + /// + public static string NormalizeVersion(string version) + { + var value = version.Trim(); + + // Build metadata is not part of package identity and never appears in the path. + var plus = value.IndexOf('+'); + if (plus >= 0) + { + value = value[..plus]; + } + + var dash = value.IndexOf('-'); + var core = dash >= 0 ? value[..dash] : value; + var prerelease = dash >= 0 ? value[(dash + 1)..] : string.Empty; + + var parts = core.Split('.'); + var numbers = new List(4); + + foreach (var part in parts) + { + if (!int.TryParse(part, out var number) || number < 0) + { + // Not a version shape this tool understands; leave it to the directory scan. + return version.Trim().ToLowerInvariant(); + } + + numbers.Add(number); + } + + while (numbers.Count < 3) + { + numbers.Add(0); + } + + if (numbers.Count >= 4 && numbers[3] == 0) + { + numbers.RemoveRange(3, numbers.Count - 3); + } + + var normalized = string.Join('.', numbers); + + if (prerelease.Length > 0) + { + normalized = $"{normalized}-{prerelease}"; + } + + return normalized.ToLowerInvariant(); + } +} diff --git a/dotnet-package-skills/src/NuGet/TargetLocator.cs b/dotnet-package-skills/src/NuGet/TargetLocator.cs new file mode 100644 index 0000000..1d6b649 --- /dev/null +++ b/dotnet-package-skills/src/NuGet/TargetLocator.cs @@ -0,0 +1,119 @@ +namespace DotnetPackageSkills.NuGet; + +/// Finds the solution or project to inspect when the user does not name one. +public static class TargetLocator +{ + private static readonly string[] SolutionExtensions = [".slnx", ".sln"]; + private static readonly string[] ProjectExtensions = [".csproj", ".fsproj", ".vbproj"]; + private static readonly string[] IgnoredDirectories = ["bin", "obj", ".git", "node_modules", "artifacts"]; + + /// + /// Resolves an explicit target, or auto-detects one under . + /// A directory is accepted and searched. + /// + public static string Resolve(string? requested, string workingDirectory) + { + if (string.IsNullOrWhiteSpace(requested)) + { + return Detect(workingDirectory); + } + + var path = Path.GetFullPath(requested, workingDirectory); + + if (Directory.Exists(path)) + { + return Detect(path); + } + + if (!File.Exists(path)) + { + throw new PackageSkillsException($"--target does not exist: {path}"); + } + + var extension = Path.GetExtension(path); + if (!SolutionExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) && + !ProjectExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) + { + throw new PackageSkillsException( + $"--target must be a solution or project file, but got '{Path.GetFileName(path)}'. " + + "Supported extensions: .sln, .slnx, .csproj, .fsproj, .vbproj."); + } + + return path; + } + + /// + /// Searches for a target, preferring a solution over a project and the top level + /// over nested directories. A solution covers every project in one pass, which is + /// almost always what someone means by "my repo". + /// + public static string Detect(string directory) + { + if (!Directory.Exists(directory)) + { + throw new PackageSkillsException($"Directory does not exist: {directory}"); + } + + foreach (var extensions in new[] { SolutionExtensions, ProjectExtensions }) + { + var match = EnumerateFiles(directory, extensions, SearchOption.TopDirectoryOnly).FirstOrDefault(); + if (match is not null) + { + return match; + } + } + + foreach (var extensions in new[] { SolutionExtensions, ProjectExtensions }) + { + var match = EnumerateFiles(directory, extensions, SearchOption.AllDirectories) + .Where(path => !IsIgnored(path, directory)) + .FirstOrDefault(); + if (match is not null) + { + return match; + } + } + + throw new PackageSkillsException( + $"No solution or project found under {directory}. " + + "Pass one explicitly, for example: --target src/MyApp.sln"); + } + + private static IEnumerable EnumerateFiles(string directory, string[] extensions, SearchOption option) + { + IEnumerable files; + try + { + files = Directory.EnumerateFiles(directory, "*", option); + } + catch (UnauthorizedAccessException) + { + return []; + } + + // Rank by the extension's position in the list, so preference between formats + // (.slnx ahead of .sln) is not left to how the file names happen to sort. + return files + .Select(file => new + { + File = file, + Rank = Array.FindIndex( + extensions, + extension => extension.Equals(Path.GetExtension(file), StringComparison.OrdinalIgnoreCase)), + }) + .Where(candidate => candidate.Rank >= 0) + .OrderBy(candidate => candidate.Rank) + .ThenBy(candidate => candidate.File, StringComparer.Ordinal) + .Select(candidate => candidate.File); + } + + private static bool IsIgnored(string path, string root) + { + var relative = Path.GetRelativePath(root, path); + var segments = relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + // The file name itself is never a directory match. + return segments.Take(segments.Length - 1) + .Any(segment => IgnoredDirectories.Contains(segment, StringComparer.OrdinalIgnoreCase)); + } +} diff --git a/dotnet-package-skills/src/PackageSkillsException.cs b/dotnet-package-skills/src/PackageSkillsException.cs new file mode 100644 index 0000000..6106713 --- /dev/null +++ b/dotnet-package-skills/src/PackageSkillsException.cs @@ -0,0 +1,8 @@ +namespace DotnetPackageSkills; + +/// +/// A failure the user needs to act on. The message is printed verbatim without a +/// stack trace, so it must read as guidance rather than as a diagnostic. +/// +public sealed class PackageSkillsException(string message, Exception? inner = null) + : Exception(message, inner); diff --git a/dotnet-package-skills/src/Program.cs b/dotnet-package-skills/src/Program.cs new file mode 100644 index 0000000..21bb1fa --- /dev/null +++ b/dotnet-package-skills/src/Program.cs @@ -0,0 +1,394 @@ +using System.CommandLine; +using DotnetPackageSkills; +using DotnetPackageSkills.Cli; +using DotnetPackageSkills.Infrastructure; +using DotnetPackageSkills.NuGet; +using DotnetPackageSkills.Skills; + +return CommandLineBuilder.Invoke(args); + +namespace DotnetPackageSkills.Cli +{ + /// Wires up the command line surface. + internal static class CommandLineBuilder + { + /// + /// Vendor-neutral default. Agents that follow another convention are one + /// --destination away, which is why this is a default rather than a hard-coded path. + /// + private const string DefaultDestination = InstallRequest.DefaultDestination; + + public static int Invoke(string[] args, TextWriter? output = null, TextWriter? error = null) => + CommandLineDiagnostics.Invoke(Build().Parse(args), output ?? Console.Out, error ?? Console.Error); + + public static RootCommand Build() + { + var target = new Option("--target", "-t") + { + Description = "Solution or project to inspect. Defaults to searching the current directory.", + HelpName = "PATH", + }; + + var package = new Option("--package", "-p") + { + Description = + "Take skills from an exact package instead of a project, as Id@Version " + + "(for example Mockly@1.10.0). Repeatable. Floating versions are not accepted.", + HelpName = "ID@VERSION", + Arity = ArgumentArity.OneOrMore, + AllowMultipleArgumentsPerToken = true, + }; + package.Validators.Add(result => + { + // Taking several values means the parser hands --package any unknown option that + // follows it, such as a --json left in an old script. No package ID starts with + // '-', so report it the way the parser reports an unknown option anywhere else. + foreach (var token in result.Tokens.Where(token => token.Value.StartsWith('-'))) + { + result.AddError($"Unrecognized command or argument '{token.Value}'."); + } + }); + + var destination = new Option("--destination", "-d") + { + Description = $"Folder to copy skills into. Default: {DefaultDestination}", + HelpName = "PATH", + DefaultValueFactory = _ => DefaultDestination, + }; + + var globalPackages = new Option("--global-packages") + { + Description = "Override the NuGet global packages folder instead of asking the CLI.", + HelpName = "PATH", + }; + + var dryRun = new Option("--dry-run") + { + Description = "Report what would change without writing anything.", + }; + + var interactive = new Option("--interactive", "-i") + { + Description = + "Choose which skills to add, with descriptions, one page at a time. " + + "Only skills that aren't installed are listed; installed skills are left as they are.", + }; + + var uninstallPackage = new Option("--package", "-p") + { + Description = + "Remove only this package's skills. Accepts Id, or Id@Version to remove them " + + "only if that version is the one installed.", + HelpName = "ID[@VERSION]", + Arity = ArgumentArity.ExactlyOne, + }; + uninstallPackage.Validators.Add(result => + { + if (result.IdentifierTokenCount > 1) + { + result.AddError("--package can be specified only once for uninstall."); + return; + } + + if (result.Tokens.Count == 1) + { + try + { + ParseUninstallFilter(result.Tokens[0].Value); + } + catch (PackageSkillsException error) + { + result.AddError(error.Message); + } + } + }); + + // Its own option rather than the one install uses, because "copy skills into" is + // nonsense on a command that only deletes. It still has to exist: skills installed + // to somewhere other than the default are unreachable without it. + var uninstallDestination = new Option("--destination", "-d") + { + Description = $"Folder to remove skills from. Default: {DefaultDestination}", + HelpName = "PATH", + DefaultValueFactory = _ => DefaultDestination, + }; + + var install = new Command("install", "Copy skills bundled in NuGet packages into the repository.") + { + target, package, destination, globalPackages, dryRun, interactive, + }; + install.Validators.Add(RejectTargetWithPackage); + install.SetAction(parseResult => Run(() => + { + var request = BuildRequest(parseResult); + var service = new SkillInstallService(new ProcessRunner()); + + var result = parseResult.GetValue(interactive) + ? InstallInteractively(service, request) + : service.Install(request); + + if (result is null) + { + new OutputWriter(Console.Out).WriteCancelled(); + return; + } + + new OutputWriter(Console.Out).WriteInstallReport(result, copied: true); + })); + + var list = new Command("list", "Show which packages ship skills, without copying anything.") + { + target, package, destination, globalPackages, + }; + list.Validators.Add(RejectTargetWithPackage); + list.SetAction(parseResult => Run(() => + { + var request = BuildRequest(parseResult) with { DryRun = true }; + var result = new SkillInstallService(new ProcessRunner()).Discover(request); + new OutputWriter(Console.Out).WriteInstallReport(result, copied: false); + })); + + var uninstallInteractive = new Option("--interactive", "-i") + { + Description = + "Choose which installed skills to remove, with descriptions, one page at a time. " + + "Only skills this tool installed are listed.", + }; + + var stale = new Option("--stale") + { + Description = + "Remove only stale skills: skills whose package the target no longer references, " + + "or references at a different version. Reads the target's package references, " + + "so it needs a solution or project.", + }; + + var staleTarget = new Option("--target", "-t") + { + Description = + "With --stale, the solution or project to compare against. " + + "Defaults to searching the current directory.", + HelpName = "PATH", + }; + + var uninstall = new Command("uninstall", "Remove skills this tool previously copied in.") + { + uninstallDestination, uninstallPackage, stale, staleTarget, dryRun, uninstallInteractive, + }; + uninstall.Validators.Add(result => + { + var isStale = result.GetResult(stale) is not null; + + if (isStale && result.GetResult(uninstallPackage) is not null) + { + result.AddError( + "--stale and --package cannot be combined. --stale removes the skills that no longer " + + "match the target; --package removes one package's skills."); + } + + if (!isStale && result.GetResult(staleTarget) is not null) + { + result.AddError("--target can be used with uninstall only together with --stale."); + } + }); + uninstall.SetAction(parseResult => Run(() => + { + var workingDirectory = Directory.GetCurrentDirectory(); + var destinationValue = parseResult.GetValue(uninstallDestination) ?? DefaultDestination; + var isDryRun = parseResult.GetValue(dryRun); + var (id, version) = ParseUninstallFilter(parseResult.GetValue(uninstallPackage)); + var root = Path.GetFullPath(destinationValue, workingDirectory); + var service = new SkillInstallService(new ProcessRunner()); + var references = parseResult.GetValue(stale) + ? service.ReadReferences(parseResult.GetValue(staleTarget), workingDirectory) + : null; + + UninstallChoice? choice = null; + + if (parseResult.GetValue(uninstallInteractive)) + { + choice = ChooseWhatToRemove(destinationValue, workingDirectory, id, version, references); + + if (choice is null) + { + new OutputWriter(Console.Out).WriteCancelled(); + return; + } + } + + var removed = service.Uninstall(destinationValue, workingDirectory, id, version, isDryRun, + choice?.Selected, choice?.ExpectedInstalled, references?.Packages); + + new OutputWriter(Console.Out).WriteUninstallReport(removed, root, isDryRun, references?.Target); + })); + + return new RootCommand( + """ + Copies agent skills bundled inside NuGet packages into a folder your coding agent reads. + + Package authors ship skills at skills/-/SKILL.md inside the package. Restore extracts them to the NuGet global packages folder, which is outside your repository and which no coding agent scans. This tool bridges that gap. + """) + { + install, list, uninstall, + }; + + InstallRequest BuildRequest(ParseResult parseResult) => new() + { + Target = parseResult.GetValue(target), + Packages = [.. (parseResult.GetValue(package) ?? []).Select(PackageCoordinate.Parse)], + Destination = parseResult.GetValue(destination) ?? DefaultDestination, + WorkingDirectory = Directory.GetCurrentDirectory(), + GlobalPackagesOverride = parseResult.GetValue(globalPackages), + DryRun = parseResult.GetValue(dryRun), + }; + + void RejectTargetWithPackage(System.CommandLine.Parsing.CommandResult result) + { + // Both would answer "which packages", and combining them hides which one won. + if (result.GetResult(target) is not null && result.GetResult(package) is not null) + { + result.AddError( + "--target and --package cannot be combined. Use --target to take versions " + + "from a project, or --package to name exact packages yourself."); + } + } + } + + /// + /// Discovers skills, lets the user pick from the ones not installed yet a page at a time, + /// then copies the picks. Returns null when the user cancelled. + /// + /// + /// Every check that could stop the install runs before the checklist opens, so a choice is + /// never made only to be refused. With nothing new to offer there is no checklist at all. + /// + private static InstallResult? InstallInteractively(SkillInstallService service, InstallRequest request) + { + var discovered = service.Discover(request); + var installed = SkillInstallService.InstalledSkills(discovered.Destination, request.WorkingDirectory); + var prepared = service.PrepareInteractiveInstall(request, discovered, installed); + var items = InteractiveSkills.ForInstall(prepared.Skills, installed); + + if (items.Count == 0) + { + return prepared with { NothingNewToInstall = prepared.SkillsDiscovered > 0 }; + } + + var picked = new SkillPicker(new SystemTerminal()) + .Choose(items, PickerTitle(discovered), PickerMode.Install, InstalledSkillsNote); + + if (picked is null) + { + return null; + } + + var choice = InteractiveSkills.InstallChoice(prepared.Skills, installed, items, picked); + + return service.Install(request, prepared, choice); + } + + /// + /// Offers the installed skills for removal and returns the ones ticked, or null when + /// the user cancelled. + /// + /// + /// The list comes from the manifest, so it holds exactly what this tool put there and + /// nothing a user wrote themselves. An empty list still returns an empty selection + /// rather than prompting, so the report can say there was nothing to remove. + /// + private static UninstallChoice? ChooseWhatToRemove( + string destination, + string workingDirectory, + string? packageId, + string? packageVersion, + TargetReferences? references) + { + var installed = SkillInstallService.InstalledSkills(destination, workingDirectory); + var matching = installed + .Where(entry => SkillInstaller.Matches(entry, packageId, packageVersion)) + .Where(entry => references is null || SkillInstaller.IsStale(entry, references.Packages)) + .ToList(); + + if (matching.Count == 0) + { + return new UninstallChoice([], installed); + } + + var items = InteractiveSkills.ForUninstall( + matching, + Path.GetFullPath(destination, workingDirectory)); + + var selected = new SkillPicker(new SystemTerminal()).Choose( + items, + "Which skills should be uninstalled?", + PickerMode.Uninstall, + references is null ? null : StaleSkillsNote); + return selected is null ? null : new UninstallChoice(selected.ToList(), installed); + } + + /// Shown under the install checklist title, because the list is not everything. + internal const string InstalledSkillsNote = "Installed skills aren't listed."; + + /// Shown under the uninstall checklist title with --stale. + internal const string StaleSkillsNote = "Only skills that don't match the target are listed."; + + private static string PickerTitle(InstallResult discovered) => + discovered.Target is null + ? "Which skills should be installed?" + : $"Which skills should be installed? ({Path.GetFileName(discovered.Target)})"; + + /// + /// Splits the uninstall filter, which unlike --package on install may omit the version + /// to mean "whichever version of this package is installed". + /// + internal static (string? Id, string? Version) ParseUninstallFilter(string? value) + { + if (value is null) + { + return (null, null); + } + + if (string.IsNullOrWhiteSpace(value)) + { + throw new PackageSkillsException( + "--package requires a non-empty package ID, optionally followed by @Version. " + + "Omit --package only when you intend to remove all tracked skills."); + } + + if (!value.Contains(PackageCoordinate.Separator)) + { + var id = value.Trim(); + PackageCoordinate.ValidateId(id); + return (id, null); + } + + var coordinate = PackageCoordinate.Parse(value); + return (coordinate.Id, coordinate.Version); + } + + /// + /// Turns expected failures into a plain message and a non-zero exit code. Users of a CLI + /// should get guidance, not a stack trace, for anything we anticipated. + /// + private static int Run(Action action) + { + try + { + action(); + return 0; + } + catch (Exception ex) when (ex is PackageSkillsException or ProcessExecutionException) + { + new OutputWriter(Console.Out).WriteError(ex.Message); + return 1; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + new OutputWriter(Console.Out).WriteError( + $"{ex.Message}{Environment.NewLine}" + + "Check that the destination folder is writable and not open in another program."); + return 1; + } + } + } +} diff --git a/dotnet-package-skills/src/SkillInstallService.cs b/dotnet-package-skills/src/SkillInstallService.cs new file mode 100644 index 0000000..e1af1cd --- /dev/null +++ b/dotnet-package-skills/src/SkillInstallService.cs @@ -0,0 +1,505 @@ +using DotnetPackageSkills.Infrastructure; +using DotnetPackageSkills.NuGet; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills; + +/// Inputs for an install or a list. +public sealed record InstallRequest +{ + /// Where skills go when no destination is given. + public const string DefaultDestination = ".agents/skills"; + + /// Solution or project to inspect. Ignored when is set. + public string? Target { get; init; } + + /// Exact packages to take skills from, instead of inspecting a project. + public IReadOnlyList Packages { get; init; } = []; + + public required string Destination { get; init; } + public required string WorkingDirectory { get; init; } + public string? GlobalPackagesOverride { get; init; } + public bool DryRun { get; init; } +} + +/// What an install or a list produced. +public sealed record InstallResult +{ + /// The solution or project inspected, or null when packages were named explicitly. + public string? Target { get; init; } + + public required string GlobalPackagesFolder { get; init; } + public required string Destination { get; init; } + public required int PackagesScanned { get; init; } + public required bool DryRun { get; init; } + public required IReadOnlyList Skills { get; init; } + + /// + /// How many skills discovery turned up, which stays put even after is + /// narrowed to what was actually installed. Without it a report cannot tell "no package ships + /// a skill" apart from "you chose none of the ones that do". + /// + public int SkillsDiscovered { get; init; } + + public IReadOnlyList Removed { get; init; } = []; + public IReadOnlyList Skipped { get; init; } = []; + + /// + /// Installed skills whose package the target no longer references. Install keeps them, and + /// the report points at uninstall --stale, the one command that removes them. + /// + public IReadOnlyList Unreferenced { get; init; } = []; + + /// + /// The command the report suggests for removing skills, spelled + /// with the target and destination this run used. + /// + public string StaleCommand { get; init; } = "dotnet-package-skills uninstall --stale"; + + /// + /// Set when an interactive install found skills but every one is installed already or + /// skipped, so there was nothing to choose from and no checklist was shown. + /// + public bool NothingNewToInstall { get; init; } + + internal IReadOnlyList ResolvedPackages { get; init; } = []; + internal IReadOnlyList? AllCandidates { get; init; } +} + +/// The skills the user checked. Installing a choice copies these and changes nothing else. +public sealed record SkillChoice(IReadOnlyList Selected) +{ + public IReadOnlyCollection? ExpectedInstalled { get; init; } +} + +/// Ties package listing, skill discovery, and installation together. +public sealed class SkillInstallService(DotnetCli dotnet, SkillInstaller installer) +{ + public SkillInstallService(IProcessRunner runner) : this(new DotnetCli(runner), new SkillInstaller()) + { + } + + /// Discovers bundled skills without writing anything. + public InstallResult Discover(InstallRequest request) + { + return request.Packages.Count > 0 + ? DiscoverFromCoordinates(request) + : DiscoverFromTarget(request); + } + + private InstallResult DiscoverFromTarget(InstallRequest request) + { + var target = TargetLocator.Resolve(request.Target, request.WorkingDirectory); + + // Ask for the global packages folder from the repository, not from wherever the user + // happened to invoke the tool: nuget.config discovery walks up from the current + // directory, and a repo-level config is exactly the case worth honouring. + var globalPackages = LocateGlobalPackages(request, Path.GetDirectoryName(target)); + + // Keep every distinct (id, version) long enough to detect unsupported multi-version + // collisions explicitly rather than silently selecting one package from the solution. + var packages = new PackageLister(dotnet).List(target); + + var (skills, skipped, candidates) = Collect(globalPackages, packages.Select(p => (p.Id, p.Version))); + + return Build(request, target, globalPackages, packages.Count, skills, skipped) + with { ResolvedPackages = packages, AllCandidates = candidates }; + } + + private InstallResult DiscoverFromCoordinates(InstallRequest request) + { + var globalPackages = LocateGlobalPackages(request, request.WorkingDirectory); + + var packages = request.Packages.DistinctBy(package => + (package.Id.ToLowerInvariant(), PackagePathResolver.NormalizeVersion(package.Version))).ToArray(); + var (skills, skipped, candidates) = Collect( + globalPackages, + packages.Select(coordinate => (coordinate.Id, coordinate.Version))); + + return Build(request, target: null, globalPackages, packages.Length, skills, skipped) + with + { + ResolvedPackages = [.. packages.Select(coordinate => new PackageReferenceInfo(coordinate.Id, coordinate.Version))], + AllCandidates = candidates, + }; + } + + private string LocateGlobalPackages(InstallRequest request, string? preferredDirectory) => + new GlobalPackagesLocator(dotnet).Locate( + request.GlobalPackagesOverride, + preferredDirectory ?? request.WorkingDirectory); + + /// + /// A package that is not in the cache contributes nothing, exactly like one that ships no + /// skills: getting packages into the cache is restore's job, not this tool's. Only a target + /// install checks for them, because it would otherwise report their skills as stale. + /// + private static (List Skills, List Skipped, List Candidates) Collect( + string globalPackages, + IEnumerable<(string Id, string Version)> packages) + { + var skills = new List(); + var skipped = new List(); + var candidates = new List(); + var destinations = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var (id, version) in packages) + { + var packageDirectory = PackagePathResolver.Resolve(globalPackages, id, version); + + if (packageDirectory is null) + { + continue; + } + + foreach (var skill in SkillDiscovery.Discover(packageDirectory, id, version)) + { + candidates.Add(skill); + if (destinations.TryAdd(skill.RelativePath, skill)) + { + skills.Add(skill); + continue; + } + + var retained = destinations[skill.RelativePath]; + skipped.Add(ToSkipped( + skill, + $"conflicts with {retained.PackageId} {retained.PackageVersion} skill " + + $"'{retained.SkillName}', which was selected first")); + } + } + + return (skills, skipped, candidates); + } + + private static InstallResult Build( + InstallRequest request, + string? target, + string globalPackages, + int packagesScanned, + IReadOnlyList skills, + IReadOnlyList skipped) => + new() + { + Target = target, + GlobalPackagesFolder = globalPackages, + Destination = Path.GetFullPath(request.Destination, request.WorkingDirectory), + PackagesScanned = packagesScanned, + DryRun = request.DryRun, + Skills = skills, + SkillsDiscovered = skills.Count, + Skipped = skipped, + }; + + /// Discovers bundled skills and copies them into the destination. + public InstallResult Install(InstallRequest request) => Install(request, Discover(request), choice: null); + + /// + /// Copies a caller-chosen subset of already-discovered skills, which is what the interactive + /// picker produces. Passing a null installs everything discovered. + /// + public InstallResult Install(InstallRequest request, InstallResult discovered, SkillChoice? choice) + { + RequireOneVersionPerPackage(request, discovered); + + if (request.Packages.Count == 0) + { + RequireEveryPackageInCache(discovered); + } + + // A choice only adds: it never refreshes or removes what it was not asked about. Without + // one, the run covers the packages it found in the cache, so a version that is not there + // never causes a removal. + var offered = choice is null + ? discovered.ResolvedPackages + .Where(package => PackagePathResolver.Resolve(discovered.GlobalPackagesFolder, package.Id, package.Version) is not null) + .GroupBy(package => package.Id, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First().Version, StringComparer.OrdinalIgnoreCase) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + + var outcome = installer.Install( + discovered.Destination, + choice?.Selected ?? discovered.AllCandidates ?? discovered.Skills, + request.DryRun, + offered, + choice?.ExpectedInstalled, + arguments => UninstallCommand(request, arguments)); + + return discovered with + { + DryRun = request.DryRun, + Skills = outcome.Installed, + Removed = outcome.Removed, + Skipped = discovered.AllCandidates is null + ? [.. discovered.Skipped, .. outcome.Skipped] + : outcome.Skipped, + // A target lists every package it references, so anything it did not offer has left + // the project. Named packages say nothing about the rest, so they report nothing. + Unreferenced = request.Packages.Count == 0 && choice is null ? outcome.Untouched : [], + StaleCommand = UninstallCommand(request, "--stale", withTarget: true), + AllCandidates = null, + }; + } + + /// + /// Stops an install when a package has more than one version, whether or not it ships skills. + /// + /// + /// The manifest records one version per package, and skills describe the version they came + /// from. With two versions there is no right answer for which guidance the repository gets, + /// so rather than guess, ask for the versions to be aligned. Central Package Management keeps + /// them aligned. does not check this, so list still shows both. + /// + private static void RequireOneVersionPerPackage(InstallRequest request, InstallResult discovered) + { + var conflicts = discovered.ResolvedPackages + .GroupBy(package => package.Id, StringComparer.OrdinalIgnoreCase) + .Select(group => ( + group.First().Id, + Versions: group + .DistinctBy(package => PackagePathResolver.NormalizeVersion(package.Version)) + .Select(package => package.Version) + .ToList())) + .Where(entry => entry.Versions.Count > 1) + .Select(entry => $"{entry.Id} ({string.Join(", ", entry.Versions)})") + .ToList(); + + if (conflicts.Count == 0) + { + return; + } + + throw new PackageSkillsException(request.Packages.Count == 0 + ? "Cannot install skills because these packages resolve to more than one version: " + + $"{string.Join("; ", conflicts)}. Skills can come from only one version of each package. " + + "Align the versions, for example with Central Package Management, and then try again. " + + "No skills were changed." + : "Cannot install skills because --package names more than one version of these packages: " + + $"{string.Join("; ", conflicts)}. Skills can come from only one version of each package, " + + "so name one version per package, and then try again. No skills were changed."); + } + + /// + /// Stops a target install when a package the target references is not in the cache. + /// + /// + /// Restoring is not this tool's job, but carrying on would read an unextracted package as + /// one that ships nothing and report its installed skills as no longer referenced. Asking + /// for a restore is the honest answer. Named packages never reach this check: naming one + /// that is not in the cache simply finds no skills. + /// + private static void RequireEveryPackageInCache(InstallResult discovered) + { + var missing = discovered.ResolvedPackages + .Where(package => PackagePathResolver.Resolve(discovered.GlobalPackagesFolder, package.Id, package.Version) is null) + .Select(package => $"{package.Id} {package.Version}") + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (missing.Length > 0) + { + throw new PackageSkillsException( + $"Cannot install skills because resolved packages are missing from '{discovered.GlobalPackagesFolder}': " + + $"{string.Join(", ", missing)}. " + + "Run dotnet restore for the target using this cache, then try again. No skills were changed."); + } + } + + /// + /// Runs every check that stops an interactive install before a checklist opens, and returns + /// what it can offer: the skills that would install cleanly and are not installed yet. + /// + /// + /// An interactive install only adds, so it cannot settle a mismatch between the installed + /// skills and the packages. With a target, any stale skill stops it until + /// uninstall --stale removes them. With named packages, an installed skill from + /// another version of one of them stops it, because adding beside it would give that + /// package two versions. + /// + internal InstallResult PrepareInteractiveInstall( + InstallRequest request, + InstallResult discovered, + IReadOnlyCollection installed) + { + RequireOneVersionPerPackage(request, discovered); + + if (request.Packages.Count == 0) + { + RequireEveryPackageInCache(discovered); + RequireNoStaleSkills(request, discovered, installed); + } + else + { + RequireNoOtherInstalledVersion(request, discovered, installed); + } + + var preview = Install( + request with { DryRun = true }, + discovered, + new SkillChoice(discovered.AllCandidates ?? discovered.Skills) { ExpectedInstalled = installed }); + var tracked = installed.Select(entry => entry.Skill).ToHashSet(StringComparer.OrdinalIgnoreCase); + + return preview with + { + DryRun = request.DryRun, + Skills = [.. preview.Skills.Where(skill => !tracked.Contains(skill.RelativePath))], + }; + } + + private static void RequireNoStaleSkills( + InstallRequest request, + InstallResult discovered, + IReadOnlyCollection installed) + { + var stale = installed + .Where(entry => SkillInstaller.IsStale(entry, discovered.ResolvedPackages)) + .OrderBy(entry => entry.Skill, StringComparer.Ordinal) + .ToList(); + + if (stale.Count == 0) + { + return; + } + + throw new PackageSkillsException( + $"Cannot choose skills interactively because {stale.Count} installed " + + $"{(stale.Count == 1 ? "skill doesn't" : "skills don't")} match the target: " + + $"{string.Join(", ", stale.Select(entry => $"{entry.Skill} ({entry.Package} {entry.Version})"))}. " + + $"Run '{UninstallCommand(request, "--stale", withTarget: true)}' first, and then try again. " + + "No skills were changed."); + } + + private static void RequireNoOtherInstalledVersion( + InstallRequest request, + InstallResult discovered, + IReadOnlyCollection installed) + { + var conflicts = discovered.ResolvedPackages + .Select(package => ( + package.Id, + Installed: installed.FirstOrDefault(entry => + entry.Package.Equals(package.Id, StringComparison.OrdinalIgnoreCase) && + !SkillInstaller.SameVersion(entry.Version, package.Version)))) + .Where(conflict => conflict.Installed is not null) + .ToList(); + + if (conflicts.Count == 0) + { + return; + } + + var command = conflicts.Count == 1 + ? $"'{UninstallCommand(request, $"--package {conflicts[0].Id}")}'" + : $"'{UninstallCommand(request, "--package ")}' for each of them"; + throw new PackageSkillsException( + $"{string.Join(" and ", conflicts.Select(conflict => $"{conflict.Id} {conflict.Installed!.Version}"))} " + + $"{(conflicts.Count == 1 ? "is" : "are")} already installed, and an interactive install only adds " + + $"skills, so it can't change a package's version. Run {command} first, and then try again. " + + "No skills were changed."); + } + + /// Skill folder names the manifest in already tracks. + public static IReadOnlySet InstalledSkillNames(string destination) => + InstalledSkills(destination, Directory.GetCurrentDirectory()) + .Select(entry => entry.Skill) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + /// + /// Everything the manifest tracks, in the order a list should show it. + /// + /// + /// This is what uninstall offers to choose from. It reads the manifest rather than the + /// folder, so skills the user wrote themselves are never on the list — the same reason + /// removal is manifest-driven in the first place. + /// + public static IReadOnlyList InstalledSkills(string destination, string workingDirectory) + { + var root = Path.GetFullPath(destination, workingDirectory); + using var destinationLock = DestinationLock.Acquire(root); + return + [ + .. InstallManifest.Load(root) + .EnumerateSkills() + .OrderBy(entry => entry.Skill, StringComparer.OrdinalIgnoreCase) + .ThenBy(entry => entry.Skill, StringComparer.Ordinal), + ]; + } + + /// + /// Removes skills this tool installed, optionally limited to one package, one exact + /// version, the names the caller chose, or the skills that are stale against a target. + /// + public IReadOnlyList Uninstall( + string destination, + string workingDirectory, + string? packageId, + string? packageVersion, + bool dryRun, + IReadOnlyCollection? only = null, + IReadOnlyCollection? expectedInstalled = null, + IReadOnlyCollection? staleAgainst = null) + { + var root = Path.GetFullPath(destination, workingDirectory); + return installer.Uninstall(root, packageId, packageVersion, dryRun, only, expectedInstalled, staleAgainst); + } + + /// + /// Finds the target and lists its direct package references with dotnet list package. + /// This is all uninstall --stale reads: deciding which skills are stale needs the + /// references, not the packages, so the tool never looks in the NuGet cache for them. + /// + public TargetReferences ReadReferences(string? target, string workingDirectory) + { + var resolved = TargetLocator.Resolve(target, workingDirectory); + return new TargetReferences(resolved, new PackageLister(dotnet).List(resolved)); + } + + private static SkippedSkill ToSkipped(BundledSkill skill, string reason) => + new( + skill.RelativePath, + skill.PackageId, + skill.PackageVersion, + skill.SkillName, + reason); + + /// + /// Spells an uninstall command that a report or an error suggests, with the target and the + /// destination this run used. + /// + /// Repeat --target, which only uninstall --stale accepts. + internal static string UninstallCommand(InstallRequest request, string arguments, bool withTarget = false) + { + // A suggestion is only useful if running it as printed acts on the same skills folder, + // compared against the same project. + var command = $"dotnet-package-skills uninstall {arguments}"; + + if (withTarget && request.Target is not null) + { + command += $" --target {CommandArgument(request.Target)}"; + } + + if (!IsDefaultDestination(request)) + { + command += $" --destination {CommandArgument(request.Destination)}"; + } + + return command; + } + + private static bool IsDefaultDestination(InstallRequest request) => + Path.TrimEndingDirectorySeparator(Path.GetFullPath(request.Destination, request.WorkingDirectory)).Equals( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(InstallRequest.DefaultDestination, request.WorkingDirectory)), + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + + /// + /// Quotes a value that a shell would otherwise split or reinterpret. Backslashes count: + /// bash treats them as escapes outside quotes, and every shell reads them literally inside. + /// + private static string CommandArgument(string value) => + value.Length > 0 && value.All(character => char.IsAsciiLetterOrDigit(character) || "._-/:+@".Contains(character)) + ? value + : $"\"{value}\""; +} + +/// A solution or project and the package versions it references directly. +public sealed record TargetReferences(string Target, IReadOnlyList Packages); diff --git a/dotnet-package-skills/src/Skills/BundledSkill.cs b/dotnet-package-skills/src/Skills/BundledSkill.cs new file mode 100644 index 0000000..0cdff99 --- /dev/null +++ b/dotnet-package-skills/src/Skills/BundledSkill.cs @@ -0,0 +1,25 @@ +namespace DotnetPackageSkills.Skills; + +/// A skill found inside an extracted NuGet package. +/// Package id as reported by NuGet, in its original casing. +/// Resolved version as reported by NuGet. +/// Folder name of the skill inside the package's skills/ directory. +/// Absolute path to the skill folder in the global packages cache. +/// +/// Destination path relative to the skills root, always with forward slashes so the manifest is +/// stable across operating systems. +/// +public sealed record BundledSkill( + string PackageId, + string PackageVersion, + string SkillName, + string SourcePath, + string RelativePath); + +/// A package skill that was not copied because its destination path collided. +public sealed record SkippedSkill( + string RelativePath, + string PackageId, + string PackageVersion, + string SkillName, + string Reason); diff --git a/dotnet-package-skills/src/Skills/DestinationLock.cs b/dotnet-package-skills/src/Skills/DestinationLock.cs new file mode 100644 index 0000000..9f74773 --- /dev/null +++ b/dotnet-package-skills/src/Skills/DestinationLock.cs @@ -0,0 +1,193 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace DotnetPackageSkills.Skills; + +/// Serializes cooperating tool processes from ownership checks through manifest persistence. +internal sealed class DestinationLock : IDisposable +{ + private readonly Mutex _mutex; + private bool _disposed; + + private DestinationLock(Mutex mutex) => _mutex = mutex; + + public static DestinationLock Acquire(string destination, TimeSpan? timeout = null) + { + var name = NameFor(destination); + var mutex = new Mutex(initiallyOwned: false, name); + var acquired = false; + try + { + try + { + acquired = mutex.WaitOne(timeout ?? TimeSpan.FromSeconds(30)); + } + catch (AbandonedMutexException error) + { + acquired = true; + throw new PackageSkillsException( + $"A previous operation on '{destination}' was interrupted. " + + "Check the destination and its manifest before trying again; no changes were made by this operation.", + error); + } + + if (!acquired) + { + throw new PackageSkillsException( + $"Another operation is using the skills destination '{destination}'. " + + "Wait for it to finish and try again. No skills were changed."); + } + + if (!name.Equals(NameFor(destination), StringComparison.Ordinal)) + { + throw new PackageSkillsException( + $"The skills destination '{destination}' changed while waiting for another operation. " + + "Run the command again to review its current location. No skills were changed."); + } + + return new DestinationLock(mutex); + } + catch + { + if (acquired) + { + mutex.ReleaseMutex(); + } + + mutex.Dispose(); + throw; + } + } + + internal static string NameFor(string destination) + { + var full = Path.TrimEndingDirectorySeparator(Path.GetFullPath(destination)); + if (OperatingSystem.IsWindows()) + { + return MutexName(CanonicalWindowsPath(full).ToUpperInvariant(), windows: true); + } + + var root = Path.GetPathRoot(full)!; + var canonical = root; + foreach (var part in full[root.Length..].Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + canonical = Path.Combine(canonical, part); + var directory = new DirectoryInfo(canonical); + if (directory.Exists && directory.LinkTarget is not null) + { + canonical = directory.ResolveLinkTarget(returnFinalTarget: true)?.FullName + ?? throw new PackageSkillsException($"Could not resolve the skills destination '{destination}'."); + } + } + + canonical = Path.TrimEndingDirectorySeparator(canonical); + return MutexName(canonical, windows: false); + } + + private static string MutexName(string canonical, bool windows) + { + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))); + return (windows ? @"Global\" : string.Empty) + "dotnet-package-skills-" + hash; + } + + [SupportedOSPlatform("windows")] + private static string CanonicalWindowsPath(string full) + { + var missing = new Stack(); + var existing = new DirectoryInfo(full); + while (!existing.Exists) + { + missing.Push(existing.Name); + existing = existing.Parent + ?? throw new PackageSkillsException($"Could not resolve the skills destination '{full}'."); + } + + // The OS resolves device prefixes, short names, drive mappings, and junctions to + // the same path. Resolve only the existing parent so previews create no directories. + var openPath = existing.FullName; + if (!openPath.StartsWith(@"\\?\", StringComparison.Ordinal) && + !openPath.StartsWith(@"\\.\", StringComparison.Ordinal)) + { + openPath = openPath.StartsWith(@"\\", StringComparison.Ordinal) + ? @"\\?\UNC\" + openPath[2..] + : @"\\?\" + openPath; + } + + using var handle = CreateFile( + openPath, 0, 7, nint.Zero, 3, 0x02000000, nint.Zero); + if (handle.IsInvalid) + { + throw PathError(full); + } + + var path = new StringBuilder(256); + var length = GetFinalPathNameByHandle(handle, path, (uint)path.Capacity, 0); + if (length == 0) + { + throw PathError(full); + } + + if (length >= path.Capacity) + { + path = new StringBuilder(checked((int)length + 1)); + length = GetFinalPathNameByHandle(handle, path, (uint)path.Capacity, 0); + if (length == 0 || length >= path.Capacity) + { + throw PathError(full); + } + } + + var canonical = path.ToString(); + if (canonical.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase)) + { + canonical = @"\\" + canonical[8..]; + } + else if (canonical.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase)) + { + canonical = canonical[4..]; + } + + foreach (var component in missing) + { + canonical = Path.Combine(canonical, component); + } + + return Path.TrimEndingDirectorySeparator(canonical); + } + + private static IOException PathError(string path) => new( + $"Could not resolve the skills destination '{path}'.", + new Win32Exception(Marshal.GetLastPInvokeError())); + + [DllImport("kernel32.dll", EntryPoint = "CreateFileW", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string path, uint access, uint share, nint security, uint disposition, uint flags, nint template); + + [DllImport("kernel32.dll", EntryPoint = "GetFinalPathNameByHandleW", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetFinalPathNameByHandle( + SafeFileHandle handle, StringBuilder path, uint size, uint flags); + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + try + { + _mutex.ReleaseMutex(); + } + finally + { + _mutex.Dispose(); + } + } +} diff --git a/dotnet-package-skills/src/Skills/InstallManifest.cs b/dotnet-package-skills/src/Skills/InstallManifest.cs new file mode 100644 index 0000000..5f25df8 --- /dev/null +++ b/dotnet-package-skills/src/Skills/InstallManifest.cs @@ -0,0 +1,348 @@ +using System.Text; +using System.Text.Json; +using DotnetPackageSkills.NuGet; + +namespace DotnetPackageSkills.Skills; + +/// One installed skill with its owning package metadata. +public sealed record TrackedSkill(string Package, string Version, string Skill); + +/// The skills this tool installed from one package, and the one version they came from. +public sealed record ManifestPackage(string Version, IReadOnlyList Skills); + +/// +/// Record of what this tool put in the destination folder. +/// +/// +/// The manifest is what makes removal safe. Refreshing, removal and uninstall act only on skill +/// folder names recorded under their owning package, never on whatever happens to be in the +/// destination, so hand-authored skills living alongside package-provided ones are never at risk. +/// +/// The layout follows dotnet-tools.json: a format version, then a packages map +/// keyed by lowercase package id, each with the one version its skills came from. Repositories +/// commit this file, so the bytes are deterministic: sorted, normalized, and LF-only on every +/// operating system. +/// +public sealed class InstallManifest +{ + public const string FileName = ".dotnet-package-skills.json"; + + /// + /// The only format this build reads and writes. A newer tool that changes the format raises + /// this number, and this build then refuses the file rather than dropping what it can't read. + /// + public const int FormatVersion = 1; + + private SortedDictionary _packages = new(StringComparer.Ordinal); + + /// Tracked packages by lowercase id. + public IReadOnlyDictionary Packages => _packages; + + internal bool IsEmpty => _packages.Count == 0; + + /// Loads and validates the manifest without changing it. + /// + /// An unreadable manifest cannot safely mean "nothing is tracked." Doing that makes every + /// folder this tool installed look user-owned, so install refuses to update it and uninstall + /// refuses to remove it. Stop instead: ownership is unknown, and guessing could overwrite or + /// delete a hand-authored skill. + /// + public static InstallManifest Load(string destinationRoot) + { + var path = Path.Combine(destinationRoot, FileName); + + if (!File.Exists(path)) + { + return new InstallManifest(); + } + + string text; + try + { + text = File.ReadAllText(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw CannotRead(path, "the file could not be opened", ex); + } + + JsonDocument document; + try + { + document = JsonDocument.Parse(text); + } + catch (JsonException ex) + { + throw CannotRead(path, "it does not contain valid manifest JSON", ex); + } + + using (document) + { + return Read(document.RootElement, path); + } + } + + private static InstallManifest Read(JsonElement root, string path) + { + if (root.ValueKind != JsonValueKind.Object) + { + throw CannotRead(path, "it must contain a JSON object"); + } + + if (FindDuplicateProperty(root) is { } duplicate) + { + throw CannotRead(path, $"the property '{duplicate}' appears more than once"); + } + + if (!TryGetProperty(root, "version", out var formatVersion)) + { + throw TryGetProperty(root, "installed", out _) && !TryGetProperty(root, "packages", out _) + ? WrittenByPreRelease(path) + : CannotRead(path, "it has no format 'version'"); + } + + if (formatVersion.ValueKind != JsonValueKind.Number || !formatVersion.TryGetInt32(out var number)) + { + throw CannotRead(path, "its format 'version' must be a whole number"); + } + + if (number > FormatVersion) + { + throw WrittenByNewerTool(path, number); + } + + if (number < 1) + { + throw CannotRead(path, $"format version {number} is not supported"); + } + + if (!TryGetProperty(root, "packages", out var packages) || packages.ValueKind != JsonValueKind.Object) + { + throw CannotRead(path, "'packages' must be an object"); + } + + var manifest = new InstallManifest(); + var claimed = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var package in packages.EnumerateObject()) + { + var id = package.Name; + + if (!PackageCoordinate.IsValidId(id)) + { + throw CannotRead(path, $"'{id}' is not a valid package id"); + } + + if (package.Value.ValueKind != JsonValueKind.Object) + { + throw CannotRead(path, $"'packages.{id}' must be an object"); + } + + if (!TryGetProperty(package.Value, "version", out var version) || + version.ValueKind != JsonValueKind.String || + string.IsNullOrWhiteSpace(version.GetString())) + { + throw CannotRead(path, $"'packages.{id}.version' must be text"); + } + + if (!TryGetProperty(package.Value, "skills", out var skills) || skills.ValueKind != JsonValueKind.Array) + { + throw CannotRead(path, $"'packages.{id}.skills' must be an array"); + } + + var names = new List(); + foreach (var skill in skills.EnumerateArray()) + { + var name = skill.ValueKind == JsonValueKind.String ? skill.GetString() : null; + + if (name is null || !SkillDiscovery.IsSafeSkillName(name)) + { + throw CannotRead( + path, + $"'packages.{id}.skills[{names.Count}]' is not a safe skill folder name"); + } + + if (!claimed.Add(name)) + { + throw CannotRead(path, $"the skill folder '{name}' is claimed more than once"); + } + + names.Add(name); + } + + manifest._packages[id.ToLowerInvariant()] = new ManifestPackage(version.GetString()!, names); + } + + return manifest; + } + + /// Names are matched without regard to case, as they were in every earlier build. + private static bool TryGetProperty(JsonElement element, string name, out JsonElement value) + { + foreach (var property in element.EnumerateObject()) + { + if (property.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) + { + value = property.Value; + return true; + } + } + + value = default; + return false; + } + + private static string? FindDuplicateProperty(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var property in element.EnumerateObject()) + { + if (!names.Add(property.Name)) + { + return property.Name; + } + + if (FindDuplicateProperty(property.Value) is { } nested) + { + return nested; + } + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + { + if (FindDuplicateProperty(item) is { } nested) + { + return nested; + } + } + } + + return null; + } + + private static PackageSkillsException CannotRead( + string path, + string reason, + Exception? inner = null) => + new( + $"Could not read the install manifest '{path}' because {reason}. " + + "No skills were changed and the file was preserved. Resolve any merge conflict or " + + "restore the file, then try again. If it cannot be recovered, move the destination " + + "folder aside before reinstalling.", + inner); + + private static PackageSkillsException WrittenByNewerTool(string path, int formatVersion) => + new( + $"Could not read the install manifest '{path}' because it uses format version {formatVersion}, " + + $"and this version of dotnet-package-skills supports only version {FormatVersion}. " + + "No skills were changed and the file was preserved. Update dotnet-package-skills, and then try again."); + + private static PackageSkillsException WrittenByPreRelease(string path) => + new( + $"Could not read the install manifest '{path}' because it was written by a pre-release version " + + "of dotnet-package-skills. No skills were changed and the file was preserved. " + + "Move the skills folder aside, and then run install again."); + + public void Save(string destinationRoot) + { + Directory.CreateDirectory(destinationRoot); + + // Rewrite the existing file rather than replacing it, which keeps its permissions. + File.WriteAllText( + Path.Combine(destinationRoot, FileName), + Serialize(), + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } + + private string Serialize() + { + using var buffer = new MemoryStream(); + using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = true })) + { + writer.WriteStartObject(); + writer.WriteNumber("version", FormatVersion); + writer.WriteStartObject("packages"); + + foreach (var (id, package) in _packages) + { + writer.WriteStartObject(id); + writer.WriteString("version", package.Version); + writer.WriteStartArray("skills"); + + foreach (var skill in package.Skills) + { + writer.WriteStringValue(skill); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + } + + writer.WriteEndObject(); + writer.WriteEndObject(); + } + + // .NET 8 indents with the platform newline. Values are escaped, so every CRLF here is + // the writer's own, and replacing them makes a Windows file identical to a Linux one. + return Encoding.UTF8.GetString(buffer.ToArray()).Replace("\r\n", "\n") + "\n"; + } + + internal IEnumerable EnumerateSkills() => + _packages.SelectMany(package => + package.Value.Skills.Select(skill => new TrackedSkill(package.Key, package.Value.Version, skill))); + + /// Replaces everything tracked. Nothing is kept if the skills break a manifest rule. + internal void SetSkills(IEnumerable skills) + { + var next = new SortedDictionary(StringComparer.Ordinal); + + foreach (var group in skills.GroupBy(skill => skill.Package.ToLowerInvariant(), StringComparer.Ordinal)) + { + // The reader refuses an invalid id, so writing one would lock every later command + // out of the destination. + if (!PackageCoordinate.IsValidId(group.Key)) + { + throw new PackageSkillsException( + $"The install manifest can't record the package '{group.Key}' because it is not a valid package id. " + + "No skills were changed."); + } + + var versions = group + .Select(skill => PackagePathResolver.NormalizeVersion(skill.Version)) + .Distinct(StringComparer.Ordinal) + .ToList(); + + if (versions.Count > 1) + { + throw new PackageSkillsException( + $"The install manifest can record only one version of each package, but skills from " + + $"{group.Key} {string.Join(" and ", versions)} were about to be recorded. " + + "No skills were changed."); + } + + next[group.Key] = new ManifestPackage( + versions[0], + [ + .. group + .Select(skill => skill.Skill) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(skill => skill, StringComparer.Ordinal), + ]); + } + + _packages = next; + } + + public static void Delete(string destinationRoot) + { + var path = Path.Combine(destinationRoot, FileName); + if (File.Exists(path)) + { + File.Delete(path); + } + } +} diff --git a/dotnet-package-skills/src/Skills/SkillDescriptionReader.cs b/dotnet-package-skills/src/Skills/SkillDescriptionReader.cs new file mode 100644 index 0000000..e093b72 --- /dev/null +++ b/dotnet-package-skills/src/Skills/SkillDescriptionReader.cs @@ -0,0 +1,241 @@ +using System.Text; +using SharpYaml; +using SharpYaml.Events; + +namespace DotnetPackageSkills.Skills; + +internal sealed record SkillDescriptionResult(string? Description, string? Warning); + +internal static class SkillDescriptionReader +{ + private const int MaxFrontmatterCharacters = 64 * 1024; + private const int MaxNestingDepth = 32; + + public static SkillDescriptionResult Read(string skillDirectory) + { + try + { + using var reader = new StreamReader(Path.Combine(skillDirectory, SkillDiscovery.SkillManifestFileName)); + var charactersRead = 0; + if (!ReadOpeningDelimiter(reader, ref charactersRead)) + { + return new(null, null); + } + + var frontmatter = new StringBuilder(); + while (ReadBoundedLine(reader, ref charactersRead) is { } line) + { + if (line.TrimEnd(' ', '\t') is "---" or "...") + { + return Parse(frontmatter.ToString()); + } + + frontmatter.Append(line).Append('\n'); + } + + return Warn("frontmatter has no closing delimiter; add a closing '---' line."); + } + catch (FileNotFoundException) + { + return Warn("was not found; restore the file to read its description."); + } + catch (DirectoryNotFoundException) + { + return Warn("was not found; restore the file to read its description."); + } + catch (UnauthorizedAccessException) + { + return Warn("could not be read; check file permissions."); + } + catch (IOException) + { + return Warn("could not be read; check the file path, permissions, and whether it is in use."); + } + catch (ArgumentException) + { + return Warn("has an invalid path; check the skill directory path."); + } + catch (YamlException) + { + return Warn("has malformed YAML frontmatter; fix the header."); + } + catch (FrontmatterException exception) + { + return Warn(exception.Message); + } + } + + private static bool ReadOpeningDelimiter(StreamReader reader, ref int charactersRead) + { + for (var index = 0; index < 3; index++) + { + if (ReadCharacter(reader, ref charactersRead) != '-') + { + return false; + } + } + + while (true) + { + switch (ReadCharacter(reader, ref charactersRead)) + { + case -1: + case '\n': + return true; + case '\r': + ReadLineFeed(reader, ref charactersRead); + return true; + case ' ': + case '\t': + break; + default: + return false; + } + } + } + + private static string? ReadBoundedLine(StreamReader reader, ref int charactersRead) + { + var line = new StringBuilder(); + while (true) + { + var character = ReadCharacter(reader, ref charactersRead); + switch (character) + { + case -1: + return line.Length == 0 ? null : line.ToString(); + case '\n': + return line.ToString(); + case '\r': + ReadLineFeed(reader, ref charactersRead); + return line.ToString(); + default: + line.Append((char)character); + break; + } + } + } + + private static void ReadLineFeed(StreamReader reader, ref int charactersRead) + { + if (reader.Peek() == '\n') + { + ReadCharacter(reader, ref charactersRead); + } + } + + private static int ReadCharacter(StreamReader reader, ref int charactersRead) + { + var character = reader.Read(); + if (character >= 0 && ++charactersRead > MaxFrontmatterCharacters) + { + throw new FrontmatterException("frontmatter exceeds 64 KiB of text; shorten the header."); + } + + return character; + } + + private static SkillDescriptionResult Parse(string frontmatter) + { + var parser = new EventReader(Parser.CreateParser(new StringReader(frontmatter))); + parser.Expect(); + if (parser.Allow() is not null) + { + return new(null, null); + } + + parser.Expect(); + var root = ReadNodeStart(parser); + if (root is not MappingStart) + { + throw new FrontmatterException("frontmatter must be a YAML mapping; use 'description: ...'."); + } + + string? description = null; + var foundDescription = false; + while (parser.Allow() is null) + { + var key = ReadNode(parser, 2); + var value = ReadNode(parser, 2); + if (key?.Value != "description") + { + continue; + } + + if (foundDescription) + { + throw new FrontmatterException("has duplicate description keys; keep only one top-level description."); + } + + foundDescription = true; + if (value is null) + { + throw new FrontmatterException("description must be a YAML scalar; replace the collection with text."); + } + + description = string.IsNullOrWhiteSpace(value.Value) ? null : value.Value; + } + + parser.Expect(); + parser.Expect(); + return new(description, null); + } + + private static Scalar? ReadNode(EventReader parser, int depth) + { + var node = ReadNodeStart(parser); + if (node is Scalar scalar) + { + return scalar; + } + + if (depth > MaxNestingDepth) + { + throw new FrontmatterException("frontmatter exceeds 32 levels of nesting; simplify the header."); + } + + if (node is MappingStart) + { + while (parser.Allow() is null) + { + ReadNode(parser, depth + 1); + ReadNode(parser, depth + 1); + } + } + else + { + while (parser.Allow() is null) + { + ReadNode(parser, depth + 1); + } + } + + return null; + } + + private static NodeEvent ReadNodeStart(EventReader parser) + { + if (parser.Accept()) + { + throw new FrontmatterException("uses YAML anchors or aliases; replace them with literal values."); + } + + var node = parser.Expect(); + if (!string.IsNullOrEmpty(node.Anchor)) + { + throw new FrontmatterException("uses YAML anchors or aliases; replace them with literal values."); + } + + if (!string.IsNullOrEmpty(node.Tag)) + { + throw new FrontmatterException("uses explicit YAML tags; remove the tags from its frontmatter."); + } + + return node; + } + + private static SkillDescriptionResult Warn(string reason) => + new(null, $"{SkillDiscovery.SkillManifestFileName} {reason}"); + + private sealed class FrontmatterException(string message) : Exception(message); +} diff --git a/dotnet-package-skills/src/Skills/SkillDiscovery.cs b/dotnet-package-skills/src/Skills/SkillDiscovery.cs new file mode 100644 index 0000000..28b8edc --- /dev/null +++ b/dotnet-package-skills/src/Skills/SkillDiscovery.cs @@ -0,0 +1,75 @@ +namespace DotnetPackageSkills.Skills; + +/// Finds skills that a package author bundled at skills/ in the package root. +public static class SkillDiscovery +{ + public const string SkillsFolderName = "skills"; + public const string SkillManifestFileName = "SKILL.md"; + + /// + /// Enumerates the skills inside an extracted package. + /// + /// + /// Each immediate subdirectory of skills/ that contains SKILL.md is one skill, + /// and the whole directory is copied as-is. Its authored folder name is also its destination + /// folder name. Nothing inside the skill is read or interpreted. + /// + public static IReadOnlyList Discover(string packageDirectory, string packageId, string packageVersion) + { + var skillsRoot = FindSkillsFolder(packageDirectory); + + if (skillsRoot is null) + { + return []; + } + + var candidates = Directory.EnumerateDirectories(skillsRoot) + .Select(directory => new { Directory = directory, Name = Path.GetFileName(directory) }) + .Where(candidate => + IsSafeSkillName(candidate.Name) && + File.Exists(Path.Combine(candidate.Directory, SkillManifestFileName))) + .OrderBy(candidate => candidate.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(candidate => candidate.Name, StringComparer.Ordinal) + .ToList(); + + return + [ + .. candidates.Select(candidate => new BundledSkill( + packageId, + packageVersion, + candidate.Name, + candidate.Directory, + candidate.Name)), + ]; + } + + /// + /// Finds the skills folder case-insensitively, because package contents are authored on + /// case-insensitive file systems as often as not. + /// + private static string? FindSkillsFolder(string packageDirectory) + { + if (!Directory.Exists(packageDirectory)) + { + return null; + } + + return Directory.EnumerateDirectories(packageDirectory) + .FirstOrDefault(directory => + string.Equals(Path.GetFileName(directory), SkillsFolderName, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Rejects names that would write outside the destination or produce an unusable path. The + /// name comes from a third-party package, so it is untrusted input even though the file + /// system has already resolved it to a real directory. Reject trailing dots and spaces on + /// every platform: Windows normalizes them, and an all-dot name can resolve to the parent. + /// + internal static bool IsSafeSkillName(string name) => + !string.IsNullOrWhiteSpace(name) && + !name.EndsWith('.') && + !name.EndsWith(' ') && + name.IndexOfAny(Path.GetInvalidFileNameChars()) < 0 && + !name.Contains('/') && + !name.Contains('\\'); +} diff --git a/dotnet-package-skills/src/Skills/SkillInstaller.cs b/dotnet-package-skills/src/Skills/SkillInstaller.cs new file mode 100644 index 0000000..1fbb374 --- /dev/null +++ b/dotnet-package-skills/src/Skills/SkillInstaller.cs @@ -0,0 +1,460 @@ +using DotnetPackageSkills.NuGet; + +namespace DotnetPackageSkills.Skills; + +/// Outcome of an install. +public sealed record InstallOutcome( + IReadOnlyList Installed, + IReadOnlyList Removed, + IReadOnlyList Skipped) +{ + /// Tracked skills whose package this run did not offer. They were left exactly as they were. + public IReadOnlyList Untouched { get; init; } = []; +} + +/// Copies discovered skills into the destination and keeps the manifest in step. +public sealed class SkillInstaller +{ + /// + /// Copies every skill into , refreshing the ones this tool + /// already installed. + /// + /// + /// The version this run installs from for each package it covers, by package id. A tracked + /// skill is removed only when its package is offered at a different version that no longer + /// ships it, which is what lets an upgrade drop a skill instead of keeping a stale copy. + /// Tracked skills of packages that are not offered are left alone and reported as + /// untouched: a package leaving the project is not a reason to delete its skills here, and + /// that cleanup belongs to uninstall --stale. Pass an empty map to only add skills. + /// Null offers the packages of at their versions. + /// + /// + /// Spells the uninstall command that an error suggests, given its arguments, so it can name + /// the destination the caller was given. + /// + public InstallOutcome Install( + string destinationRoot, + IReadOnlyList skills, + bool dryRun, + IReadOnlyDictionary? offered = null, + IReadOnlyCollection? expectedInstalled = null, + Func? uninstallCommand = null) + { + // Package ids compare without regard to case, whatever comparer the caller's map uses: + // the manifest spells them in lowercase, and packages keep NuGet's casing. + var versions = offered is null + ? skills + .GroupBy(skill => skill.PackageId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First().PackageVersion, StringComparer.OrdinalIgnoreCase) + : new Dictionary(offered, StringComparer.OrdinalIgnoreCase); + + using var destinationLock = DestinationLock.Acquire(destinationRoot); + var manifest = InstallManifest.Load(destinationRoot); + var trackedSkills = manifest.EnumerateSkills().ToList(); + CheckOwnershipSnapshot(trackedSkills, expectedInstalled); + var (selected, duplicateSkips) = SelectUniqueDestinations(skills, trackedSkills); + var accepted = new List(); + var skipped = new List(duplicateSkips); + var protectedPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var skill in selected) + { + var tracked = trackedSkills.FirstOrDefault(entry => + entry.Skill.Equals(skill.RelativePath, StringComparison.OrdinalIgnoreCase)); + var destination = ToAbsolute(destinationRoot, skill.RelativePath); + + if (File.Exists(destination)) + { + skipped.Add(ToSkipped(skill, "the destination path already exists as a file")); + if (tracked is not null) + { + protectedPaths.Add(tracked.Skill); + } + + continue; + } + + if (tracked is null && Directory.Exists(destination)) + { + skipped.Add(ToSkipped( + skill, + "the destination folder already exists and is not managed by this tool")); + continue; + } + + if (tracked is not null && !HasSameOwner(tracked, skill)) + { + skipped.Add(ToSkipped( + skill, + $"the destination folder is managed for {tracked.Package} {tracked.Version} " + + $"skill '{tracked.Skill}'; uninstall that skill before replacing its owner")); + protectedPaths.Add(tracked.Skill); + continue; + } + + accepted.Add(skill); + } + + // A package moving to a version without one of its skills normally loses that skill. + // When another package in this run ships a skill of the same name, removing it would + // hand the name to that package, which takes an explicit uninstall. Keeping it would + // record the old version's copy under the new version, where no later run removes it. + var stranded = trackedSkills + .Where(entry => protectedPaths.Contains(entry.Skill)) + .Where(entry => versions.TryGetValue(entry.Package, out var version) && !SameVersion(version, entry.Version)) + .Where(entry => !skills.Any(skill => HasSameOwner(entry, skill))) + .OrderBy(entry => entry.Skill, StringComparer.Ordinal) + .ToList(); + + if (stranded.Count > 0) + { + throw NameWouldChangeOwner( + stranded, + versions, + selected, + uninstallCommand ?? (arguments => $"dotnet-package-skills uninstall {arguments}")); + } + + var current = accepted.Select(skill => skill.RelativePath).ToHashSet(StringComparer.OrdinalIgnoreCase); + + // A skill involved in a conflict is never removed by the same run. + var removed = trackedSkills + .Where(entry => !current.Contains(entry.Skill) && !protectedPaths.Contains(entry.Skill)) + .Where(entry => versions.TryGetValue(entry.Package, out var version) && !SameVersion(version, entry.Version)) + .OrderBy(entry => entry.Skill, StringComparer.Ordinal) + .ToList(); + var removedPaths = removed.Select(entry => ToAbsolute(destinationRoot, entry.Skill)).ToList(); + var untouched = trackedSkills + .Where(entry => !versions.ContainsKey(entry.Package)) + .OrderBy(entry => entry.Skill, StringComparer.Ordinal) + .ToList(); + + foreach (var skill in accepted) + { + if (!Directory.Exists(skill.SourcePath) || + !File.Exists(Path.Combine(skill.SourcePath, SkillDiscovery.SkillManifestFileName))) + { + throw new PackageSkillsException( + $"The source for skill '{skill.SkillName}' is no longer available at '{skill.SourcePath}'. " + + "Restore its package and run the command again. No skills were changed."); + } + } + + // What stays tracked keeps its entry, moved to the offered version when its package has + // one: the manifest records a single version per package, the one this run installed. + var kept = trackedSkills + .Where(entry => !current.Contains(entry.Skill) && !removed.Contains(entry)) + .Select(entry => versions.TryGetValue(entry.Package, out var version) ? entry with { Version = version } : entry); + var installed = accepted.Select(skill => + new TrackedSkill(skill.PackageId, skill.PackageVersion, skill.SkillName)); + + // Build the new ownership record before touching any file, so a record that breaks a + // manifest rule stops the operation while the destination is still unchanged. + manifest.SetSkills(kept.Concat(installed)); + + var outcome = new InstallOutcome(accepted, removed, skipped) { Untouched = untouched }; + + if (dryRun) + { + return outcome; + } + + foreach (var path in removedPaths) + { + RemoveSkillDirectory(path); + } + + foreach (var skill in accepted) + { + CopyDirectory(skill.SourcePath, ToAbsolute(destinationRoot, skill.RelativePath)); + } + + if (manifest.IsEmpty) + { + // Nothing is tracked, so there is nothing for the manifest to be the source of truth + // about. Match uninstall rather than leaving an empty manifest, and a destination + // folder, that the user never asked for. The folder only goes if it is empty, so + // skills they wrote themselves keep it alive. + InstallManifest.Delete(destinationRoot); + TryRemoveEmptyDirectory(destinationRoot); + } + else + { + manifest.Save(destinationRoot); + } + + return outcome; + } + + internal static bool SameVersion(string left, string right) => + PackagePathResolver.NormalizeVersion(left).Equals(PackagePathResolver.NormalizeVersion(right), StringComparison.Ordinal); + + private static PackageSkillsException NameWouldChangeOwner( + IReadOnlyList stranded, + IReadOnlyDictionary versions, + IReadOnlyList selected, + Func uninstallCommand) + { + // The offered map keeps NuGet's casing, which reads better than the manifest's. + string OwnerId(TrackedSkill entry) => + versions.Keys.First(id => id.Equals(entry.Package, StringComparison.OrdinalIgnoreCase)); + + var reasons = stranded.Select(entry => + { + var other = selected.FirstOrDefault(skill => + skill.RelativePath.Equals(entry.Skill, StringComparison.OrdinalIgnoreCase)); + return $"{OwnerId(entry)} {versions[entry.Package]} no longer ships the installed skill '{entry.Skill}', " + + $"and {(other is null ? "another package" : $"{other.PackageId} {other.PackageVersion}")} " + + "ships a skill with that name"; + }); + var owners = stranded.Select(OwnerId).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var command = owners.Count == 1 + ? $"'{uninstallCommand($"--package {owners[0]}")}'" + : $"'{uninstallCommand("--package ")}' for each of {string.Join(", ", owners)}"; + + return new PackageSkillsException( + $"Cannot install skills because {string.Join("; ", reasons)}. The tool doesn't hand an installed " + + "skill to another package, and the manifest records one version per package, so it can't keep the " + + $"older copy either. Run {command} first, and then try again. No skills were changed."); + } + + /// + /// A tracked skill is stale when the target references no package at its installed version: + /// the package left the project, or the project now uses another version of it. + /// + internal static bool IsStale(TrackedSkill entry, IEnumerable referenced) => + !referenced.Any(package => + package.Id.Equals(entry.Package, StringComparison.OrdinalIgnoreCase) && + SameVersion(package.Version, entry.Version)); + + /// + /// Removes skills this tool installed, narrowed to one package, one exact version of it, + /// an explicit set of skill names, or the skills that are stale against a target. + /// + /// + /// Skill folder names to remove. Null removes everything the other filters match, which is + /// what an unattended uninstall does; a set is what the interactive picker returns. + /// + /// + /// A target's direct package references. When given, only skills whose installed version + /// the target does not reference are removed. + /// + public IReadOnlyList Uninstall( + string destinationRoot, + string? packageId, + string? packageVersion, + bool dryRun, + IReadOnlyCollection? only = null, + IReadOnlyCollection? expectedInstalled = null, + IReadOnlyCollection? staleAgainst = null) + { + using var destinationLock = DestinationLock.Acquire(destinationRoot); + var manifest = InstallManifest.Load(destinationRoot); + + var chosen = only is null + ? null + : new HashSet(only, StringComparer.OrdinalIgnoreCase); + + var trackedSkills = manifest.EnumerateSkills().ToList(); + CheckOwnershipSnapshot(trackedSkills, expectedInstalled); + var targeted = trackedSkills + .Where(entry => Matches(entry, packageId, packageVersion)) + .Where(entry => staleAgainst is null || IsStale(entry, staleAgainst)) + .Where(entry => chosen is null || chosen.Contains(entry.Skill)) + .OrderBy(entry => entry.Skill, StringComparer.Ordinal) + .ToList(); + var targetedPaths = targeted.Select(entry => ToAbsolute(destinationRoot, entry.Skill)).ToList(); + + if (targeted.Count == 0 || dryRun) + { + return targeted; + } + + manifest.SetSkills(trackedSkills.Except(targeted)); + + foreach (var path in targetedPaths) + { + RemoveSkillDirectory(path); + } + + if (manifest.IsEmpty) + { + InstallManifest.Delete(destinationRoot); + TryRemoveEmptyDirectory(destinationRoot); + } + else + { + manifest.Save(destinationRoot); + } + + // Report everything targeted, including entries whose folder a user had already + // deleted by hand: they are gone either way, and the manifest no longer claims them. + return targeted; + } + + internal static bool Matches(TrackedSkill entry, string? packageId, string? packageVersion) + { + if (packageId is not null && !entry.Package.Equals(packageId, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Compare normalized, so 1.2 and 1.2.0 identify the same installed folder. + return packageVersion is null || + PackagePathResolver.NormalizeVersion(entry.Version) + .Equals(PackagePathResolver.NormalizeVersion(packageVersion), StringComparison.OrdinalIgnoreCase); + } + + private static void CheckOwnershipSnapshot( + IReadOnlyCollection installed, + IReadOnlyCollection? expected) + { + if (expected is not null && !expected.ToHashSet().SetEquals(installed)) + { + throw new PackageSkillsException( + "Installed skill ownership changed while the picker was open. " + + "No skills were changed by this operation. Run the command again to review the current state."); + } + } + + private static string ToAbsolute(string destinationRoot, string relativePath) + { + if (!SkillDiscovery.IsSafeSkillName(relativePath)) + { + throw UnsafeSkillPath(destinationRoot, relativePath); + } + + var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(destinationRoot)); + var absolute = Path.GetFullPath(Path.Combine(root, relativePath)); + var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + if (!string.Equals(Path.GetDirectoryName(absolute), root, comparison)) + { + throw UnsafeSkillPath(destinationRoot, relativePath); + } + + return absolute; + } + + private static PackageSkillsException UnsafeSkillPath(string destinationRoot, string relativePath) => + new( + $"The path '{relativePath}' is not a safe skill folder directly inside '{destinationRoot}'. " + + "Restore the package or repair the install manifest before retrying. No skills were changed."); + + private static void RemoveSkillDirectory(string absolute) + { + if (Directory.Exists(absolute)) + { + Directory.Delete(absolute, recursive: true); + } + } + + private static bool TryRemoveEmptyDirectory(string directory) + { + if (!Directory.Exists(directory) || Directory.EnumerateFileSystemEntries(directory).Any()) + { + return false; + } + + try + { + Directory.Delete(directory); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + } + + /// + /// Replaces the destination with a fresh copy of the source. + /// + /// + /// This copies rather than moves, and that is deliberate: the global packages folder is + /// NuGet's content-addressable cache. It is validated during restore and shared by every + /// project on the machine, so moving files out of it can make restore treat the cached + /// package as corrupt and strips the skill from every other repository using it. + /// + private static void CopyDirectory(string source, string destination) + { + if (Directory.Exists(destination)) + { + // Delete first so files removed in a newer package version do not survive. + Directory.Delete(destination, recursive: true); + } + + Directory.CreateDirectory(destination); + + foreach (var directory in Directory.EnumerateDirectories(source, "*", SearchOption.AllDirectories)) + { + Directory.CreateDirectory(Path.Combine(destination, Path.GetRelativePath(source, directory))); + } + + foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) + { + var target = Path.Combine(destination, Path.GetRelativePath(source, file)); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.Copy(file, target, overwrite: true); + + // Files in the global packages folder are marked read-only by restore. Copying + // carries that attribute over, which would make the next install fail to overwrite. + ClearReadOnly(target); + } + } + + private static void ClearReadOnly(string path) + { + var attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.ReadOnly) != 0) + { + File.SetAttributes(path, attributes & ~FileAttributes.ReadOnly); + } + } + + private static (List Selected, List Skipped) SelectUniqueDestinations( + IReadOnlyList skills, + IReadOnlyList installed) + { + var selected = new List(); + var skipped = new List(); + foreach (var group in skills.GroupBy(skill => skill.RelativePath, StringComparer.OrdinalIgnoreCase)) + { + var candidates = group.ToList(); + var owner = installed.FirstOrDefault(entry => entry.Skill.Equals(group.Key, StringComparison.OrdinalIgnoreCase)); + var retainedIndex = owner is null ? 0 : candidates.FindIndex(skill => HasSameOwner(owner, skill)); + retainedIndex = Math.Max(0, retainedIndex); + var retained = candidates[retainedIndex]; + selected.Add(retained); + for (var index = 0; index < candidates.Count; index++) + { + if (index == retainedIndex) + { + continue; + } + + skipped.Add(ToSkipped( + candidates[index], + $"conflicts with {retained.PackageId} {retained.PackageVersion} skill " + + $"'{retained.SkillName}', " + + (owner is not null && HasSameOwner(owner, retained) + ? "which belongs to the current owner" + : "which was selected first"))); + } + } + + return (selected, skipped); + } + + internal static bool HasSameOwner(TrackedSkill entry, BundledSkill skill) => + entry.Package.Equals(skill.PackageId, StringComparison.OrdinalIgnoreCase) && + entry.Skill.Equals(skill.SkillName, StringComparison.OrdinalIgnoreCase); + + private static SkippedSkill ToSkipped(BundledSkill skill, string reason) => + new( + skill.RelativePath, + skill.PackageId, + skill.PackageVersion, + skill.SkillName, + reason); +} diff --git a/dotnet-package-skills/tests/CommandLineDiagnosticsTests.cs b/dotnet-package-skills/tests/CommandLineDiagnosticsTests.cs new file mode 100644 index 0000000..31e4af7 --- /dev/null +++ b/dotnet-package-skills/tests/CommandLineDiagnosticsTests.cs @@ -0,0 +1,179 @@ +using System.CommandLine; +using System.Text; +using DotnetPackageSkills.Cli; + +namespace DotnetPackageSkills.Tests; + +public class CommandLineDiagnosticsTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Diagnostic_write_overloads_share_one_buffer_until_invocation_finishes(bool standardError) + { + using var destination = new StringWriter(); + using var other = new StringWriter(); + var root = new RootCommand(); + root.SetAction(result => + { + TextWriter writer = standardError + ? result.InvocationConfiguration.Error + : result.InvocationConfiguration.Output; + writer.Write(" first\u001b"); + writer.Flush(); + writer.Write(']'); + writer.Write("52;c;".ToCharArray()); + writer.Write("SECRET".AsSpan()); + writer.WriteAsync("\a").GetAwaiter().GetResult(); + writer.Write('\ud83d'); + writer.FlushAsync().GetAwaiter().GetResult(); + Assert.Empty(destination.ToString()); + writer.Write('\udc69'); + writer.Write(new StringBuilder("🏽‍💻 e\u0301界")); + writer.Write('\r'); + writer.Flush(); + writer.Write('\n'); + writer.WriteLine(" next "); + return 23; + }); + + var exitCode = CommandLineDiagnostics.Invoke( + root.Parse([]), standardError ? other : destination, standardError ? destination : other); + + Assert.Equal(23, exitCode); + Assert.Equal( + $" first👩🏽‍💻 e\u0301界{Environment.NewLine} next {Environment.NewLine}", + destination.ToString()); + Assert.Empty(other.ToString()); + } + + [Theory] + [InlineData("before\u001b[31mRED\u001b[0mafter", "beforeREDafter")] + [InlineData("before\u001b]52;c;SECRET\u001b\\after", "beforeafter")] + [InlineData("before\u001b]52;c;\r\nSECRET\aafter", "beforeafter")] + [InlineData("before\u009d52;c;SECRET\u009cafter", "beforeafter")] + [InlineData("before\u001bPSECRET\u001b\\after", "beforeafter")] + [InlineData("before\u001b(0after", "beforeafter")] + [InlineData("before\u001b]8;;https://example.invalid\a链接\u001b]8;;\aafter", "before链接after")] + [InlineData("👩🏽‍💻 e\u0301 中文 🇨🇦", "👩🏽‍💻 e\u0301 中文 🇨🇦")] + [InlineData(" \ue000first\r\n next\ue000 \r\n", " \ue000first\n next\ue000 \n")] + public void Every_write_boundary_preserves_text_without_leaking_escape_payloads(string text, string expected) + { + for (var split = 0; split <= text.Length; split++) + { + using var output = new StringWriter(); + using var error = new StringWriter(); + var root = new RootCommand(); + root.SetAction(result => + { + var writer = result.InvocationConfiguration.Error; + writer.Write(text.AsSpan(0, split)); + writer.Flush(); + writer.Write(text.ToCharArray(), split, text.Length - split); + return 0; + }); + + Assert.Equal(0, CommandLineDiagnostics.Invoke(root.Parse([]), output, error)); + Assert.Equal(expected.Replace("\n", Environment.NewLine, StringComparison.Ordinal), error.ToString()); + Assert.Empty(output.ToString()); + } + } + + [Theory] + [InlineData("before\ue000\u001b", "before\ue000")] + [InlineData("before\ue000\u001b[", "before\ue000")] + [InlineData("before\ue000\u001b]unterminated", "before\ue000")] + [InlineData("before\ue000\u009dunterminated", "before\ue000")] + [InlineData("before\ue000\u001b]unterminated\r\n", "before\ue000\n")] + [InlineData("before\ue000\u001b(", "before\ue000")] + [InlineData(" before \u001b]unterminated\r\n", " before \n")] + [InlineData("\u001b]unterminated\r\n", "\n")] + [InlineData("before\r\n\u001b]unterminated\r\n", "before\n")] + [InlineData("before\u001b[\r\n", "before\n")] + public void Unfinished_controls_preserve_valid_text_and_the_final_line_break(string text, string expected) + { + using var output = new StringWriter(); + using var error = new StringWriter(); + var root = new RootCommand(); + root.SetAction(result => + { + foreach (var character in text) + { + result.InvocationConfiguration.Error.Write(character); + } + + return 0; + }); + + Assert.Equal(0, CommandLineDiagnostics.Invoke(root.Parse([]), output, error)); + Assert.Equal(expected.Replace("\n", Environment.NewLine, StringComparison.Ordinal), error.ToString()); + } + + [Theory] + [InlineData("\n")] + [InlineData("\r\n")] + public void Diagnostic_capture_preserves_the_callers_line_endings_and_leaves_writers_open(string newLine) + { + using var output = new StringWriter() { NewLine = newLine }; + using var error = new StringWriter() { NewLine = newLine }; + var root = new RootCommand(); + root.SetAction(result => + { + result.InvocationConfiguration.Output.WriteLine(" output"); + result.InvocationConfiguration.Output.WriteLine(); + result.InvocationConfiguration.Error.WriteLine(" error"); + return 0; + }); + + Assert.Equal(0, CommandLineDiagnostics.Invoke(root.Parse([]), output, error)); + output.Write("still open"); + error.Write("still open"); + + Assert.Equal($" output{newLine}{newLine}still open", output.ToString()); + Assert.Equal($" error{newLine}still open", error.ToString()); + } + + [Fact] + public void Default_framework_exception_diagnostics_are_sanitized_too() + { + using var output = new StringWriter(); + using var error = new StringWriter(); + var root = new RootCommand(); + root.SetAction((Func)(_ => + throw new InvalidOperationException("first\u001b]52;c;SECRET\a\nsecond"))); + + var exitCode = CommandLineDiagnostics.Invoke(root.Parse([]), output, error); + + Assert.Equal(1, exitCode); + Assert.Contains($"first{Environment.NewLine}second", error.ToString()); + Assert.DoesNotContain('\u001b', error.ToString()); + Assert.DoesNotContain('\a', error.ToString()); + Assert.DoesNotContain("SECRET", error.ToString()); + } + + [Fact] + public void Diagnostic_capture_does_not_change_canonical_values_or_global_console_streams() + { + const string Value = "original\u001b[31mvalue\u001b[0m"; + using var output = new StringWriter(); + using var error = new StringWriter(); + string? received = null; + var consoleOutput = Console.Out; + var consoleError = Console.Error; + var argument = new Argument("value"); + var root = new RootCommand { argument }; + root.SetAction(result => + { + Assert.Same(consoleOutput, Console.Out); + Assert.Same(consoleError, Console.Error); + received = result.GetValue(argument); + return 0; + }); + + Assert.Equal(0, CommandLineDiagnostics.Invoke(root.Parse([Value]), output, error)); + + Assert.Equal(Value, received); + Assert.Empty(output.ToString()); + Assert.Empty(error.ToString()); + } +} diff --git a/dotnet-package-skills/tests/CommandLineTests.cs b/dotnet-package-skills/tests/CommandLineTests.cs new file mode 100644 index 0000000..768fbc9 --- /dev/null +++ b/dotnet-package-skills/tests/CommandLineTests.cs @@ -0,0 +1,363 @@ +using System.CommandLine; +using DotnetPackageSkills.Cli; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +public class CommandLineTests +{ + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t\r\n")] + public void A_supplied_blank_uninstall_filter_is_rejected(string filter) + { + var error = Assert.Throws(() => CommandLineBuilder.ParseUninstallFilter(filter)); + + Assert.Contains("non-empty package ID", error.Message); + Assert.NotEmpty(CommandLineBuilder.Build().Parse(["uninstall", "--package", filter]).Errors); + } + + [Theory] + [InlineData("--package")] + [InlineData("-p")] + public void An_uninstall_package_option_without_a_value_is_rejected(string option) + { + Assert.NotEmpty(CommandLineBuilder.Build().Parse(["uninstall", option]).Errors); + Assert.NotEmpty(CommandLineBuilder.Build().Parse(["uninstall", option, "--dry-run"]).Errors); + } + + [Fact] + public void Only_an_absent_uninstall_filter_means_all_packages() + { + Assert.Equal((null, null), CommandLineBuilder.ParseUninstallFilter(null)); + Assert.Equal(("Mockly", null), CommandLineBuilder.ParseUninstallFilter(" Mockly ")); + } + + [Theory] + [InlineData("--package", "only once")] + [InlineData("-p", "only once")] + [InlineData("--package=", "only once")] + [InlineData("--package=Alpha", "expects a single argument")] + public void Repeated_uninstall_filters_are_rejected_even_when_the_last_value_is_missing( + string repeated, string message) + { + var result = CommandLineBuilder.Build().Parse( + ["uninstall", "--dry-run", "--package", "Alpha", repeated]); + + Assert.Contains(result.Errors, error => error.Message.Contains(message, StringComparison.Ordinal)); + } + + [Theory] + [InlineData("_Acme")] + [InlineData("Acme_")] + [InlineData("_")] + public void Uninstall_accepts_valid_underscore_boundary_package_ids(string id) + { + Assert.Equal((id, null), CommandLineBuilder.ParseUninstallFilter(id)); + Assert.Empty(CommandLineBuilder.Build().Parse(["uninstall", "--package", id, "--dry-run"]).Errors); + } + + [Theory] + [InlineData("1.2", "1.2.0")] + [InlineData("1.2.0.0", "1.2.0")] + [InlineData("1.2.0-RC.1", "1.2.0-rc.1")] + public void Uninstall_filter_matching_normalizes_versions_for_both_modes(string filterVersion, string installedVersion) + { + var (id, version) = CommandLineBuilder.ParseUninstallFilter($"mockly@{filterVersion}"); + + Assert.True(SkillInstaller.Matches(new TrackedSkill("Mockly", installedVersion, "usage"), id, version)); + Assert.False(SkillInstaller.Matches(new TrackedSkill("Other", installedVersion, "usage"), id, version)); + } + + [Fact] + public void Uninstall_accepts_the_interactive_flag() + { + Assert.Empty(CommandLineBuilder.Build().Parse(["uninstall", "-i"]).Errors); + Assert.Empty(CommandLineBuilder.Build().Parse(["uninstall", "--interactive"]).Errors); + } + + [Fact] + public void Uninstall_stale_accepts_a_target_and_every_other_uninstall_option_but_a_package() + { + Assert.Empty(CommandLineBuilder.Build().Parse( + ["uninstall", "--stale", "--target", "App.sln", "--dry-run", "-i", "-d", ".claude/skills"]) + .Errors); + Assert.Empty(CommandLineBuilder.Build().Parse(["uninstall", "--stale", "-t", "src"]).Errors); + Assert.Empty(CommandLineBuilder.Build().Parse(["uninstall", "--stale"]).Errors); + } + + [Fact] + public void Uninstall_stale_cannot_be_combined_with_a_package_filter() + { + var result = CommandLineBuilder.Build().Parse(["uninstall", "--stale", "--package", "Mockly"]); + + Assert.Contains(result.Errors, error => error.Message.Contains("--stale and --package cannot be combined")); + } + + [Theory] + [InlineData("--target")] + [InlineData("-t")] + public void Uninstall_target_needs_stale(string option) + { + var result = CommandLineBuilder.Build().Parse(["uninstall", option, "App.sln"]); + + Assert.Contains(result.Errors, error => + error.Message.Contains("--target can be used with uninstall only together with --stale", StringComparison.Ordinal)); + } + + [Theory] + [InlineData("install", null)] + [InlineData("list", null)] + [InlineData("uninstall", "--stale")] + public void No_command_offers_no_restore(string command, string? extra) + { + // Restoring is left to dotnet list package and to the customer, so there is nothing to + // turn off here. + Assert.DoesNotContain( + CommandLineBuilder.Build().Subcommands.Single(candidate => candidate.Name == command).Options, + option => option.Name == "--no-restore"); + string[] args = extra is null ? [command, "--no-restore"] : [command, extra, "--no-restore"]; + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = CommandLineBuilder.Invoke(args, output, error); + + Assert.Equal(1, exitCode); + Assert.Contains("Unrecognized command or argument '--no-restore'", error.ToString()); + } + + [Theory] + [InlineData("install")] + [InlineData("list")] + public void Only_uninstall_has_the_stale_option(string command) + { + Assert.NotEmpty(CommandLineBuilder.Build().Parse([command, "--stale"]).Errors); + } + + [Fact] + public void The_interactive_install_help_says_installed_skills_are_not_listed() + { + var install = CommandLineBuilder.Build().Subcommands.Single(command => command.Name == "install"); + + var interactive = install.Options.Single(option => option.Name == "--interactive"); + + Assert.Contains("aren't installed", interactive.Description); + Assert.DoesNotContain("remove", interactive.Description, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("install")] + [InlineData("list")] + [InlineData("uninstall")] + public void No_command_offers_json_output(string command) + { + // Reports are for people. The manifest is the only machine-readable output. + Assert.DoesNotContain( + CommandLineBuilder.Build().Subcommands.Single(candidate => candidate.Name == command).Options, + option => option.Name == "--json"); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = CommandLineBuilder.Invoke([command, "--json"], output, error); + + Assert.Equal(1, exitCode); + Assert.Contains("Unrecognized command or argument '--json'", error.ToString()); + } + + [Theory] + [InlineData("install")] + [InlineData("list")] + public void An_unknown_option_after_package_values_is_reported_as_unrecognized(string command) + { + // --package takes several values, so the parser hands it a trailing unknown option, such + // as a --json left in an old script, as one more value. + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = CommandLineBuilder.Invoke( + [command, "--package", "Mockly@1.10.0", "--json"], output, error); + + Assert.Equal(1, exitCode); + Assert.Contains("Unrecognized command or argument '--json'", error.ToString()); + Assert.DoesNotContain("missing a version", error.ToString()); + } + + [Fact] + public void Uninstall_says_it_removes_from_the_destination_rather_than_copying_into_it() + { + var uninstall = CommandLineBuilder.Build() + .Subcommands.Single(command => command.Name == "uninstall"); + + var destination = uninstall.Options.Single(option => option.Name == "--destination"); + + // The option is shared-looking but not shared: install's wording is about copying in, + // which reads as nonsense on a command that only deletes. + Assert.Contains("remove skills from", destination.Description, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("copy", destination.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Uninstall_still_accepts_a_destination() + { + // Skills installed anywhere but the default are unreachable without it. + Assert.Empty(CommandLineBuilder.Build().Parse(["uninstall", "-d", ".claude/skills"]).Errors); + } + + [Fact] + public void Install_still_says_it_copies_into_the_destination() + { + var install = CommandLineBuilder.Build() + .Subcommands.Single(command => command.Name == "install"); + + var destination = install.Options.Single(option => option.Name == "--destination"); + + Assert.Contains("copy skills into", destination.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void The_removed_sync_verb_is_rejected() + { + // Renamed to install, which pairs with uninstall. A clean break at 0.1.0 rather than + // an alias, so nothing has to carry the old name forward. + var result = CommandLineBuilder.Build().Parse(["sync"]); + + Assert.NotEmpty(result.Errors); + } + + [Fact] + public void Install_rejects_the_removed_include_transitive_option() + { + var result = CommandLineBuilder.Build().Parse(["install", "--include-transitive"]); + + Assert.NotEmpty(result.Errors); + } + + [Fact] + public void Install_accepts_the_short_interactive_alias() + { + Assert.Empty(CommandLineBuilder.Build().Parse(["install", "-i"]).Errors); + } + + [Fact] + public void Install_accepts_interactive_alongside_a_named_package() + { + // One package can ship a dozen skills, so choosing among them is exactly the case + // --package plus --interactive exists for. + Assert.Empty(CommandLineBuilder.Build().Parse(["install", "--package", "Mockly@1.10.0", "-i"]).Errors); + Assert.Empty(CommandLineBuilder.Build().Parse(["install", "-i", "--package", "Mockly@1.10.0"]).Errors); + } + + [Fact] + public void List_does_not_offer_interactive_selection() + { + // list writes nothing, so there is nothing to choose between. + Assert.NotEmpty(CommandLineBuilder.Build().Parse(["list", "--interactive"]).Errors); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Invalid_uninstall_filters_are_rejected_without_printing_terminal_controls(bool interactive) + { + const string Filter = "Safe\u001b]52;c;SECRET\aPackage"; + string[] args = interactive + ? ["uninstall", "--interactive", "--package", Filter] + : ["uninstall", "--package", Filter]; + var parsed = CommandLineBuilder.Build().Parse(args); + Assert.Contains(parsed.Errors, error => error.Message.Contains(Filter, StringComparison.Ordinal)); + Assert.Equal(Filter, parsed.GetValue("--package")); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = CommandLineBuilder.Invoke(args, output, error); + + Assert.Equal(1, exitCode); + Assert.Contains("'SafePackage' is not a valid package id.", error.ToString()); + Assert.DoesNotContain('\u001b', error.ToString()); + Assert.DoesNotContain('\a', error.ToString()); + Assert.DoesNotContain("SECRET", error.ToString()); + Assert.Contains("Usage:", output.ToString()); + } + + [Fact] + public void Uninstall_validation_keeps_multiline_guidance_readable() + { + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = CommandLineBuilder.Invoke( + ["uninstall", "--package", "Mockly@1.*\u001b]52;c;SECRET\a"], output, error); + + Assert.Equal(1, exitCode); + Assert.Contains( + "'1.*' is a floating version or a version range, and this tool needs an exact version.\n" + + "Write it out, for example --package Mockly@1.10.0.\n" + + "To let restore choose the version, point at a project or solution with --target instead.", + error.ToString().ReplaceLineEndings("\n")); + Assert.DoesNotContain('\u001b', error.ToString()); + Assert.DoesNotContain("SECRET", error.ToString()); + } + + [Theory] + [InlineData("--unknown\u001b]52;c;SECRET\a")] + [InlineData("--unknown\u009d52;c;SECRET\u009c")] + [InlineData("--dry-run=false\u001b]52;c;SECRET\u001b\\")] + public void Framework_argument_errors_do_not_emit_terminal_controls(string token) + { + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = CommandLineBuilder.Invoke(["uninstall", token], output, error); + + Assert.Equal(1, exitCode); + Assert.NotEmpty(error.ToString()); + Assert.DoesNotContain("SECRET", output.ToString() + error.ToString()); + Assert.DoesNotContain(output.ToString() + error.ToString(), + character => char.IsControl(character) && character is not ('\r' or '\n')); + } + + [Fact] + public void Framework_typo_suggestions_are_sanitized_on_standard_output_too() + { + const string Token = "uninstal\u001b"; + var parsed = CommandLineBuilder.Build().Parse([Token]); + Assert.Contains(Token, parsed.UnmatchedTokens); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = CommandLineBuilder.Invoke([Token], output, error); + + Assert.Equal(1, exitCode); + Assert.Contains("Did you mean", output.ToString()); + Assert.Contains("uninstall", output.ToString()); + Assert.Contains("Unrecognized", error.ToString()); + Assert.DoesNotContain('\u001b', output.ToString() + error.ToString()); + } + + [Theory] + [InlineData("--help", null)] + [InlineData("--version", null)] + [InlineData("uninstall", "--help")] + [InlineData("uninstall", "--missing")] + [InlineData("uninstal", null)] + public void Ordinary_framework_output_and_exit_codes_are_unchanged(string first, string? second) + { + string[] args = second is null ? [first] : [first, second]; + using var expectedOutput = new StringWriter(); + using var expectedError = new StringWriter(); + var expectedExitCode = CommandLineBuilder.Build().Parse(args).Invoke(new InvocationConfiguration + { + Output = expectedOutput, + Error = expectedError, + }); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = CommandLineBuilder.Invoke(args, output, error); + + Assert.Equal(expectedExitCode, exitCode); + Assert.Equal(expectedOutput.ToString(), output.ToString()); + Assert.Equal(expectedError.ToString(), error.ToString()); + } +} diff --git a/dotnet-package-skills/tests/DestinationLockTests.cs b/dotnet-package-skills/tests/DestinationLockTests.cs new file mode 100644 index 0000000..cfbdc9b --- /dev/null +++ b/dotnet-package-skills/tests/DestinationLockTests.cs @@ -0,0 +1,151 @@ +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +public class DestinationLockTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Install_and_uninstall_wait_for_the_destination_owner_to_finish(bool uninstall) + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + var package = temp.CreatePackageWithSkill("Alpha", "1.0.0", "shared"); + var skill = new BundledSkill("Alpha", "1.0.0", "shared", Path.Combine(package, "skills", "shared"), "shared"); + var installer = new SkillInstaller(); + installer.Install(destination, [skill], dryRun: false); + var before = File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName)); + using var attempting = new ManualResetEventSlim(); + using var finished = new ManualResetEventSlim(); + using var held = DestinationLock.Acquire(destination); + Exception? failure = null; + var operation = new Thread(() => + { + try + { + attempting.Set(); + if (uninstall) + { + installer.Uninstall(destination, null, null, dryRun: false); + } + else + { + // A version without the skill removes it, which empties the destination. + installer.Install( + destination, + [], + dryRun: false, + offered: new Dictionary { ["Alpha"] = "2.0.0" }); + } + } + catch (Exception error) + { + failure = error; + } + finally + { + finished.Set(); + } + }) { IsBackground = true }; + operation.Start(); + + try + { + Assert.True(attempting.Wait(TimeSpan.FromSeconds(5))); + Assert.False(finished.Wait(TimeSpan.FromMilliseconds(100))); + Assert.Equal(before, File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName))); + } + finally + { + held.Dispose(); + Assert.True(operation.Join(TimeSpan.FromSeconds(5))); + } + + Assert.Null(failure); + Assert.False(Directory.Exists(destination)); + } + + [Fact] + public void A_busy_destination_returns_an_actionable_error_without_creating_files() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + using var held = DestinationLock.Acquire(destination); + Exception? observed = null; + var operation = new Thread(() => + { + try + { + using var competing = DestinationLock.Acquire(destination, TimeSpan.Zero); + } + catch (Exception error) + { + observed = error; + } + }) { IsBackground = true }; + operation.Start(); + + Assert.True(operation.Join(TimeSpan.FromSeconds(5))); + var error = Assert.IsType(observed); + + Assert.Contains("Another operation", error.Message); + Assert.False(Directory.Exists(destination)); + } + + [Fact] + public void Equivalent_destination_spellings_share_the_same_lock() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + Assert.Equal(DestinationLock.NameFor(destination), + DestinationLock.NameFor(Path.Combine(destination, "..", "dest") + Path.DirectorySeparatorChar)); + if (OperatingSystem.IsWindows()) + { + Assert.Equal(DestinationLock.NameFor(destination), DestinationLock.NameFor(destination.ToUpperInvariant())); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Extended_Windows_paths_share_the_ordinary_destination_lock(bool exists) + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var temp = new TempDirectory(); + var destination = temp.Combine("nested", "skills"); + if (exists) + { + Directory.CreateDirectory(destination); + } + + Assert.Equal(DestinationLock.NameFor(destination), DestinationLock.NameFor(@"\\?\" + destination)); + Assert.Equal(exists, Directory.Exists(destination)); + } + + [Fact] + public void Existing_long_Windows_destinations_can_be_locked_with_either_spelling() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var temp = new TempDirectory(); + var destination = temp.Path; + for (var index = 0; index < 5; index++) + { + destination = Path.Combine(destination, new string('a', 60)); + } + + Directory.CreateDirectory(destination); + Assert.True(destination.Length > 260); + Assert.Equal(DestinationLock.NameFor(destination), DestinationLock.NameFor(@"\\?\" + destination)); + using var held = DestinationLock.Acquire(destination); + } +} diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests.csproj b/dotnet-package-skills/tests/DotnetPackageSkills.Tests.csproj new file mode 100644 index 0000000..fb15040 --- /dev/null +++ b/dotnet-package-skills/tests/DotnetPackageSkills.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0;net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet-package-skills/tests/FakeTerminal.cs b/dotnet-package-skills/tests/FakeTerminal.cs new file mode 100644 index 0000000..a14f3ff --- /dev/null +++ b/dotnet-package-skills/tests/FakeTerminal.cs @@ -0,0 +1,426 @@ +using System.Text; +using DotnetPackageSkills.Cli; + +namespace DotnetPackageSkills.Tests; + +internal sealed record TerminalWrite( + int Left, + int Top, + string Text, + TerminalStyle Style, + int WindowWidth, + int WindowHeight, + int OutputCodePage, + byte[] Bytes); + +/// A cell-addressed screen, scripted keys/resizes, and the styles of individual writes. +internal sealed class FakeTerminal(int windowHeight = 18, int windowWidth = 100) : ITerminal +{ + private List> _screen = []; + private readonly List _frames = []; + private readonly List _writes = []; + private readonly List> _frameWrites = []; + private readonly Queue<(ConsoleKeyInfo? Key, Action? BeforeKey)> _keys = new(); + private int _cursorTop; + private int _cursorLeft; + private int _frameWriteStart; + + public bool IsRedirected { get; init; } + + public bool SupportsColor { get; init; } = true; + + public int WindowHeight { get; private set; } = windowHeight; + + public int WindowWidth { get; private set; } = windowWidth; + + public int CursorTop => _cursorTop; + + public bool IsCursorVisible { get; private set; } = true; + + public bool IsControlCTakenAsInput { get; private set; } + + public bool ControlCWasEverTakenAsInput { get; private set; } + + public TerminalStyle CurrentStyle { get; private set; } + + public ConsoleColor Foreground { get; set; } = ConsoleColor.Gray; + + public ConsoleColor Background { get; set; } = ConsoleColor.Black; + + public Encoding OutputEncoding { get; set; } = Encoding.ASCII; + + public int ViewportClears { get; private set; } + + public bool IsInteractiveScreen { get; private set; } + + public int ScreenEntries { get; private set; } + + public int ScreenExits { get; private set; } + + public string LastPickerScreen { get; private set; } = string.Empty; + + public int LastPickerCursorTop { get; private set; } + + public Action? BeforeOperation { get; set; } + + public bool CursorVisible + { + set + { + BeforeOperation?.Invoke(nameof(CursorVisible)); + IsCursorVisible = value; + } + } + + public bool TreatControlCAsInput + { + set + { + BeforeOperation?.Invoke(nameof(TreatControlCAsInput)); + IsControlCTakenAsInput = value; + ControlCWasEverTakenAsInput |= value; + } + } + + public IReadOnlyList Frames => _frames; + + public IReadOnlyList Writes => _writes; + + public IReadOnlyList> FrameWrites => _frameWrites; + + public List<(int Width, int Height)> FrameSizes { get; } = []; + + public List CursorTopsAwaitingKey { get; } = []; + + public List StyleEvents { get; } = []; + + public List InputTimeouts { get; } = []; + + public List KeysRead { get; } = []; + + public List EncodingChanges { get; } = []; + + public string Screen => string.Join(Environment.NewLine, + _screen.Take(WindowHeight).Select(row => string.Concat(row.Take(WindowWidth)))); + + public int FinalCursorTop => _cursorTop; + + public int CursorTopAwaitingKey { get; private set; } + + public TerminalState CaptureState() => new( + IsCursorVisible, IsControlCTakenAsInput, Foreground, Background, CurrentStyle, OutputEncoding); + + public void UseUtf8Output() + { + BeforeOperation?.Invoke(nameof(UseUtf8Output)); + OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + EncodingChanges.Add(OutputEncoding); + } + + public IDisposable EnterInteractiveScreen() + { + BeforeOperation?.Invoke(nameof(EnterInteractiveScreen)); + var previous = _screen; + var previousTop = _cursorTop; + var previousLeft = _cursorLeft; + var wasInteractive = IsInteractiveScreen; + // Switching buffers preserves the cursor; the picker must position its own first frame. + _screen = []; + IsInteractiveScreen = true; + ScreenEntries++; + return new ScreenScope(() => + { + LastPickerScreen = Screen; + LastPickerCursorTop = _cursorTop; + _screen = previous; + _cursorTop = Math.Clamp(previousTop, 0, WindowHeight - 1); + _cursorLeft = Math.Clamp(previousLeft, 0, WindowWidth - 1); + IsInteractiveScreen = wasInteractive; + ScreenExits++; + }); + } + + private sealed class ScreenScope(Action restore) : IDisposable + { + private bool _disposed; + + public void Dispose() + { + if (!_disposed) + { + _disposed = true; + restore(); + } + } + } + + public void RestoreState(TerminalState state) + { + CurrentStyle = state.Style; + Foreground = state.Foreground ?? ConsoleColor.Gray; + Background = state.Background ?? ConsoleColor.Black; + IsCursorVisible = state.CursorVisible; + IsControlCTakenAsInput = state.TreatControlCAsInput; + StyleEvents.Add(CurrentStyle); + OutputEncoding = state.OutputEncoding; + EncodingChanges.Add(OutputEncoding); + } + + public void SetStyle(TerminalStyle style) + { + BeforeOperation?.Invoke(nameof(SetStyle)); + CurrentStyle = SupportsColor ? style : TerminalStyle.Default; + if (SupportsColor) + { + Foreground = style switch + { + TerminalStyle.Focus => ConsoleColor.Blue, + TerminalStyle.Selected => ConsoleColor.Blue, + TerminalStyle.Muted => ConsoleColor.DarkGray, + _ => ConsoleColor.Gray, + }; + Background = ConsoleColor.Black; + } + + StyleEvents.Add(CurrentStyle); + } + + public void ResetStyle() => SetStyle(TerminalStyle.Default); + + public FakeTerminal Press(params ConsoleKey[] keys) + { + foreach (var key in keys) + { + _keys.Enqueue((new ConsoleKeyInfo('\0', key, false, false, false), null)); + } + + return this; + } + + public FakeTerminal Press(ConsoleKey key, int times) + { + for (var press = 0; press < times; press++) + { + Press(key); + } + + return this; + } + + public FakeTerminal PressWith(ConsoleModifiers modifiers, ConsoleKey key, int times = 1) + { + for (var press = 0; press < times; press++) + { + _keys.Enqueue((new ConsoleKeyInfo( + '\0', key, + shift: (modifiers & ConsoleModifiers.Shift) != 0, + alt: (modifiers & ConsoleModifiers.Alt) != 0, + control: (modifiers & ConsoleModifiers.Control) != 0), null)); + } + + return this; + } + + public FakeTerminal Resize(int windowHeight, int windowWidth) => + ResizeBeforeKey(ConsoleKey.NoName, windowHeight, windowWidth); + + public FakeTerminal ResizeBeforeKey(ConsoleKey key, int windowHeight, int windowWidth) + { + _keys.Enqueue((new ConsoleKeyInfo('\0', key, false, false, false), + () => ApplyResize(windowHeight, windowWidth))); + return this; + } + + public FakeTerminal ResizeWhileWaiting(int windowHeight, int windowWidth) + { + _keys.Enqueue((null, () => ApplyResize(windowHeight, windowWidth))); + return this; + } + + public void ResizeNow(int windowHeight, int windowWidth) => ApplyResize(windowHeight, windowWidth); + + public FakeTerminal WaitWithoutKey(int times = 1) + { + for (var wait = 0; wait < times; wait++) + { + _keys.Enqueue((null, null)); + } + + return this; + } + + public void SetCursorPosition(int left, int top) + { + BeforeOperation?.Invoke(nameof(SetCursorPosition)); + if (left < 0 || left >= WindowWidth || top < 0 || top >= WindowHeight) + { + throw new InvalidOperationException($"Cursor ({left}, {top}) is outside {WindowWidth}x{WindowHeight}."); + } + + _cursorLeft = left; + _cursorTop = top; + } + + public void Write(string text) + { + BeforeOperation?.Invoke(nameof(Write)); + var bytes = OutputEncoding.GetBytes(text); + var displayed = OutputEncoding.GetString(bytes); + _writes.Add(new TerminalWrite(_cursorLeft, _cursorTop, displayed, CurrentStyle, + WindowWidth, WindowHeight, OutputEncoding.CodePage, bytes)); + EnsureRow(); + var row = _screen[_cursorTop]; + foreach (var element in TerminalText.Elements(displayed)) + { + if (element.Any(char.IsControl)) + { + throw new InvalidOperationException("A terminal span contained an unsanitized control."); + } + + var cells = TerminalText.CellWidth(element); + if (_cursorLeft + cells > WindowWidth || _cursorTop >= WindowHeight) + { + throw new InvalidOperationException($"Write is outside {WindowWidth}x{WindowHeight}: '{text}'."); + } + + while (row.Count < _cursorLeft + cells) + { + row.Add(" "); + } + + if (cells == 0) + { + if (_cursorLeft > 0) + { + var previous = _cursorLeft - 1; + while (previous > 0 && row[previous] is null) + { + previous--; + } + + row[previous] += element; + } + + continue; + } + + for (var cell = _cursorLeft; cell < _cursorLeft + cells; cell++) + { + var start = cell; + while (start > 0 && row[start] is null) + { + start--; + } + + var oldWidth = TerminalText.CellWidth(row[start] ?? " "); + for (var old = start; old < Math.Min(row.Count, start + oldWidth); old++) + { + row[old] = " "; + } + } + + row[_cursorLeft] = element; + for (var cell = 1; cell < cells; cell++) + { + row[_cursorLeft + cell] = null; + } + + _cursorLeft += cells; + if (_cursorLeft == WindowWidth) + { + AdvanceRow(); + EnsureRow(); + row = _screen[_cursorTop]; + } + } + } + + public void WriteLine(string text = "") + { + Write(text); + AdvanceRow(); + } + + public void ClearViewport() + { + BeforeOperation?.Invoke(nameof(ClearViewport)); + ViewportClears++; + _screen.Clear(); + _cursorLeft = 0; + _cursorTop = 0; + } + + public bool TryReadKey(TimeSpan timeout, out ConsoleKeyInfo key) + { + InputTimeouts.Add(timeout); + BeforeOperation?.Invoke(nameof(TryReadKey)); + if (_keys.TryPeek(out var next) && next.Key is null) + { + CaptureFrame(); + _keys.Dequeue().BeforeKey?.Invoke(); + key = default; + return false; + } + + key = ReadKey(); + return true; + } + + public ConsoleKeyInfo ReadKey() + { + CaptureFrame(); + BeforeOperation?.Invoke(nameof(ReadKey)); + + if (_keys.Count == 0) + { + throw new InvalidOperationException( + "The picker asked for a key the test did not script. Add one, or end with Enter or Escape."); + } + + var next = _keys.Dequeue(); + next.BeforeKey?.Invoke(); + var key = next.Key ?? throw new InvalidOperationException("An idle wait requires TryReadKey, not ReadKey."); + KeysRead.Add(key); + return key; + } + + private void CaptureFrame() + { + _frames.Add(Screen); + _frameWrites.Add(_writes.Skip(_frameWriteStart).ToArray()); + _frameWriteStart = _writes.Count; + FrameSizes.Add((WindowWidth, WindowHeight)); + CursorTopAwaitingKey = _cursorTop; + CursorTopsAwaitingKey.Add(_cursorTop); + } + + private void ApplyResize(int height, int width) + { + WindowHeight = height; + WindowWidth = width; + _cursorTop = Math.Min(_cursorTop, WindowHeight - 1); + _cursorLeft = Math.Min(_cursorLeft, WindowWidth - 1); + } + + private void EnsureRow() + { + while (_screen.Count <= _cursorTop) + { + _screen.Add([]); + } + } + + private void AdvanceRow() + { + _cursorLeft = 0; + if (_cursorTop < WindowHeight - 1) + { + _cursorTop++; + } + else + { + _screen.RemoveAt(0); + _screen.Add([]); + } + } +} diff --git a/dotnet-package-skills/tests/GlobalPackagesLocatorTests.cs b/dotnet-package-skills/tests/GlobalPackagesLocatorTests.cs new file mode 100644 index 0000000..e75aaa8 --- /dev/null +++ b/dotnet-package-skills/tests/GlobalPackagesLocatorTests.cs @@ -0,0 +1,54 @@ +using DotnetPackageSkills.NuGet; + +namespace DotnetPackageSkills.Tests; + +public class GlobalPackagesLocatorTests +{ + private static string SomeAbsolutePath => Path.Combine(Path.GetTempPath(), "nuget-packages"); + + [Fact] + public void ParseListOutput_reads_the_path_from_current_SDK_output() + { + var output = $"global-packages: {SomeAbsolutePath}"; + + Assert.Equal(Path.GetFullPath(SomeAbsolutePath), GlobalPackagesLocator.ParseListOutput(output)); + } + + [Fact] + public void ParseListOutput_reads_the_path_from_older_prefixed_output() + { + // Older SDKs prefix the line, which is why parsing keys off the label. + var output = $"info : global-packages: {SomeAbsolutePath}"; + + Assert.Equal(Path.GetFullPath(SomeAbsolutePath), GlobalPackagesLocator.ParseListOutput(output)); + } + + [Fact] + public void ParseListOutput_ignores_surrounding_lines() + { + var output = $""" + Welcome to .NET! + ---------------- + global-packages: {SomeAbsolutePath} + + """; + + Assert.Equal(Path.GetFullPath(SomeAbsolutePath), GlobalPackagesLocator.ParseListOutput(output)); + } + + [Fact] + public void ParseListOutput_tolerates_windows_line_endings() + { + var output = $"info : something\r\nglobal-packages: {SomeAbsolutePath}\r\n"; + + Assert.Equal(Path.GetFullPath(SomeAbsolutePath), GlobalPackagesLocator.ParseListOutput(output)); + } + + [Fact] + public void ParseListOutput_returns_null_when_the_label_is_absent() => + Assert.Null(GlobalPackagesLocator.ParseListOutput("http-cache: /somewhere\ntemp: /elsewhere")); + + [Fact] + public void ParseListOutput_returns_null_when_the_label_has_no_value() => + Assert.Null(GlobalPackagesLocator.ParseListOutput("global-packages: ")); +} diff --git a/dotnet-package-skills/tests/InstallManifestTests.cs b/dotnet-package-skills/tests/InstallManifestTests.cs new file mode 100644 index 0000000..c58608d --- /dev/null +++ b/dotnet-package-skills/tests/InstallManifestTests.cs @@ -0,0 +1,347 @@ +using System.Security.AccessControl; +using System.Text; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +public class InstallManifestTests +{ + private const string Contents = """ + { + "version": 1, + "packages": { + "alpha": { "version": "1.0.0", "skills": ["alpha"] }, + "beta": { "version": "1.0.0", "skills": ["beta"] } + } + } + """; + + [Fact] + public void Saving_writes_the_versioned_packages_format_with_lf_line_endings_only() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + var manifest = new InstallManifest(); + manifest.SetSkills( + [ + new TrackedSkill("Mockly", "1.10", "mockly-usage"), + new TrackedSkill("Mockly", "1.10.0", "mockly-migration"), + new TrackedSkill("Contoso.Widgets", "2.3.0", "contoso.widgets-usage"), + ]); + + manifest.Save(destination); + + var bytes = File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName)); + Assert.Equal( + "{\n" + + " \"version\": 1,\n" + + " \"packages\": {\n" + + " \"contoso.widgets\": {\n" + + " \"version\": \"2.3.0\",\n" + + " \"skills\": [\n" + + " \"contoso.widgets-usage\"\n" + + " ]\n" + + " },\n" + + " \"mockly\": {\n" + + " \"version\": \"1.10.0\",\n" + + " \"skills\": [\n" + + " \"mockly-migration\",\n" + + " \"mockly-usage\"\n" + + " ]\n" + + " }\n" + + " }\n" + + "}\n", + Encoding.UTF8.GetString(bytes)); + Assert.DoesNotContain((byte)'\r', bytes); + Assert.NotEqual(0xEF, bytes[0]); + } + + [Fact] + public void The_same_skills_produce_the_same_bytes_whatever_their_order_or_casing() + { + using var temp = new TempDirectory(); + var first = new InstallManifest(); + first.SetSkills( + [ + new TrackedSkill("Mockly", "1.0.0-RC.1", "b"), + new TrackedSkill("Alpha", "2.0", "a"), + new TrackedSkill("mockly", "1.0.0-rc.1", "a"), + ]); + var second = new InstallManifest(); + second.SetSkills( + [ + new TrackedSkill("alpha", "2.0.0", "a"), + new TrackedSkill("MOCKLY", "1.0.0-rc.1", "a"), + new TrackedSkill("Mockly", "1.0.0-RC.1", "b"), + ]); + + first.Save(temp.Combine("first")); + second.Save(temp.Combine("second")); + + Assert.Equal( + File.ReadAllBytes(temp.Combine("first", InstallManifest.FileName)), + File.ReadAllBytes(temp.Combine("second", InstallManifest.FileName))); + } + + [Fact] + public void A_saved_manifest_reads_back_with_lowercase_ids_and_normalized_versions() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + var manifest = new InstallManifest(); + manifest.SetSkills( + [ + new TrackedSkill("Mockly", "1.10", "mockly-usage"), + new TrackedSkill("Contoso.Widgets", "2.3.0", "contoso.widgets-usage"), + ]); + manifest.Save(destination); + + var loaded = InstallManifest.Load(destination); + + Assert.Equal( + [ + new TrackedSkill("contoso.widgets", "2.3.0", "contoso.widgets-usage"), + new TrackedSkill("mockly", "1.10.0", "mockly-usage"), + ], + loaded.EnumerateSkills()); + Assert.Equal(["contoso.widgets", "mockly"], loaded.Packages.Keys); + Assert.Equal("1.10.0", loaded.Packages["mockly"].Version); + } + + [Fact] + public void One_package_cannot_be_recorded_at_two_versions() + { + var manifest = new InstallManifest(); + + var error = Assert.Throws(() => manifest.SetSkills( + [ + new TrackedSkill("Mockly", "1.10.0", "mockly-usage"), + new TrackedSkill("mockly", "1.11.0", "mockly-testing"), + ])); + + Assert.Contains("mockly", error.Message); + Assert.Contains("1.10.0", error.Message); + Assert.Contains("1.11.0", error.Message); + Assert.Contains("No skills were changed", error.Message); + Assert.Empty(manifest.EnumerateSkills()); + } + + [Fact] + public void A_package_id_that_the_reader_would_refuse_is_never_written() + { + // Whatever the tool writes, it has to be able to read back. Otherwise one install would + // lock every later command out of the destination. + var manifest = new InstallManifest(); + + var error = Assert.Throws(() => manifest.SetSkills( + [new TrackedSkill("not valid!", "1.0.0", "a")])); + + Assert.Contains("'not valid!'", error.Message); + Assert.Contains("not a valid package id", error.Message); + Assert.Contains("No skills were changed", error.Message); + Assert.Empty(manifest.EnumerateSkills()); + } + + [Fact] + public void Property_names_and_package_ids_are_read_without_regard_to_case() + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + temp.CreateFile( + "dest/.dotnet-package-skills.json", + """{"Version":1,"Packages":{"Mockly":{"Version":"1.10.0","Skills":["mockly-usage"]}}}"""); + + Assert.Equal( + new TrackedSkill("mockly", "1.10.0", "mockly-usage"), + Assert.Single(InstallManifest.Load(destination).EnumerateSkills())); + } + + [Fact] + public void Unknown_properties_are_ignored_and_an_empty_packages_map_tracks_nothing() + { + using var temp = new TempDirectory(); + var withExtras = temp.CreateDirectory("extras"); + var empty = temp.CreateDirectory("empty"); + temp.CreateFile( + "extras/.dotnet-package-skills.json", + """ + { + "version": 1, + "comment": "not part of the format", + "packages": { "mockly": { "version": "1.10.0", "skills": ["mockly-usage"], "extra": true } } + } + """); + temp.CreateFile("empty/.dotnet-package-skills.json", """{"version":1,"packages":{}}"""); + + Assert.Equal("mockly-usage", Assert.Single(InstallManifest.Load(withExtras).EnumerateSkills()).Skill); + Assert.Empty(InstallManifest.Load(empty).EnumerateSkills()); + } + + [Theory] + [InlineData(2)] + [InlineData(10)] + public void A_manifest_from_a_newer_tool_asks_for_an_update_and_is_preserved(int formatVersion) + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + var contents = + "{\"version\":" + formatVersion + + ",\"packages\":{\"mockly\":{\"version\":\"1.10.0\",\"skills\":[\"mockly-usage\"]}}}"; + var path = temp.CreateFile("dest/.dotnet-package-skills.json", contents); + + var error = Assert.Throws(() => InstallManifest.Load(destination)); + + Assert.Contains($"format version {formatVersion}", error.Message); + Assert.Contains("supports only version 1", error.Message); + Assert.Contains("Update dotnet-package-skills", error.Message); + Assert.Contains("No skills were changed", error.Message); + Assert.Equal(contents, File.ReadAllText(path)); + } + + [Fact] + public void A_manifest_in_the_pre_release_format_says_how_to_start_over_and_is_preserved() + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + const string PreRelease = """ + { + "note": "Written by the dotnet-package-skills tool.", + "installed": [ { "package": "Mockly", "version": "1.10.0", "skills": ["mockly-usage"] } ] + } + """; + var path = temp.CreateFile("dest/.dotnet-package-skills.json", PreRelease); + + var error = Assert.Throws(() => InstallManifest.Load(destination)); + + Assert.Contains("pre-release version of dotnet-package-skills", error.Message); + Assert.Contains("Move the skills folder aside", error.Message); + Assert.Contains("No skills were changed", error.Message); + Assert.Equal(PreRelease, File.ReadAllText(path)); + } + + [Theory] + [InlineData("null")] + [InlineData("[]")] + [InlineData("{}")] + [InlineData("""{"packages":{}}""")] + [InlineData("""{"version":"1","packages":{}}""")] + [InlineData("""{"version":1.5,"packages":{}}""")] + [InlineData("""{"version":0,"packages":{}}""")] + [InlineData("""{"version":-1,"packages":{}}""")] + [InlineData("""{"version":1}""")] + [InlineData("""{"version":1,"packages":null}""")] + [InlineData("""{"version":1,"packages":[]}""")] + [InlineData("""{"version":1,"version":1,"packages":{}}""")] + [InlineData("""{"version":1,"packages":{},"Packages":{}}""")] + [InlineData("""{"version":1,"packages":{"mockly":{"version":"1.0.0","skills":["a"]},"Mockly":{"version":"1.0.0","skills":["b"]}}}""")] + [InlineData("""{"version":1,"packages":{"not valid!":{"version":"1.0.0","skills":["a"]}}}""")] + [InlineData("""{"version":1,"packages":{"contoso..widgets":{"version":"1.0.0","skills":["a"]}}}""")] + [InlineData("""{"version":1,"packages":{"mockly\n":{"version":"1.0.0","skills":["a"]}}}""")] + [InlineData("""{"version":1,"packages":{"mockly":null}}""")] + [InlineData("""{"version":1,"packages":{"mockly":{"skills":["a"]}}}""")] + [InlineData("""{"version":1,"packages":{"mockly":{"version":" ","skills":["a"]}}}""")] + [InlineData("""{"version":1,"packages":{"mockly":{"version":1,"skills":["a"]}}}""")] + [InlineData("""{"version":1,"packages":{"mockly":{"version":"1.0.0"}}}""")] + [InlineData("""{"version":1,"packages":{"mockly":{"version":"1.0.0","skills":"a"}}}""")] + [InlineData("""{"version":1,"packages":{"mockly":{"version":"1.0.0","skills":[null]}}}""")] + [InlineData("""{"version":1,"packages":{"mockly":{"version":"1.0.0","skills":["../outside"]}}}""")] + [InlineData("""{"version":1,"packages":{"alpha":{"version":"1.0.0","skills":["shared"]},"beta":{"version":"1.0.0","skills":["SHARED"]}}}""")] + [InlineData("""{"version":1,"packages":{"alpha":{"version":"1.0.0","skills":["shared","shared"]}}}""")] + public void A_manifest_with_an_unusable_shape_fails_before_anything_uses_it(string contents) + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + var path = temp.CreateFile("dest/.dotnet-package-skills.json", contents); + + var error = Assert.Throws(() => InstallManifest.Load(destination)); + + Assert.Contains("Could not read the install manifest", error.Message); + Assert.Contains("preserved", error.Message); + Assert.Equal(contents, File.ReadAllText(path)); + } + + [Fact] + public void The_first_save_creates_an_ordinary_manifest_file() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + var path = Path.Combine(destination, InstallManifest.FileName); + var manifest = new InstallManifest(); + manifest.SetSkills([new TrackedSkill("New", "2.0.0", "new")]); + + manifest.Save(destination); + + Assert.True(File.Exists(path)); + Assert.Null(new FileInfo(path).LinkTarget); + Assert.False(File.GetAttributes(path).HasFlag(FileAttributes.ReparsePoint)); + Assert.Equal("new", Assert.Single(InstallManifest.Load(destination).EnumerateSkills()).Skill); + Assert.Equal(path, Assert.Single(Directory.EnumerateFileSystemEntries(destination))); + } + + [Fact] + public void Saving_a_regular_manifest_updates_it_without_creating_temporary_files() + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + var path = temp.CreateFile("dest/.dotnet-package-skills.json", Contents); + var manifest = InstallManifest.Load(destination); + manifest.SetSkills([new TrackedSkill("New", "2.0.0", "new")]); + + manifest.Save(destination); + + Assert.Equal("new", Assert.Single(InstallManifest.Load(destination).EnumerateSkills()).Skill); + Assert.Equal(path, Assert.Single(Directory.EnumerateFileSystemEntries(destination))); + Assert.Null(new FileInfo(path).LinkTarget); + } + + [Fact] + public void A_read_only_manifest_is_not_silently_overwritten() + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + var path = temp.CreateFile("dest/.dotnet-package-skills.json", Contents); + var attributes = File.GetAttributes(path); + File.SetAttributes(path, attributes | FileAttributes.ReadOnly); + try + { + Assert.Throws(() => new InstallManifest().Save(destination)); + Assert.Equal(Contents, File.ReadAllText(path)); + Assert.Equal(path, Assert.Single(Directory.EnumerateFileSystemEntries(destination))); + } + finally + { + File.SetAttributes(path, attributes); + } + } + + [Fact] + public void Saving_a_manifest_preserves_its_access_permissions() + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + var path = temp.CreateFile("dest/.dotnet-package-skills.json", Contents); + if (OperatingSystem.IsWindows()) + { + var file = new FileInfo(path); + var access = file.GetAccessControl(AccessControlSections.Access); + access.SetAccessRuleProtection(isProtected: true, preserveInheritance: true); + file.SetAccessControl(access); + var before = file.GetAccessControl(AccessControlSections.Access) + .GetSecurityDescriptorSddlForm(AccessControlSections.Access); + + InstallManifest.Load(destination).Save(destination); + + Assert.Equal(before, new FileInfo(path).GetAccessControl(AccessControlSections.Access) + .GetSecurityDescriptorSddlForm(AccessControlSections.Access)); + } + else + { + var mode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + File.SetUnixFileMode(path, mode); + + InstallManifest.Load(destination).Save(destination); + + Assert.Equal(mode, File.GetUnixFileMode(path)); + } + } +} diff --git a/dotnet-package-skills/tests/InteractiveSkillsTests.cs b/dotnet-package-skills/tests/InteractiveSkillsTests.cs new file mode 100644 index 0000000..4ebc683 --- /dev/null +++ b/dotnet-package-skills/tests/InteractiveSkillsTests.cs @@ -0,0 +1,186 @@ +using DotnetPackageSkills.Cli; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +public class InteractiveSkillsTests +{ + [Fact] + public void Install_offers_only_skills_that_are_not_installed_with_their_descriptions() + { + using var temp = new TempDirectory(); + var first = Skill(temp, "first", "The first package skill."); + var second = Skill(temp, "second", "The second package skill."); + var source = File.ReadAllBytes(Path.Combine(first.SourcePath, "SKILL.md")); + + var items = InteractiveSkills.ForInstall([first, second], Owners("SECOND")); + + var item = Assert.Single(items); + Assert.Equal("first", item.Name); + Assert.Equal("Example.Package", item.Package); + Assert.Equal("1.0.0", item.Version); + Assert.Equal("The first package skill.", item.Description); + Assert.Null(item.DescriptionWarning); + Assert.Equal(source, File.ReadAllBytes(Path.Combine(first.SourcePath, "SKILL.md"))); + } + + [Fact] + public void Uninstall_reads_the_installed_copy_and_only_offers_manifest_owned_skills() + { + using var temp = new TempDirectory(); + var skill = Skill(temp, "example", "Description shipped with the installed version."); + var destination = temp.Combine("destination"); + new SkillInstaller().Install(destination, [skill], dryRun: false); + File.WriteAllText( + Path.Combine(skill.SourcePath, "SKILL.md"), + "---\ndescription: A different description now in the cache.\n---\n"); + var handwritten = Path.Combine(destination, "team-conventions"); + Directory.CreateDirectory(handwritten); + File.WriteAllText(Path.Combine(handwritten, "SKILL.md"), "---\ndescription: Our own skill.\n---\n"); + var manifest = File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName)); + + var tracked = SkillInstallService.InstalledSkills(destination, temp.Path); + var items = InteractiveSkills.ForUninstall(tracked, destination); + + var item = Assert.Single(items); + Assert.Equal("example", item.Name); + Assert.Equal("Description shipped with the installed version.", item.Description); + Assert.Equal(manifest, File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName))); + Assert.True(File.Exists(Path.Combine(handwritten, "SKILL.md"))); + } + + [Fact] + public void Missing_or_invalid_descriptions_do_not_remove_skills_from_the_picker() + { + using var temp = new TempDirectory(); + var missing = Skill(temp, "missing", "unused"); + var malformed = Skill(temp, "malformed", "unused"); + File.WriteAllText(Path.Combine(missing.SourcePath, "SKILL.md"), "# No frontmatter\n"); + File.WriteAllText(Path.Combine(malformed.SourcePath, "SKILL.md"), "---\ndescription: [broken\n---\n"); + + var items = InteractiveSkills.ForInstall([missing, malformed], []); + + Assert.Equal(2, items.Count); + var absent = Assert.Single(items, item => item.Name == "missing"); + Assert.Null(absent.Description); + Assert.Null(absent.DescriptionWarning); + var invalid = Assert.Single(items, item => item.Name == "malformed"); + Assert.Null(invalid.Description); + Assert.False(string.IsNullOrWhiteSpace(invalid.DescriptionWarning)); + } + + [Fact] + public void An_installed_skill_whose_file_is_missing_can_still_be_selected_for_removal() + { + using var temp = new TempDirectory(); + var skill = Skill(temp, "example", "An installed skill."); + var destination = temp.Combine("destination"); + var installer = new SkillInstaller(); + installer.Install(destination, [skill], dryRun: false); + File.Delete(Path.Combine(destination, "example", "SKILL.md")); + + var items = InteractiveSkills.ForUninstall( + SkillInstallService.InstalledSkills(destination, temp.Path), + destination); + + var item = Assert.Single(items); + Assert.Equal("example", item.Name); + Assert.Null(item.Description); + Assert.False(string.IsNullOrWhiteSpace(item.DescriptionWarning)); + var removed = installer.Uninstall(destination, null, null, dryRun: false, only: [item.Name]); + Assert.Equal("example", Assert.Single(removed).Skill); + } + + [Fact] + public void Description_loading_does_not_bypass_a_corrupt_ownership_manifest() + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("destination"); + var manifest = Path.Combine(destination, InstallManifest.FileName); + const string conflict = "<<<<<<< HEAD\n{}\n=======\n{}\n>>>>>>> branch"; + File.WriteAllText(manifest, conflict); + + Assert.Throws(() => + InteractiveSkills.ForUninstall( + SkillInstallService.InstalledSkills(destination, temp.Path), + destination)); + + Assert.Equal(conflict, File.ReadAllText(manifest)); + } + + [Fact] + public void An_install_choice_never_refreshes_or_removes_an_installed_skill() + { + using var temp = new TempDirectory(); + var installedSkill = Skill(temp, "installed", "Already installed."); + var fresh = Skill(temp, "fresh", "Not installed yet."); + var destination = temp.Combine("destination"); + var installer = new SkillInstaller(); + installer.Install(destination, [installedSkill], dryRun: false); + var edited = temp.CreateFile("destination/installed/SKILL.md", "edited locally"); + var installed = SkillInstallService.InstalledSkills(destination, temp.Path); + + var items = InteractiveSkills.ForInstall([installedSkill, fresh], installed); + var choice = InteractiveSkills.InstallChoice( + [installedSkill, fresh], installed, items, Names("installed", "fresh")); + var outcome = installer.Install( + destination, + choice.Selected, + dryRun: false, + offered: new Dictionary(), + expectedInstalled: choice.ExpectedInstalled); + + Assert.Equal("fresh", Assert.Single(items).Name); + Assert.Equal("fresh", Assert.Single(choice.Selected).SkillName); + Assert.Empty(outcome.Removed); + Assert.Equal("edited locally", File.ReadAllText(edited)); + Assert.Equal( + ["fresh", "installed"], + InstallManifest.Load(destination).EnumerateSkills().Select(entry => entry.Skill).Order(StringComparer.Ordinal)); + } + + [Fact] + public void An_install_choice_takes_only_ticked_skills_that_were_shown() + { + using var temp = new TempDirectory(); + var first = Skill(temp, "first", "One."); + var second = Skill(temp, "second", "Two."); + + var installed = Owners("first", "unshown"); + var items = InteractiveSkills.ForInstall([first, second], installed); + var choice = InteractiveSkills.InstallChoice( + [first, second], installed, items, Names("FIRST", "second", "unknown")); + + Assert.Equal([second], choice.Selected); + Assert.Same(installed, choice.ExpectedInstalled); + } + + [Fact] + public void Another_packages_same_named_skill_is_not_offered() + { + using var temp = new TempDirectory(); + var candidate = Skill(temp, "shared", "A candidate from Example.Package."); + var installed = new[] { new TrackedSkill("other.package", "2.0.0", "shared") }; + + var items = InteractiveSkills.ForInstall([candidate], installed); + var choice = InteractiveSkills.InstallChoice([candidate], installed, items, Names("shared")); + + Assert.Empty(items); + Assert.Empty(choice.Selected); + } + + private static IReadOnlyList Owners(params string[] names) => + [.. names.Select(name => new TrackedSkill("Example.Package", "1.0.0", name))]; + + private static HashSet Names(params string[] names) => new(names, StringComparer.OrdinalIgnoreCase); + + private static BundledSkill Skill(TempDirectory temp, string name, string description) + { + var package = temp.CreatePackageWithSkill("Example.Package", "1.0.0", name); + var directory = Path.Combine(package, "skills", name); + File.WriteAllText( + Path.Combine(directory, "SKILL.md"), + $"---\nname: {name}\ndescription: {description}\n---\n# Body\n"); + return new BundledSkill("Example.Package", "1.0.0", name, directory, name); + } +} diff --git a/dotnet-package-skills/tests/OutputLayoutTests.cs b/dotnet-package-skills/tests/OutputLayoutTests.cs new file mode 100644 index 0000000..7ff58a6 --- /dev/null +++ b/dotnet-package-skills/tests/OutputLayoutTests.cs @@ -0,0 +1,199 @@ +using DotnetPackageSkills; +using DotnetPackageSkills.Cli; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +/// +/// Renders every report shape and checks its blank lines. +/// +/// +/// Vertical whitespace is invisible in a diff and obvious on a projector, so the rules that +/// keep it tidy are asserted rather than left to whoever edits the writer next. +/// +public class OutputLayoutTests +{ + private static readonly BundledSkill[] TwoSkills = + [ + new("Contoso.Widgets", "2.3.0", "contoso.widgets-usage", "/p/usage", "contoso.widgets-usage"), + new("Mockly", "1.10.0", "mockly-usage", "/p/mockly", "mockly-usage"), + ]; + + public static TheoryData EveryReport() + { + var data = new TheoryData(); + + foreach (var (name, report) in Reports()) + { + data.Add(name, report); + } + + return data; + } + + private static IEnumerable<(string Name, string Report)> Reports() + { + yield return ("list", Render(Result() with { DryRun = true }, copied: false)); + yield return ("install", Render(Result(), copied: true)); + yield return ("install dry run", Render(Result() with { DryRun = true }, copied: true)); + yield return ("install nothing found", Render( + Result() with { Skills = [], SkillsDiscovered = 0 }, copied: true)); + yield return ("install nothing chosen", Render( + Result() with { Skills = [], SkillsDiscovered = 2 }, copied: true)); + yield return ("install with removals", Render( + Result() with { Removed = [new TrackedSkill("Old.Package", "1.0.0", "old-skill")] }, + copied: true)); + yield return ("install with collisions", Render( + Result() with + { + Skipped = + [ + new SkippedSkill("shared", "Beta", "2.0.0", "shared", "conflicts with Alpha 1.0.0"), + ], + }, + copied: true)); + yield return ("install with unreferenced skills", Render( + Result() with { Unreferenced = [new TrackedSkill("left.package", "3.0.0", "left-skill")] }, + copied: true)); + yield return ("install with every section", Render( + Result() with + { + Removed = [new TrackedSkill("old.package", "1.0.0", "old-skill")], + Unreferenced = + [ + new TrackedSkill("left.package", "3.0.0", "left-skill"), + new TrackedSkill("gone.package", "1.0.0", "gone-skill"), + ], + Skipped = + [ + new SkippedSkill("shared", "Beta", "2.0.0", "shared", "conflicts with Alpha 1.0.0"), + ], + }, + copied: true)); + yield return ("interactive install with nothing new", Render( + Result() with { Skills = [], NothingNewToInstall = true }, copied: true)); + yield return ("interactive install with nothing new and collisions", Render( + Result() with + { + Skills = [], + NothingNewToInstall = true, + Skipped = + [ + new SkippedSkill("shared", "Beta", "2.0.0", "shared", "conflicts with Alpha 1.0.0"), + ], + }, + copied: true)); + yield return ("uninstall", RenderUninstall( + [new TrackedSkill("Contoso.Widgets", "2.3.0", "contoso.widgets-usage")], dryRun: false)); + yield return ("uninstall dry run", RenderUninstall( + [new TrackedSkill("Contoso.Widgets", "2.3.0", "contoso.widgets-usage")], dryRun: true)); + yield return ("uninstall nothing to do", RenderUninstall([], dryRun: false)); + yield return ("uninstall stale", RenderUninstall( + [new TrackedSkill("contoso.widgets", "2.3.0", "contoso.widgets-usage")], dryRun: false, + target: @"C:\repo\App.slnx")); + yield return ("uninstall stale nothing to do", RenderUninstall([], dryRun: false, target: @"C:\repo\App.slnx")); + yield return ("cancelled", RenderCancelled()); + } + + [Theory] + [MemberData(nameof(EveryReport))] + public void No_report_starts_or_ends_with_a_blank_line(string name, string report) + { + var lines = Lines(report); + + Assert.False(lines[0].Length == 0, $"{name} opens with a blank line"); + Assert.False(lines[^1].Length == 0, $"{name} closes with a blank line"); + } + + [Theory] + [MemberData(nameof(EveryReport))] + public void No_report_has_two_blank_lines_together(string name, string report) + { + var lines = Lines(report); + + for (var index = 1; index < lines.Count; index++) + { + Assert.False( + lines[index].Length == 0 && lines[index - 1].Length == 0, + $"{name} has a double blank line at {index + 1}"); + } + } + + [Theory] + [MemberData(nameof(EveryReport))] + public void No_report_wraps_a_sentence_onto_the_next_line(string name, string report) + { + // A line ending without terminal punctuation, followed by one starting lower case, + // is prose someone hard-wrapped at a width the reader never asked for. + var lines = Lines(report).Where(line => line.Length > 0 && !line.StartsWith(' ')).ToList(); + + for (var index = 0; index < lines.Count - 1; index++) + { + var ends = lines[index].TrimEnd(); + var next = lines[index + 1]; + + Assert.False( + ends.Length > 0 && ends[^1] is not ('.' or ':' or '!' or '?') && char.IsLower(next[0]), + $"{name}: '{ends}' looks wrapped into '{next}'"); + } + } + + [Fact] + public void A_skill_and_the_package_it_came_from_share_one_line() + { + var report = Render(Result(), copied: true); + + Assert.Contains("contoso.widgets-usage (Contoso.Widgets 2.3.0)", report); + // The old shape put "from Package Version" on its own indented line, doubling the + // length of every list to say something the brackets say for free. + Assert.DoesNotContain(" from ", report); + } + + [Fact] + public void A_twelve_skill_list_is_twelve_lines_of_skills() + { + var skills = Enumerable.Range(1, 12) + .Select(number => new BundledSkill( + "Contoso.Widgets", "2.3.0", $"skill-{number:00}", $"/p/{number}", $"skill-{number:00}")) + .ToList(); + + var report = Render(Result() with { Skills = skills }, copied: true); + + Assert.Equal(12, Lines(report).Count(line => line.StartsWith(" skill-", StringComparison.Ordinal))); + } + + private static List Lines(string report) => + [.. report.TrimEnd('\r', '\n').Split(Environment.NewLine)]; + + private static string Render(InstallResult result, bool copied) + { + using var output = new StringWriter(); + new OutputWriter(output).WriteInstallReport(result, copied); + return output.ToString(); + } + + private static string RenderUninstall(IReadOnlyList removed, bool dryRun, string? target = null) + { + using var output = new StringWriter(); + new OutputWriter(output).WriteUninstallReport(removed, @"C:\repo\.agents\skills", dryRun, target); + return output.ToString(); + } + + private static string RenderCancelled() + { + using var output = new StringWriter(); + new OutputWriter(output).WriteCancelled(); + return output.ToString(); + } + + private static InstallResult Result() => new() + { + Target = @"C:\repo\App.slnx", + GlobalPackagesFolder = @"C:\packages", + Destination = @"C:\repo\.agents\skills", + PackagesScanned = 3, + DryRun = false, + Skills = TwoSkills, + SkillsDiscovered = TwoSkills.Length, + }; +} diff --git a/dotnet-package-skills/tests/OutputWriterTests.cs b/dotnet-package-skills/tests/OutputWriterTests.cs new file mode 100644 index 0000000..95f969d --- /dev/null +++ b/dotnet-package-skills/tests/OutputWriterTests.cs @@ -0,0 +1,448 @@ +using System.Text.Json; +using DotnetPackageSkills.Cli; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +public class OutputWriterTests +{ + private const string ClipboardControl = "\u001b]52;c;ZWNobyBleGFtcGxl\u0007"; + + [Fact] + public void The_trust_notice_is_a_single_line() + { + using var output = new StringWriter(); + + new OutputWriter(output).WriteInstallReport(ResultWithCollision(), copied: true); + + // Any break we pick is a guess at the reader's width. It is one thought, so it goes + // out as one line and the terminal wraps it wherever it needs to. + var notice = output.ToString() + .Split(Environment.NewLine) + .Single(line => line.StartsWith("These skills are", StringComparison.Ordinal)); + + Assert.EndsWith("Review them before relying on them.", notice); + } + + [Fact] + public void No_reported_line_breaks_in_the_middle_of_a_sentence() + { + using var output = new StringWriter(); + + new OutputWriter(output).WriteInstallReport(ResultWithCollision(), copied: true); + + // A line that ends without terminal punctuation, followed by one starting lower + // case, is prose someone hard-wrapped. Indented lines are data, not prose. + var lines = output.ToString() + .Split(Environment.NewLine) + .Where(line => line.Length > 0 && !line.StartsWith(' ')) + .ToList(); + + for (var index = 0; index < lines.Count - 1; index++) + { + var ends = lines[index].TrimEnd(); + var next = lines[index + 1]; + + Assert.False( + ends.Length > 0 && ends[^1] is not ('.' or ':' or '!' or '?') && char.IsLower(next[0]), + $"'{ends}' looks hard-wrapped into '{next}'"); + } + } + + [Fact] + public void Install_report_warns_about_skipped_collisions() + { + using var output = new StringWriter(); + var result = ResultWithCollision(); + + new OutputWriter(output).WriteInstallReport(result, copied: true); + + var report = output.ToString(); + Assert.Contains("Warning: skipped 1 colliding skill:", report); + Assert.Contains("shared-skill (Beta.Widgets 2.0.0)", report); + Assert.Contains("selected first", report); + } + + [Fact] + public void Skills_whose_package_left_the_target_are_listed_with_the_command_that_removes_them() + { + using var output = new StringWriter(); + var result = ResultWithCollision() with + { + Unreferenced = [new TrackedSkill("contoso.widgets", "2.3.0", "contoso.widgets-usage")], + }; + + new OutputWriter(output).WriteInstallReport(result, copied: true); + + Assert.Contains( + "1 installed skill belongs to a package that the target no longer references:" + Environment.NewLine + + " contoso.widgets-usage (contoso.widgets 2.3.0)" + Environment.NewLine + + "Run 'dotnet-package-skills uninstall --stale' to remove it.", + output.ToString()); + } + + [Theory] + [InlineData("other.package", "packages")] + [InlineData("contoso.widgets", "a package")] + public void The_stale_hint_counts_skills_and_packages_separately(string secondPackage, string packages) + { + using var output = new StringWriter(); + var result = ResultWithCollision() with + { + Unreferenced = + [ + new TrackedSkill("contoso.widgets", "2.3.0", "contoso.widgets-usage"), + new TrackedSkill(secondPackage, "2.3.0", "second-skill"), + ], + }; + + new OutputWriter(output).WriteInstallReport(result, copied: true); + + Assert.Contains($"2 installed skills belong to {packages} that the target no longer references:", output.ToString()); + Assert.Contains("Run 'dotnet-package-skills uninstall --stale' to remove them.", output.ToString()); + } + + [Fact] + public void The_stale_hint_prints_the_command_for_the_destination_and_target_that_were_used() + { + using var output = new StringWriter(); + var result = ResultWithCollision() with + { + Unreferenced = [new TrackedSkill("contoso.widgets", "2.3.0", "contoso.widgets-usage")], + StaleCommand = "dotnet-package-skills uninstall --stale --destination \"my\u001b[2Jskills\"", + }; + + new OutputWriter(output).WriteInstallReport(result, copied: true); + + Assert.Contains( + "Run 'dotnet-package-skills uninstall --stale --destination \"myskills\"' to remove it.", + output.ToString()); + Assert.DoesNotContain('\u001b', output.ToString()); + } + + [Fact] + public void Deselecting_everything_does_not_claim_the_packages_ship_no_skills() + { + using var output = new StringWriter(); + var result = ResultWithCollision() with { Skills = [], SkillsDiscovered = 2 }; + + new OutputWriter(output).WriteInstallReport(result, copied: true); + + var report = output.ToString(); + Assert.Contains("Copied no skills.", report); + Assert.DoesNotContain("ship a skills/ folder", report); + } + + [Fact] + public void A_scan_that_discovered_nothing_says_so_plainly() + { + using var output = new StringWriter(); + var result = ResultWithCollision() with { Skills = [], Skipped = [], SkillsDiscovered = 0 }; + + new OutputWriter(output).WriteInstallReport(result, copied: true); + + // A package missing from the cache is indistinguishable from one without skills, so the + // report claims nothing about why nothing was found. + Assert.Contains($"{Environment.NewLine}No bundled skills found.{Environment.NewLine}", output.ToString()); + Assert.DoesNotContain("ship a skills/ folder", output.ToString()); + Assert.DoesNotContain("not extracted", output.ToString()); + } + + [Theory] + [InlineData(false, "Nothing new to install. Every skill that these packages ship is already installed.")] + [InlineData(true, "Nothing new to install.")] + public void An_interactive_install_with_nothing_to_offer_says_so(bool skipped, string expected) + { + using var output = new StringWriter(); + var result = ResultWithCollision() with + { + Skills = [], + SkillsDiscovered = 1, + NothingNewToInstall = true, + Skipped = skipped ? ResultWithCollision().Skipped : [], + }; + + new OutputWriter(output).WriteInstallReport(result, copied: true); + + var lines = output.ToString().Split(Environment.NewLine); + Assert.Contains(expected, lines); + Assert.DoesNotContain("Copied no skills.", lines); + Assert.Equal(skipped, output.ToString().Contains("Warning: skipped 1 colliding skill:", StringComparison.Ordinal)); + } + + [Fact] + public void List_reports_what_it_found_rather_than_a_pending_copy() + { + using var output = new StringWriter(); + + // list always runs as a dry run internally, but it is a query: it was never going + // to copy anything, so "Would copy" would misdescribe it. + new OutputWriter(output).WriteInstallReport(ResultWithCollision() with { DryRun = true }, copied: false); + + var report = output.ToString(); + Assert.Contains("Found 1 skill:", report); + Assert.DoesNotContain("Would copy", report); + } + + [Fact] + public void An_install_dry_run_still_says_what_it_would_copy() + { + using var output = new StringWriter(); + + new OutputWriter(output).WriteInstallReport(ResultWithCollision() with { DryRun = true }, copied: true); + + Assert.Contains("Would copy 1 skill:", output.ToString()); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Install_and_list_reports_sanitize_every_untrusted_display_field(bool copied, bool dryRun) + { + var clean = ResultWithCollision() with + { + DryRun = dryRun, + Removed = [new TrackedSkill("Old.Package", "1.0.0", "old-skill")], + Unreferenced = [new TrackedSkill("left.package", "3.0.0", "left-skill")], + }; + var untrusted = clean with + { + Target = WithControls(clean.Target!), + GlobalPackagesFolder = WithControls(clean.GlobalPackagesFolder), + Destination = WithControls(clean.Destination), + Skills = + [ + .. clean.Skills.Select(skill => skill with + { + SkillName = WithControls(skill.SkillName), + RelativePath = WithControls(skill.RelativePath), + PackageId = WithControls(skill.PackageId), + PackageVersion = WithControls(skill.PackageVersion), + }), + ], + Removed = + [ + .. clean.Removed.Select(skill => new TrackedSkill( + WithControls(skill.Package), WithControls(skill.Version), WithControls(skill.Skill))), + ], + Unreferenced = + [ + .. clean.Unreferenced.Select(skill => new TrackedSkill( + WithControls(skill.Package), WithControls(skill.Version), WithControls(skill.Skill))), + ], + Skipped = + [ + .. clean.Skipped.Select(skill => skill with + { + SkillName = WithControls(skill.SkillName), + RelativePath = WithControls(skill.RelativePath), + PackageId = WithControls(skill.PackageId), + PackageVersion = WithControls(skill.PackageVersion), + Reason = WithControls(skill.Reason), + }), + ], + }; + using var expected = new StringWriter(); + using var actual = new StringWriter(); + + new OutputWriter(expected).WriteInstallReport(clean, copied); + new OutputWriter(actual).WriteInstallReport(untrusted, copied); + + AssertPlainText(actual.ToString()); + Assert.Equal(expected.ToString(), actual.ToString()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Uninstall_reports_strip_clipboard_and_other_terminal_sequences(bool dryRun) + { + string[] controls = + [ + ClipboardControl, + "\u001b]52;c;ZWNobyBleGFtcGxl\u001b\\", + "\u009d52;c;ZWNobyBleGFtcGxl\u009c", + "\u001b[2J\u001b[H", + "\u009b2J", + "\u001b]8;;https://invalid.example\u001b\\", + "\u001bPignored\u001b\\", + "\u0007\u0008\u007f\u202e\u2066", + ]; + using var expected = new StringWriter(); + new OutputWriter(expected).WriteUninstallReport( + [new TrackedSkill("Example.Package", "1.0.0", "example-skill")], @"C:\repo", dryRun); + + foreach (var control in controls) + { + using var actual = new StringWriter(); + new OutputWriter(actual).WriteUninstallReport( + [new TrackedSkill("Example" + control + ".Package", "1.0" + control + ".0", + "example" + control + "-skill")], + @"C:\re" + control + "po", dryRun); + + AssertPlainText(actual.ToString()); + Assert.Equal(expected.ToString(), actual.ToString()); + } + } + + [Theory] + [InlineData(false, "Removed 1 skill:")] + [InlineData(true, "Would remove 1 skill:")] + public void A_stale_uninstall_report_names_the_target_it_compared_against(bool dryRun, string heading) + { + using var output = new StringWriter(); + + new OutputWriter(output).WriteUninstallReport( + [new TrackedSkill("contoso.widgets", "2.3.0", "widget-usage")], @"C:\repo\.agents\skills", dryRun, + target: @"C:\repo\App.sln"); + + Assert.Equal( + [ + @"Target: C:\repo\App.sln", + @"Destination: C:\repo\.agents\skills", + string.Empty, + heading, + " widget-usage (contoso.widgets 2.3.0)", + string.Empty, + ], + output.ToString().Split(Environment.NewLine)); + } + + [Fact] + public void A_stale_uninstall_with_nothing_stale_says_so() + { + using var output = new StringWriter(); + + new OutputWriter(output).WriteUninstallReport([], @"C:\repo\.agents\skills", dryRun: false, target: @"C:\repo\App.sln"); + + Assert.Contains("Nothing to remove. No stale skills were found.", output.ToString()); + Assert.DoesNotContain("No skills installed by this tool", output.ToString()); + } + + [Fact] + public void An_unterminated_control_in_one_identity_field_cannot_hide_the_following_fields() + { + using var output = new StringWriter(); + + new OutputWriter(output).WriteUninstallReport( + [new TrackedSkill("Example\u001b]52;c;unterminated", "1.0.0", "example-skill")], + @"C:\repo", dryRun: true); + + AssertPlainText(output.ToString()); + Assert.Contains("example-skill (Example 1.0.0)", output.ToString()); + } + + [Fact] + public void A_manifest_clipboard_payload_is_safe_to_preview_without_rewriting_identity_or_files() + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + var skillFile = temp.CreateFile("dest/example-skill/SKILL.md", "installed guidance"); + var handwritten = temp.CreateFile("dest/our-own-skill/SKILL.md", "handwritten guidance"); + // Package ids are validated when the manifest is read, so the version carries the payload. + var version = "1.0.0" + ClipboardControl; + var manifest = temp.CreateFile("dest/.dotnet-package-skills.json", JsonSerializer.Serialize(new + { + version = 1, + packages = new Dictionary + { + ["example"] = new { version, skills = new[] { "example-skill" } }, + }, + })); + var before = File.ReadAllBytes(manifest); + var removed = new SkillInstaller().Uninstall(destination, null, null, dryRun: true); + using var text = new StringWriter(); + + new OutputWriter(text).WriteUninstallReport(removed, destination, dryRun: true); + + AssertPlainText(text.ToString()); + Assert.Contains("example-skill (example 1.0.0)", text.ToString()); + Assert.Equal("example", Assert.Single(removed).Package); + Assert.Equal(version, Assert.Single(removed).Version); + Assert.Equal(before, File.ReadAllBytes(manifest)); + Assert.Equal("installed guidance", File.ReadAllText(skillFile)); + Assert.Equal("handwritten guidance", File.ReadAllText(handwritten)); + } + + [Theory] + [InlineData("\r\n")] + [InlineData("\n")] + [InlineData("\r")] + public void Operational_errors_are_sanitized_without_losing_line_breaks_or_stderr_routing(string newline) + { + using var output = new StringWriter(); + using var errors = new StringWriter(); + + new OutputWriter(output, errors).WriteError( + WithControls("Could not read the package.") + newline + WithControls("Restore it and try again.")); + + Assert.Empty(output.ToString()); + AssertPlainText(errors.ToString()); + Assert.Equal( + $"error: Could not read the package.{Environment.NewLine}Restore it and try again.{Environment.NewLine}", + errors.ToString()); + } + + [Fact] + public void Newlines_in_report_metadata_cannot_insert_additional_report_rows() + { + using var output = new StringWriter(); + + new OutputWriter(output).WriteUninstallReport( + [new TrackedSkill("Example\r\nPackage", "1.0.0", "example-skill")], @"C:\repo", dryRun: true); + + AssertPlainText(output.ToString()); + Assert.Contains(" example-skill (Example Package 1.0.0)", output.ToString()); + Assert.Equal(3, output.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries).Length); + } + + [Fact] + public void Human_reports_preserve_ordinary_unicode_names() + { + using var output = new StringWriter(); + const string Skill = "\u6280\u80fd-\U0001f9ea"; + + new OutputWriter(output).WriteUninstallReport( + [new TrackedSkill("Caf\u00e9.Tools", "1.0.0", Skill)], @"C:\repo", dryRun: true); + + AssertPlainText(output.ToString()); + Assert.Contains($"{Skill} (Caf\u00e9.Tools 1.0.0)", output.ToString()); + } + + private static string WithControls(string text) => ClipboardControl + text + "\u001b[0m"; + + private static void AssertPlainText(string text) => + Assert.True( + !text.Any(character => char.IsControl(character) && character is not ('\r' or '\n') || + character is '\u202e' or '\u2066'), + "Captured human-readable output contains unsafe terminal controls."); + + private static InstallResult ResultWithCollision() => new() + { + Target = @"C:\repo\App.sln", + GlobalPackagesFolder = @"C:\packages", + Destination = @"C:\repo\.agents\skills", + PackagesScanned = 2, + DryRun = false, + Skills = + [ + new BundledSkill( + "Alpha.Widgets", + "1.0.0", + "shared-skill", + @"C:\packages\alpha.widgets\1.0.0\skills\shared-skill", + "shared-skill"), + ], + Skipped = + [ + new SkippedSkill( + "shared-skill", + "Beta.Widgets", + "2.0.0", + "shared-skill", + "conflicts with Alpha.Widgets 1.0.0 skill 'shared-skill', which was selected first"), + ], + }; +} diff --git a/dotnet-package-skills/tests/PackageCoordinateTests.cs b/dotnet-package-skills/tests/PackageCoordinateTests.cs new file mode 100644 index 0000000..0b1b890 --- /dev/null +++ b/dotnet-package-skills/tests/PackageCoordinateTests.cs @@ -0,0 +1,70 @@ +using DotnetPackageSkills.NuGet; + +namespace DotnetPackageSkills.Tests; + +public class PackageCoordinateTests +{ + [Theory] + [InlineData("Mockly@1.10.0", "Mockly", "1.10.0")] + [InlineData("Contoso.Widgets@2.3.0", "Contoso.Widgets", "2.3.0")] + [InlineData("My_Package-Name@1.0.0-beta.1", "My_Package-Name", "1.0.0-beta.1")] + [InlineData("_Acme@1.0.0", "_Acme", "1.0.0")] + [InlineData("Acme_@1.0.0", "Acme_", "1.0.0")] + [InlineData("Widgets@2.0", "Widgets", "2.0")] + [InlineData("Widgets@1.2.3.4", "Widgets", "1.2.3.4")] + [InlineData("Widgets@1.2.3+sha.abc", "Widgets", "1.2.3+sha.abc")] + [InlineData(" Mockly@1.10.0 ", "Mockly", "1.10.0")] + [InlineData("Contoso.Überlib@1.0.0", "Contoso.Überlib", "1.0.0")] + public void Parse_accepts_an_exact_package_and_version(string input, string id, string version) + { + var coordinate = PackageCoordinate.Parse(input); + + Assert.Equal(id, coordinate.Id); + Assert.Equal(version, coordinate.Version); + } + + [Theory] + [InlineData("Mockly@4.*")] + [InlineData("Mockly@*")] + [InlineData("Mockly@1.2.*")] + [InlineData("Mockly@[1.0,2.0)")] + [InlineData("Mockly@(,3.0]")] + [InlineData("Mockly@[1.0]")] + public void Parse_refuses_floating_versions_and_ranges(string input) + { + var exception = Assert.Throws(() => PackageCoordinate.Parse(input)); + + // Guessing a version would copy skills describing a release the user does not use, + // so the message has to point at the option that resolves versions properly. + Assert.Contains("exact version", exception.Message); + Assert.Contains("--target", exception.Message); + } + + [Fact] + public void Parse_tells_the_user_how_to_add_a_missing_version() + { + var exception = Assert.Throws(() => PackageCoordinate.Parse("Mockly")); + + Assert.Contains("Mockly@1.10.0", exception.Message); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("@1.0.0")] + [InlineData("Mockly@")] + [InlineData("Mockly@@1.0.0")] + [InlineData("Mockly@not-a-version")] + [InlineData("Mockly@v1.0.0")] + [InlineData("../evil@1.0.0")] + [InlineData("path/to/thing@1.0.0")] + [InlineData("Contoso..Widgets@1.0.0")] + [InlineData("Contoso.-Widgets@1.0.0")] + [InlineData("Contoso\u200B.Widgets@1.0.0")] + public void Parse_rejects_malformed_input(string input) => + Assert.Throws(() => PackageCoordinate.Parse(input)); + + [Fact] + public void ToString_round_trips() => + Assert.Equal("Mockly@1.10.0", PackageCoordinate.Parse("Mockly@1.10.0").ToString()); +} diff --git a/dotnet-package-skills/tests/PackageListerTests.cs b/dotnet-package-skills/tests/PackageListerTests.cs new file mode 100644 index 0000000..d7e2502 --- /dev/null +++ b/dotnet-package-skills/tests/PackageListerTests.cs @@ -0,0 +1,212 @@ +using DotnetPackageSkills.Infrastructure; +using DotnetPackageSkills.NuGet; + +namespace DotnetPackageSkills.Tests; + +public class PackageListerTests +{ + /// Records what was asked of the CLI and replays a canned result. + private sealed class RecordingRunner(int exitCode, string standardOutput, string standardError = "") + : IProcessRunner + { + public List Invocations { get; } = []; + + public ProcessResult Run(string fileName, IReadOnlyList arguments, string? workingDirectory = null) + { + Invocations.Add(string.Join(' ', arguments)); + return new ProcessResult(exitCode, standardOutput, standardError); + } + } + + private const string UnrestoredError = + "No assets file was found for 'App.csproj'. Run restore before running this command."; + + /// What the .NET 10 SDK writes when its own restore fails during a JSON listing. + private const string RestoreFailedJson = """ + { + "version": 1, + "problems": [ + { "text": "Restore failed. Run `dotnet restore` for more details on the issue.", "level": "error" } + ] + } + """; + + [Fact] + public void List_runs_dotnet_list_package_without_a_restore_option() + { + // Whether listing restores is the SDK's call: .NET 10 restores when it needs to, and + // earlier SDKs say that the target has to be restored first. + var runner = new RecordingRunner(0, TwoProjectsJson); + + new PackageLister(new DotnetCli(runner)).List("App.csproj"); + + Assert.Equal("list App.csproj package --format json", Assert.Single(runner.Invocations)); + } + + [Fact] + public void An_unrestored_target_is_reported_with_the_sdk_output_and_the_tool_never_restores_it() + { + // The customer restores and runs the command again; the tool doesn't restore for them. + var runner = new RecordingRunner(1, string.Empty, UnrestoredError); + + var error = Assert.Throws( + () => new PackageLister(new DotnetCli(runner)).List("App.csproj")); + + Assert.Contains("'dotnet list \"App.csproj\" package' failed with exit code 1", error.Message); + Assert.Contains(UnrestoredError, error.Message); + Assert.Contains("and then run this command again", error.Message); + Assert.Equal("list App.csproj package --format json", Assert.Single(runner.Invocations)); + } + + [Fact] + public void Problems_that_the_sdk_reports_in_json_are_shown_as_text() + { + var runner = new RecordingRunner(1, RestoreFailedJson); + + var error = Assert.Throws( + () => new PackageLister(new DotnetCli(runner)).List("App.csproj")); + + Assert.Contains("error: Restore failed. Run `dotnet restore` for more details on the issue.", error.Message); + Assert.DoesNotContain("\"problems\"", error.Message); + Assert.Single(runner.Invocations); + } + + private const string TwoProjectsJson = """ + { + "version": 1, + "parameters": "", + "projects": [ + { + "path": "/repo/src/Api/Api.csproj", + "frameworks": [ + { + "framework": "net8.0", + "topLevelPackages": [ + { "id": "Serilog", "requestedVersion": "4.1.0", "resolvedVersion": "4.1.0" }, + { "id": "Mockly", "requestedVersion": "1.10.0", "resolvedVersion": "1.10.0" } + ], + "transitivePackages": [ + { "id": "System.Text.Json", "resolvedVersion": "8.0.5" } + ] + } + ] + }, + { + "path": "/repo/src/Worker/Worker.csproj", + "frameworks": [ + { + "framework": "net8.0", + "topLevelPackages": [ + { "id": "Serilog", "requestedVersion": "4.1.0", "resolvedVersion": "4.1.0" } + ] + } + ] + } + ] + } + """; + + [Fact] + public void Parse_returns_direct_packages_only() + { + var packages = PackageLister.Parse(TwoProjectsJson); + + Assert.Equal(["Mockly", "Serilog"], packages.Select(p => p.Id)); + } + + [Fact] + public void Parse_deduplicates_a_package_referenced_by_several_projects() + { + var packages = PackageLister.Parse(TwoProjectsJson); + + Assert.Single(packages, package => package.Id == "Serilog"); + } + + [Fact] + public void Parse_ignores_transitive_packages() + { + Assert.DoesNotContain(PackageLister.Parse(TwoProjectsJson), package => package.Id == "System.Text.Json"); + } + + [Fact] + public void Parse_keeps_both_versions_when_frameworks_resolve_a_package_differently() + { + // Each version has its own folder in the global packages cache, so both matter. + const string json = """ + { + "projects": [ + { + "frameworks": [ + { "framework": "net8.0", "topLevelPackages": [ { "id": "Widgets", "resolvedVersion": "1.0.0" } ] }, + { "framework": "net10.0", "topLevelPackages": [ { "id": "Widgets", "resolvedVersion": "2.0.0" } ] } + ] + } + ] + } + """; + + var packages = PackageLister.Parse(json); + + Assert.Equal(["1.0.0", "2.0.0"], packages.Select(p => p.Version)); + } + + [Fact] + public void Parse_prefers_the_resolved_version_over_the_requested_one() + { + // Central Package Management and floating versions leave a range in + // requestedVersion; only resolvedVersion names a folder that exists. + const string json = """ + { + "projects": [ + { + "frameworks": [ + { + "framework": "net8.0", + "topLevelPackages": [ + { "id": "Widgets", "requestedVersion": "4.*", "resolvedVersion": "4.7.2" } + ] + } + ] + } + ] + } + """; + + Assert.Equal("4.7.2", PackageLister.Parse(json).Single().Version); + } + + [Fact] + public void Parse_handles_an_unrestored_project_with_no_frameworks_array() + { + const string json = """{ "version": 1, "projects": [ { "path": "/repo/src/Api/Api.csproj" } ] }"""; + + Assert.Empty(PackageLister.Parse(json)); + } + + [Fact] + public void Parse_handles_a_project_with_no_packages() + { + const string json = """ + { "projects": [ { "frameworks": [ { "framework": "net8.0", "topLevelPackages": [] } ] } ] } + """; + + Assert.Empty(PackageLister.Parse(json)); + } + + [Fact] + public void Parse_skips_MSBuild_noise_printed_before_the_payload() + { + var noisy = "warning NU1503: Skipping restore for project.\n" + TwoProjectsJson; + + Assert.NotEmpty(PackageLister.Parse(noisy)); + } + + [Fact] + public void Parse_reports_unusable_output_as_actionable_guidance() + { + var exception = Assert.Throws( + () => PackageLister.Parse("Unrecognized option '--format'")); + + Assert.Contains("7.0.200", exception.Message); + } +} diff --git a/dotnet-package-skills/tests/PackagePathResolverTests.cs b/dotnet-package-skills/tests/PackagePathResolverTests.cs new file mode 100644 index 0000000..698e5cf --- /dev/null +++ b/dotnet-package-skills/tests/PackagePathResolverTests.cs @@ -0,0 +1,80 @@ +using DotnetPackageSkills.NuGet; + +namespace DotnetPackageSkills.Tests; + +public class PackagePathResolverTests +{ + [Theory] + // Already normalized. + [InlineData("1.2.3", "1.2.3")] + // Padded to three parts. + [InlineData("1.2", "1.2.0")] + [InlineData("1", "1.0.0")] + // A zero fourth part is dropped; a non-zero one is kept. + [InlineData("1.2.3.0", "1.2.3")] + [InlineData("1.2.3.4", "1.2.3.4")] + // Leading zeros are not part of the folder name. + [InlineData("01.02.03", "1.2.3")] + // Prerelease labels are preserved but lowercased. + [InlineData("1.0.0-Beta.1", "1.0.0-beta.1")] + [InlineData("2.0-RC1", "2.0.0-rc1")] + // Build metadata is not part of package identity. + [InlineData("1.2.3+build.99", "1.2.3")] + [InlineData("1.2.3-alpha+sha.abc", "1.2.3-alpha")] + [InlineData(" 1.2.3 ", "1.2.3")] + public void NormalizeVersion_matches_NuGet_folder_naming(string input, string expected) => + Assert.Equal(expected, PackagePathResolver.NormalizeVersion(input)); + + [Fact] + public void NormalizeVersion_leaves_unparseable_versions_alone_for_the_directory_scan() => + Assert.Equal("1.x.3", PackagePathResolver.NormalizeVersion("1.X.3")); + + [Fact] + public void Resolve_finds_the_lowercased_folder_for_a_mixed_case_package_id() + { + using var temp = new TempDirectory(); + temp.CreateDirectory("packages", "newtonsoft.json", "13.0.3"); + + var resolved = PackagePathResolver.Resolve(temp.Combine("packages"), "Newtonsoft.Json", "13.0.3"); + + Assert.Equal(temp.Combine("packages", "newtonsoft.json", "13.0.3"), resolved); + } + + [Fact] + public void Resolve_normalizes_the_version_before_looking() + { + using var temp = new TempDirectory(); + temp.CreateDirectory("packages", "serilog", "4.1.0"); + + Assert.NotNull(PackagePathResolver.Resolve(temp.Combine("packages"), "Serilog", "4.1")); + } + + [Fact] + public void Resolve_falls_back_to_scanning_when_normalization_does_not_match() + { + using var temp = new TempDirectory(); + + // A folder name our rules would not produce, so only the scan can find it. + temp.CreateDirectory("packages", "oddball", "1.2.3.4.5"); + + Assert.NotNull(PackagePathResolver.Resolve(temp.Combine("packages"), "Oddball", "1.2.3.4.5")); + } + + [Fact] + public void Resolve_returns_null_when_the_package_is_not_extracted() + { + using var temp = new TempDirectory(); + temp.CreateDirectory("packages"); + + Assert.Null(PackagePathResolver.Resolve(temp.Combine("packages"), "Missing.Package", "1.0.0")); + } + + [Fact] + public void Resolve_returns_null_when_only_a_different_version_is_extracted() + { + using var temp = new TempDirectory(); + temp.CreateDirectory("packages", "serilog", "4.1.0"); + + Assert.Null(PackagePathResolver.Resolve(temp.Combine("packages"), "Serilog", "3.0.0")); + } +} diff --git a/dotnet-package-skills/tests/PickerLayoutTests.cs b/dotnet-package-skills/tests/PickerLayoutTests.cs new file mode 100644 index 0000000..1f14b82 --- /dev/null +++ b/dotnet-package-skills/tests/PickerLayoutTests.cs @@ -0,0 +1,259 @@ +using DotnetPackageSkills.Cli; + +namespace DotnetPackageSkills.Tests; + +public class PickerLayoutTests +{ + [Fact] + public void Mixed_height_entries_have_exact_whole_entry_page_boundaries() + { + var layout = Layout(80, 24, [1, 3, 2, 4, 1, 2, 3, 1, 5, 2, 1, 4, 2, 3]); + + Assert.Equal( + [ + new PickerLayout.Page(0, 6, 13, false), + new PickerLayout.Page(6, 5, 12, false), + new PickerLayout.Page(11, 3, 9, false), + ], + layout.Pages); + Assert.Equal(0, layout.PageIndexFor(5)); + Assert.Equal(1, layout.PageIndexFor(6)); + Assert.Equal(2, layout.PageIndexFor(13)); + Assert.Equal(22, layout.MaxFrameHeight); + } + + [Fact] + public void Only_an_oversized_entry_gets_a_scrolling_one_item_page() + { + var layout = Layout(80, 24, [2, 50, 2, 1]); + + Assert.Equal( + [ + new PickerLayout.Page(0, 1, 2, false), + new PickerLayout.Page(1, 1, 13, true), + new PickerLayout.Page(2, 2, 3, false), + ], + layout.Pages); + Assert.Equal(37, layout.MaxScroll(1)); + Assert.Equal(0, layout.MaxScroll(0)); + Assert.Equal(0, layout.MaxScroll(2)); + Assert.Equal(23, layout.MaxFrameHeight); + } + + [Fact] + public void Forty_six_columns_measure_wrapped_rows_and_wrapped_controls() + { + var items = Enumerable.Range(1, 24) + .Select(number => new SkillPickerItem($"skill-{number:00}", $"Package.{number}", "1.0.0")) + .ToArray(); + + var layout = PickerLayout.For(items, "Skills for App.slnx", PickerMode.Install, 46, 18, true); + + Assert.Equal(45, layout.Width); + Assert.All(layout.Entries, entry => + { + Assert.Equal(17, entry.DescriptionColumn); + Assert.Equal(["No description provided."], entry.Description); + }); + Assert.Equal([5, 5, 5, 5, 4], layout.Pages.Select(page => page.Count)); + Assert.Equal(17, layout.MaxFrameHeight); + Assert.Contains("(Press to select, to accept)", layout.Help); + Assert.Contains(" to cancel)", string.Join(" ", layout.Help)); + } + + [Fact] + public void A_normal_eighty_by_twenty_four_window_uses_its_real_row_budget() + { + var layout = Layout(80, 24, Enumerable.Repeat(1, 24).ToArray()); + + Assert.Equal(new PickerLayout.Page(0, 14, 14, false), layout.Pages[0]); + Assert.Equal(new PickerLayout.Page(14, 10, 10, false), layout.Pages[1]); + Assert.Equal(23, layout.MaxFrameHeight); + } + + [Fact] + public void A_wide_tall_window_has_no_fixed_item_or_width_ceiling() + { + var layout = Layout(240, 100, Enumerable.Repeat(1, 60).ToArray()); + + Assert.Equal(new PickerLayout.Page(0, 60, 60, false), Assert.Single(layout.Pages)); + Assert.Equal(68, layout.MaxFrameHeight); + Assert.True(layout.Width < 80); + Assert.DoesNotContain(layout.Help, line => line.Contains("change page", StringComparison.Ordinal)); + } + + [Fact] + public void A_wrapped_title_takes_space_away_from_entries_not_from_the_footer() + { + var items = Enumerable.Range(1, 24) + .Select(number => new SkillPickerItem($"skill-{number:00}", "P", "1", "short")) + .ToArray(); + var title = string.Join(" ", Enumerable.Repeat("titleword", 20)); + + var layout = PickerLayout.For(items, title, PickerMode.Install, 80, 24, true); + + Assert.Equal(3, layout.Header(0).Count); + Assert.Equal(12, layout.Pages[0].Count); + Assert.Equal(2, layout.Pages.Count); + Assert.Equal($"{title} page 1 of 2", string.Join(" ", layout.Header(0))); + Assert.Equal(23, layout.MaxFrameHeight); + } + + [Fact] + public void Page_counter_digit_growth_is_included_in_the_fixed_point() + { + var items = Enumerable.Range(1, 120) + .Select(number => new SkillPickerItem($"skill-{number:000}", "Package", "1.0.0")) + .ToArray(); + + var layout = PickerLayout.For(items, new string('t', 32), PickerMode.Install, 46, 18, true); + + Assert.Equal(30, layout.Pages.Count); + Assert.Single(layout.Header(0)); + Assert.Equal(2, layout.Header(29).Count); + Assert.Equal(29, layout.PageIndexFor(119)); + Assert.All(layout.Pages, page => Assert.Equal(4, page.Count)); + Assert.True(layout.MaxFrameHeight < 18); + } + + [Fact] + public void Summary_reservation_measures_attainable_counts_not_impossible_combinations() + { + var items = Enumerable.Range(1, 10) + .Select(number => new SkillPickerItem($"skill-{number:00}", "P", "1", "Short.")) + .ToArray(); + + var layout = PickerLayout.For(items, "Skills", PickerMode.Install, 45, 18, true); + + Assert.Equal( + [ + new PickerLayout.Page(0, 5, 5, false), + new PickerLayout.Page(5, 5, 5, false), + ], + layout.Pages); + Assert.Equal(17, layout.MaxFrameHeight); + } + + [Fact] + public void Package_metadata_does_not_consume_space_or_change_page_boundaries() + { + var items = Enumerable.Range(1, 12) + .Select(number => new SkillPickerItem($"skill-{number:00}", "P", "1", "A short description.")) + .ToArray(); + var verboseMetadata = items.Select(item => item with + { + Package = new string('p', 120), + Version = "1.0.0-a-very-long-prerelease-version", + }).ToArray(); + + var original = PickerLayout.For(items, "Skills", PickerMode.Install, 100, 24, true); + var verbose = PickerLayout.For(verboseMetadata, "Skills", PickerMode.Install, 100, 24, true); + + Assert.Equal(original.Width, verbose.Width); + Assert.Equal(original.Pages, verbose.Pages); + Assert.Equal(original.Entries.Select(entry => entry.Label), verbose.Entries.Select(entry => entry.Label)); + Assert.Equal( + original.Entries.SelectMany(entry => entry.Description), + verbose.Entries.SelectMany(entry => entry.Description)); + } + + [Fact] + public void Shorter_names_give_their_descriptions_more_space_to_wrap() + { + const string description = "One two three four five six seven eight nine ten."; + var layout = PickerLayout.For( + [ + new SkillPickerItem("alpha", "P", "1", description), + new SkillPickerItem("longer-skill", "P", "1", description), + ], + "Skills", PickerMode.Install, 46, 24, true); + + Assert.Equal(14, layout.Entries[0].DescriptionColumn); + Assert.Equal( + ["One two three four five six", "seven eight nine ten."], + layout.Entries[0].Description); + Assert.Equal(21, layout.Entries[1].DescriptionColumn); + Assert.Equal( + ["One two three four five", "six seven eight nine ten."], + layout.Entries[1].Description); + Assert.Equal(6, layout.ContinuationColumn); + Assert.Equal(new PickerLayout.Page(0, 2, 4, false), Assert.Single(layout.Pages)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Every_keyboard_hint_starts_with_Press_in_both_modes(bool uninstall) + { + var items = Enumerable.Range(1, 24) + .Select(number => new SkillPickerItem($"skill-{number:00}", "P", "1", "Short.")) + .ToArray(); + var layout = PickerLayout.For( + items, "Skills", uninstall ? PickerMode.Uninstall : PickerMode.Install, 100, 18, true); + + Assert.Contains(PickerLayout.PrimaryHelp, layout.Help); + Assert.Contains("(Press / to move, / for first/last)", layout.Help); + Assert.Contains("(Press /, / to change page)", layout.Help); + Assert.Contains("(Press to select all, to clear all, // to cancel)", layout.Help); + Assert.All(layout.Help.Where(line => line.StartsWith('(')), line => Assert.StartsWith("(Press <", line)); + Assert.Equal( + "(Press / to scroll description: 1-4/20)", + PickerLayout.ScrollHelp(1, 4, 20)); + } + + [Fact] + public void Both_pickers_share_one_legend_and_rows_start_in_the_same_column_without_color() + { + var items = new[] { new SkillPickerItem("alpha", "P", "1", "One two three four five six seven eight nine ten.") }; + + var install = PickerLayout.For(items, "Skills", PickerMode.Install, 46, 24, true); + var uninstall = PickerLayout.For(items, "Skills", PickerMode.Uninstall, 46, 24, true); + var plain = PickerLayout.For(items, "Skills", PickerMode.Uninstall, 46, 24, false); + + Assert.Equal(install.Help, uninstall.Help); + Assert.Contains("Blue X: selected", uninstall.Help); + Assert.All(plain.Help, line => Assert.StartsWith("(Press <", line)); + Assert.Equal(install.ContinuationColumn, plain.ContinuationColumn); + Assert.Equal(install.Entries[0].DescriptionColumn, plain.Entries[0].DescriptionColumn); + Assert.Equal(install.Entries[0].Description, plain.Entries[0].Description); + } + + [Fact] + public void A_window_too_small_for_the_note_drops_the_note_rather_than_the_checklist() + { + // At 46x18 the scrolling description needs every row the rest of the frame leaves, so the + // note under the title gives way. A roomier window keeps it. + const string note = "Installed skills aren't listed."; + const string title = "Which skills should be installed? (App.slnx)"; + var items = new[] + { + new SkillPickerItem("skill-01", "P", "1", "short"), + new SkillPickerItem("skill-02", "P", "1", string.Join(" ", Enumerable.Repeat("word", 200))), + }; + + var small = PickerLayout.For(items, title, PickerMode.Install, 46, 18, true, note); + var roomy = PickerLayout.For(items, title, PickerMode.Install, 100, 30, true, note); + + Assert.DoesNotContain(note, small.Header(0)); + Assert.True(small.MaxFrameHeight < 18); + Assert.Contains(note, roomy.Header(0)); + } + + [Theory] + [InlineData(1, 1)] + [InlineData(5, 20)] + [InlineData(80, 4)] + public void Impossible_viewports_are_rejected_with_resize_guidance(int width, int height) + { + var error = Assert.Throws(() => Layout(width, height, [1, 2])); + + Assert.Contains("Enlarge the window", error.Message); + Assert.Contains($"{width}x{height}", error.Message); + } + + private static PickerLayout Layout(int width, int height, int[] rows) => PickerLayout.For( + rows.Select((count, index) => new SkillPickerItem( + $"skill-{index + 1:00}", "P", "1", + string.Join("\n", Enumerable.Range(1, count).Select(line => $"line {line:00}")))).ToArray(), + "Skills", PickerMode.Install, width, height, true); +} diff --git a/dotnet-package-skills/tests/PipelineVersionTests.cs b/dotnet-package-skills/tests/PipelineVersionTests.cs new file mode 100644 index 0000000..666aab2 --- /dev/null +++ b/dotnet-package-skills/tests/PipelineVersionTests.cs @@ -0,0 +1,90 @@ +using System.Diagnostics; + +namespace DotnetPackageSkills.Tests; + +public class PipelineVersionTests +{ + [Theory] + [InlineData(false, false, "Manual", "refs/heads/main", "", "12345", "0.1.0-ci.12345")] + [InlineData(false, false, "PullRequest", "refs/pull/42/merge", "42", "12345", "0.1.0-pr.42.12345")] + [InlineData(true, false, "IndividualCI", "refs/heads/main", "", "12345", "0.1.0-preview.12345")] + [InlineData(true, false, "Manual", "refs/heads/main", "", "12345", "0.1.0-preview.12345")] + [InlineData(true, true, "Manual", "refs/heads/main", "", "12345", "0.1.0")] + [InlineData(true, false, "IndividualCI", "refs/heads/main", "", "12346", "0.1.0-preview.12346")] + [InlineData(true, false, "IndividualCI", "refs/heads/main", "", "10000000", "0.1.0-preview.10000000")] + public async Task Pipeline_versions_distinguish_build_kinds( + bool official, bool release, string reason, string branch, string pullRequest, + string buildId, string expected) + { + var result = await Calculate("0.1.0", buildId, reason, branch, pullRequest, official, release); + + Assert.True(result.ExitCode == 0, result.Error); + Assert.Equal(expected, result.Output.Trim()); + } + + [Theory] + [InlineData("0.1.0", "12345", "Manual", "refs/heads/main", "", false, true, "manual official build")] + [InlineData("0.1.0", "12345", "IndividualCI", "refs/heads/main", "", true, true, "manual official build")] + [InlineData("0.1.0", "12345", "Manual", "refs/heads/feature", "", true, true, "manual official build")] + [InlineData("0.1.0", "12345", "PullRequest", "refs/pull/42/merge", "42", true, false, "cannot use the official signing path")] + [InlineData("0.1.0", "$(Build.BuildId)", "Manual", "refs/heads/main", "", false, false, "BuildId must be a positive integer")] + [InlineData("0.1.0", "12345", "PullRequest", "refs/pull/42/merge", "", false, false, "PullRequestNumber must be a positive integer")] + [InlineData("0.1.0", "12345", "PullRequest", "refs/pull/42/merge", "042", false, false, "PullRequestNumber must be a positive integer")] + [InlineData("01.0.0", "12345", "Manual", "refs/heads/main", "", false, false, "BaseVersion must be a three-part semantic version")] + public async Task Invalid_pipeline_versions_fail_explicitly( + string baseVersion, string buildId, string reason, string branch, string pullRequest, + bool official, bool release, string diagnostic) + { + var result = await Calculate(baseVersion, buildId, reason, branch, pullRequest, official, release); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains(diagnostic, result.Error); + Assert.Empty(result.Output.Trim()); + } + + private static async Task<(int ExitCode, string Output, string Error)> Calculate( + string baseVersion, string buildId, string reason, string branch, string pullRequest, + bool official, bool release) + { + var start = new ProcessStartInfo("pwsh") + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + foreach (var argument in new[] + { + "-NoLogo", "-NoProfile", "-NonInteractive", "-File", + Path.Combine(AppContext.BaseDirectory, "Pipeline", "Get-PackageVersion.ps1"), + "-BaseVersion", baseVersion, "-BuildId", buildId, + "-BuildReason", reason, "-SourceBranch", branch, + }) + { + start.ArgumentList.Add(argument); + } + if (pullRequest.Length > 0) + { + start.ArgumentList.Add("-PullRequestNumber"); + start.ArgumentList.Add(pullRequest); + } + if (official) { start.ArgumentList.Add("-Official"); } + if (release) { start.ArgumentList.Add("-ReleaseBuild"); } + + using var process = Process.Start(start) ?? throw new InvalidOperationException("Could not start PowerShell."); + var output = process.StandardOutput.ReadToEndAsync(); + var error = process.StandardError.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + throw new TimeoutException("Package version calculation did not finish."); + } + return (process.ExitCode, await output, await error); + } +} diff --git a/dotnet-package-skills/tests/SkillDescriptionReaderTests.cs b/dotnet-package-skills/tests/SkillDescriptionReaderTests.cs new file mode 100644 index 0000000..c764674 --- /dev/null +++ b/dotnet-package-skills/tests/SkillDescriptionReaderTests.cs @@ -0,0 +1,439 @@ +using System.Text; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +public class SkillDescriptionReaderTests +{ + [Theory] + [InlineData("description: Use widgets safely.", "Use widgets safely.")] + [InlineData("description: Use widgets. # Not part of the description", "Use widgets.")] + [InlineData("description: 'Use widgets: safely.'", "Use widgets: safely.")] + [InlineData("description: 'Use ''quoted'' names.'", "Use 'quoted' names.")] + [InlineData("description: \"Use \\\"quoted\\\" names and \\\\ paths.\"", "Use \"quoted\" names and \\ paths.")] + [InlineData("description: 'C:\\widgets\\skills'", "C:\\widgets\\skills")] + [InlineData("description: \"First\\nSecond\\t\\u263A\\U0001F680\"", "First\nSecond\t☺🚀")] + [InlineData("description: \" Keep surrounding spaces. \"", " Keep surrounding spaces. ")] + [InlineData("description: \"Keep\\e terminal\\r controls.\"", "Keep\u001b terminal\r controls.")] + [InlineData("description: First line\n continues here", "First line continues here")] + [InlineData("description: First paragraph\n\n second paragraph", "First paragraph\nsecond paragraph")] + [InlineData("description: \"First line\n continues here\"", "First line continues here")] + [InlineData("description: >\n First line\n continues here", "First line continues here\n")] + [InlineData("description: >-\n First paragraph\n continues here\n\n Second paragraph", "First paragraph continues here\nSecond paragraph")] + [InlineData("description: >+\n First line\n continues here\n", "First line continues here\n\n")] + [InlineData("description: >-\n First line\n indented line\n Last line", "First line\n indented line\nLast line")] + [InlineData("description: |\n First line\n Second line", "First line\nSecond line\n")] + [InlineData("description: |-\n First line\n Second line", "First line\nSecond line")] + [InlineData("description: |+\n First line\n Second line\n", "First line\nSecond line\n\n")] + [InlineData("description: |2-\n Indented line\n Last line", " Indented line\nLast line")] + [InlineData("description: |-\n ---\n ...\n description: Still scalar text", "---\n...\ndescription: Still scalar text")] + [InlineData("\"description\": A quoted key works.", "A quoted key works.")] + [InlineData("{description: 'A flow mapping works.', other: [one, two]}", "A flow mapping works.")] + [InlineData("description: 42", "42")] + [InlineData("description: true", "true")] + public void A_top_level_scalar_preserves_the_YAML_description_value(string yaml, string expected) + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, yaml); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Equal(expected, result.Description); + Assert.Null(result.Warning); + } + + [Theory] + [InlineData("")] + [InlineData("# Only a comment")] + [InlineData("{}")] + [InlineData("name: widget-usage")] + [InlineData("metadata:\n description: Nested descriptions are not the skill description.")] + [InlineData("items:\n - description: Nested descriptions are not the skill description.")] + [InlineData("notes: |\n description: This is other scalar text.")] + [InlineData("? [description]\n: This is a collection key, not the description key.")] + [InlineData("Description: Keys are case-sensitive.")] + [InlineData("description:")] + [InlineData("description: # Empty")] + [InlineData("description: ''")] + [InlineData("description: \" \"")] + [InlineData("description: \"\\n\\t\\r\"")] + [InlineData("description: |\n \n ")] + public void Missing_or_blank_descriptions_have_no_warning(string yaml) + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, yaml); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Null(result.Description); + Assert.Null(result.Warning); + } + + [Fact] + public void Nested_descriptions_do_not_replace_the_top_level_description() + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, """ + before: + description: Ignore this. + description: Use this description. + after: + - description: Ignore this too. + """); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Equal("Use this description.", result.Description); + Assert.Null(result.Warning); + } + + [Theory] + [InlineData("")] + [InlineData("# Just Markdown\n---\ndescription: Not frontmatter.\n---")] + [InlineData("\n---\ndescription: Not at the beginning.\n---")] + [InlineData(" ---\ndescription: Indented delimiters do not open frontmatter.\n---")] + [InlineData("---not-a-delimiter\ndescription: Not frontmatter.\n---")] + public void Files_without_frontmatter_have_no_description_or_warning(string source) + { + using var temp = new TempDirectory(); + temp.CreateFile("SKILL.md", source); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Null(result.Description); + Assert.Null(result.Warning); + } + + [Theory] + [InlineData("Markdown")] + [InlineData("---not-a-delimiter")] + public void A_huge_Markdown_first_line_is_not_mistaken_for_oversized_frontmatter(string prefix) + { + using var temp = new TempDirectory(); + temp.CreateFile("SKILL.md", prefix + new string('x', 128 * 1024)); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Null(result.Description); + Assert.Null(result.Warning); + } + + [Theory] + [InlineData(false, "\n")] + [InlineData(true, "\n")] + [InlineData(false, "\r\n")] + [InlineData(true, "\r\n")] + public void UTF8_BOM_and_line_endings_preserve_multiline_description_values(bool bom, string newline) + { + using var temp = new TempDirectory(); + var file = temp.CreateFile("SKILL.md"); + File.WriteAllText(file, $"---{newline}description: |{newline} First{newline} Second{newline}---{newline}", + new UTF8Encoding(encoderShouldEmitUTF8Identifier: bom)); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Equal("First\nSecond\n", result.Description); + Assert.Null(result.Warning); + } + + [Theory] + [InlineData("---")] + [InlineData("...")] + [InlineData("--- \t")] + [InlineData("... \t")] + public void Closing_delimiters_end_parsing_before_a_large_invalid_Markdown_body(string delimiter) + { + using var temp = new TempDirectory(); + temp.CreateFile("SKILL.md", $"--- \t\ndescription: Only the header.\n{delimiter}\n" + + "description: !invalid *alias\n" + new string('[', 128 * 1024)); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Equal("Only the header.", result.Description); + Assert.Null(result.Warning); + } + + [Theory] + [InlineData("---")] + [InlineData("...")] + public void A_closing_delimiter_does_not_require_a_final_newline(string delimiter) + { + using var temp = new TempDirectory(); + temp.CreateFile("SKILL.md", $"---\ndescription: Complete.\n{delimiter}"); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Equal("Complete.", result.Description); + Assert.Null(result.Warning); + } + + [Theory] + [InlineData("description: [unfinished")] + [InlineData("description: \"unfinished")] + [InlineData("description: Value\n unexpected: mapping")] + [InlineData("description: Valid first\nlater: {unfinished")] + [InlineData("name: No description\nlater: [unfinished")] + [InlineData("description: Valid first\nlater:\n\tinvalid: indentation")] + public void Malformed_YAML_warns_instead_of_returning_partial_metadata(string yaml) + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, yaml); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md has malformed YAML frontmatter; fix the header."); + } + + [Theory] + [InlineData("description: [one, two]")] + [InlineData("description: []")] + [InlineData("description: {}")] + [InlineData("description:\n nested: mapping")] + public void Collection_descriptions_warn_instead_of_being_converted_to_text(string yaml) + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, yaml); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md description must be a YAML scalar; replace the collection with text."); + } + + [Theory] + [InlineData("Just a scalar")] + [InlineData("''")] + [InlineData("[description, value]")] + [InlineData("- description: Inside a sequence")] + public void Non_mapping_frontmatter_explains_the_expected_shape(string yaml) + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, yaml); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md frontmatter must be a YAML mapping; use 'description: ...'."); + } + + [Fact] + public void Duplicate_top_level_descriptions_warn_instead_of_choosing_one() + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, "description: First\ndescription: Second"); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md has duplicate description keys; keep only one top-level description."); + } + + [Theory] + [InlineData("description: !!str Text")] + [InlineData("!!map {description: Text}")] + [InlineData("description: !custom Text")] + [InlineData("description: ! Text")] + [InlineData("description: Otherwise valid\nother: !custom value")] + public void Explicit_tags_are_rejected_without_type_deserialization(string yaml) + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, yaml); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md uses explicit YAML tags; remove the tags from its frontmatter."); + } + + [Theory] + [InlineData("description: &text Anchored text")] + [InlineData("description: *missing")] + [InlineData("&root {description: Text}")] + [InlineData("description: Otherwise valid\nother: *missing")] + [InlineData("a: &a [text]\nb: &b [*a, *a]\ndescription: *b")] + [InlineData("a: &a [*a]\ndescription: Otherwise valid")] + public void Anchors_and_aliases_are_rejected_without_expansion(string yaml) + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, yaml); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md uses YAML anchors or aliases; replace them with literal values."); + } + + [Fact] + public void The_nesting_limit_allows_32_collection_levels_including_the_root_mapping() + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, "other: " + new string('[', 31) + "leaf" + new string(']', 31) + + "\ndescription: Within the limit."); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Equal("Within the limit.", result.Description); + Assert.Null(result.Warning); + } + + [Theory] + [InlineData(32)] + [InlineData(5000)] + public void Excessive_nesting_warns_even_in_irrelevant_metadata(int levels) + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, "description: Valid first\nother: " + + new string('[', levels) + "leaf" + new string(']', levels)); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md frontmatter exceeds 32 levels of nesting; simplify the header."); + } + + [Fact] + public void The_character_limit_includes_delimiters_and_allows_exactly_64_Ki_characters() + { + using var temp = new TempDirectory(); + const string prefix = "---\ndescription: "; + const string suffix = "\n---"; + var description = new string('é', 64 * 1024 - prefix.Length - suffix.Length); + temp.CreateFile("SKILL.md", prefix + description + suffix); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Equal(description, result.Description); + Assert.Null(result.Warning); + } + + [Fact] + public void One_character_past_the_frontmatter_limit_warns() + { + using var temp = new TempDirectory(); + const string prefix = "---\ndescription: "; + const string suffix = "\n---"; + temp.CreateFile("SKILL.md", prefix + new string('x', 64 * 1024 + 1 - prefix.Length - suffix.Length) + suffix); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md frontmatter exceeds 64 KiB of text; shorten the header."); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void A_huge_single_header_line_cannot_bypass_the_read_limit(bool terminated) + { + using var temp = new TempDirectory(); + temp.CreateFile("SKILL.md", "---\ndescription: " + new string('x', 256 * 1024) + + (terminated ? "\n---\n" : "")); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md frontmatter exceeds 64 KiB of text; shorten the header."); + } + + [Fact] + public void Many_short_header_lines_share_one_read_limit() + { + using var temp = new TempDirectory(); + temp.CreateFile("SKILL.md", "---\n" + string.Concat(Enumerable.Repeat("# Notes\n", 10_000)) + "---\n"); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md frontmatter exceeds 64 KiB of text; shorten the header."); + } + + [Fact] + public void A_huge_opening_delimiter_line_cannot_bypass_the_read_limit() + { + using var temp = new TempDirectory(); + temp.CreateFile("SKILL.md", "---" + new string(' ', 64 * 1024)); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md frontmatter exceeds 64 KiB of text; shorten the header."); + } + + [Theory] + [InlineData("---")] + [InlineData("---\n")] + [InlineData("---\ndescription: Not closed.")] + [InlineData("---\ndescription: |-\n An indented delimiter is scalar content.\n ---\n")] + public void Unterminated_frontmatter_explains_how_to_close_it(string source) + { + using var temp = new TempDirectory(); + temp.CreateFile("SKILL.md", source); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md frontmatter has no closing delimiter; add a closing '---' line."); + } + + [Fact] + public void A_missing_SKILL_file_warns_without_searching_subdirectories() + { + using var temp = new TempDirectory(); + temp.CreateFile("nested\\SKILL.md", "---\ndescription: Do not discover this.\n---\n"); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md was not found; restore the file to read its description."); + } + + [Fact] + public void A_missing_skill_directory_warns() + { + using var temp = new TempDirectory(); + + AssertWarning(SkillDescriptionReader.Read(temp.Combine("missing")), + "SKILL.md was not found; restore the file to read its description."); + } + + [Fact] + public void An_unreadable_SKILL_path_warns() + { + using var temp = new TempDirectory(); + temp.CreateDirectory("SKILL.md"); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md could not be read; check file permissions."); + } + + [Fact] + public void A_locked_SKILL_file_warns_instead_of_throwing() + { + using var temp = new TempDirectory(); + var file = WriteFrontmatter(temp, "description: Locked."); + using var locked = new FileStream(file, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md could not be read; check the file path, permissions, and whether it is in use."); + } + + [Fact] + public void An_invalid_skill_directory_path_warns() + { + using var temp = new TempDirectory(); + + AssertWarning(SkillDescriptionReader.Read(temp.Path + '\0'), + "SKILL.md has an invalid path; check the skill directory path."); + } + + [Theory] + [InlineData("---\r\ndescription: |\r\n Keep original bytes.\r\n---\r\n# Body\r\n", "Keep original bytes.\n", null)] + [InlineData("# Only Markdown", null, null)] + [InlineData("---\ndescription: [broken\n---\n", null, "SKILL.md has malformed YAML frontmatter; fix the header.")] + [InlineData("---\ndescription: *alias\n---\n", null, "SKILL.md uses YAML anchors or aliases; replace them with literal values.")] + public void Reading_leaves_every_file_byte_unchanged(string source, string? description, string? warning) + { + using var temp = new TempDirectory(); + var file = temp.CreateFile("SKILL.md"); + File.WriteAllText(file, source, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); + var before = File.ReadAllBytes(file); + var attributes = File.GetAttributes(file); + File.SetAttributes(file, attributes | FileAttributes.ReadOnly); + try + { + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Equal(description, result.Description); + Assert.Equal(warning, result.Warning); + Assert.Equal(before, File.ReadAllBytes(file)); + } + finally + { + File.SetAttributes(file, attributes); + } + } + + private static string WriteFrontmatter(TempDirectory temp, string yaml) => + temp.CreateFile("SKILL.md", $"---\n{yaml}\n---\n"); + + private static void AssertWarning(SkillDescriptionResult result, string expected) + { + Assert.Null(result.Description); + Assert.Equal(expected, result.Warning); + } +} diff --git a/dotnet-package-skills/tests/SkillDiscoveryTests.cs b/dotnet-package-skills/tests/SkillDiscoveryTests.cs new file mode 100644 index 0000000..b6c09f2 --- /dev/null +++ b/dotnet-package-skills/tests/SkillDiscoveryTests.cs @@ -0,0 +1,122 @@ +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +public class SkillDiscoveryTests +{ + [Fact] + public void A_package_with_one_skill_uses_its_authored_folder_name_as_the_destination() + { + using var temp = new TempDirectory(); + var package = temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly-usage"); + + var skill = Assert.Single(SkillDiscovery.Discover(package, "Mockly", "1.10.0")); + + Assert.Equal("mockly-usage", skill.RelativePath); + } + + [Fact] + public void A_package_with_several_skills_keeps_each_authored_folder_name() + { + using var temp = new TempDirectory(); + var package = temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage", "widget-testing"); + + var skills = SkillDiscovery.Discover(package, "Contoso.Widgets", "2.3.0"); + + Assert.Equal( + ["widget-testing", "widget-usage"], + skills.Select(s => s.RelativePath)); + } + + [Fact] + public void Discover_finds_each_subdirectory_of_the_skills_folder() + { + using var temp = new TempDirectory(); + var package = temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage", "widget-testing"); + + var skills = SkillDiscovery.Discover(package, "Contoso.Widgets", "2.3.0"); + + Assert.Equal(["widget-testing", "widget-usage"], skills.Select(s => s.SkillName)); + } + + [Fact] + public void Discover_keeps_package_version_as_metadata_only() + { + using var temp = new TempDirectory(); + var package = temp.CreatePackageWithSkill("Widgets", "2.0", "usage"); + + var skill = Assert.Single(SkillDiscovery.Discover(package, "Widgets", "2.0")); + + Assert.Equal("2.0", skill.PackageVersion); + Assert.Equal("usage", skill.RelativePath); + } + + [Fact] + public void Discover_returns_nothing_when_the_package_ships_no_skills_folder() + { + using var temp = new TempDirectory(); + var package = temp.CreateDirectory("packages", "newtonsoft.json", "13.0.3"); + temp.CreateFile("packages/newtonsoft.json/13.0.3/lib/net8.0/Newtonsoft.Json.dll"); + + Assert.Empty(SkillDiscovery.Discover(package, "Newtonsoft.Json", "13.0.3")); + } + + [Fact] + public void Discover_ignores_a_skill_placed_directly_in_the_skills_folder() + { + using var temp = new TempDirectory(); + var package = temp.CreateDirectory("packages", "widgets", "1.0.0"); + temp.CreateFile("packages/widgets/1.0.0/skills/SKILL.md", "---\nname: widgets\n---\n"); + + Assert.Empty(SkillDiscovery.Discover(package, "Widgets", "1.0.0")); + } + + [Fact] + public void Discover_ignores_subdirectories_without_a_skill_manifest() + { + using var temp = new TempDirectory(); + var package = temp.CreateDirectory("packages", "widgets", "1.0.0"); + temp.CreateFile("packages/widgets/1.0.0/skills/notes/readme.md", "not a skill"); + + Assert.Empty(SkillDiscovery.Discover(package, "Widgets", "1.0.0")); + } + + [Fact] + public void Discover_matches_the_skills_folder_regardless_of_casing() + { + using var temp = new TempDirectory(); + var package = temp.CreateDirectory("packages", "widgets", "1.0.0"); + temp.CreateFile("packages/widgets/1.0.0/Skills/usage/SKILL.md", "---\n---\n"); + + Assert.Single(SkillDiscovery.Discover(package, "Widgets", "1.0.0")); + } + + [Fact] + public void Discover_returns_nothing_for_a_package_that_is_not_on_disk() + { + using var temp = new TempDirectory(); + + Assert.Empty(SkillDiscovery.Discover(temp.Combine("nope"), "Ghost", "1.0.0")); + } + + [Theory] + [InlineData("widget-usage", true)] + [InlineData("Widget.Usage_2", true)] + [InlineData(".hidden-skill", true)] + [InlineData("...usage", true)] + [InlineData("skill name", true)] + [InlineData("..", false)] + [InlineData(".", false)] + [InlineData("...", false)] + [InlineData("....", false)] + [InlineData(".. ", false)] + [InlineData("... ", false)] + [InlineData("skill.", false)] + [InlineData("skill ", false)] + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData("a/b", false)] + [InlineData("a\\b", false)] + public void Skill_names_that_could_escape_the_destination_are_rejected(string name, bool expected) => + Assert.Equal(expected, SkillDiscovery.IsSafeSkillName(name)); +} diff --git a/dotnet-package-skills/tests/SkillInstallServiceTests.cs b/dotnet-package-skills/tests/SkillInstallServiceTests.cs new file mode 100644 index 0000000..01bbc0a --- /dev/null +++ b/dotnet-package-skills/tests/SkillInstallServiceTests.cs @@ -0,0 +1,1082 @@ +using DotnetPackageSkills.Infrastructure; +using DotnetPackageSkills.NuGet; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +/// +/// Exercises the whole flow with the dotnet CLI stubbed out, so the wiring between +/// listing, path resolution, discovery, and installation is covered without a restore. +/// +public class SkillInstallServiceTests +{ + private sealed class FakeDotnet(string globalPackagesFolder, string listPackageJson) : IProcessRunner + { + public List Invocations { get; } = []; + + public ProcessResult Run(string fileName, IReadOnlyList arguments, string? workingDirectory = null) + { + var line = string.Join(' ', arguments); + Invocations.Add(line); + + if (arguments.Contains("locals")) + { + return new ProcessResult(0, $"global-packages: {globalPackagesFolder}", string.Empty); + } + + if (arguments.Contains("list")) + { + return new ProcessResult(0, listPackageJson, string.Empty); + } + + if (arguments.Contains("restore")) + { + return new ProcessResult(0, "Restore succeeded.", string.Empty); + } + + throw new InvalidOperationException($"Unexpected invocation: {line}"); + } + } + + private static string Json(params (string Id, string Version)[] packages) + { + var entries = packages.Select(p => $$"""{ "id": "{{p.Id}}", "resolvedVersion": "{{p.Version}}" }"""); + + return $$""" + { + "projects": [ + { + "frameworks": [ + { "framework": "net8.0", "topLevelPackages": [ {{string.Join(",", entries)}} ] } + ] + } + ] + } + """; + } + + private static InstallRequest Request(TempDirectory temp) => new() + { + Destination = ".agents/skills", + WorkingDirectory = temp.Path, + GlobalPackagesOverride = temp.Combine("packages"), + }; + + [Fact] + public void Install_copies_skills_from_packages_that_ship_them_and_ignores_the_rest() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreateDirectory("packages", "newtonsoft.json", "13.0.3"); + + var runner = new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0"), ("Newtonsoft.Json", "13.0.3"))); + var result = new SkillInstallService(runner).Install(Request(temp)); + + Assert.Equal(2, result.PackagesScanned); + Assert.Equal("mockly", Assert.Single(result.Skills).RelativePath); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "mockly", "SKILL.md"))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Target_install_rejects_incomplete_discovery_before_changing_skills(bool dryRun) + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Existing", "1.0.0", "existing"); + var initial = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Existing", "1.0.0")))); + initial.Install(Request(temp)); + var manifestPath = temp.Combine(".agents", "skills", InstallManifest.FileName); + var before = File.ReadAllBytes(manifestPath); + + var runner = new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0"))); + var error = Assert.Throws(() => + new SkillInstallService(runner).Install(Request(temp) with { DryRun = dryRun })); + + Assert.Contains("Mockly 1.10.0", error.Message); + Assert.Contains("restore", error.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(before, File.ReadAllBytes(manifestPath)); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "existing", "SKILL.md"))); + } + + [Fact] + public void Discovery_treats_a_package_missing_from_the_cache_as_one_without_skills() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreateDirectory("packages"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Missing", "1.0.0")))); + + var result = service.Discover(Request(temp)); + + Assert.Equal(1, result.PackagesScanned); + Assert.Empty(result.Skills); + Assert.Empty(result.Skipped); + Assert.False(Directory.Exists(result.Destination)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Missing_target_packages_also_block_refreshing_available_skills(bool interactive) + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + var package = temp.CreatePackageWithSkill("Present", "1.0.0", "present"); + new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Present", "1.0.0")))) + .Install(Request(temp)); + var destination = temp.Combine(".agents", "skills"); + var previous = File.ReadAllBytes(Path.Combine(destination, "present", "SKILL.md")); + var manifest = File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName)); + File.WriteAllText(Path.Combine(package, "skills", "present", "SKILL.md"), "new source content"); + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Present", "1.0.0"), ("Missing", "2.0.0")))); + var discovered = service.Discover(Request(temp)); + + Assert.Throws(() => + { + if (interactive) + { + service.PrepareInteractiveInstall( + Request(temp), discovered, SkillInstallService.InstalledSkills(destination, temp.Path)); + } + else + { + service.Install(Request(temp), discovered, null); + } + }); + + Assert.Equal(previous, File.ReadAllBytes(Path.Combine(destination, "present", "SKILL.md"))); + Assert.Equal(manifest, File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName))); + } + + [Fact] + public void Preparing_a_picker_excludes_conflicting_candidates_without_losing_the_warning() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Alpha", "1.0.0", "shared"); + temp.CreatePackageWithSkill("Beta", "2.0.0", "shared"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + service.Install(Request(temp) with { Packages = [PackageCoordinate.Parse("Alpha@1.0.0")] }); + var request = Request(temp) with { Packages = [PackageCoordinate.Parse("Beta@2.0.0")] }; + var discovered = service.Discover(request); + var installed = SkillInstallService.InstalledSkills(discovered.Destination, temp.Path); + + var prepared = service.PrepareInteractiveInstall(request, discovered, installed); + var result = service.Install(request, prepared, + new SkillChoice([]) { ExpectedInstalled = installed }); + + Assert.Empty(prepared.Skills); + Assert.Contains("managed for alpha", Assert.Single(result.Skipped).Reason); + Assert.Empty(result.Removed); + Assert.False(result.DryRun); + Assert.Equal("alpha", Assert.Single(InstallManifest.Load(result.Destination).Packages).Key); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void An_interactive_target_install_stops_while_installed_skills_are_stale(bool versionChanged) + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly", "mockly-testing"); + temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage"); + new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Mockly", "1.10.0"), ("Contoso.Widgets", "2.3.0")))).Install(Request(temp)); + var destination = temp.Combine(".agents", "skills"); + var before = Snapshot(destination); + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), + versionChanged + ? Json(("Mockly", "1.11.0"), ("Contoso.Widgets", "2.3.0")) + : Json(("Mockly", "1.10.0")))); + var discovered = service.Discover(Request(temp)); + + var error = Assert.Throws(() => service.PrepareInteractiveInstall( + Request(temp), discovered, SkillInstallService.InstalledSkills(destination, temp.Path))); + + Assert.Contains( + versionChanged + ? "1 installed skill doesn't match the target: mockly (mockly 1.10.0)" + : "1 installed skill doesn't match the target: widget-usage (contoso.widgets 2.3.0)", + error.Message); + Assert.Contains("uninstall --stale", error.Message); + Assert.Contains("No skills were changed", error.Message); + Assert.Equal(before, Snapshot(destination)); + } + + [Fact] + public void An_interactive_install_offers_only_the_skills_that_are_not_installed() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly-usage", "mockly-testing"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))); + var first = service.Discover(Request(temp)); + service.Install(Request(temp), first, new SkillChoice([.. first.Skills.Where(skill => skill.SkillName == "mockly-usage")])); + var destination = temp.Combine(".agents", "skills"); + var installed = SkillInstallService.InstalledSkills(destination, temp.Path); + + var prepared = service.PrepareInteractiveInstall(Request(temp), service.Discover(Request(temp)), installed); + + Assert.Equal("mockly-testing", Assert.Single(prepared.Skills).SkillName); + Assert.Empty(prepared.Removed); + Assert.Empty(prepared.Unreferenced); + Assert.False(Directory.Exists(Path.Combine(destination, "mockly-testing"))); + } + + [Fact] + public void An_interactive_install_with_nothing_new_offers_an_empty_list() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))); + service.Install(Request(temp)); + var installed = SkillInstallService.InstalledSkills(temp.Combine(".agents", "skills"), temp.Path); + + var prepared = service.PrepareInteractiveInstall(Request(temp), service.Discover(Request(temp)), installed); + + Assert.Empty(prepared.Skills); + Assert.Equal(1, prepared.SkillsDiscovered); + Assert.Empty(prepared.Skipped); + } + + [Fact] + public void An_interactive_install_of_a_named_package_stops_when_another_version_is_installed() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly", "mockly-testing"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + service.Install(Request(temp) with { Packages = [PackageCoordinate.Parse("Mockly@1.10.0")] }); + var destination = temp.Combine(".agents", "skills"); + var before = Snapshot(destination); + var request = Request(temp) with { Packages = [PackageCoordinate.Parse("Mockly@1.11.0")] }; + + var error = Assert.Throws(() => service.PrepareInteractiveInstall( + request, service.Discover(request), SkillInstallService.InstalledSkills(destination, temp.Path))); + + Assert.Contains("Mockly 1.10.0 is already installed", error.Message); + Assert.Contains("'dotnet-package-skills uninstall --package Mockly'", error.Message); + Assert.Equal(before, Snapshot(destination)); + } + + [Theory] + [InlineData(".agents/skills", null, "--stale", true, "dotnet-package-skills uninstall --stale")] + [InlineData(".agents/skills/", null, "--stale", true, "dotnet-package-skills uninstall --stale")] + [InlineData(".claude/skills", null, "--stale", true, "dotnet-package-skills uninstall --stale --destination .claude/skills")] + [InlineData( + "my skills", "src/My App.slnx", "--stale", true, + "dotnet-package-skills uninstall --stale --target \"src/My App.slnx\" --destination \"my skills\"")] + [InlineData( + ".claude/skills", "src/App.slnx", "--package Mockly", false, + "dotnet-package-skills uninstall --package Mockly --destination .claude/skills")] + [InlineData( + @"C:\src\skills", null, "--stale", true, + "dotnet-package-skills uninstall --stale --destination \"C:\\src\\skills\"")] + public void Suggested_commands_repeat_the_target_and_destination_that_were_used( + string destination, string? target, string arguments, bool withTarget, string expected) + { + // A suggestion is only useful if running it as printed acts on the same skills folder, + // compared against the same project. + using var temp = new TempDirectory(); + var request = Request(temp) with { Destination = destination, Target = target }; + + Assert.Equal(expected, SkillInstallService.UninstallCommand(request, arguments, withTarget)); + } + + [Fact] + public void A_suggested_command_leaves_out_a_destination_that_is_the_default_spelled_in_full() + { + using var temp = new TempDirectory(); + var request = Request(temp) with { Destination = temp.Combine(".agents", "skills") }; + + Assert.Equal("dotnet-package-skills uninstall --stale", SkillInstallService.UninstallCommand(request, "--stale")); + } + + [Fact] + public void The_stale_hint_and_the_stale_stop_name_the_destination_that_was_used() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage"); + var request = Request(temp) with { Destination = ".claude/skills" }; + new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Mockly", "1.10.0"), ("Contoso.Widgets", "2.3.0")))).Install(request); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))); + + var result = service.Install(request); + var error = Assert.Throws(() => service.PrepareInteractiveInstall( + request, service.Discover(request), SkillInstallService.InstalledSkills(".claude/skills", temp.Path))); + + const string Command = "dotnet-package-skills uninstall --stale --destination .claude/skills"; + Assert.Equal(Command, result.StaleCommand); + Assert.Contains($"Run '{Command}' first", error.Message); + } + + [Fact] + public void The_other_version_stop_names_the_destination_that_was_used() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + var request = Request(temp) with { Destination = ".claude/skills" }; + service.Install(request with { Packages = [PackageCoordinate.Parse("Mockly@1.10.0")] }); + var upgrade = request with { Packages = [PackageCoordinate.Parse("Mockly@1.11.0")] }; + + var error = Assert.Throws(() => service.PrepareInteractiveInstall( + upgrade, service.Discover(upgrade), SkillInstallService.InstalledSkills(".claude/skills", temp.Path))); + + Assert.Contains("'dotnet-package-skills uninstall --package Mockly --destination .claude/skills' first", error.Message); + } + + [Fact] + public void A_version_change_that_would_hand_a_skill_to_another_package_stops_with_a_command_for_this_destination() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Alpha", "1.0.0", "alpha-usage", "shared"); + temp.CreatePackageWithSkill("Alpha", "2.0.0", "alpha-usage"); + temp.CreatePackageWithSkill("Beta", "1.0.0", "shared"); + var request = Request(temp) with { Destination = ".claude/skills" }; + new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Alpha", "1.0.0")))).Install(request); + var destination = temp.Combine(".claude", "skills"); + var before = Snapshot(destination); + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Alpha", "2.0.0"), ("Beta", "1.0.0")))); + + var error = Assert.Throws(() => service.Install(request)); + + Assert.Contains("Alpha 2.0.0 no longer ships the installed skill 'shared'", error.Message); + Assert.Contains("'dotnet-package-skills uninstall --package Alpha --destination .claude/skills' first", error.Message); + Assert.Equal(before, Snapshot(destination)); + } + + [Fact] + public void An_interactive_install_of_a_named_package_ignores_other_installed_packages() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + service.Install(Request(temp) with { Packages = [PackageCoordinate.Parse("Contoso.Widgets@2.3.0")] }); + var request = Request(temp) with { Packages = [PackageCoordinate.Parse("Mockly@1.10.0")] }; + var installed = SkillInstallService.InstalledSkills(temp.Combine(".agents", "skills"), temp.Path); + + var prepared = service.PrepareInteractiveInstall(request, service.Discover(request), installed); + + Assert.Equal("mockly", Assert.Single(prepared.Skills).SkillName); + } + + [Fact] + public void A_package_that_disappears_after_the_preview_also_blocks_acceptance() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Present", "1.0.0", "present"); + var emptyPackage = temp.CreateDirectory("packages", "empty", "1.0.0"); + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Present", "1.0.0"), ("Empty", "1.0.0")))); + var discovered = service.Discover(Request(temp)); + var prepared = service.PrepareInteractiveInstall(Request(temp), discovered, []); + Directory.Delete(emptyPackage); + + var error = Assert.Throws(() => + service.Install(Request(temp), prepared, new SkillChoice(prepared.Skills))); + + Assert.Contains("Empty 1.0.0", error.Message); + Assert.False(Directory.Exists(prepared.Destination)); + } + + [Fact] + public void Installation_prefers_the_current_owners_upgrade_over_an_earlier_named_collision() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Zeta", "1.0.0", "shared"); + new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Zeta", "1.0.0")))) + .Install(Request(temp)); + temp.CreatePackageWithSkill("Alpha", "1.0.0", "shared"); + var newer = temp.CreatePackageWithSkill("Zeta", "2.0.0", "shared"); + File.WriteAllText(Path.Combine(newer, "skills", "shared", "SKILL.md"), "updated owner"); + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Alpha", "1.0.0"), ("Zeta", "2.0.0")))); + + var discovered = service.Discover(Request(temp)); + Assert.Equal("Alpha", Assert.Single(discovered.Skills).PackageId); + var result = service.Install(Request(temp), discovered, choice: null); + + Assert.Equal("Zeta", Assert.Single(result.Skills).PackageId); + Assert.Equal("Alpha", Assert.Single(result.Skipped).PackageId); + Assert.Empty(result.Removed); + Assert.Equal("updated owner", File.ReadAllText(Path.Combine(result.Destination, "shared", "SKILL.md"))); + Assert.Equal("2.0.0", Assert.Single(InstallManifest.Load(result.Destination).Packages).Value.Version); + } + + [Fact] + public void Repeating_equivalent_package_coordinates_does_not_create_self_collisions() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Alpha", "1.0.0", "alpha"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + + var result = service.Install(Request(temp) with + { + Packages = [ + PackageCoordinate.Parse("Alpha@1.0"), + PackageCoordinate.Parse("alpha@1.0.0"), + PackageCoordinate.Parse("Alpha@1.0.0.0"), + ], + }); + + Assert.Equal(1, result.PackagesScanned); + Assert.Single(result.Skills); + Assert.Empty(result.Skipped); + } + + [Fact] + public void Install_auto_detects_the_solution_when_no_target_is_given() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreateDirectory("packages"); + + var runner = new FakeDotnet(temp.Combine("packages"), Json()); + var result = new SkillInstallService(runner).Install(Request(temp)); + + Assert.EndsWith("MyApp.sln", result.Target); + } + + [Fact] + public void Install_honours_a_custom_destination() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + + var runner = new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0"))); + new SkillInstallService(runner).Install(Request(temp) with { Destination = ".claude/skills" }); + + Assert.True(File.Exists(temp.Combine(".claude", "skills", "mockly", "SKILL.md"))); + } + + [Fact] + public void Discover_does_not_write_to_the_destination() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + + var runner = new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0"))); + var result = new SkillInstallService(runner).Discover(Request(temp)); + + Assert.Single(result.Skills); + Assert.False(Directory.Exists(temp.Combine(".agents"))); + } + + [Fact] + public void Install_never_requests_transitive_packages() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreateDirectory("packages"); + + var runner = new FakeDotnet(temp.Combine("packages"), Json()); + new SkillInstallService(runner).Install(Request(temp)); + + Assert.DoesNotContain(runner.Invocations, line => line.Contains("--include-transitive")); + } + + [Fact] + public void Install_passes_the_target_before_the_package_verb() + { + // `dotnet list package` is the required order; the reverse silently + // lists the packages of whatever project is in the current directory instead. + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreateDirectory("packages"); + + var runner = new FakeDotnet(temp.Combine("packages"), Json()); + new SkillInstallService(runner).Install(Request(temp)); + + var listCall = Assert.Single(runner.Invocations, line => line.StartsWith("list", StringComparison.Ordinal)); + Assert.Matches(@"^list .*MyApp\.sln package ", listCall); + } + + [Fact] + public void An_upgrade_replaces_the_previous_version_end_to_end() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly"); + + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))); + service.Install(Request(temp)); + + var upgraded = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.11.0")))); + var result = upgraded.Install(Request(temp)); + + Assert.Empty(result.Removed); + Assert.True(Directory.Exists(temp.Combine(".agents", "skills", "mockly"))); + Assert.Equal( + "1.11.0", + Assert.Single(InstallManifest.Load(temp.Combine(".agents", "skills")).Packages).Value.Version); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void A_solution_whose_projects_disagree_on_a_version_stops_every_install_mode(bool interactive, bool dryRun) + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly"); + new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))) + .Install(Request(temp)); + var destination = temp.Combine(".agents", "skills"); + var before = Snapshot(destination); + + const string json = """ + { + "projects": [ + { + "path": "/repo/src/Api/Api.csproj", + "frameworks": [ + { "framework": "net8.0", "topLevelPackages": [ { "id": "Mockly", "resolvedVersion": "1.10.0" } ] } + ] + }, + { + "path": "/repo/src/Worker/Worker.csproj", + "frameworks": [ + { "framework": "net8.0", "topLevelPackages": [ { "id": "Mockly", "resolvedVersion": "1.11.0" } ] } + ] + } + ] + } + """; + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), json)); + var request = Request(temp) with { DryRun = dryRun }; + + var error = Assert.Throws(() => + { + if (interactive) + { + service.PrepareInteractiveInstall( + request, service.Discover(request), SkillInstallService.InstalledSkills(destination, temp.Path)); + } + else + { + service.Install(request); + } + }); + + Assert.Contains("Mockly (1.10.0, 1.11.0)", error.Message); + Assert.Contains("Central Package Management", error.Message); + Assert.Contains("No skills were changed", error.Message); + Assert.Equal(before, Snapshot(destination)); + } + + [Fact] + public void Two_versions_of_a_package_that_ships_no_skills_also_stop_the_install() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreateDirectory("packages", "newtonsoft.json", "12.0.3"); + temp.CreateDirectory("packages", "newtonsoft.json", "13.0.3"); + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), + Json(("Mockly", "1.10.0"), ("Newtonsoft.Json", "12.0.3"), ("Newtonsoft.Json", "13.0.3")))); + + var error = Assert.Throws(() => service.Install(Request(temp))); + + Assert.Contains("Newtonsoft.Json (12.0.3, 13.0.3)", error.Message); + Assert.DoesNotContain("Mockly", error.Message); + Assert.False(Directory.Exists(temp.Combine(".agents"))); + } + + [Fact] + public void List_still_shows_what_each_version_ships_when_a_package_has_two() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly", "mockly-testing"); + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Mockly", "1.10.0"), ("Mockly", "1.11.0")))); + + var result = service.Discover(Request(temp)); + + Assert.Equal(["mockly", "mockly-testing"], result.Skills.Select(skill => skill.SkillName)); + Assert.Equal("1.11.0", Assert.Single(result.Skipped).PackageVersion); + Assert.False(Directory.Exists(temp.Combine(".agents"))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Naming_one_package_with_two_versions_stops_the_install(bool interactive) + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly-testing"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + var request = Request(temp) with + { + Packages = [PackageCoordinate.Parse("Mockly@1.10.0"), PackageCoordinate.Parse("mockly@1.11")], + }; + + var error = Assert.Throws(() => + { + if (interactive) + { + service.PrepareInteractiveInstall(request, service.Discover(request), []); + } + else + { + service.Install(request); + } + }); + + Assert.Contains("Mockly (1.10.0, 1.11)", error.Message); + Assert.Contains("one version per package", error.Message); + Assert.Contains("No skills were changed", error.Message); + Assert.False(Directory.Exists(temp.Combine(".agents"))); + } + + [Fact] + public void Skills_from_different_packages_that_share_a_name_keep_the_first_and_warn() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Alpha.Widgets", "1.0.0", "shared-skill"); + temp.CreatePackageWithSkill("Beta.Widgets", "1.0.0", "SHARED-SKILL"); + + var runner = new FakeDotnet( + temp.Combine("packages"), + Json(("Beta.Widgets", "1.0.0"), ("Alpha.Widgets", "1.0.0"))); + var result = new SkillInstallService(runner).Install(Request(temp)); + + Assert.Equal("Alpha.Widgets", Assert.Single(result.Skills).PackageId); + Assert.Equal("Beta.Widgets", Assert.Single(result.Skipped).PackageId); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "shared-skill", "SKILL.md"))); + } + + [Fact] + public void Install_takes_skills_from_an_explicitly_named_package_without_a_project() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + + // No solution or project exists in the temp directory at all. + var runner = new FakeDotnet(temp.Combine("packages"), Json()); + var result = new SkillInstallService(runner).Install( + Request(temp) with { Packages = [PackageCoordinate.Parse("Mockly@1.10.0")] }); + + Assert.Null(result.Target); + Assert.Equal("mockly", Assert.Single(result.Skills).RelativePath); + Assert.DoesNotContain(runner.Invocations, line => line.StartsWith("list", StringComparison.Ordinal)); + } + + [Fact] + public void Naming_a_package_explicitly_does_not_prune_skills_installed_from_a_project() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage"); + + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))); + service.Install(Request(temp)); + + // Naming one package says nothing about the others, so this must be additive. + var result = service.Install( + Request(temp) with { Packages = [PackageCoordinate.Parse("Contoso.Widgets@2.3.0")] }); + + Assert.Empty(result.Removed); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "mockly", "SKILL.md"))); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "widget-usage", "SKILL.md"))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void A_target_install_keeps_skills_whose_package_left_the_project_and_reports_them(bool dryRun) + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage"); + new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Mockly", "1.10.0"), ("Contoso.Widgets", "2.3.0")))).Install(Request(temp)); + var destination = temp.Combine(".agents", "skills"); + var before = Snapshot(destination); + + var result = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))) + .Install(Request(temp) with { DryRun = dryRun }); + + Assert.Empty(result.Removed); + Assert.Equal(new TrackedSkill("contoso.widgets", "2.3.0", "widget-usage"), Assert.Single(result.Unreferenced)); + Assert.True(File.Exists(Path.Combine(destination, "widget-usage", "SKILL.md"))); + Assert.Equal(before, Snapshot(destination)); + } + + [Fact] + public void A_target_upgrade_removes_the_skills_the_new_version_dropped() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly-usage", "mockly-migration"); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly-usage"); + new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))) + .Install(Request(temp)); + + var result = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.11.0")))) + .Install(Request(temp)); + + Assert.Equal("mockly-usage", Assert.Single(result.Skills).SkillName); + Assert.Equal(new TrackedSkill("mockly", "1.10.0", "mockly-migration"), Assert.Single(result.Removed)); + Assert.Empty(result.Unreferenced); + Assert.False(Directory.Exists(temp.Combine(".agents", "skills", "mockly-migration"))); + Assert.Equal("1.11.0", InstallManifest.Load(result.Destination).Packages["mockly"].Version); + } + + [Fact] + public void Naming_a_newer_version_upgrades_that_package_and_leaves_the_others_alone() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly-usage", "mockly-migration"); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly-usage"); + temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage"); + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Mockly", "1.10.0"), ("Contoso.Widgets", "2.3.0")))); + service.Install(Request(temp)); + + var result = service.Install(Request(temp) with { Packages = [PackageCoordinate.Parse("Mockly@1.11.0")] }); + + Assert.Equal("mockly-migration", Assert.Single(result.Removed).Skill); + Assert.Empty(result.Unreferenced); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "widget-usage", "SKILL.md"))); + var manifest = InstallManifest.Load(result.Destination); + Assert.Equal("1.11.0", manifest.Packages["mockly"].Version); + Assert.Equal("2.3.0", manifest.Packages["contoso.widgets"].Version); + } + + [Fact] + public void A_named_version_missing_from_the_cache_never_removes_anything() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly-usage", "mockly-migration"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + service.Install(Request(temp) with { Packages = [PackageCoordinate.Parse("Mockly@1.10.0")] }); + var destination = temp.Combine(".agents", "skills"); + var before = Snapshot(destination); + + var result = service.Install(Request(temp) with { Packages = [PackageCoordinate.Parse("Mockly@1.11.0")] }); + + Assert.Empty(result.Skills); + Assert.Empty(result.Removed); + Assert.Equal(before, Snapshot(destination)); + } + + [Fact] + public void An_explicitly_named_package_missing_from_the_cache_installs_nothing_without_an_error() + { + using var temp = new TempDirectory(); + temp.CreateDirectory("packages"); + + var runner = new FakeDotnet(temp.Combine("packages"), Json()); + var result = new SkillInstallService(runner).Install( + Request(temp) with { Packages = [PackageCoordinate.Parse("Mockly@9.9.9")] }); + + Assert.Equal(1, result.PackagesScanned); + Assert.Empty(result.Skills); + Assert.Empty(result.Skipped); + Assert.False(Directory.Exists(result.Destination)); + } + + [Fact] + public void A_selection_can_take_only_some_of_one_packages_skills() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill( + "Mockly", + "1.10.0", + "mockly-assertions", + "mockly-testing", + "mockly-usage", + "mockly-verification"); + + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + var request = Request(temp) with { Packages = [PackageCoordinate.Parse("Mockly@1.10.0")] }; + + var discovered = service.Discover(request); + Assert.Equal(4, discovered.Skills.Count); + + var chosen = discovered.Skills + .Where(skill => skill.RelativePath is "mockly-assertions" or "mockly-usage") + .ToList(); + + var result = service.Install(request, discovered, new SkillChoice(chosen)); + + Assert.Equal( + ["mockly-assertions", "mockly-usage"], + result.Skills.Select(skill => skill.RelativePath)); + Assert.False(Directory.Exists(temp.Combine(".agents", "skills", "mockly-testing"))); + Assert.False(Directory.Exists(temp.Combine(".agents", "skills", "mockly-verification"))); + } + + [Fact] + public void A_selection_installs_only_the_skills_that_were_chosen() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage"); + + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), + Json(("Mockly", "1.10.0"), ("Contoso.Widgets", "2.3.0")))); + + var request = Request(temp); + var discovered = service.Discover(request); + var chosen = discovered.Skills.Where(skill => skill.RelativePath == "mockly").ToList(); + + var result = service.Install(request, discovered, new SkillChoice(chosen)); + + Assert.Equal("mockly", Assert.Single(result.Skills).RelativePath); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "mockly", "SKILL.md"))); + Assert.False(Directory.Exists(temp.Combine(".agents", "skills", "widget-usage"))); + } + + [Fact] + public void A_selection_never_removes_an_installed_skill_it_does_not_mention() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage"); + + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + var request = Request(temp) with + { + Packages = + [ + PackageCoordinate.Parse("Mockly@1.10.0"), + PackageCoordinate.Parse("Contoso.Widgets@2.3.0"), + ], + }; + + service.Install(request); + + var discovered = service.Discover(request); + var keep = discovered.Skills.Where(skill => skill.RelativePath == "mockly").ToList(); + + var result = service.Install(request, discovered, new SkillChoice(keep)); + + Assert.Empty(result.Removed); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "widget-usage", "SKILL.md"))); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "mockly", "SKILL.md"))); + } + + [Fact] + public void A_selection_in_a_dry_run_writes_nothing() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))); + var request = Request(temp) with { DryRun = true }; + var discovered = service.Discover(request); + + var result = service.Install(request, discovered, new SkillChoice(discovered.Skills)); + + Assert.Single(result.Skills); + Assert.False(Directory.Exists(temp.Combine(".agents", "skills"))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void A_selection_never_removes_skills_the_packages_no_longer_offer(bool noCandidates) + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "current", "stale"); + var initial = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))); + initial.Install(Request(temp)); + var service = noCandidates + ? new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())) + : initial; + if (!noCandidates) + { + Directory.Delete(temp.Combine("packages", "mockly", "1.10.0", "skills", "stale"), recursive: true); + } + + var discovered = service.Discover(Request(temp)); + var result = service.Install(Request(temp), discovered, new SkillChoice(discovered.Skills)); + + Assert.Empty(result.Removed); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "current", "SKILL.md"))); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "stale", "SKILL.md"))); + Assert.Equal(2, InstallManifest.Load(result.Destination).EnumerateSkills().Count()); + } + + [Fact] + public void Installed_skill_names_are_read_from_the_destination_manifest() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))); + service.Install(Request(temp)); + + var installed = SkillInstallService.InstalledSkillNames(temp.Combine(".agents", "skills")); + + Assert.Contains("mockly", installed); + // Destination names compare case-insensitively everywhere else, so they must here too. + Assert.Contains("MOCKLY", installed); + } + + [Fact] + public void Installed_skill_names_are_empty_for_a_destination_that_does_not_exist_yet() + { + using var temp = new TempDirectory(); + + Assert.Empty(SkillInstallService.InstalledSkillNames(temp.Combine("nowhere"))); + } + + [Fact] + public void Uninstall_version_filter_leaves_another_version_installed() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly"); + + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + service.Install(Request(temp) with + { + Packages = [PackageCoordinate.Parse("Mockly@1.11.0")], + }); + + service.Uninstall(".agents/skills", temp.Path, "Mockly", "1.10.0", dryRun: false); + + Assert.True(Directory.Exists(temp.Combine(".agents", "skills", "mockly"))); + } + + [Fact] + public void Uninstall_reverses_an_install() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))); + service.Install(Request(temp)); + + var removed = service.Uninstall(".agents/skills", temp.Path, packageId: null, packageVersion: null, dryRun: false); + + Assert.Single(removed); + Assert.False(Directory.Exists(temp.Combine(".agents", "skills"))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Uninstall_stale_removes_skills_whose_package_left_the_target_or_changed_version(bool dryRun) + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage"); + temp.CreatePackageWithSkill("Alpha", "1.0.0", "alpha"); + new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), + Json(("Mockly", "1.10.0"), ("Contoso.Widgets", "2.3.0"), ("Alpha", "1.0.0")))).Install(Request(temp)); + temp.CreateFile(".agents/skills/team-notes/SKILL.md", "ours"); + var destination = temp.Combine(".agents", "skills"); + var before = Snapshot(destination); + // The cache is gone too: deciding what is stale needs only the references. + Directory.Delete(temp.Combine("packages"), recursive: true); + var runner = new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.11.0"), ("Alpha", "1.0.0"))); + var service = new SkillInstallService(runner); + + var references = service.ReadReferences(target: null, temp.Path); + var removed = service.Uninstall( + ".agents/skills", temp.Path, packageId: null, packageVersion: null, dryRun, staleAgainst: references.Packages); + + Assert.EndsWith("MyApp.sln", references.Target); + Assert.Equal( + [ + new TrackedSkill("mockly", "1.10.0", "mockly"), + new TrackedSkill("contoso.widgets", "2.3.0", "widget-usage"), + ], + removed); + Assert.DoesNotContain(runner.Invocations, line => line.Contains("locals", StringComparison.Ordinal)); + if (dryRun) + { + Assert.Equal(before, Snapshot(destination)); + } + else + { + Assert.False(Directory.Exists(Path.Combine(destination, "mockly"))); + Assert.False(Directory.Exists(Path.Combine(destination, "widget-usage"))); + Assert.True(File.Exists(Path.Combine(destination, "alpha", "SKILL.md"))); + Assert.Equal("ours", File.ReadAllText(Path.Combine(destination, "team-notes", "SKILL.md"))); + Assert.Equal("alpha", Assert.Single(InstallManifest.Load(destination).Packages).Key); + } + } + + [Fact] + public void Uninstall_stale_keeps_a_skill_whose_installed_version_is_still_referenced_beside_another() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))) + .Install(Request(temp)); + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Mockly", "1.10.0"), ("Mockly", "1.11.0")))); + + var references = service.ReadReferences(target: null, temp.Path); + var removed = service.Uninstall( + ".agents/skills", temp.Path, null, null, dryRun: false, staleAgainst: references.Packages); + + Assert.Empty(removed); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "mockly", "SKILL.md"))); + } + + [Fact] + public void Deciding_what_is_stale_requires_a_solution_or_project() + { + using var temp = new TempDirectory(); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + + var error = Assert.Throws(() => + service.ReadReferences(target: null, temp.Path)); + + Assert.Contains("--target", error.Message); + } + + private static (string Path, string Contents)[] Snapshot(string root) => + Directory.Exists(root) + ? + [ + .. Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories) + .OrderBy(path => path, StringComparer.Ordinal) + .Select(path => (Path.GetRelativePath(root, path), Convert.ToHexString(File.ReadAllBytes(path)))), + ] + : []; +} diff --git a/dotnet-package-skills/tests/SkillInstallerTests.cs b/dotnet-package-skills/tests/SkillInstallerTests.cs new file mode 100644 index 0000000..bab35a0 --- /dev/null +++ b/dotnet-package-skills/tests/SkillInstallerTests.cs @@ -0,0 +1,943 @@ +using DotnetPackageSkills.NuGet; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +public class SkillInstallerTests +{ + private readonly SkillInstaller _installer = new(); + + private static BundledSkill Skill( + TempDirectory temp, + string packageId, + string version, + string skillName) + { + var packageDirectory = temp.CreatePackageWithSkill(packageId, version, skillName); + + return new BundledSkill( + packageId, + version, + skillName, + Path.Combine(packageDirectory, "skills", skillName), + skillName); + } + + [Fact] + public void Install_copies_a_skill_to_its_authored_folder_name() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + + Assert.True(File.Exists(Path.Combine(destination, "mockly", "SKILL.md"))); + } + + [Fact] + public void Install_copies_nested_files_such_as_references() + { + using var temp = new TempDirectory(); + var skill = Skill(temp, "Contoso.Widgets", "2.3.0", "widget-usage"); + temp.CreateFile("packages/contoso.widgets/2.3.0/skills/widget-usage/references/batching.md", "rules"); + + _installer.Install(temp.Combine("dest"), [skill], dryRun: false); + + Assert.Equal( + "rules", + File.ReadAllText(temp.Combine("dest", "widget-usage", "references", "batching.md"))); + } + + [Fact] + public void Manifest_groups_skill_names_by_package_and_version() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + _installer.Install( + destination, + [ + Skill(temp, "Contoso.Widgets", "2.3.0", "contoso.widgets-widget-testing"), + Skill(temp, "Contoso.Widgets", "2.3.0", "contoso.widgets-widget-usage"), + Skill(temp, "Mockly", "1.10.0", "mockly"), + ], + dryRun: false); + + var manifest = InstallManifest.Load(destination); + Assert.Equal( + ["contoso.widgets-widget-testing", "contoso.widgets-widget-usage"], + manifest.Packages["contoso.widgets"].Skills); + + var json = File.ReadAllText(Path.Combine(destination, InstallManifest.FileName)); + Assert.Contains("\"packages\":", json); + Assert.Contains("\"skills\":", json); + Assert.DoesNotContain("\"path\":", json); + Assert.DoesNotContain("\"skill\":", json); + } + + [Fact] + public void Install_with_dryRun_writes_nothing() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + var outcome = _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: true); + + Assert.Single(outcome.Installed); + Assert.False(Directory.Exists(destination)); + } + + [Fact] + public void Install_removes_the_previous_version_when_a_package_is_upgraded() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + var outcome = _installer.Install(destination, [Skill(temp, "Mockly", "1.11.0", "mockly")], dryRun: false); + + Assert.True(Directory.Exists(Path.Combine(destination, "mockly"))); + Assert.Empty(outcome.Removed); + Assert.Equal("1.11.0", Assert.Single(InstallManifest.Load(destination).Packages).Value.Version); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void An_upgrade_removes_the_skills_the_new_version_no_longer_ships(bool dryRun) + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "mockly-usage"), Skill(temp, "Mockly", "1.10.0", "mockly-migration")], + dryRun: false); + var before = Snapshot(destination); + + var outcome = _installer.Install(destination, [Skill(temp, "Mockly", "1.11.0", "mockly-usage")], dryRun); + + Assert.Equal("mockly-usage", Assert.Single(outcome.Installed).SkillName); + Assert.Equal(new TrackedSkill("mockly", "1.10.0", "mockly-migration"), Assert.Single(outcome.Removed)); + if (dryRun) + { + Assert.Equal(before, Snapshot(destination)); + } + else + { + Assert.False(Directory.Exists(Path.Combine(destination, "mockly-migration"))); + var package = Assert.Single(InstallManifest.Load(destination).Packages); + Assert.Equal("1.11.0", package.Value.Version); + Assert.Equal(["mockly-usage"], package.Value.Skills); + } + } + + [Fact] + public void An_upgrade_to_a_version_without_skills_removes_every_skill_of_that_package() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "mockly-usage"), Skill(temp, "Contoso.Widgets", "2.3.0", "widget-usage")], + dryRun: false); + + var outcome = _installer.Install(destination, [], dryRun: false, offered: Offer(("Mockly", "1.11.0"))); + + Assert.Equal("mockly-usage", Assert.Single(outcome.Removed).Skill); + Assert.False(Directory.Exists(Path.Combine(destination, "mockly-usage"))); + Assert.Equal("contoso.widgets", Assert.Single(InstallManifest.Load(destination).Packages).Key); + } + + [Fact] + public void The_same_version_never_removes_a_tracked_skill_it_does_not_ship() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "mockly-usage"), Skill(temp, "Mockly", "1.10.0", "mockly-migration")], + dryRun: false); + + // A package version never changes, so a skill missing from it means the cache is not + // what it was. Guessing that the author removed the skill would delete it on a hunch. + var outcome = _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly-usage")], dryRun: false); + + Assert.Empty(outcome.Removed); + Assert.True(File.Exists(Path.Combine(destination, "mockly-migration", "SKILL.md"))); + Assert.Equal( + ["mockly-migration", "mockly-usage"], + InstallManifest.Load(destination).Packages["mockly"].Skills); + } + + [Fact] + public void Skills_of_packages_the_run_does_not_offer_are_kept_and_reported_as_untouched() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "mockly"), Skill(temp, "Contoso.Widgets", "2.3.0", "widget-usage")], + dryRun: false); + + var outcome = _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + + Assert.Empty(outcome.Removed); + Assert.Equal(new TrackedSkill("contoso.widgets", "2.3.0", "widget-usage"), Assert.Single(outcome.Untouched)); + Assert.True(File.Exists(Path.Combine(destination, "widget-usage", "SKILL.md"))); + Assert.Equal(["contoso.widgets", "mockly"], InstallManifest.Load(destination).Packages.Keys); + } + + [Fact] + public void Install_keeps_the_first_skill_when_destination_names_collide() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + var outcome = _installer.Install( + destination, + [ + Skill(temp, "Mockly", "1.10.0", "shared-skill"), + Skill(temp, "Contoso.Widgets", "2.3.0", "shared-skill"), + ], + dryRun: false); + + Assert.Equal("Mockly", Assert.Single(outcome.Installed).PackageId); + Assert.Equal("Contoso.Widgets", Assert.Single(outcome.Skipped).PackageId); + Assert.True(File.Exists(Path.Combine(destination, "shared-skill", "SKILL.md"))); + } + + [Fact] + public void Install_detects_destination_collisions_case_insensitively() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + var outcome = _installer.Install( + destination, + [ + Skill(temp, "Mockly", "1.10.0", "shared-skill"), + Skill(temp, "Contoso.Widgets", "2.3.0", "SHARED-SKILL"), + ], + dryRun: false); + + Assert.Single(outcome.Installed); + Assert.Single(outcome.Skipped); + } + + [Fact] + public void Install_with_nothing_to_record_leaves_no_folder_behind() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + // Most packages ship no skills, so this is the common outcome. It should not leave an + // empty skills folder in a repository that never had one. + _installer.Install(destination, [], dryRun: false); + + Assert.False(Directory.Exists(destination)); + } + + [Fact] + public void Removing_the_last_tracked_skill_on_an_upgrade_removes_the_manifest_like_uninstall_does() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + + _installer.Install(destination, [], dryRun: false, offered: Offer(("Mockly", "1.11.0"))); + + Assert.False(File.Exists(Path.Combine(destination, InstallManifest.FileName))); + Assert.False(Directory.Exists(destination)); + } + + [Fact] + public void Removing_everything_still_keeps_a_folder_holding_hand_authored_skills() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + + var handAuthored = temp.CreateFile("dest/our-own-skill/SKILL.md", "ours"); + + _installer.Install(destination, [], dryRun: false, offered: Offer(("Mockly", "1.11.0"))); + + Assert.False(File.Exists(Path.Combine(destination, InstallManifest.FileName))); + Assert.Equal("ours", File.ReadAllText(handAuthored)); + } + + [Fact] + public void Install_never_touches_skills_it_did_not_install() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + // Removal is driven by the manifest, so a hand-authored skill sitting alongside + // package-provided ones has to survive every install. + Directory.CreateDirectory(destination); + var handAuthored = Path.Combine(destination, "our-own-skill"); + Directory.CreateDirectory(handAuthored); + File.WriteAllText(Path.Combine(handAuthored, "SKILL.md"), "ours"); + + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + _installer.Install(destination, [], dryRun: false, offered: Offer(("Mockly", "2.0.0"))); + + Assert.True(File.Exists(Path.Combine(handAuthored, "SKILL.md"))); + Assert.False(Directory.Exists(Path.Combine(destination, "mockly"))); + } + + [Fact] + public void Install_skips_an_existing_untracked_destination_folder() + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + var existing = temp.CreateFile("dest/mockly/SKILL.md", "ours"); + + var outcome = _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "mockly")], + dryRun: false); + + Assert.Empty(outcome.Installed); + Assert.Single(outcome.Skipped); + Assert.Equal("ours", File.ReadAllText(existing)); + } + + [Fact] + public void Install_skips_an_existing_file_at_the_destination_path() + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + var existing = temp.CreateFile("dest/mockly", "ours"); + + var outcome = _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "mockly")], + dryRun: false); + + Assert.Empty(outcome.Installed); + Assert.Single(outcome.Skipped); + Assert.Equal("ours", File.ReadAllText(existing)); + } + + [Fact] + public void Additive_install_skips_a_path_tracked_for_another_package() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Contoso.Widgets", "2.3.0", "shared-skill")], + dryRun: false); + + var outcome = _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "shared-skill")], + dryRun: false, + offered: Offer()); + + Assert.Empty(outcome.Installed); + Assert.Single(outcome.Skipped); + Assert.Equal("contoso.widgets", Assert.Single(InstallManifest.Load(destination).Packages).Key); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Complete_install_preserves_a_path_owned_by_another_package(bool dryRun) + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Contoso.Widgets", "2.3.0", "shared-skill")], + dryRun: false); + var contents = File.ReadAllBytes(Path.Combine(destination, "shared-skill", "SKILL.md")); + var manifest = File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName)); + + var outcome = _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "shared-skill")], + dryRun); + + Assert.Empty(outcome.Installed); + Assert.Empty(outcome.Removed); + Assert.Contains("managed for contoso.widgets", Assert.Single(outcome.Skipped).Reason); + Assert.Equal("contoso.widgets", Assert.Single(InstallManifest.Load(destination).Packages).Key); + Assert.Equal(contents, File.ReadAllBytes(Path.Combine(destination, "shared-skill", "SKILL.md"))); + Assert.Equal(manifest, File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Moving_a_package_to_a_version_without_a_skill_that_another_package_ships_stops_before_any_change( + bool dryRun) + { + // Removing the old copy would hand its name to the other package, which takes an explicit + // uninstall. Keeping it would record the old version's copy under the new version, where + // no later run would ever remove it. + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Alpha", "1.0.0", "alpha-usage"), Skill(temp, "Alpha", "1.0.0", "shared")], + dryRun: false); + var before = Snapshot(destination); + + var error = Assert.Throws(() => _installer.Install( + destination, + [Skill(temp, "Alpha", "2.0.0", "alpha-usage"), Skill(temp, "Beta", "2.0.0", "shared")], + dryRun, + offered: Offer(("Alpha", "2.0.0"), ("Beta", "2.0.0")))); + + Assert.Contains( + "Alpha 2.0.0 no longer ships the installed skill 'shared', and Beta 2.0.0 ships a skill with that name", + error.Message); + Assert.Contains("'dotnet-package-skills uninstall --package Alpha' first", error.Message); + Assert.Contains("No skills were changed", error.Message); + Assert.Equal(before, Snapshot(destination)); + } + + [Fact] + public void A_conflicting_copy_keeps_its_owner_when_the_owners_package_is_not_part_of_the_run() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Alpha", "1.0.0", "shared")], dryRun: false); + + var result = _installer.Install( + destination, + [Skill(temp, "Beta", "2.0.0", "shared")], + dryRun: false, + offered: Offer(("Beta", "2.0.0"))); + + Assert.Empty(result.Installed); + Assert.Empty(result.Removed); + Assert.Contains("managed for alpha 1.0.0", Assert.Single(result.Skipped).Reason); + Assert.Equal(new TrackedSkill("alpha", "1.0.0", "shared"), Assert.Single(result.Untouched)); + var alpha = InstallManifest.Load(destination).Packages["alpha"]; + Assert.Equal("1.0.0", alpha.Version); + Assert.Equal(["shared"], alpha.Skills); + } + + [Fact] + public void A_package_id_with_letters_outside_ascii_is_recorded_in_a_manifest_that_reads_back() + { + // NuGet accepts any Unicode letter in a package ID. A manifest that the tool's own reader + // refused would stop every later install and uninstall with advice that can't help. + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + _installer.Install(destination, [Skill(temp, "Contoso.Überlib", "1.0.0", "uber-usage")], dryRun: false); + + Assert.Equal("contoso.überlib", Assert.Single(InstallManifest.Load(destination).Packages).Key); + Assert.Single(_installer.Uninstall(destination, "Contoso.Überlib", null, dryRun: false)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void A_changed_owner_invalidates_an_interactive_selection_before_writing(bool uninstall) + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Alpha", "1.0.0", "shared")], dryRun: false); + var observed = InstallManifest.Load(destination).EnumerateSkills().ToList(); + _installer.Uninstall(destination, null, null, dryRun: false); + _installer.Install(destination, [Skill(temp, "Beta", "2.0.0", "shared")], dryRun: false); + var before = File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName)); + + var error = Assert.Throws(() => + { + if (uninstall) + { + _installer.Uninstall(destination, null, null, false, ["shared"], observed); + } + else + { + _installer.Install(destination, [], dryRun: false, offered: Offer(), expectedInstalled: observed); + } + }); + + Assert.Contains("ownership changed", error.Message); + Assert.Equal(before, File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName))); + Assert.True(File.Exists(Path.Combine(destination, "shared", "SKILL.md"))); + } + + [Fact] + public void Upgrading_one_package_leaves_the_other_packages_untouched() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Alpha", "1.0.0", "shared"), Skill(temp, "Beta", "1.0.0", "beta")], + dryRun: false); + + var result = _installer.Install(destination, [Skill(temp, "Alpha", "2.0.0", "shared")], dryRun: false); + + Assert.Empty(result.Skipped); + Assert.Single(result.Installed); + var manifest = InstallManifest.Load(destination); + Assert.Equal("2.0.0", manifest.Packages["alpha"].Version); + Assert.Equal("1.0.0", manifest.Packages["beta"].Version); + } + + [Fact] + public void An_only_adding_install_cannot_record_a_second_version_of_a_package() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly-usage")], dryRun: false); + var before = Snapshot(destination); + + var error = Assert.Throws(() => _installer.Install( + destination, [Skill(temp, "Mockly", "1.11.0", "mockly-testing")], dryRun: false, offered: Offer())); + + Assert.Contains("only one version", error.Message); + Assert.Equal(before, Snapshot(destination)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void A_source_that_disappears_after_discovery_blocks_all_writes(bool dryRun) + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Old", "1.0.0", "old")], dryRun: false); + var first = Skill(temp, "Alpha", "1.0.0", "first"); + var missing = Skill(temp, "Beta", "1.0.0", "missing"); + Directory.Delete(missing.SourcePath, recursive: true); + var before = File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName)); + + var error = Assert.Throws(() => + _installer.Install(destination, [first, missing], dryRun: dryRun)); + + Assert.Contains("no longer available", error.Message); + Assert.Equal(before, File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName))); + Assert.True(File.Exists(Path.Combine(destination, "old", "SKILL.md"))); + Assert.False(Directory.Exists(Path.Combine(destination, "first"))); + } + + [Fact] + public void An_upgrade_reports_removed_tracking_entries_even_when_the_folder_was_already_deleted() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Alpha", "1.0.0", "gone")], dryRun: false); + Directory.Delete(Path.Combine(destination, "gone"), recursive: true); + + var preview = _installer.Install(destination, [], dryRun: true, offered: Offer(("Alpha", "2.0.0"))); + var applied = _installer.Install(destination, [], dryRun: false, offered: Offer(("Alpha", "2.0.0"))); + + Assert.Equal(preview.Removed, applied.Removed); + Assert.Equal("gone", Assert.Single(applied.Removed).Skill); + Assert.False(File.Exists(Path.Combine(destination, InstallManifest.FileName))); + } + + [Fact] + public void An_only_adding_install_leaves_every_other_tracked_skill_alone() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "mockly"), Skill(temp, "Contoso.Widgets", "2.3.0", "widget-usage")], + dryRun: false); + var installedCopy = temp.CreateFile("dest/mockly/local-notes.md", "edited after install"); + + var outcome = _installer.Install( + destination, + [Skill(temp, "Gamma", "1.0.0", "gamma")], + dryRun: false, + offered: Offer()); + + Assert.Equal("gamma", Assert.Single(outcome.Installed).SkillName); + Assert.Empty(outcome.Removed); + Assert.Equal("edited after install", File.ReadAllText(installedCopy)); + Assert.True(Directory.Exists(Path.Combine(destination, "widget-usage"))); + Assert.Equal( + ["contoso.widgets", "gamma", "mockly"], + InstallManifest.Load(destination).Packages.Keys); + } + + [Fact] + public void Install_replaces_files_that_a_newer_package_version_dropped() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + var first = Skill(temp, "Mockly", "1.10.0", "mockly"); + File.WriteAllText(Path.Combine(first.SourcePath, "obsolete.md"), "gone in the next version"); + _installer.Install(destination, [first], dryRun: false); + + // Reinstalling the same version from a source that no longer has the file must + // not leave the stale copy behind. + File.Delete(Path.Combine(first.SourcePath, "obsolete.md")); + _installer.Install(destination, [first], dryRun: false); + + Assert.False(File.Exists(Path.Combine(destination, "mockly", "obsolete.md"))); + } + + [Fact] + public void Install_clears_the_read_only_flag_that_restore_puts_on_cached_files() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + var skill = Skill(temp, "Mockly", "1.10.0", "mockly"); + + var source = Path.Combine(skill.SourcePath, "SKILL.md"); + File.SetAttributes(source, File.GetAttributes(source) | FileAttributes.ReadOnly); + + try + { + _installer.Install(destination, [skill], dryRun: false); + + var copied = Path.Combine(destination, "mockly", "SKILL.md"); + Assert.False(File.GetAttributes(copied).HasFlag(FileAttributes.ReadOnly)); + + // The real point: a second install must be able to overwrite the copy. + _installer.Install(destination, [skill], dryRun: false); + } + finally + { + File.SetAttributes(source, File.GetAttributes(source) & ~FileAttributes.ReadOnly); + } + } + + [Fact] + public void Uninstall_against_references_removes_only_stale_skills_within_a_chosen_list() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [ + Skill(temp, "Mockly", "1.10.0", "mockly-usage"), + Skill(temp, "Mockly", "1.10.0", "mockly-setup"), + Skill(temp, "Contoso.Widgets", "2.3.0", "widget-usage"), + ], + dryRun: false); + PackageReferenceInfo[] references = [new("Contoso.Widgets", "2.3.0"), new("Mockly", "1.11.0")]; + + var removed = _installer.Uninstall( + destination, packageId: null, packageVersion: null, dryRun: false, + only: ["mockly-setup", "widget-usage"], staleAgainst: references); + + // widget-usage was chosen, but the target still references its version. + Assert.Equal("mockly-setup", Assert.Single(removed).Skill); + Assert.True(Directory.Exists(Path.Combine(destination, "mockly-usage"))); + Assert.True(Directory.Exists(Path.Combine(destination, "widget-usage"))); + } + + [Fact] + public void Uninstall_removes_only_the_skills_it_was_given() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [ + Skill(temp, "Mockly", "1.10.0", "mockly-usage"), + Skill(temp, "Mockly", "1.10.0", "mockly-setup"), + Skill(temp, "Contoso.Widgets", "2.3.0", "widget-usage"), + ], + dryRun: false); + + // What the interactive picker hands back: an explicit list, not a package filter. + var removed = _installer.Uninstall( + destination, + packageId: null, + packageVersion: null, + dryRun: false, + only: ["mockly-setup", "widget-usage"]); + + Assert.Equal(["mockly-setup", "widget-usage"], removed.Select(entry => entry.Skill)); + Assert.True(Directory.Exists(Path.Combine(destination, "mockly-usage"))); + Assert.False(Directory.Exists(Path.Combine(destination, "mockly-setup"))); + Assert.Single(InstallManifest.Load(destination).EnumerateSkills()); + } + + [Fact] + public void Uninstall_given_an_empty_list_removes_nothing() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + + // Confirming the picker without ticking anything must not be read as "all of them". + var removed = _installer.Uninstall( + destination, + packageId: null, + packageVersion: null, + dryRun: false, + only: []); + + Assert.Empty(removed); + Assert.True(Directory.Exists(Path.Combine(destination, "mockly"))); + } + + [Fact] + public void Uninstall_combines_a_chosen_list_with_a_package_filter() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [ + Skill(temp, "Mockly", "1.10.0", "mockly-usage"), + Skill(temp, "Contoso.Widgets", "2.3.0", "widget-usage"), + ], + dryRun: false); + + var removed = _installer.Uninstall( + destination, + packageId: "Mockly", + packageVersion: null, + dryRun: false, + only: ["mockly-usage", "widget-usage"]); + + // widget-usage was ticked but belongs to another package, so the filter still holds. + Assert.Equal("mockly-usage", Assert.Single(removed).Skill); + Assert.True(Directory.Exists(Path.Combine(destination, "widget-usage"))); + } + + [Fact] + public void Uninstall_removes_everything_it_installed_including_the_manifest() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + + var removed = _installer.Uninstall(destination, packageId: null, packageVersion: null, dryRun: false); + + Assert.Single(removed); + Assert.False(Directory.Exists(destination)); + } + + [Fact] + public void Uninstall_can_target_a_single_package() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "mockly"), Skill(temp, "Contoso.Widgets", "2.3.0", "widget-usage")], + dryRun: false); + + _installer.Uninstall(destination, packageId: "mockly", packageVersion: null, dryRun: false); + + Assert.False(Directory.Exists(Path.Combine(destination, "mockly"))); + Assert.True(Directory.Exists(Path.Combine(destination, "widget-usage"))); + Assert.True(File.Exists(Path.Combine(destination, InstallManifest.FileName))); + } + + [Fact] + public void Uninstall_with_dryRun_writes_nothing() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + + var removed = _installer.Uninstall(destination, packageId: null, packageVersion: null, dryRun: true); + + Assert.Single(removed); + Assert.True(Directory.Exists(Path.Combine(destination, "mockly"))); + } + + [Fact] + public void Uninstall_on_an_untouched_folder_reports_nothing_to_do() + { + using var temp = new TempDirectory(); + + Assert.Empty(_installer.Uninstall(temp.Combine("dest"), packageId: null, packageVersion: null, dryRun: false)); + } + + [Fact] + public void Uninstall_leaves_hand_authored_skills_in_place() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + + var handAuthored = Path.Combine(destination, "our-own-skill"); + Directory.CreateDirectory(handAuthored); + File.WriteAllText(Path.Combine(handAuthored, "SKILL.md"), "ours"); + + _installer.Uninstall(destination, packageId: null, packageVersion: null, dryRun: false); + + Assert.True(File.Exists(Path.Combine(handAuthored, "SKILL.md"))); + } + + [Fact] + public void A_corrupt_manifest_blocks_install_and_preserves_everything() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Contoso.Widgets", "2.3.0", "already-installed")], + dryRun: false); + + var manifest = Path.Combine(destination, InstallManifest.FileName); + const string corrupt = "{ not json"; + File.WriteAllText(manifest, corrupt); + + var error = Assert.Throws(() => + _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "not-installed")], + dryRun: false)); + + Assert.Contains(manifest, error.Message); + Assert.Contains("preserved", error.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(corrupt, File.ReadAllText(manifest)); + Assert.True(File.Exists(Path.Combine(destination, "already-installed", "SKILL.md"))); + Assert.False(Directory.Exists(Path.Combine(destination, "not-installed"))); + } + + [Fact] + public void A_corrupt_manifest_blocks_uninstall_and_preserves_everything() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "mockly")], + dryRun: false); + + var manifest = Path.Combine(destination, InstallManifest.FileName); + const string corrupt = """ + <<<<<<< HEAD + {"installed":[]} + ======= + {"installed":[]} + >>>>>>> feature + """; + File.WriteAllText(manifest, corrupt); + + var error = Assert.Throws(() => + _installer.Uninstall( + destination, + packageId: null, + packageVersion: null, + dryRun: false)); + + Assert.Contains(manifest, error.Message); + Assert.Equal(corrupt, File.ReadAllText(manifest)); + Assert.True(File.Exists(Path.Combine(destination, "mockly", "SKILL.md"))); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Unsafe_manifest_aliases_block_all_changes_and_preserve_handwritten_skills(bool uninstall, bool dryRun) + { + foreach (var unsafeName in new[] { "...", "....", ".. ", "... ", "our-own-skill.", "our-own-skill " }) + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Contoso.Widgets", "2.3.0", "already-installed")], + dryRun: false); + temp.CreateFile("dest/our-own-skill/SKILL.md", "handwritten guidance"); + temp.CreateFile("dest/our-own-skill/references/details.md", "handwritten reference"); + var next = Skill(temp, "Mockly", "1.10.0", "not-installed"); + var manifest = Path.Combine(destination, InstallManifest.FileName); + File.WriteAllText(manifest, $$""" + { + "version": 1, + "packages": { + "contoso.widgets": {"version":"2.3.0","skills":["already-installed"]}, + "mockly": {"version":"1.10.0","skills":["{{unsafeName}}"]} + } + } + """); + var before = Snapshot(destination); + + var error = Assert.Throws(() => + { + if (uninstall) + { + _installer.Uninstall(destination, packageId: null, packageVersion: null, dryRun); + } + else + { + _installer.Install(destination, [next], dryRun); + } + }); + + Assert.Contains("not a safe skill folder name", error.Message); + Assert.Contains("No skills were changed", error.Message); + Assert.Contains("preserved", error.Message); + Assert.Equal(before, Snapshot(destination)); + Assert.False(Directory.Exists(Path.Combine(destination, "not-installed"))); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Unsafe_candidate_paths_cannot_bypass_manifest_validation_or_prune_existing_skills(bool dryRun) + { + foreach (var unsafeName in new[] { "...", "..", "our-own-skill.", "our-own-skill ", "../outside" }) + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Old", "1.0.0", "old")], dryRun: false); + temp.CreateFile("dest/our-own-skill/SKILL.md", "handwritten guidance"); + temp.CreateFile("outside/SKILL.md", "outside the destination"); + var next = Skill(temp, "Alpha", "1.0.0", "next"); + var unsafeSkill = Skill(temp, "Beta", "1.0.0", "source") with + { + SkillName = unsafeName, + RelativePath = unsafeName, + }; + var before = Snapshot(temp.Path); + + var error = Assert.Throws(() => + _installer.Install(destination, [next, unsafeSkill], dryRun)); + + Assert.Contains("safe skill folder", error.Message); + Assert.Equal(before, Snapshot(temp.Path)); + } + } + + [Theory] + [InlineData(".hidden-skill")] + [InlineData("...usage")] + [InlineData("skill name")] + public void Safe_names_with_dots_or_spaces_remain_installable_and_removable(string skillName) + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest") + Path.DirectorySeparatorChar; + var handwritten = temp.CreateFile("dest/our-own-skill/SKILL.md", "ours"); + + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", skillName)], dryRun: false); + Assert.True(File.Exists(Path.Combine(destination, skillName, "SKILL.md"))); + + var removed = _installer.Uninstall(destination, packageId: null, packageVersion: null, dryRun: false); + + Assert.Equal(skillName, Assert.Single(removed).Skill); + Assert.False(Directory.Exists(Path.Combine(destination, skillName))); + Assert.False(File.Exists(Path.Combine(destination, InstallManifest.FileName))); + Assert.Equal("ours", File.ReadAllText(handwritten)); + } + + private static (string Path, string Contents)[] Snapshot(string root) => + Directory.Exists(root) + ? + [ + .. Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories) + .OrderBy(path => path, StringComparer.Ordinal) + .Select(path => (Path.GetRelativePath(root, path), Convert.ToHexString(File.ReadAllBytes(path)))), + ] + : []; + + private static Dictionary Offer(params (string Id, string Version)[] packages) => + packages.ToDictionary(package => package.Id, package => package.Version, StringComparer.OrdinalIgnoreCase); +} diff --git a/dotnet-package-skills/tests/SkillPickerTests.cs b/dotnet-package-skills/tests/SkillPickerTests.cs new file mode 100644 index 0000000..3c59aa2 --- /dev/null +++ b/dotnet-package-skills/tests/SkillPickerTests.cs @@ -0,0 +1,2194 @@ +using System.Text; +using DotnetPackageSkills.Cli; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +public class SkillPickerTests +{ + private const string Title = "Skills for App.slnx"; + + [Theory] + [InlineData(0, true, false)] + [InlineData(8, true, false)] + [InlineData(58, true, false)] + [InlineData(8, false, false)] + [InlineData(0, true, true)] + [InlineData(8, true, true)] + [InlineData(58, true, true)] + [InlineData(8, false, true)] + public void The_first_frame_starts_at_the_top_regardless_of_the_shell_cursor( + int cursorRow, bool color, bool uninstall) + { + var terminal = new FakeTerminal(windowHeight: 70, windowWidth: 140) + { + SupportsColor = color, + }.Press(ConsoleKey.Escape); + terminal.WriteLine("previous shell output"); + terminal.SetCursorPosition(12, cursorRow); + var originalScreen = terminal.Screen; + var originalState = terminal.CaptureState(); + terminal.BeforeOperation = operation => + { + if (operation == nameof(FakeTerminal.ClearViewport)) + { + Assert.True(terminal.IsInteractiveScreen); + } + }; + + new SkillPicker(terminal).Choose(Items(3), Title, + uninstall ? PickerMode.Uninstall : PickerMode.Install); + + Assert.StartsWith(Title, Assert.Single(terminal.Frames)); + Assert.Equal(0, Assert.Single(terminal.Writes, write => write.Text == Title).Top); + Assert.Equal(originalScreen, terminal.Screen); + Assert.Equal(cursorRow, terminal.FinalCursorTop); + Assert.Equal(originalState, terminal.CaptureState()); + Assert.Equal(1, terminal.ViewportClears); + } + + [Theory] + [InlineData(ConsoleKey.Enter)] + [InlineData(ConsoleKey.Escape)] + [InlineData(ConsoleKey.Q)] + public void Picker_frames_are_isolated_from_prior_shell_output_and_removed_on_exit(ConsoleKey exit) + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, exit); + terminal.WriteLine("earlier shell output"); + var before = terminal.Screen; + var cursor = terminal.CursorTop; + var state = terminal.CaptureState(); + terminal.BeforeOperation = operation => + { + if (operation == nameof(FakeTerminal.ReadKey)) + { + Assert.True(terminal.IsInteractiveScreen); + Assert.DoesNotContain("earlier shell output", terminal.Screen); + } + }; + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Equal(before, terminal.Screen); + Assert.Equal(cursor, terminal.CursorTop); + Assert.Equal(state, terminal.CaptureState()); + Assert.Contains(Title, terminal.LastPickerScreen); + Assert.DoesNotContain(Title, terminal.Screen); + Assert.Equal(1, terminal.ScreenEntries); + Assert.Equal(1, terminal.ScreenExits); + Assert.False(terminal.IsInteractiveScreen); + } + + [Fact] + public void Ctrl_C_restores_the_normal_screen_without_retaining_a_picker_copy() + { + var terminal = new FakeTerminal().Press(ConsoleKey.A).PressWith(ConsoleModifiers.Control, ConsoleKey.C); + terminal.WriteLine("prior output"); + var before = terminal.Screen; + + Assert.Null(new SkillPicker(terminal).Choose(Items(24), Title)); + + Assert.Equal(before, terminal.Screen); + Assert.False(terminal.IsInteractiveScreen); + Assert.Equal(1, terminal.ScreenExits); + } + + [Fact] + public void Rendering_failure_still_restores_the_original_screen() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar); + terminal.WriteLine("prior output"); + var before = terminal.Screen; + terminal.BeforeOperation = operation => + { + if (operation == nameof(FakeTerminal.Write) && terminal.CurrentStyle == TerminalStyle.Selected) + { + throw new IOException("failed during selection render"); + } + }; + + Assert.Throws(() => new SkillPicker(terminal).Choose(Items(3), Title)); + + Assert.Equal(before, terminal.Screen); + Assert.False(terminal.IsInteractiveScreen); + Assert.Equal(1, terminal.ScreenExits); + } + + [Fact] + public void A_note_under_the_title_is_shown_on_every_page_and_fits_the_frame() + { + const string Note = "Installed skills aren't listed."; + var terminal = new FakeTerminal(windowHeight: 18, windowWidth: 46) + .Press(ConsoleKey.PageDown, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title, PickerMode.Install, Note); + + Assert.All(terminal.Frames, frame => + { + var lines = frame.Split(Environment.NewLine).Select(line => line.TrimEnd()).ToArray(); + Assert.StartsWith(Title, lines[0]); + Assert.Equal(Note, lines[1]); + Assert.Equal(string.Empty, lines[2]); + }); + Assert.Contains("page 2 of", terminal.Frames[1]); + AssertWithinWindow(terminal); + } + + [Fact] + public void Picker_shows_one_page_of_skills_at_a_time() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + var frame = terminal.Frames[0]; + Assert.Contains("skill-01", frame); + Assert.Contains("skill-08", frame); + Assert.DoesNotContain("skill-09", frame); + Assert.Contains("page 1 of 3", frame); + } + + [Fact] + public void An_install_list_starts_with_nothing_ticked() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + var choice = new SkillPicker(terminal).Choose(Items(3), Title); + + var frame = terminal.Frames[0]; + Assert.Contains("[ ] skill-01", frame); + Assert.Contains("[ ] skill-02", frame); + Assert.Contains("[ ] skill-03", frame); + Assert.DoesNotContain("[X]", frame); + Assert.DoesNotContain("installed", frame); + Assert.Empty(choice!); + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 0, 1).Style); + Assert.Equal(TerminalStyle.Default, SkillSpan(terminal, 0, 2).Style); + } + + [Fact] + public void Neutral_rows_show_the_description_instead_of_a_status_column() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(4), Title); + + var rows = Rows(terminal.Frames[0]); + + Assert.Equal("[ ] skill-01 - No description provided.", rows[0]); + Assert.Equal("[ ] skill-02 - No description provided.", rows[1]); + } + + [Fact] + public void Ticking_a_new_skill_colors_only_its_X_blue_when_it_is_not_focused() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.DownArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 0, 1).Style); + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 2, 1, "X").Style); + Assert.Equal(TerminalStyle.Default, CheckboxSpan(terminal, 2, 1, "[").Style); + Assert.Equal(TerminalStyle.Default, CheckboxSpan(terminal, 2, 1, "]").Style); + Assert.Equal(TerminalStyle.Default, SkillSpan(terminal, 2, 1).Style); + Assert.Equal("[X] skill-01 - No description provided.", Rows(terminal.Frames[1])[0]); + Assert.DoesNotContain("will install", terminal.Frames[1]); + } + + [Fact] + public void An_install_list_never_mentions_removal() + { + var terminal = new FakeTerminal() + .Press(ConsoleKey.Spacebar, ConsoleKey.Spacebar, ConsoleKey.A, ConsoleKey.C, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.All(terminal.Frames, frame => + Assert.DoesNotContain("remove", frame, StringComparison.OrdinalIgnoreCase)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Both_pickers_draw_a_tick_the_same_way(bool uninstall) + { + // Each checklist does one thing, so a tick needs no second cue for what it does; the + // title and the summary say that. Only the X is blue, and the brackets follow the row. + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.DownArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title, uninstall ? PickerMode.Uninstall : PickerMode.Install); + + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 1, 1, "X").Style); + Assert.Equal(TerminalStyle.Focus, CheckboxSpan(terminal, 1, 1, "[").Style); + Assert.Equal(TerminalStyle.Focus, CheckboxSpan(terminal, 1, 1, "]").Style); + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 2, 1, "X").Style); + Assert.Equal(TerminalStyle.Default, CheckboxSpan(terminal, 2, 1, "[").Style); + Assert.Equal(TerminalStyle.Default, CheckboxSpan(terminal, 2, 1, "]").Style); + Assert.All(terminal.Frames, frame => + { + Assert.Contains("Blue X: selected", frame); + Assert.DoesNotContain("brackets", frame, StringComparison.OrdinalIgnoreCase); + }); + } + + [Fact] + public void Package_attribution_does_not_appear_after_the_authored_skill_name() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Contains("skill-01 - No description provided.", terminal.Frames[0]); + Assert.DoesNotContain("Package.1", terminal.Frames[0]); + Assert.DoesNotContain("1.0.0", terminal.Frames[0]); + } + + /// The skill rows of a frame, trimmed of the cursor column and padding. + private static List Rows(string frame) => + [ + .. frame.Split(Environment.NewLine) + .Where(line => line.Contains('[', StringComparison.Ordinal)) + .Select(line => line[line.IndexOf('[')..].TrimEnd()), + ]; + + private static TerminalWrite SkillSpan(FakeTerminal terminal, int frame, int skill) => + Assert.Single(terminal.FrameWrites[frame], write => + write.Text == $" skill-{skill:00}"); + + private static TerminalWrite CheckboxSpan(FakeTerminal terminal, int frame, int skill, string part) + { + var row = SkillSpan(terminal, frame, skill).Top; + return Assert.Single(terminal.FrameWrites[frame], write => write.Top == row && write.Text == part); + } + + [Fact] + public void Uninstalling_starts_with_nothing_ticked() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + // Every row is installed, and a tick deletes. Pre-ticking them would make a mistaken + // enter wipe the lot. + var choice = new SkillPicker(terminal) + .Choose(Items(5), Title, PickerMode.Uninstall); + + Assert.NotNull(choice); + Assert.Empty(choice); + } + + [Fact] + public void Uninstalling_a_ticked_row_says_how_many_skills_will_be_removed() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + + var choice = new SkillPicker(terminal) + .Choose(Items(3), Title, PickerMode.Uninstall); + + Assert.Equal("skill-01", Assert.Single(choice!)); + Assert.Equal("[X] skill-01 - No description provided.", Rows(terminal.Frames[1])[0]); + Assert.Contains("1 of 3 selected; 1 to remove", terminal.Frames[1]); + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 1, 1, "X").Style); + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 1, 1).Style); + } + + [Fact] + public void Uninstalling_says_nothing_about_rows_left_alone() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title, PickerMode.Uninstall); + + var frame = terminal.Frames[0]; + Assert.DoesNotContain("installed", frame); + Assert.DoesNotContain("will install", frame); + Assert.Equal("[ ] skill-01 - No description provided.", Rows(frame)[0]); + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 0, 1).Style); + } + + [Fact] + public void Uninstalling_counts_selected_removals_without_an_install_count() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(4), Title, PickerMode.Uninstall); + + Assert.Contains("0 of 4 selected; 0 to remove", terminal.Frames[0]); + Assert.Contains("1 of 4 selected; 1 to remove", terminal.Frames[1]); + Assert.DoesNotContain("to install", terminal.Frames[1]); + } + + [Fact] + public void Uninstalling_can_take_everything_with_one_key() + { + var terminal = new FakeTerminal().Press(ConsoleKey.A, ConsoleKey.Enter); + + var choice = new SkillPicker(terminal) + .Choose(Items(24), Title, PickerMode.Uninstall); + + Assert.Equal(24, choice!.Count); + } + + [Fact] + public void Uninstalling_cancelled_removes_nothing() + { + var terminal = new FakeTerminal().Press(ConsoleKey.A, ConsoleKey.Escape); + + Assert.Null(new SkillPicker(terminal) + .Choose(Items(5), Title, PickerMode.Uninstall)); + } + + [Fact] + public void Uninstalling_without_a_terminal_says_what_to_do_instead() + { + var terminal = new FakeTerminal { IsRedirected = true }; + + var error = Assert.Throws( + () => new SkillPicker(terminal).Choose(Items(3), Title, PickerMode.Uninstall)); + + // The install wording tells you to drop the flag and install everything, which is the + // opposite of what this command would then do. It also can't point at --package, which + // uninstall --stale refuses. + Assert.Contains("remove every skill that the command matches", error.Message); + Assert.DoesNotContain("--package", error.Message); + } + + [Fact] + public void Pressing_enter_immediately_installs_nothing() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + var choice = new SkillPicker(terminal).Choose(Items(5), Title); + + Assert.NotNull(choice); + Assert.Empty(choice); + } + + [Fact] + public void Moving_past_the_last_item_on_a_page_shows_the_next_page() + { + var terminal = new FakeTerminal().Press(ConsoleKey.DownArrow, times: 8).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + var frame = terminal.Frames[8]; + Assert.Contains("page 2 of 3", frame); + Assert.Contains("> [ ] skill-09", frame); + Assert.DoesNotContain("skill-08", frame); + } + + [Fact] + public void Moving_up_from_the_first_skill_wraps_to_the_last_page() + { + var terminal = new FakeTerminal().Press(ConsoleKey.UpArrow).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + var frame = terminal.Frames[1]; + Assert.Contains("page 3 of 3", frame); + Assert.Contains("> [ ] skill-24", frame); + } + + [Fact] + public void Right_arrow_pages_forward_without_moving_within_the_page() + { + var terminal = new FakeTerminal().Press(ConsoleKey.RightArrow).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.Contains("> [ ] skill-09", terminal.Frames[1]); + } + + [Fact] + public void Moving_down_from_the_last_skill_wraps_to_the_first_page() + { + var terminal = new FakeTerminal().Press(ConsoleKey.End, ConsoleKey.DownArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + var frame = terminal.Frames[2]; + Assert.Contains("page 1 of 3", frame); + Assert.Contains("> [ ] skill-01", frame); + } + + [Fact] + public void Left_arrow_pages_back() + { + var terminal = new FakeTerminal() + .Press(ConsoleKey.RightArrow, ConsoleKey.RightArrow, ConsoleKey.LeftArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.Contains("page 3 of 3", terminal.Frames[2]); + + var frame = terminal.Frames[3]; + Assert.Contains("page 2 of 3", frame); + Assert.Contains("> [ ] skill-09", frame); + } + + [Fact] + public void Page_up_and_page_down_page_like_the_arrows() + { + var terminal = new FakeTerminal().Press(ConsoleKey.PageDown, ConsoleKey.PageUp, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.Contains("page 2 of 3", terminal.Frames[1]); + Assert.Contains("page 1 of 3", terminal.Frames[2]); + } + + [Fact] + public void Home_and_end_jump_to_the_first_and_last_skill() + { + var terminal = new FakeTerminal().Press(ConsoleKey.End, ConsoleKey.Home, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.Contains("> [ ] skill-24", terminal.Frames[1]); + Assert.Contains("> [ ] skill-01", terminal.Frames[2]); + } + + [Fact] + public void Space_selects_the_focused_skill() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + + var choice = new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.NotNull(choice); + Assert.Equal("skill-01", Assert.Single(choice)); + } + + [Fact] + public void Pressing_space_again_unticks_the_skill() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Spacebar, ConsoleKey.Enter); + + var choice = new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.NotNull(choice); + Assert.Empty(choice); + } + + [Fact] + public void A_selects_every_skill_on_every_page() + { + var terminal = new FakeTerminal().Press(ConsoleKey.A, ConsoleKey.Enter); + + var choice = new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.NotNull(choice); + Assert.Equal(24, choice.Count); + } + + [Fact] + public void C_clears_every_skill_on_every_page() + { + var terminal = new FakeTerminal().Press(ConsoleKey.A, ConsoleKey.C, ConsoleKey.Enter); + + var choice = new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.NotNull(choice); + Assert.Empty(choice); + } + + [Fact] + public void The_install_summary_counts_selections_only() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(5), Title); + + Assert.Contains("0 of 5 selected", terminal.Frames[0]); + Assert.Contains("1 of 5 selected", terminal.Frames[1]); + Assert.DoesNotContain("to install", terminal.Frames[1]); + Assert.DoesNotContain("to remove", terminal.Frames[1]); + } + + [Fact] + public void Ctrl_c_cancels_without_choosing_anything() + { + var terminal = new FakeTerminal().Press(ConsoleKey.A).PressWith(ConsoleModifiers.Control, ConsoleKey.C); + + Assert.Null(new SkillPicker(terminal).Choose(Items(24), Title)); + } + + [Fact] + public void Ctrl_c_is_not_mistaken_for_the_clear_all_key() + { + var terminal = new FakeTerminal().PressWith(ConsoleModifiers.Control, ConsoleKey.C); + + // A bare 'c' clears the selection and keeps going, so the modifier has to win. + Assert.Null(new SkillPicker(terminal).Choose(Items(5), Title)); + } + + [Fact] + public void Ctrl_c_restores_the_terminal_on_the_way_out() + { + var terminal = new FakeTerminal().PressWith(ConsoleModifiers.Control, ConsoleKey.C); + + new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.True(terminal.IsCursorVisible); + Assert.False(terminal.IsControlCTakenAsInput); + } + + [Fact] + public void Ctrl_c_is_taken_as_a_key_rather_than_killing_the_process() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Escape); + + new SkillPicker(terminal).Choose(Items(3), Title); + + // Left to the runtime, Ctrl+C ends the process mid-frame and the cursor is never + // put back. Capturing it is what makes the restore reachable at all. + Assert.True(terminal.ControlCWasEverTakenAsInput); + Assert.False(terminal.IsControlCTakenAsInput); + } + + [Fact] + public void Cancelling_on_a_partial_page_parks_the_picker_then_restores_the_shell_cursor() + { + var terminal = new FakeTerminal().Press(ConsoleKey.RightArrow); + terminal.PressWith(ConsoleModifiers.Control, ConsoleKey.C); + + new SkillPicker(terminal).Choose(Items(12), Title); + + // Four complete entries, nine measured chrome rows, and the closing blank line. + Assert.Equal(14, terminal.LastPickerCursorTop); + Assert.Equal(0, terminal.FinalCursorTop); + } + + [Fact] + public void A_partial_page_parks_the_cursor_under_the_footer_while_it_waits() + { + var terminal = new FakeTerminal().Press(ConsoleKey.RightArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(12), Title); + + // Where the cursor rests between keys is where a prompt lands if the process is + // killed outright, which is what Ctrl+C does on a host that will not hand it over. + // Leaving it at the bottom of the reserved rows is the whitespace bug itself. + Assert.Equal(13, terminal.CursorTopAwaitingKey); + } + + [Fact] + public void A_full_page_parks_the_cursor_under_the_footer_while_it_waits() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(12), Title); + + Assert.Equal(17, terminal.CursorTopAwaitingKey); + } + + [Fact] + public void Cancelling_on_a_full_page_restores_the_original_screen_cursor() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Escape); + + new SkillPicker(terminal).Choose(Items(12), Title); + + Assert.Equal(17, terminal.LastPickerCursorTop); + Assert.Equal(0, terminal.FinalCursorTop); + } + + [Fact] + public void Escape_cancels_without_choosing_anything() + { + var terminal = new FakeTerminal().Press(ConsoleKey.A, ConsoleKey.Escape); + + Assert.Null(new SkillPicker(terminal).Choose(Items(3), Title)); + } + + [Fact] + public void Q_cancels_without_choosing_anything() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Q); + + Assert.Null(new SkillPicker(terminal).Choose(Items(3), Title)); + } + + [Fact] + public void A_tall_window_shows_more_skills_per_page() + { + var terminal = new FakeTerminal(windowHeight: 40).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + // The entire list and its measured, non-paging footer fit in this viewport. + var frame = terminal.Frames[0]; + Assert.Contains("skill-24", frame); + Assert.DoesNotContain("page 1 of", frame); + } + + [Fact] + public void A_window_taller_than_the_list_does_not_page_at_all() + { + var terminal = new FakeTerminal(windowHeight: 40).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + // Nothing to page to, so the paging key is dropped along with the counter. + Assert.DoesNotContain("change page", terminal.Frames[0]); + } + + [Fact] + public void The_page_is_the_window_height_not_a_fixed_ceiling() + { + // Eighteen entries fit beside the measured footer. A fixed ten-row cap would hide + // eight entries that fit, even before descriptions change their rendered heights. + var terminal = new FakeTerminal(windowHeight: 28).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(60), Title); + + var frame = terminal.Frames[0]; + Assert.Contains("skill-18", frame); + Assert.DoesNotContain("skill-19", frame); + Assert.Contains("page 1 of 4", frame); + } + + [Fact] + public void A_short_window_shrinks_the_page_rather_than_overflowing_it() + { + var terminal = new FakeTerminal(windowHeight: 12).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + var frame = terminal.Frames[0]; + Assert.Contains("skill-02", frame); + Assert.DoesNotContain("skill-03", frame); + Assert.Contains("page 1 of 12", frame); + } + + [Fact] + public void A_window_too_short_for_essential_controls_gives_actionable_guidance() + { + var terminal = new FakeTerminal(windowHeight: 4).Press(ConsoleKey.Enter); + + var error = Assert.Throws( + () => new SkillPicker(terminal).Choose(Items(3), Title)); + + Assert.Contains("too small", error.Message); + Assert.Contains("Enlarge the window", error.Message); + Assert.Contains("--package", error.Message); + Assert.Empty(terminal.Frames); + Assert.Empty(terminal.Writes); + } + + [Fact] + public void A_partial_last_page_puts_the_summary_under_its_final_skill() + { + var terminal = new FakeTerminal().Press(ConsoleKey.RightArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(12), Title); + + var lines = terminal.Frames[1].Split(Environment.NewLine); + var lastSkill = Array.FindIndex(lines, line => line.Contains("skill-12", StringComparison.Ordinal)); + var summary = Array.FindIndex(lines, line => line.Contains("of 12 selected", StringComparison.Ordinal)); + + Assert.True(lastSkill > 0, "the last skill should be on the page"); + // A blank separator, not padding out to a full page. + Assert.Equal(lastSkill + 2, summary); + } + + [Fact] + public void Paging_to_a_shorter_page_erases_what_the_taller_one_left_behind() + { + var terminal = new FakeTerminal().Press(ConsoleKey.RightArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(12), Title); + + // In-place redrawing can only erase by overwriting, so page one's rows have to be + // blanked rather than simply skipped. + var frame = terminal.Frames[1]; + Assert.DoesNotContain("skill-01", frame); + Assert.DoesNotContain("skill-08", frame); + Assert.Contains("skill-09", frame); + Assert.Contains("skill-12", frame); + } + + [Fact] + public void Paging_back_to_a_full_page_redraws_every_row() + { + var terminal = new FakeTerminal().Press(ConsoleKey.RightArrow, ConsoleKey.LeftArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(12), Title); + + var frame = terminal.Frames[2]; + Assert.Contains("skill-01", frame); + Assert.Contains("skill-08", frame); + Assert.DoesNotContain("skill-09", frame); + } + + [Fact] + public void A_full_last_page_is_unchanged_by_the_partial_page_handling() + { + var terminal = new FakeTerminal().Press(ConsoleKey.End, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(16), Title); + + var lines = terminal.Frames[1].Split(Environment.NewLine); + var lastSkill = Array.FindIndex(lines, line => line.Contains("skill-16", StringComparison.Ordinal)); + var summary = Array.FindIndex(lines, line => line.Contains("of 16 selected", StringComparison.Ordinal)); + + Assert.Equal(lastSkill + 2, summary); + Assert.Contains("page 2 of 2", terminal.Frames[1]); + } + + [Fact] + public void The_frame_is_only_as_wide_as_its_content_on_a_wide_terminal() + { + var terminal = new FakeTerminal(windowWidth: 200).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title); + + // Padding every row out to the window would strand the page counter at the far edge + // and trail whitespace far past the text it belongs to. + Assert.All( + terminal.Frames[0].Split(Environment.NewLine), + line => Assert.True(line.Length < 80, $"line is {line.Length} columns wide: '{line}'")); + } + + [Fact] + public void The_page_counter_sits_beside_the_title_not_at_the_far_edge() + { + var terminal = new FakeTerminal(windowWidth: 200).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + var header = terminal.Frames[0].Split(Environment.NewLine)[0]; + Assert.EndsWith("page 1 of 3", header.TrimEnd()); + Assert.True(header.TrimEnd().Length < 80, $"header is {header.TrimEnd().Length} columns wide"); + } + + [Fact] + public void A_long_skill_name_still_widens_the_frame_to_fit() + { + var terminal = new FakeTerminal(windowWidth: 200).Press(ConsoleKey.Enter); + var name = new string('x', 40); + + new SkillPicker(terminal).Choose( + [ + new SkillPickerItem(name, "Some.Package", "1.0.0"), + ], + Title); + + Assert.Contains(name, terminal.Frames[0]); + } + + [Fact] + public void A_name_far_longer_than_any_constant_survives_on_a_wide_terminal() + { + var terminal = new FakeTerminal(windowWidth: 200).Press(ConsoleKey.Enter); + + // The column used to stop at a hardcoded 44, so this lost its tail with most of the + // window still empty beside it. The terminal is the only thing that gets to decide. + var name = "contoso.widgets-extremely-long-skill-name-that-keeps-going-and-going"; + + new SkillPicker(terminal).Choose( + [ + new SkillPickerItem(name, "Contoso.Widgets", "2.3.0"), + ], + Title); + + Assert.Contains(name, terminal.Frames[0]); + Assert.DoesNotContain("...", terminal.Frames[0]); + } + + [Fact] + public void A_narrow_terminal_truncates_the_name_rather_than_overflowing_the_row() + { + var terminal = new FakeTerminal(windowWidth: 50).Press(ConsoleKey.Enter); + var name = "contoso.widgets-extremely-long-skill-name-that-keeps-going"; + + new SkillPicker(terminal).Choose( + [ + new SkillPickerItem(name, "Contoso.Widgets", "2.3.0"), + ], + Title); + + var frame = terminal.Frames[0]; + Assert.Contains("...", frame); + Assert.All( + frame.Split(Environment.NewLine), + line => Assert.True(line.Length < 50, $"'{line}' is {line.Length} columns wide")); + } + + [Fact] + public void The_name_column_grows_with_the_terminal() + { + const string Name = "contoso.widgets-a-name-of-some-considerable-length-indeed"; + + static int DescriptionColumnAt(int windowWidth) + { + var terminal = new FakeTerminal(windowWidth: windowWidth).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose( + [ + new SkillPickerItem(Name, "Contoso.Widgets", "2.3.0"), + ], + Title); + + return terminal.Frames[0] + .Split(Environment.NewLine) + .Single(line => line.StartsWith("> [", StringComparison.Ordinal)) + .IndexOf(" - ", StringComparison.Ordinal); + } + + Assert.True(DescriptionColumnAt(140) > DescriptionColumnAt(70), "a wider terminal should give the name more room"); + } + + [Fact] + public void Rows_are_padded_so_a_shorter_frame_cannot_leave_the_previous_one_behind() + { + // Changing counts or focus must not leave an earlier row showing through. + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title); + + var lengths = terminal.Frames[0].Split(Environment.NewLine).Select(line => line.Length).Distinct(); + Assert.Single(lengths); + } + + [Fact] + public void A_single_skill_does_not_leave_a_page_of_blank_rows_behind_it() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(1), Title); + + // A single entry and its useful footer, not a page's worth of reserved skill rows. + Assert.Equal(8, terminal.Frames[0].Split(Environment.NewLine).Length); + } + + [Fact] + public void A_short_list_shrinks_the_frame_to_fit_it() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Equal(11, terminal.Frames[0].Split(Environment.NewLine).Length); + } + + [Fact] + public void A_list_longer_than_a_page_still_fills_the_page() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.Equal(17, terminal.Frames[0].Split(Environment.NewLine).Length); + } + + [Fact] + public void One_page_of_skills_shows_no_page_counter() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(4), Title); + + var frame = terminal.Frames[0]; + Assert.Contains(Title, frame); + Assert.DoesNotContain("page 1 of 1", frame); + // Nothing to page to, so offering the key would teach a control that does nothing. + Assert.DoesNotContain("change page", frame); + Assert.Contains("/ to move", frame); + } + + [Fact] + public void A_single_skill_offers_neither_paging_nor_movement() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(1), Title); + + var frame = terminal.Frames[0]; + Assert.DoesNotContain("/", frame); + Assert.DoesNotContain("change page", frame); + // With one skill, select-all and clear-all are a slower way to press space. + Assert.DoesNotContain("select all", frame); + Assert.DoesNotContain("clear all", frame); + Assert.Contains(PickerLayout.PrimaryHelp, frame); + Assert.Contains("(Press // to cancel)", frame); + } + + [Fact] + public void The_bottom_help_uses_Aspire_key_syntax_and_subdued_spans() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + var frame = terminal.Frames[0]; + Assert.Contains("(Press to select, to accept)", frame); + Assert.Contains("(Press / to move", frame); + Assert.Contains("(Press /, / to change page)", frame); + Assert.Contains("/ for first/last", frame); + Assert.Contains("(Press to select all", frame); + Assert.Contains(" to clear all", frame); + Assert.Contains("// to cancel", frame); + Assert.All( + terminal.FrameWrites[0].Where(write => write.Text.StartsWith("(", StringComparison.Ordinal)), + write => Assert.StartsWith("(Press <", write.Text)); + var help = Assert.Single(terminal.FrameWrites[0], write => write.Text == PickerLayout.PrimaryHelp); + Assert.Equal(TerminalStyle.Muted, help.Style); + Assert.True(help.Top > SkillSpan(terminal, 0, 8).Top); + } + + [Fact] + public void More_than_one_page_still_shows_the_counter_and_the_paging_key() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + var frame = terminal.Frames[0]; + Assert.Contains("page 1 of 3", frame); + Assert.Contains("(Press /, / to change page)", frame); + } + + [Fact] + public void A_single_skill_can_still_be_toggled_and_confirmed() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + + var choice = new SkillPicker(terminal).Choose(Items(1), Title); + + Assert.NotNull(choice); + Assert.Equal("skill-01", Assert.Single(choice)); + } + + [Fact] + public void Moving_within_a_single_page_never_leaves_it() + { + var terminal = new FakeTerminal() + .Press(ConsoleKey.DownArrow, ConsoleKey.DownArrow, ConsoleKey.RightArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.All( + terminal.Frames, + frame => Assert.Equal(11, frame.Split(Environment.NewLine).Length)); + Assert.Contains("> [ ] skill-03", terminal.Frames[^1]); + } + + [Fact] + public void The_frame_never_grows_beyond_the_rows_it_reserved() + { + var terminal = new FakeTerminal().Press(ConsoleKey.DownArrow, times: 30).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + // Eight entry rows and nine measured chrome rows, however far the cursor travels. + Assert.All(terminal.Frames, frame => Assert.Equal(17, frame.Split(Environment.NewLine).Length)); + } + + [Fact] + public void The_frame_stays_ascii_so_a_legacy_console_code_page_renders_all_of_it() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + // Windows consoles default to an OEM code page that silently drops arrows and box + // glyphs, so a legend built from them reads as gaps on the most common terminal. + Assert.All( + terminal.Frames[0].Replace(Environment.NewLine, string.Empty), + character => Assert.InRange(character, ' ', '~')); + } + + [Fact] + public void Long_skill_names_are_truncated_rather_than_wrapped() + { + var terminal = new FakeTerminal(windowWidth: 40).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(2), Title); + + Assert.All( + terminal.Frames[0].Split(Environment.NewLine), + line => Assert.True(line.Length < 40, $"'{line}' is {line.Length} characters wide")); + } + + [Fact] + public void The_cursor_is_put_back_when_the_picker_leaves() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Escape); + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.True(terminal.IsCursorVisible); + } + + [Fact] + public void A_redirected_terminal_is_refused_with_guidance() + { + var terminal = new FakeTerminal { IsRedirected = true }; + + var error = Assert.Throws( + () => new SkillPicker(terminal).Choose(Items(3), Title)); + + Assert.Contains("--interactive needs a terminal", error.Message); + Assert.Contains("--package", error.Message); + } + + [Fact] + public void Nothing_to_choose_between_never_prompts() + { + var terminal = new FakeTerminal { IsRedirected = true }; + + var choice = new SkillPicker(terminal).Choose([], Title); + + Assert.NotNull(choice); + Assert.Empty(choice); + Assert.Empty(terminal.Frames); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Both_modes_put_the_description_immediately_after_the_skill_name(bool uninstall) + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 80).Press(ConsoleKey.Enter); + var mode = uninstall ? PickerMode.Uninstall : PickerMode.Install; + + new SkillPicker(terminal).Choose( + [new SkillPickerItem("alpha", "Pkg", "1.2.3", "Short description.")], Title, mode); + + var row = Assert.Single(Rows(terminal.Frames[0])); + Assert.Equal("[ ] alpha - Short description.", row); + var description = Assert.Single(terminal.FrameWrites[0], write => write.Text == " - Short description."); + Assert.Equal(11, description.Left); + Assert.Equal(TerminalStyle.Focus, description.Style); + Assert.Equal(1, terminal.Frames[0].Split("Short description.", StringSplitOptions.None).Length - 1); + Assert.DoesNotContain("will install", row); + Assert.DoesNotContain("will remove", row); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" \t\r\n ")] + [InlineData("\x1b[31m\x1b[0m\u202e")] + [InlineData("\u200d")] + public void Missing_or_invisible_description_text_has_an_explicit_fallback(string? description) + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose( + [new SkillPickerItem("alpha", "Pkg", "1", description)], Title); + + Assert.Equal("alpha", Assert.Single(chosen!)); + Assert.Contains(" - No description provided.", terminal.Frames[0]); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Description_warnings_are_visible_and_do_not_change_eligibility(bool uninstall) + { + var terminal = new FakeTerminal(windowWidth: 160).Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + var mode = uninstall ? PickerMode.Uninstall : PickerMode.Install; + + var chosen = new SkillPicker(terminal).Choose( + [new SkillPickerItem("alpha", "Pkg", "1", "stale text", "cannot read SKILL.md.")], + Title, mode); + + Assert.Equal("alpha", Assert.Single(chosen!)); + Assert.Contains(" - Description unavailable: cannot read SKILL.md.", terminal.Frames[0]); + Assert.DoesNotContain("stale text", terminal.Frames[0]); + Assert.DoesNotContain("No description provided.", terminal.Frames[0]); + } + + [Fact] + public void Descriptions_continue_at_the_skill_text_edge_without_a_name_sized_gap() + { + var terminal = new FakeTerminal(windowHeight: 18, windowWidth: 46).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose( + [new SkillPickerItem("alpha", "Pkg", "1.2.3", + "One two three four five six seven eight nine ten.")], Title); + + var lines = terminal.Frames[0].Split(Environment.NewLine).Select(line => line.TrimEnd()).ToArray(); + Assert.Equal("> [ ] alpha - One two three four five six", lines[2]); + Assert.Equal(new string(' ', 6) + "seven eight nine ten.", lines[3]); + Assert.Equal(string.Empty, lines[4]); + Assert.Equal("0 of 1 selected", lines[5]); + AssertWithinWindow(terminal); + } + + [Fact] + public void Descriptions_follow_differently_sized_names_without_padding() + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 80).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose( + [ + new SkillPickerItem("alpha", "Pkg", "1.2.3", "First."), + new SkillPickerItem("beta", "Pkg", "1.2.3", "Second."), + ], + Title); + + var lines = terminal.Frames[0].Split(Environment.NewLine); + Assert.Equal("> [ ] alpha - First.", lines[2].TrimEnd()); + Assert.Equal(" [ ] beta - Second.", lines[3].TrimEnd()); + var descriptions = terminal.FrameWrites[0].Where(write => write.Text.StartsWith(" - ", StringComparison.Ordinal)).ToArray(); + Assert.Equal([11, 10], descriptions.Select(write => write.Left)); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Picker_names_keep_the_package_prefix_but_omit_the_metadata_suffix(bool uninstall, bool color) + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 100) { SupportsColor = color }; + terminal.Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + const string name = "contoso.widgets-batching"; + + var chosen = new SkillPicker(terminal).Choose( + [new SkillPickerItem(name, "Contoso.Widgets", "2.3.0", "Batching widget calls.")], + Title, + uninstall ? PickerMode.Uninstall : PickerMode.Install); + + Assert.Equal(name, Assert.Single(chosen!)); + Assert.All(terminal.Frames, frame => + { + Assert.Contains($"{name} - Batching widget calls.", frame); + Assert.DoesNotContain("(Contoso.Widgets 2.3.0)", frame); + }); + AssertWithinWindow(terminal); + } + + [Fact] + public void Help_wraps_instead_of_truncating_the_Aspire_prompt() + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 40).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(1), Title); + + var lines = terminal.Frames[0].Split(Environment.NewLine).Select(line => line.TrimEnd()).ToArray(); + var first = Array.FindIndex(lines, line => line.StartsWith("(Press ", StringComparison.Ordinal)); + Assert.Equal("(Press to select, to", lines[first]); + Assert.Equal("accept)", lines[first + 1]); + Assert.Equal("(Press to select, to accept)", $"{lines[first]} {lines[first + 1]}"); + Assert.Contains("(Press // to cancel)", string.Join(" ", lines)); + Assert.All(terminal.FrameWrites[0].Where(write => write.Top == first || write.Top == first + 1), + write => Assert.True(string.IsNullOrWhiteSpace(write.Text) || write.Style == TerminalStyle.Muted)); + AssertWithinWindow(terminal); + } + + [Fact] + public void Ticked_additions_are_counted_and_only_their_X_is_colored() + { + var terminal = new FakeTerminal() + .Press(ConsoleKey.Spacebar, ConsoleKey.DownArrow, ConsoleKey.Spacebar, ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Equal(["skill-01", "skill-02"], chosen!.Order(StringComparer.Ordinal)); + Assert.Contains("2 of 3 selected", terminal.Frames[^1]); + Assert.Equal(TerminalStyle.Default, SkillSpan(terminal, 3, 1).Style); + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 3, 1, "X").Style); + Assert.Equal(TerminalStyle.Default, CheckboxSpan(terminal, 3, 1, "[").Style); + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 3, 2, "X").Style); + Assert.Equal(TerminalStyle.Focus, CheckboxSpan(terminal, 3, 2, "[").Style); + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 3, 2).Style); + Assert.Equal(TerminalStyle.Default, SkillSpan(terminal, 3, 3).Style); + } + + [Fact] + public void Blue_focus_covers_the_whole_row_and_a_tick_away_from_focus_keeps_only_its_blue_x() + { + var terminal = new FakeTerminal() + .Press(ConsoleKey.Spacebar, ConsoleKey.DownArrow, ConsoleKey.Spacebar, ConsoleKey.UpArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title, PickerMode.Uninstall); + + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 1, 1).Style); + Assert.Equal(TerminalStyle.Default, SkillSpan(terminal, 2, 1).Style); + Assert.Equal(TerminalStyle.Focus, CheckboxSpan(terminal, 3, 2, "[").Style); + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 4, 1).Style); + Assert.Equal(TerminalStyle.Default, SkillSpan(terminal, 4, 2).Style); + Assert.Equal(TerminalStyle.Default, CheckboxSpan(terminal, 4, 2, "]").Style); + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 4, 2, "X").Style); + Assert.All(terminal.FrameWrites, writes => + { + var focus = Assert.Single(writes, write => write.Text == ">"); + Assert.Equal(TerminalStyle.Focus, focus.Style); + Assert.Equal(0, focus.Left); + Assert.All(writes.Where(write => write.Text.StartsWith(" - ", StringComparison.Ordinal)), + write => Assert.Equal(write.Top == focus.Top ? TerminalStyle.Focus : TerminalStyle.Default, write.Style)); + }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Focus_colors_every_wrapped_line_and_clears_from_the_previous_skill(bool uninstall) + { + var terminal = new FakeTerminal(windowHeight: 30, windowWidth: 80) + .Press(ConsoleKey.DownArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose( + [ + new SkillPickerItem("alpha", "Pkg", "1", "First line.\nSecond line.\nThird line."), + new SkillPickerItem("beta", "Pkg", "1", "Another line.\nAnother continuation."), + ], + Title, uninstall ? PickerMode.Uninstall : PickerMode.Install); + + var alpha = Assert.Single(terminal.FrameWrites[0], write => write.Text == " alpha").Top; + var beta = Assert.Single(terminal.FrameWrites[0], write => write.Text == " beta").Top; + Assert.Equal(3, beta - alpha); + for (var frame = 0; frame < 2; frame++) + { + Assert.All( + terminal.FrameWrites[frame].Where(write => write.Top >= alpha && write.Top < beta && + !string.IsNullOrWhiteSpace(write.Text)), + write => Assert.Equal(frame == 0 ? TerminalStyle.Focus : TerminalStyle.Default, write.Style)); + Assert.All( + terminal.FrameWrites[frame].Where(write => write.Top >= beta && write.Top < beta + 2 && + !string.IsNullOrWhiteSpace(write.Text)), + write => Assert.Equal(frame == 1 ? TerminalStyle.Focus : TerminalStyle.Default, write.Style)); + } + + AssertWithinWindow(terminal); + } + + [Fact] + public void Unticking_a_new_installation_returns_it_to_neutral() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Spacebar, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 1, 1, "X").Style); + Assert.DoesNotContain(terminal.FrameWrites[2], write => write.Text == "X"); + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 2, 1).Style); + Assert.Contains("0 of 3 selected", terminal.Frames[2]); + } + + [Fact] + public void Unticking_an_uninstall_row_returns_it_to_neutral() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Spacebar, ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose(Items(3), Title, PickerMode.Uninstall); + + Assert.Empty(chosen!); + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 1, 1, "X").Style); + Assert.Equal(TerminalStyle.Focus, CheckboxSpan(terminal, 2, 1, "[").Style); + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 2, 1).Style); + Assert.Contains("0 of 3 selected; 0 to remove", terminal.Frames[2]); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Without_color_both_pickers_show_a_tick_with_the_checkbox_alone(bool uninstall) + { + // A tick means one thing throughout a checklist, so nothing is needed beside the + // checkbox, and there is no color left for a legend to explain. + var terminal = new FakeTerminal { SupportsColor = false }; + terminal.Press(ConsoleKey.A, ConsoleKey.C, ConsoleKey.Spacebar, ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal) + .Choose(Items(3), Title, uninstall ? PickerMode.Uninstall : PickerMode.Install); + + Assert.Equal("skill-01", Assert.Single(chosen!)); + Assert.Contains("> [ ] skill-01", terminal.Frames[0]); + Assert.Contains("> [X] skill-01", terminal.Frames[1]); + Assert.Contains(" [X] skill-02", terminal.Frames[1]); + Assert.Contains(" [ ] skill-02", terminal.Frames[2]); + Assert.Contains("> [X] skill-01", terminal.Frames[3]); + Assert.Contains(" [ ] skill-02", terminal.Frames[3]); + Assert.All(terminal.StyleEvents, style => Assert.Equal(TerminalStyle.Default, style)); + Assert.All(terminal.Frames, frame => + { + Assert.DoesNotContain("+ [", frame); + Assert.DoesNotContain("- [", frame); + Assert.DoesNotContain("+ install", frame); + Assert.DoesNotContain("- remove", frame); + Assert.DoesNotContain("Blue X", frame); + }); + } + + [Theory] + [InlineData(false, null, null, true, true)] + [InlineData(true, null, "xterm-256color", true, false)] + [InlineData(false, "", "xterm-256color", true, false)] + [InlineData(false, "1", "xterm-256color", false, false)] + [InlineData(false, null, "dumb", true, false)] + [InlineData(false, null, "DuMb", false, false)] + [InlineData(false, null, "vt100", true, false)] + [InlineData(false, null, "vt220", false, false)] + [InlineData(false, null, "unknown", false, false)] + [InlineData(false, null, null, false, false)] + [InlineData(false, null, "xterm-256color", false, true)] + [InlineData(false, null, "screen", false, true)] + [InlineData(false, null, "tmux-256color", false, true)] + [InlineData(false, null, "linux", false, true)] + public void System_terminal_respects_NO_COLOR_redirection_and_color_capabilities( + bool redirected, string? noColor, string? term, bool windows, bool expected) + { + Assert.Equal(expected, SystemTerminal.CanUseColor(redirected, noColor, term, windows)); + } + + [Fact] + public void Mixed_description_pages_preserve_whole_entries_and_the_preferred_page_offset() + { + var items = MixedItems(1, 3, 2, 4, 1, 2, 3, 1, 5, 2, 1, 4, 2, 3); + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 80) + .Press(ConsoleKey.DownArrow, times: 3) + .Press(ConsoleKey.Spacebar, ConsoleKey.RightArrow, ConsoleKey.Spacebar, + ConsoleKey.RightArrow, ConsoleKey.LeftArrow, ConsoleKey.PageUp, ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose(items, Title); + + Assert.Equal(["skill-04", "skill-10"], chosen!); + Assert.Equal(Enumerable.Range(1, 6).Select(number => $"skill-{number:00}"), FrameSkillNames(terminal.Frames[0])); + Assert.Equal(Enumerable.Range(7, 5).Select(number => $"skill-{number:00}"), FrameSkillNames(terminal.Frames[5])); + Assert.Equal(Enumerable.Range(12, 3).Select(number => $"skill-{number:00}"), FrameSkillNames(terminal.Frames[7])); + Assert.Contains("> [ ] skill-10", terminal.Frames[5]); + Assert.Contains("> [ ] skill-14", terminal.Frames[7]); + Assert.Contains("> [X] skill-10", terminal.Frames[8]); + Assert.Contains("> [X] skill-04", terminal.Frames[9]); + Assert.Contains("desc-06-2", terminal.Frames[0]); + Assert.DoesNotContain("desc-07-1", terminal.Frames[0]); + Assert.Contains("desc-09-5", terminal.Frames[5]); + Assert.Contains("desc-11-1", terminal.Frames[5]); + Assert.Contains("desc-12-4", terminal.Frames[7]); + Assert.Contains("desc-14-3", terminal.Frames[7]); + Assert.DoesNotContain("desc-11-1", terminal.Frames[7]); + Assert.Equal(18, terminal.CursorTopsAwaitingKey[7]); + Assert.All(terminal.Frames[7].Split(Environment.NewLine).Skip(18), + line => Assert.True(string.IsNullOrWhiteSpace(line))); + AssertWithinWindow(terminal); + } + + [Fact] + public void Changing_actions_and_focus_never_changes_page_membership() + { + var items = Items(24) + .Select(item => item with { Description = "Short." }).ToArray(); + var terminal = new FakeTerminal(windowHeight: 18, windowWidth: 46) + .Press(ConsoleKey.C, ConsoleKey.A, ConsoleKey.DownArrow, ConsoleKey.Spacebar, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(items, Title); + + Assert.All(terminal.Frames, frame => Assert.Equal(FrameSkillNames(terminal.Frames[0]), FrameSkillNames(frame))); + Assert.Equal(Enumerable.Range(1, 5).Select(number => $"skill-{number:00}"), FrameSkillNames(terminal.Frames[0])); + Assert.Equal(SkillSpan(terminal, 0, 1).Top, SkillSpan(terminal, 4, 1).Top); + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 2, 2, "X").Style); + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 2, 1).Style); + AssertWithinWindow(terminal); + } + + [Fact] + public void A_growing_then_shrinking_summary_wraps_without_repaginating_or_leaving_old_footer_rows() + { + var items = Items(100).Select(item => item with { Description = "Short." }).ToArray(); + // At this width the empty uninstall summary fits on one line and the full one does not, + // so selecting everything grows the footer by a row and clearing shrinks it again. + var terminal = new FakeTerminal(windowHeight: 30, windowWidth: 33) + .Press(ConsoleKey.A, ConsoleKey.C, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(items, Title, PickerMode.Uninstall); + + var tops = terminal.CursorTopsAwaitingKey; + Assert.Equal([tops[0], tops[0] + 1, tops[0]], tops); + Assert.All(terminal.Frames, frame => Assert.Equal(FrameSkillNames(terminal.Frames[0]), FrameSkillNames(frame))); + Assert.Contains("100 of 100 selected; 100 to", terminal.Frames[1]); + Assert.Contains("0 of 100 selected; 0 to remove", terminal.Frames[2]); + Assert.True(string.IsNullOrWhiteSpace(terminal.Frames[2].Split(Environment.NewLine)[tops[0]])); + AssertWithinWindow(terminal); + } + + [Fact] + public void Normal_arrows_navigate_skills_even_when_a_description_is_scrollable() + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 80) + .PressWith(ConsoleModifiers.Control, ConsoleKey.DownArrow) + .Press(ConsoleKey.DownArrow, ConsoleKey.UpArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(MixedItems(50, 1, 1), Title); + + Assert.Contains(" desc-01-3", terminal.Frames[1]); + Assert.Contains(" - desc-01-1", terminal.Frames[1]); + Assert.DoesNotContain("desc-01-2", terminal.Frames[1].Split(Environment.NewLine).Select(line => line.Trim())); + Assert.Contains("> [ ] skill-02", terminal.Frames[2]); + Assert.Contains("page 2 of 2", terminal.Frames[2]); + Assert.DoesNotContain("", terminal.Frames[2]); + Assert.Contains("> [ ] skill-01", terminal.Frames[3]); + Assert.Contains(" desc-01-3", terminal.Frames[3]); + Assert.Contains("/", terminal.Frames[3]); + AssertWithinWindow(terminal); + } + + [Fact] + public void Description_scrolling_reaches_every_line_and_clamps_both_ends_without_selecting() + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 80) + .PressWith(ConsoleModifiers.Control, ConsoleKey.UpArrow, times: 3) + .PressWith(ConsoleModifiers.Control, ConsoleKey.DownArrow, times: 80) + .PressWith(ConsoleModifiers.Control, ConsoleKey.UpArrow, times: 80) + .Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + var description = string.Join("\n", Enumerable.Range(1, 60).Select(line => $"detail-{line:00}")); + + var chosen = new SkillPicker(terminal).Choose( + [new SkillPickerItem("alpha", "Pkg", "1", description)], Title); + + Assert.Equal("alpha", Assert.Single(chosen!)); + Assert.Equal(terminal.Frames[0], terminal.Frames[3]); + Assert.Contains("detail-60", terminal.Frames[83]); + Assert.Contains(" - detail-01", terminal.Frames[83]); + Assert.DoesNotContain("detail-02", terminal.Frames[83]); + Assert.Equal(terminal.Frames[82], terminal.Frames[83]); + Assert.Equal(terminal.Frames[0], terminal.Frames[163]); + for (var line = 1; line <= 60; line++) + { + Assert.Contains(terminal.Frames, frame => frame.Contains($"detail-{line:00}", StringComparison.Ordinal)); + } + + Assert.All(terminal.Frames.Take(164), frame => Assert.Contains("> [ ] alpha", frame)); + Assert.Contains("> [X] alpha", terminal.Frames[164]); + Assert.All(terminal.Frames, frame => Assert.Contains("/", frame)); + AssertWithinWindow(terminal); + } + + [Fact] + public void Scroll_controls_are_ignored_and_not_advertised_when_every_line_fits() + { + var terminal = new FakeTerminal().PressWith(ConsoleModifiers.Control, ConsoleKey.DownArrow) + .PressWith(ConsoleModifiers.Control, ConsoleKey.UpArrow) + .Press(ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Empty(chosen!); + Assert.All(terminal.Frames, frame => + { + Assert.Contains("> [ ] skill-01", frame); + Assert.DoesNotContain("", frame); + Assert.Equal(terminal.Frames[0], frame); + }); + } + + [Fact] + public void Resizing_down_and_up_preserves_focus_and_selections_and_erases_old_cells() + { + var terminal = new FakeTerminal(windowHeight: 40, windowWidth: 160) + .Press(ConsoleKey.End, ConsoleKey.Spacebar, ConsoleKey.Home, ConsoleKey.Spacebar, ConsoleKey.End) + .Resize(windowHeight: 18, windowWidth: 46) + .Resize(windowHeight: 40, windowWidth: 160) + .Press(ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.Equal(["skill-01", "skill-24"], chosen!); + Assert.Equal(3, terminal.ViewportClears); + Assert.Equal((46, 18), terminal.FrameSizes[6]); + Assert.Contains("> [X] skill-24", terminal.Frames[6]); + Assert.DoesNotContain("skill-01", terminal.Frames[6]); + Assert.DoesNotContain("Package.24", terminal.Frames[6]); + Assert.Contains("> [X] skill-24 - No description provided.", terminal.Frames[7]); + Assert.Contains("[X] skill-01 - No description provided.", terminal.Frames[7]); + Assert.Equal(24, Rows(terminal.Frames[7]).Count); + Assert.DoesNotContain("change page", terminal.Frames[7]); + AssertWithinWindow(terminal); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Resizing_during_a_redraw_restarts_the_frame_without_losing_selection(bool uninstall, bool color) + { + var terminal = new FakeTerminal(windowHeight: 50, windowWidth: 120) { SupportsColor = color }; + terminal.Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + var resized = false; + terminal.BeforeOperation = operation => + { + if (!resized && operation == nameof(FakeTerminal.SetStyle) && + terminal.KeysRead.Count == 1 && terminal.Writes.LastOrDefault()?.Text.StartsWith('[') == true) + { + resized = true; + terminal.ResizeNow(windowHeight: 18, windowWidth: 46); + } + }; + + var selected = new SkillPicker(terminal).Choose( + MixedItems(8, 8, 8), Title, uninstall ? PickerMode.Uninstall : PickerMode.Install); + + Assert.True(resized); + Assert.Equal("skill-01", Assert.Single(selected!)); + Assert.Equal(2, terminal.ViewportClears); + Assert.Contains("page 1 of 3", terminal.Frames[^1]); + Assert.Contains(PickerLayout.PrimaryHelp, terminal.Frames[^1]); + Assert.Contains("1 of 3 selected", terminal.Frames[^1]); + AssertWithinWindow(terminal); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Continuation_lines_use_the_width_beneath_the_skill_text(bool color) + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 46) { SupportsColor = color }; + terminal.Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + + var selected = new SkillPicker(terminal).Choose( + [new SkillPickerItem("longer-skill", "P", "1", + "One two three four five six seven eight nine ten.")], Title); + + var lines = terminal.Frames[0].Split(Environment.NewLine).Select(line => line.TrimEnd()).ToArray(); + // Rows start in the same column with and without color, so they wrap the same way. + Assert.EndsWith("longer-skill - One two three four five", lines[2]); + Assert.Equal(new string(' ', 6) + "six seven eight nine ten.", lines[3]); + Assert.Equal(string.Empty, lines[4]); + Assert.Equal("longer-skill", Assert.Single(selected!)); + AssertWithinWindow(terminal); + } + + [Theory] + [InlineData(false, true)] + [InlineData(false, false)] + [InlineData(true, true)] + [InlineData(true, false)] + public void Resizing_without_a_key_reflows_before_input_and_preserves_focus_and_selections( + bool uninstall, bool color) + { + var terminal = new FakeTerminal(windowHeight: 32, windowWidth: 120) { SupportsColor = color }; + terminal.Press(ConsoleKey.Spacebar, ConsoleKey.End, ConsoleKey.Spacebar) + .ResizeWhileWaiting(windowHeight: 18, windowWidth: 46) + .WaitWithoutKey(times: 2) + .Press(ConsoleKey.Enter); + const string focused = "> [X] skill-24"; + // Without color there is no legend line, so each page holds one more skill: all 24 fit + // on one page at 120x32, and the narrow window needs four pages rather than five. + var lastPage = color ? "page 5 of 5" : "page 4 of 4"; + terminal.BeforeOperation = operation => + { + if (operation == nameof(FakeTerminal.ReadKey) && terminal.KeysRead.Count == 3) + { + // Assert the resize is already visible BEFORE the next real key is consumed. + Assert.Equal(2, terminal.ViewportClears); + Assert.Contains(lastPage, terminal.Screen); + Assert.Contains(focused, terminal.Screen); + Assert.Contains("(Press to select, to accept)", terminal.Screen); + } + }; + var items = Items(24); + + var chosen = new SkillPicker(terminal).Choose(items, Title, + uninstall ? PickerMode.Uninstall : PickerMode.Install); + + Assert.Equal(["skill-01", "skill-24"], chosen!); + if (color) + { + Assert.Contains("page 2 of 2", terminal.Frames[3]); + } + else + { + Assert.DoesNotContain("page", terminal.Frames[3]); + } + + Assert.Equal((46, 18), terminal.FrameSizes[4]); + Assert.Contains(lastPage, terminal.Frames[4]); + Assert.Contains(focused, terminal.Frames[4]); + Assert.DoesNotContain("Package.24", terminal.Frames[4]); + Assert.Equal(color ? TerminalStyle.Focus : TerminalStyle.Default, + SkillSpan(terminal, 4, 24).Style); + Assert.Equal(color ? TerminalStyle.Selected : TerminalStyle.Default, CheckboxSpan(terminal, 4, 24, "X").Style); + Assert.Equal(terminal.Frames[4], terminal.Frames[5]); + Assert.Equal(terminal.Frames[4], terminal.Frames[6]); + Assert.Empty(terminal.FrameWrites[5]); + Assert.Empty(terminal.FrameWrites[6]); + Assert.Equal([ConsoleKey.Spacebar, ConsoleKey.End, ConsoleKey.Spacebar, ConsoleKey.Enter], + terminal.KeysRead.Select(key => key.Key)); + AssertWithinWindow(terminal); + } + + [Fact] + public void Idle_input_waits_are_bounded_and_do_not_repaint_unchanged_frames() + { + var terminal = new FakeTerminal().WaitWithoutKey(times: 3).Press(ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Empty(chosen!); + Assert.Equal(4, terminal.InputTimeouts.Count); + Assert.All(terminal.InputTimeouts, timeout => Assert.Equal(TimeSpan.FromMilliseconds(100), timeout)); + Assert.All(terminal.Frames, frame => Assert.Equal(terminal.Frames[0], frame)); + Assert.All(terminal.FrameWrites.Skip(1), writes => Assert.Empty(writes)); + Assert.Equal(1, terminal.ViewportClears); + Assert.Equal(ConsoleKey.Enter, Assert.Single(terminal.KeysRead).Key); + AssertWithinWindow(terminal); + } + + [Fact] + public void An_idle_resize_can_reveal_a_complete_description_and_remove_the_scroll_hint() + { + var terminal = new FakeTerminal(windowHeight: 18, windowWidth: 46) + .Press(ConsoleKey.Spacebar) + .PressWith(ConsoleModifiers.Control, ConsoleKey.DownArrow, times: 5) + .ResizeWhileWaiting(windowHeight: 100, windowWidth: 120) + .WaitWithoutKey(times: 2) + .Press(ConsoleKey.Enter); + terminal.BeforeOperation = operation => + { + if (operation == nameof(FakeTerminal.ReadKey) && terminal.KeysRead.Count == 6) + { + Assert.Contains("desc-01-60", terminal.Screen); + Assert.DoesNotContain("", terminal.Screen); + Assert.Contains("> [X] skill-01 - desc-01-1", terminal.Screen); + } + }; + + var chosen = new SkillPicker(terminal).Choose(MixedItems(60), Title); + + Assert.Equal("skill-01", Assert.Single(chosen!)); + Assert.Contains(" desc-01-7", terminal.Frames[6]); + Assert.Contains(" - desc-01-1", terminal.Frames[6]); + Assert.EndsWith(" - desc-01-1", terminal.Frames[7].Split(Environment.NewLine)[2].TrimEnd()); + Assert.Contains("desc-01-60", terminal.Frames[7]); + Assert.DoesNotContain("", terminal.Frames[7]); + Assert.Equal(terminal.Frames[7], terminal.Frames[8]); + Assert.Equal(terminal.Frames[7], terminal.Frames[9]); + Assert.Empty(terminal.FrameWrites[8]); + Assert.Empty(terminal.FrameWrites[9]); + Assert.Equal(7, terminal.KeysRead.Count); + Assert.Equal(2, terminal.ViewportClears); + AssertWithinWindow(terminal); + } + + [Fact] + public void A_page_key_received_with_a_resize_uses_the_new_page_boundaries() + { + var terminal = new FakeTerminal(windowHeight: 40, windowWidth: 200) + .Press(ConsoleKey.DownArrow, times: 2) + .ResizeBeforeKey(ConsoleKey.PageDown, windowHeight: 18, windowWidth: 100) + .Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.Equal("skill-11", Assert.Single(chosen!)); + Assert.Contains("> [ ] skill-11", terminal.Frames[3]); + Assert.Contains("page 2 of 3", terminal.Frames[3]); + Assert.DoesNotContain("skill-01", terminal.Frames[3]); + Assert.Equal(2, terminal.ViewportClears); + AssertWithinWindow(terminal); + } + + [Theory] + [InlineData(ConsoleKey.Enter)] + [InlineData(ConsoleKey.Escape)] + [InlineData(ConsoleKey.Q)] + public void Resizing_with_an_exit_key_still_redraws_and_restores_the_terminal(ConsoleKey exit) + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 80) + .ResizeBeforeKey(exit, windowHeight: 18, windowWidth: 46); + + var chosen = new SkillPicker(terminal).Choose(Items(4), Title); + + if (exit == ConsoleKey.Enter) + { + Assert.Empty(chosen!); + } + else + { + Assert.Null(chosen); + } + + Assert.Equal(2, terminal.ViewportClears); + Assert.Contains("(Press to select, to accept)", terminal.LastPickerScreen); + Assert.All(terminal.LastPickerScreen.Split(Environment.NewLine), line => Assert.True(TerminalText.Width(line) < 46)); + Assert.Empty(terminal.Screen); + Assert.InRange(terminal.FinalCursorTop, 0, 17); + Assert.True(terminal.IsCursorVisible); + Assert.False(terminal.IsControlCTakenAsInput); + Assert.Equal(TerminalStyle.Default, terminal.CurrentStyle); + AssertWithinWindow(terminal); + } + + [Fact] + public void A_scrolled_description_is_clamped_after_resizing_and_fully_shown_when_it_fits() + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 80) + .Press(ConsoleKey.Spacebar) + .PressWith(ConsoleModifiers.Control, ConsoleKey.DownArrow, times: 5) + .Resize(windowHeight: 18, windowWidth: 46) + .Resize(windowHeight: 100, windowWidth: 160) + .Resize(windowHeight: 18, windowWidth: 46) + .Press(ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose(MixedItems(60), Title); + + Assert.Equal("skill-01", Assert.Single(chosen!)); + Assert.Contains(" desc-01-7", terminal.Frames[7]); + Assert.Contains(" - desc-01-1", terminal.Frames[7]); + Assert.Contains(" - desc-01-1", terminal.Frames[8]); + Assert.Contains("desc-01-60", terminal.Frames[8]); + Assert.DoesNotContain("", terminal.Frames[8]); + Assert.Contains(" - desc-01-1", terminal.Frames[9]); + Assert.DoesNotContain("desc-01-60", terminal.Frames[9]); + Assert.Contains("/", terminal.Frames[9]); + Assert.All(terminal.Frames.Skip(1), frame => Assert.Contains("> [X] skill-01", frame)); + AssertWithinWindow(terminal); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Resizing_to_an_impossible_viewport_fails_safely_instead_of_drawing_outside_it(bool idle) + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar); + if (idle) + { + terminal.ResizeWhileWaiting(windowHeight: 4, windowWidth: 5); + } + else + { + terminal.Resize(windowHeight: 4, windowWidth: 5); + } + + var error = Assert.Throws(() => new SkillPicker(terminal).Choose(Items(3), Title)); + + Assert.Contains("5x4", error.Message); + Assert.Contains("Enlarge the window", error.Message); + Assert.True(terminal.IsCursorVisible); + Assert.False(terminal.IsControlCTakenAsInput); + Assert.Equal(ConsoleColor.Gray, terminal.Foreground); + Assert.Equal(ConsoleColor.Black, terminal.Background); + Assert.Equal(TerminalStyle.Default, terminal.CurrentStyle); + Assert.InRange(terminal.FinalCursorTop, 0, 3); + AssertWithinWindow(terminal); + } + + [Theory] + [InlineData(46, 18, true)] + [InlineData(46, 18, false)] + [InlineData(80, 24, true)] + [InlineData(80, 24, false)] + [InlineData(160, 40, true)] + [InlineData(240, 80, true)] + public void All_navigation_and_selection_keys_stay_inside_the_viewport( + int width, int height, bool color) + { + var terminal = new FakeTerminal(windowHeight: height, windowWidth: width) { SupportsColor = color }; + terminal.Press( + ConsoleKey.DownArrow, ConsoleKey.RightArrow, ConsoleKey.PageDown, ConsoleKey.UpArrow, + ConsoleKey.PageUp, ConsoleKey.LeftArrow, ConsoleKey.End, ConsoleKey.Home, ConsoleKey.A, + ConsoleKey.C, ConsoleKey.Spacebar, ConsoleKey.Enter); + var items = MixedItems(1, 3, 2, 4, 1, 2, 3, 1, 5, 2, 1, 4, 2, 3); + + var chosen = new SkillPicker(terminal).Choose(items, Title); + + Assert.Equal("skill-01", Assert.Single(chosen!)); + Assert.Contains("[X] skill-01", terminal.Frames[^1]); + Assert.Contains("(Press to select, to accept)", terminal.Frames[^1]); + AssertWithinWindow(terminal); + } + + [Fact] + public void A_picker_started_near_the_bottom_does_not_scroll_the_shell_buffer() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + terminal.SetCursorPosition(0, 16); + terminal.Write("previous prompt"); + var before = terminal.Screen; + + new SkillPicker(terminal).Choose(Items(3), Title); + + var lines = terminal.Frames[0].Split(Environment.NewLine); + Assert.Equal(Title, lines[0].TrimEnd()); + Assert.Contains("> [ ] skill-01", lines[2]); + Assert.DoesNotContain("previous prompt", terminal.Frames[0]); + Assert.Equal(11, terminal.CursorTopAwaitingKey); + Assert.Equal(before, terminal.Screen); + Assert.Equal(16, terminal.FinalCursorTop); + AssertWithinWindow(terminal); + } + + [Fact] + public void A_small_frame_is_separate_from_preceding_output_and_restores_it() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + terminal.Write("earlier output"); + terminal.SetCursorPosition(0, 3); + var before = terminal.Screen; + + new SkillPicker(terminal).Choose(Items(3), Title); + + var lines = terminal.Frames[0].Split(Environment.NewLine); + Assert.Equal(Title, lines[0].TrimEnd()); + Assert.DoesNotContain("earlier output", terminal.Frames[0]); + Assert.Equal(11, terminal.CursorTopAwaitingKey); + Assert.Equal(before, terminal.Screen); + Assert.Equal(3, terminal.FinalCursorTop); + AssertWithinWindow(terminal); + } + + [Theory] + [InlineData(ConsoleKey.Enter)] + [InlineData(ConsoleKey.Escape)] + [InlineData(ConsoleKey.Q)] + public void Exit_restores_the_original_colors_style_cursor_and_Ctrl_C_ownership(ConsoleKey exit) + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, exit); + terminal.SetStyle(TerminalStyle.Muted); + terminal.Foreground = ConsoleColor.Yellow; + terminal.Background = ConsoleColor.DarkMagenta; + terminal.CursorVisible = false; + terminal.TreatControlCAsInput = true; + var original = terminal.CaptureState(); + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Contains(TerminalStyle.Selected, terminal.StyleEvents); + Assert.Equal(original, terminal.CaptureState()); + } + + [Fact] + public void Ctrl_C_restores_colors_after_a_colored_selection() + { + var terminal = new FakeTerminal().Press(ConsoleKey.A) + .PressWith(ConsoleModifiers.Control, ConsoleKey.C); + var original = terminal.CaptureState(); + + var chosen = new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Null(chosen); + Assert.Contains(TerminalStyle.Selected, terminal.StyleEvents); + Assert.Equal(original, terminal.CaptureState()); + } + + [Theory] + [InlineData("UseUtf8Output")] + [InlineData("EnterInteractiveScreen")] + [InlineData("CursorVisible")] + [InlineData("TreatControlCAsInput")] + [InlineData("SetStyle")] + [InlineData("Write")] + [InlineData("SetCursorPosition")] + [InlineData("TryReadKey")] + [InlineData("ReadKey")] + [InlineData("ClearViewport")] + public void Exceptions_in_setup_render_input_or_resize_restore_all_terminal_state(string operation) + { + var terminal = new FakeTerminal() + .Resize(windowHeight: 24, windowWidth: 80) + .Press(ConsoleKey.Enter); + terminal.Foreground = ConsoleColor.Yellow; + terminal.Background = ConsoleColor.DarkMagenta; + var original = terminal.CaptureState(); + var failed = false; + terminal.BeforeOperation = current => + { + if (!failed && current == operation) + { + failed = true; + throw new IOException($"failure in {operation}"); + } + }; + + var error = Assert.Throws(() => new SkillPicker(terminal).Choose(Items(3), Title)); + + Assert.Equal($"failure in {operation}", error.Message); + Assert.True(failed); + Assert.Equal(original, terminal.CaptureState()); + Assert.False(terminal.IsInteractiveScreen); + Assert.Equal(terminal.ScreenEntries, terminal.ScreenExits); + } + + [Fact] + public void An_input_exception_after_an_action_span_does_not_leak_its_color() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar); + terminal.BeforeOperation = operation => + { + if (operation == nameof(FakeTerminal.ReadKey) && terminal.Frames.Count == 2) + { + throw new IOException("input disappeared"); + } + }; + + Assert.Throws(() => new SkillPicker(terminal).Choose(Items(3), Title)); + + Assert.Contains(TerminalStyle.Selected, terminal.StyleEvents); + Assert.Equal(TerminalStyle.Default, terminal.CurrentStyle); + Assert.Equal(ConsoleColor.Gray, terminal.Foreground); + Assert.Equal(ConsoleColor.Black, terminal.Background); + Assert.True(terminal.IsCursorVisible); + Assert.False(terminal.IsControlCTakenAsInput); + Assert.Equal(12, terminal.LastPickerCursorTop); + Assert.Equal(0, terminal.FinalCursorTop); + } + + [Fact] + public void A_failed_colored_write_restores_style_and_parks_below_the_last_complete_frame() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar); + terminal.Foreground = ConsoleColor.Yellow; + terminal.Background = ConsoleColor.DarkMagenta; + var original = terminal.CaptureState(); + terminal.BeforeOperation = operation => + { + if (operation == nameof(FakeTerminal.Write) && terminal.CurrentStyle == TerminalStyle.Selected) + { + throw new IOException("colored write failed"); + } + }; + + var error = Assert.Throws(() => new SkillPicker(terminal).Choose(Items(3), Title)); + + Assert.Equal("colored write failed", error.Message); + Assert.Contains(TerminalStyle.Selected, terminal.StyleEvents); + Assert.Equal(original, terminal.CaptureState()); + Assert.Equal(12, terminal.LastPickerCursorTop); + Assert.Equal(0, terminal.FinalCursorTop); + Assert.Contains("Blue X: selected", terminal.LastPickerScreen); + Assert.Empty(terminal.Screen); + } + + [Theory] + [InlineData(ConsoleKey.Enter)] + [InlineData(ConsoleKey.Escape)] + [InlineData(ConsoleKey.Q)] + public void UTF8_output_is_scoped_to_the_picker_and_the_exact_original_encoding_is_restored(ConsoleKey exit) + { + var originalEncoding = (Encoding)Encoding.Latin1.Clone(); + originalEncoding.EncoderFallback = EncoderFallback.ExceptionFallback; + var terminal = new FakeTerminal { OutputEncoding = originalEncoding }; + terminal.Press(ConsoleKey.Spacebar, exit); + terminal.BeforeOperation = operation => + { + if (operation == nameof(FakeTerminal.ReadKey)) + { + Assert.Equal(Encoding.UTF8.CodePage, terminal.OutputEncoding.CodePage); + Assert.Empty(terminal.OutputEncoding.GetPreamble()); + } + }; + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Same(originalEncoding, terminal.OutputEncoding); + Assert.Same(EncoderFallback.ExceptionFallback, terminal.OutputEncoding.EncoderFallback); + Assert.Equal([Encoding.UTF8.CodePage, originalEncoding.CodePage], + terminal.EncodingChanges.Select(encoding => encoding.CodePage)); + Assert.All(terminal.Writes, write => Assert.Equal(Encoding.UTF8.CodePage, write.OutputCodePage)); + } + + [Fact] + public void Ctrl_C_restores_the_original_output_encoding() + { + var terminal = new FakeTerminal { OutputEncoding = Encoding.Latin1 }; + terminal.Press(ConsoleKey.Spacebar).PressWith(ConsoleModifiers.Control, ConsoleKey.C); + var original = terminal.CaptureState(); + + Assert.Null(new SkillPicker(terminal).Choose(Items(3), Title)); + + Assert.Equal(original, terminal.CaptureState()); + Assert.Equal([Encoding.UTF8.CodePage, Encoding.Latin1.CodePage], + terminal.EncodingChanges.Select(encoding => encoding.CodePage)); + } + + [Theory] + [InlineData(false, true)] + [InlineData(false, false)] + [InlineData(true, true)] + [InlineData(true, false)] + public void Unicode_descriptions_are_encoded_losslessly_and_cannot_emit_terminal_controls( + bool uninstall, bool color) + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 160) + { + OutputEncoding = Encoding.Latin1, + SupportsColor = color, + }; + terminal.Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + const string Description = "Café, 測試, 🧪, Cafe\u0301. \x1b[2JUNICODE-END"; + const string ExpectedSpan = " - Café, 測試, 🧪, Cafe\u0301. UNICODE-END"; + + var chosen = new SkillPicker(terminal).Choose( + [new SkillPickerItem("alpha", "Pkg", "1", Description)], Title, + uninstall ? PickerMode.Uninstall : PickerMode.Install); + + Assert.Equal("alpha", Assert.Single(chosen!)); + var description = Assert.Single(terminal.FrameWrites[0], + write => write.Text.StartsWith(" - ", StringComparison.Ordinal)); + Assert.Equal(ExpectedSpan, description.Text); + Assert.Equal(Encoding.UTF8.GetBytes(ExpectedSpan), description.Bytes); + Assert.Equal(Encoding.UTF8.CodePage, description.OutputCodePage); + Assert.DoesNotContain((byte)0x1b, description.Bytes); + Assert.Equal(color ? TerminalStyle.Focus : TerminalStyle.Default, description.Style); + Assert.Contains("測試", terminal.Frames[0]); + Assert.Contains("🧪", terminal.Frames[0]); + Assert.Contains("Cafe\u0301", terminal.Frames[0]); + Assert.DoesNotContain('?', description.Text); + Assert.Same(Encoding.Latin1, terminal.OutputEncoding); + AssertWithinWindow(terminal); + } + + [Fact] + public void The_test_terminal_observes_legacy_encoding_loss_instead_of_assuming_Unicode_output() + { + var terminal = new FakeTerminal { OutputEncoding = Encoding.Latin1 }; + const string Text = "Café, 測試, 🧪, Cafe\u0301."; + + terminal.Write(Text); + + var write = Assert.Single(terminal.Writes); + Assert.Equal(Encoding.Latin1.GetBytes(Text), write.Bytes); + Assert.Equal(Encoding.Latin1.GetString(write.Bytes), terminal.Screen); + Assert.DoesNotContain("測試", terminal.Screen); + Assert.DoesNotContain("🧪", terminal.Screen); + Assert.DoesNotContain("Cafe\u0301", terminal.Screen); + Assert.NotEqual(Text, terminal.Screen); + } + + [Fact] + public void Noninteractive_and_empty_paths_do_not_change_output_encoding() + { + var redirected = new FakeTerminal { IsRedirected = true, OutputEncoding = Encoding.Latin1 }; + var empty = new FakeTerminal { IsRedirected = true, OutputEncoding = Encoding.Latin1 }; + + Assert.Throws(() => new SkillPicker(redirected).Choose(Items(1), Title)); + Assert.Empty(new SkillPicker(empty).Choose([], Title)!); + + Assert.Empty(redirected.EncodingChanges); + Assert.Empty(empty.EncodingChanges); + Assert.Same(Encoding.Latin1, redirected.OutputEncoding); + Assert.Same(Encoding.Latin1, empty.OutputEncoding); + Assert.Empty(redirected.Writes); + Assert.Empty(empty.Writes); + Assert.Equal(0, redirected.ScreenEntries); + Assert.Equal(0, empty.ScreenEntries); + } + + [Fact] + public void Authored_unicode_names_use_display_cells_for_column_alignment() + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 80).Press(ConsoleKey.A, ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose( + [ + new SkillPickerItem("猫", "P", "1", "First."), + new SkillPickerItem("ab", "P", "1", "Second."), + new SkillPickerItem("👩🏽‍💻", "P", "1", "Third."), + ], + Title); + + Assert.Equal(["猫", "ab", "👩🏽‍💻"], chosen!); + var descriptions = terminal.FrameWrites[0].Where(write => write.Text.StartsWith(" - ", StringComparison.Ordinal)).ToArray(); + Assert.Equal([8, 8, 8], descriptions.Select(write => write.Left)); + Assert.Equal( + [TerminalStyle.Focus, TerminalStyle.Default, TerminalStyle.Default], + descriptions.Select(write => write.Style)); + Assert.Contains("> [X] 猫 - First.", terminal.Frames[1]); + Assert.Contains("[X] 👩🏽‍💻 - Third.", terminal.Frames[1]); + AssertWithinWindow(terminal); + } + + [Fact] + public void Wrapped_unicode_descriptions_keep_every_grapheme_and_do_not_split_surrogates() + { + var terminal = new FakeTerminal(windowHeight: 18, windowWidth: 46).Press(ConsoleKey.Enter); + var description = string.Concat(Enumerable.Repeat("界e\u0301👩🏽‍💻", 12)); + + new SkillPicker(terminal).Choose( + [new SkillPickerItem("alpha", "Pkg", "1.2.3", description)], Title); + + var indent = new string(' ', 6); + var rendered = terminal.FrameWrites[0] + .Where(write => write.Text.StartsWith(" - ", StringComparison.Ordinal) || + write.Text.StartsWith(indent, StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(write.Text)) + .Select(write => write.Text.StartsWith(" - ", StringComparison.Ordinal) ? write.Text[3..] : write.Text[6..]) + .ToArray(); + Assert.Equal(description, string.Concat(rendered)); + Assert.Equal(TerminalText.Elements(description), rendered.SelectMany(TerminalText.Elements)); + Assert.All(rendered, line => Assert.DoesNotContain('\ufffd', line)); + Assert.DoesNotContain("...", terminal.Frames[0]); + AssertWithinWindow(terminal); + } + + [Fact] + public void All_author_metadata_is_sanitized_but_returned_skill_identity_is_unchanged() + { + const string Name = "unsafe\x1b[2Jskill\r\nname\u202e"; + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 120) + .Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + var item = new SkillPickerItem(Name, "P\a", "\u009b2J1", + "\x1b]8;;malicious\aVisible\x1b]8;;\x1b\\\nrow\tend\u202e\0"); + + var chosen = new SkillPicker(terminal).Choose( + [item], "\x1b[31mTitle\x1b[0m\nnext\tline"); + + Assert.Equal(Name, Assert.Single(chosen!)); + Assert.Contains("Title next line", terminal.Frames[0]); + Assert.Contains("unsafeskill name - Visible", terminal.Frames[0]); + Assert.Contains("row end", terminal.Frames[0]); + Assert.DoesNotContain("malicious", terminal.Frames[0]); + Assert.DoesNotContain('\u202e', terminal.Frames[0]); + Assert.All(terminal.Frames, frame => + Assert.DoesNotContain(frame.Replace(Environment.NewLine, string.Empty), char.IsControl)); + AssertWithinWindow(terminal); + } + + [Fact] + public void Unicode_name_clipping_keeps_whole_graphemes_and_is_not_a_fixed_width_cap() + { + var name = string.Concat(Enumerable.Repeat("👩🏽‍💻e\u0301界", 16)); + var item = new SkillPickerItem(name, "P", "1", "Short."); + var narrow = new FakeTerminal(windowHeight: 24, windowWidth: 46).Press(ConsoleKey.Enter); + var wide = new FakeTerminal(windowHeight: 24, windowWidth: 240).Press(ConsoleKey.Enter); + + new SkillPicker(narrow).Choose([item], Title); + new SkillPicker(wide).Choose([item], Title); + + Assert.Contains("...", narrow.Frames[0]); + Assert.DoesNotContain('\ufffd', narrow.Frames[0]); + Assert.Contains(name, wide.Frames[0]); + Assert.DoesNotContain("...", wide.Frames[0]); + AssertWithinWindow(narrow); + AssertWithinWindow(wide); + } + + private static IReadOnlyList MixedItems(params int[] heights) => + Items(heights.Length).Select((item, index) => item with + { + Description = string.Join("\n", + Enumerable.Range(1, heights[index]).Select(line => $"desc-{index + 1:00}-{line}")), + }).ToArray(); + + private static string[] FrameSkillNames(string frame) => Rows(frame) + .Select(row => System.Text.RegularExpressions.Regex.Match(row, @"\bskill-\d+\b").Value).ToArray(); + + private static void AssertWithinWindow(FakeTerminal terminal) + { + Assert.All(terminal.Writes, write => + { + Assert.InRange(write.Left, 0, write.WindowWidth - 1); + Assert.InRange(write.Top, 0, write.WindowHeight - 1); + Assert.True(write.Left + TerminalText.Width(write.Text) < write.WindowWidth, + $"Write used the wrap column of {write.WindowWidth}x{write.WindowHeight}: '{write.Text}'"); + }); + for (var frame = 0; frame < terminal.Frames.Count; frame++) + { + var (width, height) = terminal.FrameSizes[frame]; + var lines = terminal.Frames[frame].Split(Environment.NewLine); + Assert.True(lines.Length <= height); + Assert.All(lines, line => Assert.True(TerminalText.Width(line) < width)); + var lastContent = Array.FindLastIndex(lines, line => !string.IsNullOrWhiteSpace(line)); + Assert.True(lastContent < height - 1); + Assert.Equal(lastContent + 1, terminal.CursorTopsAwaitingKey[frame]); + } + } + + private static IReadOnlyList Items(int count) => + [ + .. Enumerable.Range(1, count).Select(number => + new SkillPickerItem($"skill-{number:00}", $"Package.{number}", "1.0.0")), + ]; +} diff --git a/dotnet-package-skills/tests/TargetLocatorTests.cs b/dotnet-package-skills/tests/TargetLocatorTests.cs new file mode 100644 index 0000000..52db0b3 --- /dev/null +++ b/dotnet-package-skills/tests/TargetLocatorTests.cs @@ -0,0 +1,118 @@ +using DotnetPackageSkills.NuGet; + +namespace DotnetPackageSkills.Tests; + +public class TargetLocatorTests +{ + [Fact] + public void Detect_prefers_a_solution_over_a_project() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreateFile("MyApp.csproj"); + + Assert.EndsWith("MyApp.sln", TargetLocator.Detect(temp.Path)); + } + + [Fact] + public void Detect_prefers_slnx_over_sln() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreateFile("MyApp.slnx"); + + Assert.EndsWith("MyApp.slnx", TargetLocator.Detect(temp.Path)); + } + + [Fact] + public void Detect_prefers_the_top_level_over_a_nested_solution() + { + using var temp = new TempDirectory(); + temp.CreateFile("Root.sln"); + temp.CreateFile("nested/Inner.sln"); + + Assert.EndsWith("Root.sln", TargetLocator.Detect(temp.Path)); + } + + [Fact] + public void Detect_descends_when_nothing_is_at_the_top_level() + { + using var temp = new TempDirectory(); + temp.CreateFile("src/MyApp/MyApp.csproj"); + + Assert.EndsWith("MyApp.csproj", TargetLocator.Detect(temp.Path)); + } + + [Fact] + public void Detect_ignores_build_output_directories() + { + using var temp = new TempDirectory(); + + // Project files copied into obj/ during restore would otherwise win by sort order. + temp.CreateFile("obj/Aaa.csproj"); + temp.CreateFile("src/Real.csproj"); + + Assert.EndsWith("Real.csproj", TargetLocator.Detect(temp.Path)); + } + + [Fact] + public void Detect_finds_fsproj_and_vbproj_too() + { + using var temp = new TempDirectory(); + temp.CreateFile("src/MyApp.fsproj"); + + Assert.EndsWith("MyApp.fsproj", TargetLocator.Detect(temp.Path)); + } + + [Fact] + public void Detect_explains_what_to_do_when_there_is_no_target() + { + using var temp = new TempDirectory(); + + var exception = Assert.Throws(() => TargetLocator.Detect(temp.Path)); + + Assert.Contains("--target", exception.Message); + } + + [Fact] + public void Resolve_accepts_a_path_relative_to_the_working_directory() + { + using var temp = new TempDirectory(); + temp.CreateFile("src/MyApp/MyApp.csproj"); + + var resolved = TargetLocator.Resolve("src/MyApp/MyApp.csproj", temp.Path); + + Assert.True(Path.IsPathRooted(resolved)); + Assert.True(File.Exists(resolved)); + } + + [Fact] + public void Resolve_searches_within_a_directory_that_was_passed_as_the_target() + { + using var temp = new TempDirectory(); + temp.CreateFile("src/MyApp/MyApp.csproj"); + + Assert.EndsWith("MyApp.csproj", TargetLocator.Resolve("src", temp.Path)); + } + + [Fact] + public void Resolve_rejects_a_file_that_is_not_a_project_or_solution() + { + using var temp = new TempDirectory(); + temp.CreateFile("notes.txt"); + + var exception = Assert.Throws(() => TargetLocator.Resolve("notes.txt", temp.Path)); + + Assert.Contains(".csproj", exception.Message); + } + + [Fact] + public void Resolve_reports_a_missing_target_by_full_path() + { + using var temp = new TempDirectory(); + + var exception = Assert.Throws(() => TargetLocator.Resolve("Ghost.sln", temp.Path)); + + Assert.Contains("Ghost.sln", exception.Message); + } +} diff --git a/dotnet-package-skills/tests/TempDirectory.cs b/dotnet-package-skills/tests/TempDirectory.cs new file mode 100644 index 0000000..fa4373f --- /dev/null +++ b/dotnet-package-skills/tests/TempDirectory.cs @@ -0,0 +1,61 @@ +namespace DotnetPackageSkills.Tests; + +/// A scratch directory that cleans itself up, for tests that touch the file system. +public sealed class TempDirectory : IDisposable +{ + public TempDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "dps-tests-" + Guid.NewGuid().ToString("N")[..12]); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public string Combine(params string[] parts) => System.IO.Path.Combine([Path, .. parts]); + + /// Creates a directory under the temp root and returns its full path. + public string CreateDirectory(params string[] parts) + { + var full = Combine(parts); + Directory.CreateDirectory(full); + return full; + } + + /// Creates a file (and its parent directories) under the temp root. + public string CreateFile(string relativePath, string content = "") + { + var full = Combine(relativePath.Split('/')); + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(full)!); + File.WriteAllText(full, content); + return full; + } + + /// Builds an extracted-package layout with a bundled skill, mirroring the NuGet cache. + public string CreatePackageWithSkill(string packageId, string version, params string[] skillNames) + { + var packageDirectory = CreateDirectory("packages", packageId.ToLowerInvariant(), version); + + foreach (var skillName in skillNames) + { + CreateFile($"packages/{packageId.ToLowerInvariant()}/{version}/skills/{skillName}/SKILL.md", + $"---\nname: {skillName}\n---\n"); + } + + return packageDirectory; + } + + public void Dispose() + { + try + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + catch (IOException) + { + // A locked file in a temp directory is not worth failing a test over. + } + } +} diff --git a/dotnet-package-skills/tests/TerminalTextTests.cs b/dotnet-package-skills/tests/TerminalTextTests.cs new file mode 100644 index 0000000..11b5cde --- /dev/null +++ b/dotnet-package-skills/tests/TerminalTextTests.cs @@ -0,0 +1,211 @@ +using DotnetPackageSkills.Cli; + +namespace DotnetPackageSkills.Tests; + +public class TerminalTextTests +{ + [Theory] + [InlineData("", 0)] + [InlineData("plain", 5)] + [InlineData("界", 2)] + [InlineData("A", 2)] + [InlineData("e\u0301", 1)] + [InlineData("😀", 2)] + [InlineData("👩🏽‍💻", 2)] + [InlineData("🇺🇸", 2)] + [InlineData("❤️", 2)] + [InlineData("♥", 1)] + [InlineData("1️⃣", 2)] + [InlineData("𐍈", 1)] + [InlineData("가", 2)] + [InlineData("\u0301", 0)] + [InlineData("a\u200d", 1)] + [InlineData("\u303f", 1)] + public void Width_counts_display_cells_not_UTF16_code_units(string text, int cells) + { + Assert.Equal(cells, TerminalText.Width(text)); + } + + [Fact] + public void Wrapping_prefers_word_boundaries() + { + Assert.Equal(["One two three", "four five"], TerminalText.Wrap("One two three four five", 13)); + } + + [Fact] + public void Wrapping_keeps_explicit_paragraph_breaks() + { + Assert.Equal(["one", "", "two", "three"], TerminalText.Wrap("one\n\ntwo three", 6)); + } + + [Fact] + public void Long_words_are_split_without_losing_their_tail() + { + Assert.Equal(["abcde", "fghij", "kl"], TerminalText.Wrap("abcdefghijkl", 5)); + } + + [Fact] + public void Wrapping_never_splits_a_combining_sequence_flag_or_joined_emoji() + { + const string Text = "ab👩🏽‍💻e\u0301🇺🇸界"; + + var lines = TerminalText.Wrap(Text, 3); + + Assert.Equal(["ab", "👩🏽‍💻e\u0301", "🇺🇸", "界"], lines); + Assert.Equal(Text, string.Concat(lines)); + Assert.All(lines, line => Assert.InRange(TerminalText.Width(line), 1, 3)); + } + + [Fact] + public void An_exactly_fitting_grapheme_is_not_replaced_or_split() + { + Assert.Equal(["👩🏽‍💻", "🇺🇸", "界"], TerminalText.Wrap("👩🏽‍💻🇺🇸界", 2)); + } + + [Fact] + public void A_column_that_cannot_fit_one_grapheme_is_rejected_instead_of_losing_it() + { + Assert.Throws(() => TerminalText.Wrap("界", 1)); + Assert.Throws(() => TerminalText.Wrap("text", 0)); + } + + [Fact] + public void Continuation_lines_can_use_more_space_than_the_first_line() + { + Assert.Equal( + ["One two", "three four five six", "seven eight nine"], + TerminalText.Wrap("One two three four five six seven eight nine", 7, 19)); + } + + [Fact] + public void Wider_continuations_keep_paragraph_breaks_and_long_word_tails() + { + Assert.Equal( + ["abc", "defgh", "", "one two three"], + TerminalText.Wrap("abcdefgh\n\none two three", 3, 13)); + } + + [Fact] + public void Wider_continuations_preserve_unicode_graphemes() + { + const string text = "界👩🏽‍💻e\u0301界"; + + var lines = TerminalText.Wrap(text, 2, 5); + + Assert.Equal(["界", "👩🏽‍💻e\u0301界"], lines); + Assert.Equal(TerminalText.Elements(text), lines.SelectMany(TerminalText.Elements)); + Assert.Equal(["a", "界"], TerminalText.Wrap("a界", 1, 2)); + } + + [Fact] + public void Both_wrapping_widths_must_be_positive() + { + Assert.Throws(() => TerminalText.Wrap("text", 0, 10)); + Assert.Throws(() => TerminalText.Wrap("text", 10, 0)); + } + + [Theory] + [InlineData(6, "e\u0301界...")] + [InlineData(4, "e\u0301...")] + [InlineData(3, "...")] + [InlineData(2, "..")] + [InlineData(1, ".")] + [InlineData(0, "")] + public void Clipping_names_preserves_graphemes_and_reserves_the_ellipsis(int width, string expected) + { + var clipped = TerminalText.Clip("e\u0301界👩🏽‍💻rest", width); + + Assert.Equal(expected, clipped); + Assert.True(TerminalText.Width(clipped) <= width); + Assert.DoesNotContain('\ufffd', clipped); + } + + [Fact] + public void Fitting_text_is_not_given_an_ellipsis() + { + Assert.Equal("👩🏽‍💻e\u0301", TerminalText.Clip("👩🏽‍💻e\u0301", 3)); + } + + [Fact] + public void Padding_aligns_cells_instead_of_surrogates_or_combining_marks() + { + Assert.Equal("界e\u0301 ", TerminalText.PadRight("界e\u0301", 6)); + } + + [Fact] + public void Sanitizing_strips_ANSI_commands_hyperlinks_controls_and_direction_overrides() + { + const string Text = + "before\x1b[31mRED\x1b[0m\x1b]8;;https://invalid.example\a" + + "link\x1b]8;;\x1b\\\r\nnext\tpart\0\a\u007f\u009b2J\u202eafter\u2066\u2069"; + + Assert.Equal("beforeREDlink\nnext partafter", TerminalText.Sanitize(Text, multiline: true)); + Assert.Equal("beforeREDlink next partafter", TerminalText.Sanitize(Text)); + } + + [Fact] + public void Sanitizing_trims_boundaries_by_default_but_can_preserve_them() + { + const string Text = " \t\u001b[31mfirst\u001b[0m\r\n second \r\n"; + const string Preserved = " first\n second \n"; + + Assert.Equal("first\n second", TerminalText.Sanitize(Text, multiline: true)); + Assert.Equal("first\n second", TerminalText.Sanitize(Text, multiline: true, trim: true)); + Assert.Equal(Preserved, TerminalText.Sanitize(Text, multiline: true, trim: false)); + Assert.Equal(Preserved.Replace('\n', ' ').Trim(), TerminalText.Sanitize(Text)); + Assert.Equal(Preserved.Replace('\n', ' '), TerminalText.Sanitize(Text, trim: false)); + } + + [Theory] + [InlineData(null, "")] + [InlineData("", "")] + [InlineData(" \r\n\t", " \n ")] + public void Preserving_boundaries_keeps_blank_text_without_inventing_content(string? text, string expected) + { + Assert.Equal(string.Empty, TerminalText.Sanitize(text, multiline: true)); + Assert.Equal(expected, TerminalText.Sanitize(text, multiline: true, trim: false)); + } + + [Fact] + public void Preserving_boundaries_still_discards_unterminated_escape_payloads() + { + const string Text = " before \u001b]52;c;SECRET\r\n"; + + Assert.Equal("before", TerminalText.Sanitize(Text, multiline: true)); + Assert.Equal(" before ", TerminalText.Sanitize(Text, multiline: true, trim: false)); + } + + [Theory] + [InlineData("a\x1bPignored\x1b\\b", "ab")] + [InlineData("a\u009dignored\u009cb", "ab")] + [InlineData("a\x1b(0b", "ab")] + [InlineData("a\x1b[999mvisible", "avisible")] + [InlineData("a\x1b]unterminated", "a")] + [InlineData("a\x1b", "a")] + public void Terminal_escape_strings_do_not_leak_their_payload(string input, string expected) + { + Assert.Equal(expected, TerminalText.Sanitize(input)); + } + + [Fact] + public void Authored_unicode_is_not_forced_to_ASCII() + { + const string Text = "👩🏽‍💻 Café e\u0301 中文 🇨🇦"; + + Assert.Equal(Text, TerminalText.Sanitize(Text)); + } + + [Fact] + public void Unpaired_surrogates_become_visible_replacement_characters() + { + Assert.Equal("before\ufffdafter\ufffd", TerminalText.Sanitize("before\ud800after\udfff")); + } + + [Fact] + public void Line_separators_are_normalized_without_allowing_terminal_control_characters() + { + Assert.Equal("one\ntwo\nthree\nfour", + TerminalText.Sanitize("one\r\ntwo\rthree\u2028four", multiline: true)); + Assert.Equal("one two three four", TerminalText.Sanitize("one\r\ntwo\rthree\u2028four")); + } +} diff --git a/eng/pipelines/dotnet-package-skills/Get-PackageVersion.ps1 b/eng/pipelines/dotnet-package-skills/Get-PackageVersion.ps1 new file mode 100644 index 0000000..11d7be9 --- /dev/null +++ b/eng/pipelines/dotnet-package-skills/Get-PackageVersion.ps1 @@ -0,0 +1,50 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $BaseVersion, + + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $BuildId, + + [Parameter(Mandatory)] + [string] $BuildReason, + + [Parameter(Mandatory)] + [string] $SourceBranch, + + [string] $PullRequestNumber, + + [switch] $Official, + + [switch] $ReleaseBuild +) + +$ErrorActionPreference = 'Stop' + +if ($BaseVersion -cnotmatch '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { + throw 'BaseVersion must be a three-part semantic version without leading zeros or a suffix.' +} +if ($BuildId -cnotmatch '^[1-9][0-9]*$') { + throw 'BuildId must be a positive integer supplied by Azure Pipelines.' +} +if ($Official -and $BuildReason -eq 'PullRequest') { + throw 'Pull requests cannot use the official signing path.' +} +if ($ReleaseBuild) { + if (-not $Official -or $BuildReason -ne 'Manual' -or $SourceBranch -ne 'refs/heads/main') { + throw 'A stable release requires a manual official build from refs/heads/main.' + } + return $BaseVersion +} +if ($Official) { + return "$BaseVersion-preview.$BuildId" +} +if ($BuildReason -eq 'PullRequest') { + if ($PullRequestNumber -cnotmatch '^[1-9][0-9]*$') { + throw 'PullRequestNumber must be a positive integer supplied by the GitHub PR build.' + } + return "$BaseVersion-pr.$PullRequestNumber.$BuildId" +} + +return "$BaseVersion-ci.$BuildId" diff --git a/eng/pipelines/dotnet-package-skills/README.md b/eng/pipelines/dotnet-package-skills/README.md new file mode 100644 index 0000000..0aedd4e --- /dev/null +++ b/eng/pipelines/dotnet-package-skills/README.md @@ -0,0 +1,159 @@ +# dotnet-package-skills pipeline + +`stage.yml` contributes one independent `DotnetPackageSkills` stage to each root pipeline. +All tool-specific build, test, version, signing, package-verification, and artifact steps live +here. The source, SDK pin, restore configuration, and C# tests live in the repository's +`dotnet-package-skills` folder. There is no Python or Node dependency. + +| Entry point | Source | Outputs | +| --- | --- | --- | +| `eng\pipelines\pr.yml` | GitHub, PRs targeting `main`, in `dnceng-public/public` | Unsigned validation package, C# results, logs | +| `eng\pipelines\official.yml` | Trusted `dnceng/internal/NuGet-Client.Tools` Azure Repos mirror, `main` only | Signed package with 1ES-governed outputs, C# results, logs | + +The official definition must select the trusted repository as its source. `checkout: self` +does not change a definition that was incorrectly configured to use GitHub. + +## Stage contract + +The stage accepts `isOfficialBuild` and `releaseBuild`, both defaulting to false. +The root PR pipeline fixes both to false. The official entry point fixes `isOfficialBuild` +to true and passes its `DotnetPackageSkillsReleaseBuild` queue parameter, which defaults to false. +Do not expose real signing as a PR queue option. + +The stage has `dependsOn: []`. Other tools must not consume its variables/artifacts or +implicitly depend on its position in the stage list. Pipeline-wide pools, triggers, the trusted +mirror, and the official 1ES wrapper remain repository-owned. Platform-injected 1ES governance +may add stages; do not disable it to force the expanded official pipeline to contain one stage. + +Within the tool stage: + +1. Install the SDK from the tool's `global.json` and the .NET 8 runtime. +2. Calculate and validate the package version. +3. Restore and build the solution in Release, then run C# tests for net8.0 and net10.0. +4. Official only: sign and verify the tool-owned DLL in each target framework's build output. +5. Pack only the tool project with `--no-build --no-restore`. +6. Official only: scan, sign, and verify the exact `.nupkg`. +7. Verify the package payload, install it for each framework, and smoke-test the installed commands. +8. Publish the successful package. Retain test results and diagnostic logs when a step fails. + +All dotnet commands run from the tool folder so SDK selection honors its scoped `global.json`. +PRs do not require the 1ES template repository or any production credential. + +## Package versions + +The tool project's `VersionPrefix` is the single base-version setting, initially `0.1.0`. +Local builds use the `dev` suffix. CI computes a version with `Get-PackageVersion.ps1` and +passes `DotnetPackageSkillsVersion` to both build and pack. This override is consumed only by +the tool project. + +| Run | Example | +| --- | --- | +| Local | `0.1.0-dev` | +| GitHub PR 42, build 12345 | `0.1.0-pr.42.12345` | +| Manually queued public definition, build 12345 | `0.1.0-ci.12345` | +| Ordinary official build 12345 | `0.1.0-preview.12345` | +| Manual official release on trusted main | `0.1.0` | + +To produce a stable artifact, review/merge the intended base version, wait for trusted-mirror +parity, and manually queue the official pipeline from `main` with +`DotnetPackageSkillsReleaseBuild=true`. CI-triggered, PR, and non-main release requests fail. +Changing major/minor/patch remains a reviewed source change; CI does not create version commits +or tags. A new BuildId yields a new prerelease version, while retrying the same build retains it. + +Package/informational versions carry the suffix and source provenance. The build ID is not a +numeric assembly-version component. Do not override the version only during pack: the packaged +assembly and NuGet metadata must describe the same build. + +## Official signing setup + +Official builds require owner-approved ESRP v6 WIF/MSI configuration. No connection, identity, +certificate, or signing profile is created or selected automatically. Configure these +non-secret Azure Pipeline variables on the official definition before its first signing run: + +| Variable | Required value | +| --- | --- | +| `DotnetPackageSkillsEsrpServiceConnection` | Name of the approved WIF connection, authorized specifically for this pipeline | +| `DotnetPackageSkillsEsrpManagedIdentityClientId` | Managed identity client ID used by that connection | +| `DotnetPackageSkillsEsrpTenantId` | Tenant ID of the managed identity | +| `DotnetPackageSkillsEsrpClientId` | ESRP account's registered client/application ID | +| `DotnetPackageSkillsEsrpKeyVault` | Key vault containing the ESRP request-signing certificate | +| `DotnetPackageSkillsEsrpRequestSigningCertificate` | Request-signing certificate name, not certificate material | +| `DotnetPackageSkillsEsrpBinaryKeyCode` | Approved code-signing profile for the tool's DLLs | +| `DotnetPackageSkillsEsrpNuGetKeyCode` | Approved NuGet author-signing profile | + +The service-connection variable must be available during pipeline resource authorization, not +created by a runtime step. Keep secrets and certificate material in the approved service, never +in YAML or pipeline logs. Do not authorize all pipelines as a shortcut. + +The owner must also authorize the internal pool, confirm access to the organization-local 1ES +template repository, install/enable the ESRP v6 signing and scanning tasks, and complete required +signing approvals and branch controls. The stage checks that it is running on `main` from the +trusted repository. Missing settings or access fail the run; there is no unsigned official mode. +Azure DevOps may reject a missing/unauthorized service connection before any job starts. + +`steps-sign.yml` targets only `dotnet-package-skills.dll` in net8.0 and net10.0, followed by the +single generated tool `.nupkg`. Tool packing publishes the intermediate assemblies under +`obj\Release`, not the copies under `bin\Release`: the signing step first checks that these +match the tested build outputs, signs the intermediate assemblies, then copies the verified +signed bytes back to `bin` for final payload comparison. Signing only `bin` would allow pack +to replace the signed payload. No test assemblies or third-party dependencies are +signed, and strong-name identities are unchanged. + +`Verify-Package.ps1 -RequireSigned` requires a NuGet signature, checks `dotnet nuget verify --all`, +validates assembly signatures, and compares each packaged DLL with its signed build output. +Packing must not rebuild or replace those assemblies. + +The first successful signed run remains an onboarding acceptance gate. Local and public PR +validation alone do not establish that ESRP permissions are configured. + +### Alternative considered: `dotnet/sign` + +[`dotnet/sign`](https://github.com/dotnet/sign) is a .NET Foundation CLI tool that signs +`.nupkg`/`.dll`/`.vsix`/ClickOnce files by delegating to an Azure Key Vault certificate, with no +ESRP client, OneCert registration, or SAW access required. A team only needs a managed identity +granted `sign`/`get` on its own Key Vault certificate, behind an ordinary workload-identity-federated +service connection. This is a materially lighter onboarding path than ESRP. + +It is not used here because Microsoft's internal SFI compliance for production/official Azure +Pipelines requires the `EsrpCodeSigning` task itself to execute; a cryptographically valid +signature produced by `dotnet sign` against a self-owned certificate does not satisfy that +requirement, and the resulting artifact would not carry Microsoft's own code-signing identity. +`dotnet/sign` remains worth a second look if this tool's signing requirements ever change, for +example a community-owned fork that signs with its own certificate instead of Microsoft's. + +## Artifacts and local verification + +Artifacts are named `dotnet-package-skills-packages`, `dotnet-package-skills-testresults`, and +`dotnet-package-skills-logs`. Their staging directory is +`$(Build.ArtifactStagingDirectory)\dotnet-package-skills`. Official artifacts use 1ES +`templateContext.outputs`; only the public path uses ordinary publish tasks. Nothing is pushed +to an internal feed or nuget.org. + +From the tool source folder, after building and packing: + +```powershell +pwsh -NoProfile -File ..\eng\pipelines\dotnet-package-skills\Verify-Package.ps1 ` + -PackagePath .\artifacts\packages\dotnet-package-skills.0.1.0-dev.nupkg ` + -ExpectedVersion 0.1.0-dev ` + -BuildOutputPath .\src\bin\Release +``` + +Use the actual package version when verifying CI artifacts. The helper uses a local-only +temporary feed, separate NuGet cache/CLI home, and isolated tool paths. It exercises both +target frameworks with patch-only runtime roll-forward, preserves handwritten fixture skills, +and removes only the temporary directory it created. + +## Retirement when the tool moves into the .NET SDK + +1. Delete the repository-root `dotnet-package-skills` source folder and this + `eng\pipelines\dotnet-package-skills` pipeline folder. +2. Remove their single stage-template reference from `pr.yml` and `official.yml`, and remove + `DotnetPackageSkillsReleaseBuild` from `official.yml`. +3. Remove the tool entry from the root README. No root SDK pin, NuGet configuration, or MSBuild + hook was added for this tool. +4. Verify that remaining stages reference none of the removed files, variables, or artifacts. + Other tools should require no implementation changes. +5. Have the pipeline owner remove tool-specific settings/permissions where appropriate. Keep + shared pools, connections, the trusted mirror, and the official 1ES wrapper. +6. If no workload remains, retire the unused pipeline definitions/entry points with their + owner's approval instead of leaving invalid empty-stage YAML. diff --git a/eng/pipelines/dotnet-package-skills/Verify-Package.ps1 b/eng/pipelines/dotnet-package-skills/Verify-Package.ps1 new file mode 100644 index 0000000..4708025 --- /dev/null +++ b/eng/pipelines/dotnet-package-skills/Verify-Package.ps1 @@ -0,0 +1,186 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $PackagePath, + + [Parameter(Mandatory)] + [string] $ExpectedVersion, + + [Parameter(Mandatory)] + [string] $BuildOutputPath, + + [switch] $RequireSigned +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Invoke-CheckedCommand { + param([string] $Command, [string[]] $Arguments) + + $output = & $Command @Arguments 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "'$Command' failed with exit code ${LASTEXITCODE}:`n$($output -join "`n")" + } + return $output -join "`n" +} + +function Read-ArchiveText { + param([IO.Compression.ZipArchive] $Archive, [string] $Name) + + $entry = $Archive.GetEntry($Name) + if ($null -eq $entry) { throw "Package is missing $Name." } + $reader = [IO.StreamReader]::new($entry.Open()) + try { return $reader.ReadToEnd() } + finally { $reader.Dispose() } +} + +$package = (Resolve-Path -LiteralPath $PackagePath).Path +$buildOutput = (Resolve-Path -LiteralPath $BuildOutputPath).Path +$archive = [IO.Compression.ZipFile]::OpenRead($package) +try { + [xml] $nuspec = Read-ArchiveText $archive 'dotnet-package-skills.nuspec' + if ($nuspec.package.metadata.id -cne 'dotnet-package-skills') { + throw 'Package ID must be dotnet-package-skills.' + } + if ($nuspec.package.metadata.version -cne $ExpectedVersion) { + throw "Package version '$($nuspec.package.metadata.version)' does not match '$ExpectedVersion'." + } + if ($nuspec.package.metadata.packageTypes.packageType.name -cne 'DotnetTool') { + throw 'Package must declare the DotnetTool package type.' + } + if ($nuspec.package.metadata.license.type -cne 'expression' -or + $nuspec.package.metadata.license.InnerText -cne 'MIT') { + throw 'Package must retain its MIT license expression.' + } + if ([string]::IsNullOrWhiteSpace((Read-ArchiveText $archive 'README.md'))) { + throw 'Package README must not be empty.' + } + if ($RequireSigned -and $null -eq $archive.GetEntry('.signature.p7s')) { + throw 'Package is not signed; official artifacts must have a NuGet signature.' + } + + foreach ($framework in @('net8.0', 'net10.0')) { + [xml] $settings = Read-ArchiveText $archive "tools/$framework/any/DotnetToolSettings.xml" + $command = $settings.DotNetCliTool.Commands.Command + if ($command.Name -cne 'dotnet-package-skills' -or $command.EntryPoint -cne 'dotnet-package-skills.dll') { + throw "Incorrect tool command settings for $framework." + } + + $entry = $archive.GetEntry("tools/$framework/any/dotnet-package-skills.dll") + if ($null -eq $entry) { throw "Package is missing the $framework assembly." } + $assembly = Join-Path $buildOutput "$framework\dotnet-package-skills.dll" + $stream = $entry.Open() + try { $packageHash = (Get-FileHash -InputStream $stream -Algorithm SHA256).Hash } + finally { $stream.Dispose() } + if ($packageHash -cne (Get-FileHash -LiteralPath $assembly -Algorithm SHA256).Hash) { + throw "The $framework package payload does not match the built assembly." + } + if ($RequireSigned) { + $signature = Get-AuthenticodeSignature -LiteralPath $assembly + if ($signature.Status -ne 'Valid') { + throw "The $framework assembly signature is not valid: $($signature.StatusMessage)" + } + } + } +} +finally { + $archive.Dispose() +} + +if ($RequireSigned) { + Write-Host (Invoke-CheckedCommand dotnet @('nuget', 'verify', $package, '--all')) +} + +$temporary = [IO.Directory]::CreateTempSubdirectory('dotnet-package-skills-verify-').FullName +$originalEnvironment = @{} +foreach ($name in @( + 'NUGET_PACKAGES', 'DOTNET_CLI_HOME', 'DOTNET_ROLL_FORWARD', + 'DOTNET_GENERATE_ASPNET_CERTIFICATE', 'DOTNET_ADD_GLOBAL_TOOLS_TO_PATH', + 'DOTNET_CLI_TELEMETRY_OPTOUT', 'DOTNET_NOLOGO' +)) { + $originalEnvironment[$name] = [Environment]::GetEnvironmentVariable($name) +} + +try { + $feed = [IO.Directory]::CreateDirectory((Join-Path $temporary 'feed')).FullName + Copy-Item -LiteralPath $package -Destination $feed + $config = Join-Path $temporary 'NuGet.config' + $escapedFeed = [Security.SecurityElement]::Escape($feed) + [IO.File]::WriteAllText($config, @" + + + + + +"@) + $env:NUGET_PACKAGES = Join-Path $temporary 'nuget-cache' + $env:DOTNET_CLI_HOME = Join-Path $temporary 'cli-home' + $env:DOTNET_ROLL_FORWARD = 'LatestPatch' + $env:DOTNET_GENERATE_ASPNET_CERTIFICATE = 'false' + $env:DOTNET_ADD_GLOBAL_TOOLS_TO_PATH = 'false' + $env:DOTNET_CLI_TELEMETRY_OPTOUT = '1' + $env:DOTNET_NOLOGO = '1' + + $fixtureCache = Join-Path $temporary 'fixture-cache' + $skill = Join-Path $fixtureCache 'contoso.widgets\2.3.0\skills\contoso.widgets-widget-usage' + [IO.Directory]::CreateDirectory($skill) | Out-Null + [IO.File]::WriteAllText((Join-Path $skill 'SKILL.md'), "---`nname: contoso.widgets-widget-usage`ndescription: Local package verification fixture.`n---`n") + + foreach ($framework in @('net8.0', 'net10.0')) { + $toolPath = Join-Path $temporary "tools-$framework" + Write-Host (Invoke-CheckedCommand dotnet @( + 'tool', 'install', 'dotnet-package-skills', '--tool-path', $toolPath, + '--version', $ExpectedVersion, '--framework', $framework, + '--configfile', $config, '--no-http-cache', '--verbosity', 'quiet' + )) + + $tool = Join-Path $toolPath 'dotnet-package-skills.exe' + $version = (Invoke-CheckedCommand $tool @('--version')).Trim() + if (($version -split '\+', 2)[0] -cne $ExpectedVersion) { + throw "Installed $framework tool version '$version' does not match '$ExpectedVersion'." + } + $help = Invoke-CheckedCommand $tool @('--help') + if ($help -notmatch 'install' -or $help -notmatch 'uninstall') { + throw "Installed $framework tool does not expose its expected commands." + } + + $destination = Join-Path $temporary "skills-$framework" + $handwritten = Join-Path $destination 'handwritten' + [IO.Directory]::CreateDirectory($handwritten) | Out-Null + [IO.File]::WriteAllText((Join-Path $handwritten 'SKILL.md'), 'Preserve this skill.') + $packageArguments = @('--package', 'Contoso.Widgets@2.3.0', '--global-packages', $fixtureCache, '--destination', $destination) + + $listing = Invoke-CheckedCommand $tool (@('list') + $packageArguments) + if ($listing -notmatch 'contoso.widgets-widget-usage') { + throw "Installed $framework tool did not discover the local fixture." + } + Write-Host (Invoke-CheckedCommand $tool (@('install') + $packageArguments)) + $installedSkill = Join-Path $destination 'contoso.widgets-widget-usage\SKILL.md' + if (-not (Test-Path -LiteralPath $installedSkill)) { + throw "Installed $framework tool did not copy the fixture skill." + } + $manifestPath = Join-Path $destination '.dotnet-package-skills.json' + $manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json + if ($manifest.packages.'contoso.widgets'.version -cne '2.3.0') { + throw "Installed $framework tool did not record the fixture package version." + } + Write-Host (Invoke-CheckedCommand $tool @('uninstall', '--package', 'Contoso.Widgets', '--destination', $destination)) + if (Test-Path -LiteralPath $installedSkill) { + throw "Installed $framework tool did not remove the fixture skill." + } + if ([IO.File]::ReadAllText((Join-Path $handwritten 'SKILL.md')) -cne 'Preserve this skill.') { + throw "Installed $framework tool changed a handwritten skill." + } + } +} +finally { + foreach ($name in $originalEnvironment.Keys) { + $value = $originalEnvironment[$name] + if ($null -eq $value) { $value = [NullString]::Value } + [Environment]::SetEnvironmentVariable($name, $value) + } + Remove-Item -LiteralPath $temporary -Recurse -Force +} + +Write-Host "Verified dotnet-package-skills $ExpectedVersion on .NET 8 and .NET 10." diff --git a/eng/pipelines/dotnet-package-skills/stage.yml b/eng/pipelines/dotnet-package-skills/stage.yml new file mode 100644 index 0000000..e4e7159 --- /dev/null +++ b/eng/pipelines/dotnet-package-skills/stage.yml @@ -0,0 +1,215 @@ +parameters: +- name: isOfficialBuild + type: boolean + default: false +- name: releaseBuild + type: boolean + default: false + +stages: +- stage: DotnetPackageSkills + displayName: dotnet-package-skills + dependsOn: [] + variables: + - name: DotnetPackageSkillsDirectory + value: $(Build.SourcesDirectory)\dotnet-package-skills + - name: DotnetPackageSkillsPipelineDirectory + value: $(Build.SourcesDirectory)\eng\pipelines\dotnet-package-skills + - name: DotnetPackageSkillsArtifacts + value: $(Build.ArtifactStagingDirectory)\dotnet-package-skills + - name: DOTNET_NOLOGO + value: '1' + - name: DOTNET_CLI_TELEMETRY_OPTOUT + value: '1' + - name: DOTNET_GENERATE_ASPNET_CERTIFICATE + value: 'false' + - name: DOTNET_ADD_GLOBAL_TOOLS_TO_PATH + value: 'false' + jobs: + - job: BuildWindows + displayName: Build, test, and package + timeoutInMinutes: 60 + ${{ if eq(parameters.isOfficialBuild, true) }}: + templateContext: + outputs: + - output: pipelineArtifact + targetPath: $(DotnetPackageSkillsArtifacts)\packages + artifactName: dotnet-package-skills-packages + - output: pipelineArtifact + targetPath: $(DotnetPackageSkillsArtifacts)\logs + artifactName: dotnet-package-skills-logs + condition: succeededOrFailed() + isProduction: false + - output: pipelineArtifact + targetPath: $(DotnetPackageSkillsArtifacts)\testresults + artifactName: dotnet-package-skills-testresults + condition: succeededOrFailed() + isProduction: false + steps: + - checkout: self + clean: true + fetchDepth: 1 + fetchTags: false + + - pwsh: | + foreach ($directory in @('logs', 'testresults', 'packages')) { + New-Item -ItemType Directory -Force -Path "$(DotnetPackageSkillsArtifacts)\$directory" | Out-Null + } + displayName: Prepare tool artifact directories + + - ${{ if eq(parameters.isOfficialBuild, true) }}: + - pwsh: | + if ($env:BUILD_REPOSITORY_PROVIDER -ne 'TfsGit' -or + $env:BUILD_REPOSITORY_NAME -ne 'NuGet-Client.Tools' -or + $env:SYSTEM_TEAMPROJECT -ne 'internal' -or + $env:SYSTEM_COLLECTIONURI -ne 'https://dev.azure.com/dnceng/' -or + $env:BUILD_SOURCEBRANCH -ne 'refs/heads/main' -or + $env:BUILD_REASON -eq 'PullRequest') { + throw 'Official signing requires main from the trusted dnceng/internal/NuGet-Client.Tools repository.' + } + foreach ($name in @( + 'ESRP_SERVICE_CONNECTION', 'ESRP_MANAGED_IDENTITY', 'ESRP_TENANT', 'ESRP_CLIENT', + 'ESRP_KEY_VAULT', 'ESRP_REQUEST_CERTIFICATE', 'ESRP_BINARY_KEY', 'ESRP_NUGET_KEY' + )) { + $value = [Environment]::GetEnvironmentVariable($name) + if ([string]::IsNullOrWhiteSpace($value) -or $value.Contains('$(')) { + throw "Missing official signing setting $name. Configure and authorize the pipeline as described in eng/pipelines/dotnet-package-skills/README.md." + } + } + foreach ($name in @('ESRP_MANAGED_IDENTITY', 'ESRP_TENANT', 'ESRP_CLIENT')) { + $parsed = [guid]::Empty + if (-not [guid]::TryParse([Environment]::GetEnvironmentVariable($name), [ref] $parsed)) { + throw "Signing setting $name must be a GUID." + } + } + foreach ($name in @('ESRP_BINARY_KEY', 'ESRP_NUGET_KEY')) { + if ([Environment]::GetEnvironmentVariable($name) -cnotmatch '^[A-Za-z0-9-]+$') { + throw "Signing setting $name must be an approved ESRP key code." + } + } + displayName: Validate trusted source and required signing configuration + env: + ESRP_SERVICE_CONNECTION: $(DotnetPackageSkillsEsrpServiceConnection) + ESRP_MANAGED_IDENTITY: $(DotnetPackageSkillsEsrpManagedIdentityClientId) + ESRP_TENANT: $(DotnetPackageSkillsEsrpTenantId) + ESRP_CLIENT: $(DotnetPackageSkillsEsrpClientId) + ESRP_KEY_VAULT: $(DotnetPackageSkillsEsrpKeyVault) + ESRP_REQUEST_CERTIFICATE: $(DotnetPackageSkillsEsrpRequestSigningCertificate) + ESRP_BINARY_KEY: $(DotnetPackageSkillsEsrpBinaryKeyCode) + ESRP_NUGET_KEY: $(DotnetPackageSkillsEsrpNuGetKeyCode) + + - task: UseDotNet@2 + displayName: Install pinned .NET SDK + inputs: + packageType: sdk + useGlobalJson: true + workingDirectory: $(DotnetPackageSkillsDirectory) + + - task: UseDotNet@2 + displayName: Install .NET 8 runtime for compatibility tests + inputs: + packageType: runtime + version: 8.0.x + + - pwsh: | + $baseVersion = dotnet msbuild .\src\DotnetPackageSkills.csproj -nologo -getProperty:VersionPrefix + if ($LASTEXITCODE -ne 0) { throw 'Could not evaluate the tool base version.' } + $official = [bool]::Parse('${{ parameters.isOfficialBuild }}') + $release = [bool]::Parse('${{ parameters.releaseBuild }}') + $version = & "$(DotnetPackageSkillsPipelineDirectory)\Get-PackageVersion.ps1" ` + -BaseVersion $baseVersion.Trim() -BuildId $env:BUILD_BUILDID ` + -BuildReason $env:BUILD_REASON -SourceBranch $env:BUILD_SOURCEBRANCH ` + -PullRequestNumber $env:SYSTEM_PULLREQUEST_PULLREQUESTNUMBER ` + -Official:$official -ReleaseBuild:$release + Write-Host "##vso[task.setvariable variable=DotnetPackageSkillsVersion]$version" + Write-Host "dotnet-package-skills package version: $version" + displayName: Resolve package version + workingDirectory: $(DotnetPackageSkillsDirectory) + + - pwsh: | + dotnet restore .\DotnetPackageSkills.slnx --configfile .\NuGet.config ` + "-p:DotnetPackageSkillsVersion=$(DotnetPackageSkillsVersion)" ` + "-bl:$(DotnetPackageSkillsArtifacts)\logs\restore.binlog" + if ($LASTEXITCODE -ne 0) { throw 'Restore failed.' } + displayName: Restore tool and tests + workingDirectory: $(DotnetPackageSkillsDirectory) + + - pwsh: | + dotnet build .\DotnetPackageSkills.slnx --configuration Release --no-restore ` + "-p:DotnetPackageSkillsVersion=$(DotnetPackageSkillsVersion)" ` + "-p:SourceRevisionId=$(Build.SourceVersion)" ` + "-bl:$(DotnetPackageSkillsArtifacts)\logs\build.binlog" + if ($LASTEXITCODE -ne 0) { throw 'Build failed.' } + displayName: Build tool and C# tests + workingDirectory: $(DotnetPackageSkillsDirectory) + + - pwsh: | + dotnet test .\tests\DotnetPackageSkills.Tests.csproj ` + --configuration Release --no-build --no-restore ` + "-p:DotnetPackageSkillsVersion=$(DotnetPackageSkillsVersion)" ` + --logger "trx;LogFilePrefix=dotnet-package-skills" ` + --results-directory "$(DotnetPackageSkillsArtifacts)\testresults" + if ($LASTEXITCODE -ne 0) { throw 'C# tests failed.' } + displayName: Run C# tests on .NET 8 and .NET 10 + workingDirectory: $(DotnetPackageSkillsDirectory) + + - task: PublishTestResults@2 + displayName: Publish C# test results + condition: succeededOrFailed() + inputs: + testResultsFormat: VSTest + testResultsFiles: $(DotnetPackageSkillsArtifacts)\testresults\**\*.trx + testRunTitle: dotnet-package-skills + failTaskOnFailedTests: true + failTaskOnMissingResultsFile: true + failTaskOnFailureToPublishResults: true + + - ${{ if eq(parameters.isOfficialBuild, true) }}: + - template: steps-sign.yml + parameters: + target: assemblies + + - pwsh: | + dotnet pack .\src\DotnetPackageSkills.csproj ` + --configuration Release --no-build --no-restore ` + "-p:DotnetPackageSkillsVersion=$(DotnetPackageSkillsVersion)" ` + "-p:SourceRevisionId=$(Build.SourceVersion)" ` + --output "$(DotnetPackageSkillsArtifacts)\packages" ` + "-bl:$(DotnetPackageSkillsArtifacts)\logs\pack.binlog" + if ($LASTEXITCODE -ne 0) { throw 'Tool packaging failed.' } + displayName: Pack the tool without rebuilding + workingDirectory: $(DotnetPackageSkillsDirectory) + + - ${{ if eq(parameters.isOfficialBuild, true) }}: + - template: steps-sign.yml + parameters: + target: package + + - pwsh: | + $signed = [bool]::Parse('${{ parameters.isOfficialBuild }}') + & "$(DotnetPackageSkillsPipelineDirectory)\Verify-Package.ps1" ` + -PackagePath "$(DotnetPackageSkillsArtifacts)\packages\dotnet-package-skills.$(DotnetPackageSkillsVersion).nupkg" ` + -ExpectedVersion "$(DotnetPackageSkillsVersion)" ` + -BuildOutputPath "$(DotnetPackageSkillsDirectory)\src\bin\Release" ` + -RequireSigned:$signed + displayName: Verify and install the exact package on both runtimes + workingDirectory: $(DotnetPackageSkillsDirectory) + + - ${{ if eq(parameters.isOfficialBuild, false) }}: + - task: PublishPipelineArtifact@1 + displayName: Publish unsigned PR package + inputs: + targetPath: $(DotnetPackageSkillsArtifacts)\packages + artifact: dotnet-package-skills-packages + - task: PublishPipelineArtifact@1 + displayName: Publish diagnostic logs + condition: succeededOrFailed() + inputs: + targetPath: $(DotnetPackageSkillsArtifacts)\logs + artifact: dotnet-package-skills-logs + - task: PublishPipelineArtifact@1 + displayName: Publish test result files + condition: succeededOrFailed() + inputs: + targetPath: $(DotnetPackageSkillsArtifacts)\testresults + artifact: dotnet-package-skills-testresults diff --git a/eng/pipelines/dotnet-package-skills/steps-sign.yml b/eng/pipelines/dotnet-package-skills/steps-sign.yml new file mode 100644 index 0000000..6b92d01 --- /dev/null +++ b/eng/pipelines/dotnet-package-skills/steps-sign.yml @@ -0,0 +1,121 @@ +parameters: +- name: target + type: string + values: + - assemblies + - package + +steps: +- ${{ if eq(parameters.target, 'assemblies') }}: + - pwsh: | + foreach ($framework in @('net8.0', 'net10.0')) { + $assembly = Get-Item -LiteralPath "$(DotnetPackageSkillsDirectory)\src\obj\Release\$framework\dotnet-package-skills.dll" + if ($assembly.Length -eq 0) { throw "Empty signing input for $framework." } + $built = "$(DotnetPackageSkillsDirectory)\src\bin\Release\$framework\dotnet-package-skills.dll" + if ((Get-FileHash -LiteralPath $assembly.FullName).Hash -cne (Get-FileHash -LiteralPath $built).Hash) { + throw "The $framework pack input does not match the tested build output." + } + } + displayName: Check expected tool assembly signing inputs + - task: EsrpCodeSigning@6 + displayName: Sign and verify tool assemblies + inputs: + ConnectedServiceName: $(DotnetPackageSkillsEsrpServiceConnection) + AppRegistrationClientId: $(DotnetPackageSkillsEsrpManagedIdentityClientId) + AppRegistrationTenantId: $(DotnetPackageSkillsEsrpTenantId) + EsrpClientId: $(DotnetPackageSkillsEsrpClientId) + AuthAKVName: $(DotnetPackageSkillsEsrpKeyVault) + AuthSignCertName: $(DotnetPackageSkillsEsrpRequestSigningCertificate) + UseMSIAuthentication: true + # PackAsTool publishes the intermediate assembly, not the copy in bin. + FolderPath: $(DotnetPackageSkillsDirectory)\src\obj\Release + Pattern: | + net8.0/dotnet-package-skills.dll + net10.0/dotnet-package-skills.dll + UseMinimatch: true + signConfigType: inlineSignParams + inlineOperation: | + [ + { + "keyCode": "$(DotnetPackageSkillsEsrpBinaryKeyCode)", + "operationCode": "SigntoolSign", + "parameters": { + "OpusName": "Microsoft", + "OpusInfo": "http://www.microsoft.com", + "FileDigest": "/fd \"SHA256\"", + "PageHash": "/NPH", + "TimeStamp": "/tr \"http://rfc3161.gtm.corp.microsoft.com/TSS/HttpTspServer\" /td sha256" + }, + "toolName": "sign", + "toolVersion": "1.0" + }, + { + "keyCode": "$(DotnetPackageSkillsEsrpBinaryKeyCode)", + "operationCode": "SigntoolVerify", + "parameters": {}, + "toolName": "sign", + "toolVersion": "1.0" + } + ] + - pwsh: | + foreach ($framework in @('net8.0', 'net10.0')) { + $assembly = "$(DotnetPackageSkillsDirectory)\src\obj\Release\$framework\dotnet-package-skills.dll" + $signature = Get-AuthenticodeSignature -LiteralPath $assembly + if ($signature.Status -ne 'Valid') { + throw "The $framework assembly signature is not valid: $($signature.StatusMessage)" + } + Copy-Item -LiteralPath $assembly -Destination "$(DotnetPackageSkillsDirectory)\src\bin\Release\$framework\dotnet-package-skills.dll" -Force + } + displayName: Verify assembly signatures before packing + +- ${{ if eq(parameters.target, 'package') }}: + - pwsh: | + $packages = @(Get-ChildItem -LiteralPath "$(DotnetPackageSkillsArtifacts)\packages" -Filter '*.nupkg') + $expected = "dotnet-package-skills.$(DotnetPackageSkillsVersion).nupkg" + if ($packages.Count -ne 1 -or $packages[0].Name -cne $expected -or $packages[0].Length -eq 0) { + throw "Expected exactly one nonempty tool package: $expected" + } + displayName: Check exact NuGet signing input + - task: EsrpMalwareScanning@6 + displayName: Scan the tool package before signing + inputs: + ConnectedServiceName: $(DotnetPackageSkillsEsrpServiceConnection) + AppRegistrationClientId: $(DotnetPackageSkillsEsrpManagedIdentityClientId) + AppRegistrationTenantId: $(DotnetPackageSkillsEsrpTenantId) + EsrpClientId: $(DotnetPackageSkillsEsrpClientId) + UseMSIAuthentication: true + FolderPath: $(DotnetPackageSkillsArtifacts)\packages + Pattern: dotnet-package-skills.$(DotnetPackageSkillsVersion).nupkg + UseMinimatch: true + CleanupTempStorage: true + - task: EsrpCodeSigning@6 + displayName: Sign and verify the NuGet package + inputs: + ConnectedServiceName: $(DotnetPackageSkillsEsrpServiceConnection) + AppRegistrationClientId: $(DotnetPackageSkillsEsrpManagedIdentityClientId) + AppRegistrationTenantId: $(DotnetPackageSkillsEsrpTenantId) + EsrpClientId: $(DotnetPackageSkillsEsrpClientId) + AuthAKVName: $(DotnetPackageSkillsEsrpKeyVault) + AuthSignCertName: $(DotnetPackageSkillsEsrpRequestSigningCertificate) + UseMSIAuthentication: true + FolderPath: $(DotnetPackageSkillsArtifacts)\packages + Pattern: dotnet-package-skills.$(DotnetPackageSkillsVersion).nupkg + UseMinimatch: true + signConfigType: inlineSignParams + inlineOperation: | + [ + { + "keyCode": "$(DotnetPackageSkillsEsrpNuGetKeyCode)", + "operationSetCode": "NuGetSign", + "parameters": [], + "toolName": "sign", + "toolVersion": "1.0" + }, + { + "keyCode": "$(DotnetPackageSkillsEsrpNuGetKeyCode)", + "operationSetCode": "NuGetVerify", + "parameters": [], + "toolName": "sign", + "toolVersion": "1.0" + } + ] diff --git a/eng/pipelines/official.yml b/eng/pipelines/official.yml index a101be1..8d73a6c 100644 --- a/eng/pipelines/official.yml +++ b/eng/pipelines/official.yml @@ -3,6 +3,12 @@ trigger: pr: none +parameters: +- name: DotnetPackageSkillsReleaseBuild + displayName: Produce a stable dotnet-package-skills package (manual main builds only) + type: boolean + default: false + resources: repositories: - repository: 1esPipelines @@ -18,8 +24,7 @@ extends: demands: ImageOverride -equals windows.vs2026.amd64 os: windows stages: - - stage: Build - jobs: - - job: HelloWorld - steps: - - script: echo Hello world! + - template: /eng/pipelines/dotnet-package-skills/stage.yml@self + parameters: + isOfficialBuild: true + releaseBuild: ${{ parameters.DotnetPackageSkillsReleaseBuild }} diff --git a/eng/pipelines/pr.yml b/eng/pipelines/pr.yml index fac0871..418ae31 100644 --- a/eng/pipelines/pr.yml +++ b/eng/pipelines/pr.yml @@ -11,9 +11,7 @@ pool: - ImageOverride -equals windows.vs2026.amd64.open stages: -- stage: Build - jobs: - - job: HelloWorld - steps: - - script: echo Hello world! - displayName: Run PR smoke check +- template: dotnet-package-skills/stage.yml + parameters: + isOfficialBuild: false + releaseBuild: false