diff --git a/.appends/.github/labels.yml b/.appends/.github/labels.yml new file mode 100644 index 000000000..2bef75771 --- /dev/null +++ b/.appends/.github/labels.yml @@ -0,0 +1,8 @@ +# ----------------------------------------------------------------------------------------- # +# These are the repository-specific labels that augment the Exercise-wide labels defined in # +# https://github.com/exercism/org-wide-files/blob/main/global-files/.github/labels.yml. # +# ----------------------------------------------------------------------------------------- # + +- name: "bug?" + description: "" + color: "eb6420" diff --git a/.gha.gofmt.sh b/.gha.gofmt.sh new file mode 100755 index 000000000..2902ab797 --- /dev/null +++ b/.gha.gofmt.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +cd "$(dirname $0)" +if [ -n "$(go fmt ./...)" ]; then + echo "Go code is not formatted, run 'go fmt github.com/exercism/cli/...'" >&2 + exit 1 +fi diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 000000000..73ac4bb57 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,4 @@ + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..5c6cb5943 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 + +updates: + # Keep dependencies for GitHub Actions up-to-date + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: 'monthly' + labels: + - 'x:size/small' diff --git a/.github/labels.yml b/.github/labels.yml new file mode 100644 index 000000000..cd989c70f --- /dev/null +++ b/.github/labels.yml @@ -0,0 +1,177 @@ +# --------------------------------------------------------------- # +# This is an auto-generated file - Do not manually edit this file # +# --------------------------------------------------------------- # + +# This file is automatically generated by concatenating two files: +# +# 1. The Exercism-wide labels: defined in https://github.com/exercism/org-wide-files/blob/main/global-files/.github/labels.yml +# 2. The repository-specific labels: defined in the `.appends/.github/labels.yml` file within this repository. +# +# If any of these two files change, a pull request is automatically created containing a re-generated version of this file. +# Consequently, to change repository-specific labels you should update the `.appends/.github/labels.yml` file and _not_ this file. +# +# When the pull request has been merged, the GitHub labels will be automatically updated by the "Sync labels" workflow. +# This typically takes 5-10 minutes. + +# --------------------------------------------------------------------- # +# These are the Exercism-wide labels which are shared across all repos. # +# --------------------------------------------------------------------- # + +# The following Exercism-wide labels are used to show "tasks" on the website, which will point users to things they can contribute to. + +# The `x:action/` labels describe what sort of work the contributor will be engaged in when working on the issue +- name: "x:action/create" + description: "Work on something from scratch" + color: "ffffff" + +- name: "x:action/fix" + description: "Fix an issue" + color: "ffffff" + +- name: "x:action/improve" + description: "Improve existing functionality/content" + color: "ffffff" + +- name: "x:action/proofread" + description: "Proofread text" + color: "ffffff" + +- name: "x:action/sync" + description: "Sync content with its latest version" + color: "ffffff" + +# The `x:knowledge/` labels describe how much Exercism knowledge is required by the contributor +- name: "x:knowledge/none" + description: "No existing Exercism knowledge required" + color: "ffffff" + +- name: "x:knowledge/elementary" + description: "Little Exercism knowledge required" + color: "ffffff" + +- name: "x:knowledge/intermediate" + description: "Quite a bit of Exercism knowledge required" + color: "ffffff" + +- name: "x:knowledge/advanced" + description: "Comprehensive Exercism knowledge required" + color: "ffffff" + +# The `x:module/` labels indicate what part of Exercism the contributor will be working on +- name: "x:module/analyzer" + description: "Work on Analyzers" + color: "ffffff" + +- name: "x:module/concept" + description: "Work on Concepts" + color: "ffffff" + +- name: "x:module/concept-exercise" + description: "Work on Concept Exercises" + color: "ffffff" + +- name: "x:module/generator" + description: "Work on Exercise generators" + color: "ffffff" + +- name: "x:module/practice-exercise" + description: "Work on Practice Exercises" + color: "ffffff" + +- name: "x:module/representer" + description: "Work on Representers" + color: "ffffff" + +- name: "x:module/test-runner" + description: "Work on Test Runners" + color: "ffffff" + +# The `x:rep/` labels describe the amount of reputation to award +# +# For more information on reputation and how these labels should be used, +# check out https://exercism.org/docs/using/product/reputation +- name: "x:rep/tiny" + description: "Tiny amount of reputation" + color: "ffffff" + +- name: "x:rep/small" + description: "Small amount of reputation" + color: "ffffff" + +- name: "x:rep/medium" + description: "Medium amount of reputation" + color: "ffffff" + +- name: "x:rep/large" + description: "Large amount of reputation" + color: "ffffff" + +- name: "x:rep/massive" + description: "Massive amount of reputation" + color: "ffffff" + +# The `x:size/` labels describe the expected amount of work for a contributor +- name: "x:size/tiny" + description: "Tiny amount of work" + color: "ffffff" + +- name: "x:size/small" + description: "Small amount of work" + color: "ffffff" + +- name: "x:size/medium" + description: "Medium amount of work" + color: "ffffff" + +- name: "x:size/large" + description: "Large amount of work" + color: "ffffff" + +- name: "x:size/massive" + description: "Massive amount of work" + color: "ffffff" + +# The `x:status/` label indicates if there is already someone working on the issue +- name: "x:status/claimed" + description: "Someone is working on this issue" + color: "ffffff" + +# The `x:type/` labels describe what type of work the contributor will be engaged in +- name: "x:type/ci" + description: "Work on Continuous Integration (e.g. GitHub Actions workflows)" + color: "ffffff" + +- name: "x:type/coding" + description: "Write code that is not student-facing content (e.g. test-runners, generators, but not exercises)" + color: "ffffff" + +- name: "x:type/content" + description: "Work on content (e.g. exercises, concepts)" + color: "ffffff" + +- name: "x:type/docker" + description: "Work on Dockerfiles" + color: "ffffff" + +- name: "x:type/docs" + description: "Work on Documentation" + color: "ffffff" + +# This Exercism-wide label is added to all automatically created pull requests that help migrate/prepare a track for Exercism v3 +- name: "v3-migration 🤖" + description: "Preparing for Exercism v3" + color: "e99695" + +# This Exercism-wide label can be used to bulk-close issues in preparation for pausing community contributions +- name: "paused" + description: "Work paused until further notice" + color: "e4e669" + +# ----------------------------------------------------------------------------------------- # +# These are the repository-specific labels that augment the Exercise-wide labels defined in # +# https://github.com/exercism/org-wide-files/blob/main/global-files/.github/labels.yml. # +# ----------------------------------------------------------------------------------------- # + +- name: "bug?" + description: "" + color: "eb6420" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..c6f903425 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + workflow_dispatch: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + tests: + name: Go ${{ matrix.go-version }} - ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + go-version: + - '1.26.x' + os: [ubuntu-latest, windows-latest, macOS-latest] + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e + with: + go-version: ${{ matrix.go-version }} + + - name: Run Tests + run: | + go test -cover ./... + shell: bash + + formatting: + name: Go Format + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + + - name: Check formatting + run: ./.gha.gofmt.sh diff --git a/.github/workflows/pause-community-contributions.yml b/.github/workflows/pause-community-contributions.yml new file mode 100644 index 000000000..46f0c60b4 --- /dev/null +++ b/.github/workflows/pause-community-contributions.yml @@ -0,0 +1,22 @@ +name: Pause Community Contributions + +on: + issues: + types: + - opened + pull_request_target: + types: + - opened + +permissions: + issues: write + pull-requests: write + +jobs: + pause: + if: github.repository_owner == 'exercism' # Stops this job from running on forks + uses: exercism/github-actions/.github/workflows/community-contributions.yml@main + with: + forum_category: support + secrets: + github_membership_token: ${{ secrets.COMMUNITY_CONTRIBUTIONS_WORKFLOW_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..a28848ea2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,41 @@ +name: release + +on: + push: + tags: + - 'v*.*.*' # semver release tags + - 'v*.*.*-*' # pre-release tags for testing + +permissions: + contents: write # needed by goreleaser/goreleaser-action for publishing release artifacts + +jobs: + goreleaser: + runs-on: ubuntu-26.04 + steps: + + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: '1.26.x' + + - name: Import GPG Key + id: import_gpg + uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0 + with: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + passphrase: ${{ secrets.PASSPHRASE }} + + - name: Cut Release + uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 + with: + version: latest + args: release --clean --release-header .release/header.md --timeout 120m # default time is 30m + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} diff --git a/.github/workflows/sync-labels.yml b/.github/workflows/sync-labels.yml new file mode 100644 index 000000000..e7b99e504 --- /dev/null +++ b/.github/workflows/sync-labels.yml @@ -0,0 +1,19 @@ +name: Tools + +on: + push: + branches: + - main + paths: + - .github/labels.yml + - .github/workflows/sync-labels.yml + workflow_dispatch: + schedule: + - cron: 0 0 1 * * # First day of each month + +permissions: + issues: write + +jobs: + sync-labels: + uses: exercism/github-actions/.github/workflows/labels.yml@main diff --git a/.gitignore b/.gitignore index 3fb2474e8..c7963cd6f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ # Folders _obj _test +vendor/ +dist/ # Architecture specific extensions/prefixes *.[568vq] @@ -24,3 +26,8 @@ _testmain.go out/ release/ go-exercism +testercism + +# Intellij +/.idea + diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 000000000..86f2100cc --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,131 @@ +# You can find the GoReleaser documentation at http://goreleaser.com +version: 2 +project_name: exercism + +env: + - CGO_ENABLED=0 +builds: + - id: release-build + main: ./exercism/main.go + mod_timestamp: "{{ .CommitTimestamp }}" + flags: + - -trimpath # removes file system paths from compiled executable + ldflags: + - "-s -w" # strip debug symbols and DWARF debugging info + goos: + - darwin + - linux + - windows + - freebsd + - openbsd + goarch: + - amd64 + - 386 + - arm + - arm64 + - ppc64 + goarm: + - 5 + - 6 + ignore: + - goos: openbsd + goarch: arm + - goos: freebsd + goarch: arm + - id: installer-build + main: ./exercism/main.go + mod_timestamp: "{{ .CommitTimestamp }}" + flags: + - -trimpath # removes file system paths from compiled executable + ldflags: + - "-s -w" # strip debug symbols and DWARF debugging info + goos: + - windows + goarch: + - amd64 + - 386 + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + +archives: + - id: release-archives + ids: + - release-build + name_template: >- + {{- .ProjectName }}- + {{- .Version }}- + {{- .Os }}- + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{- .Arch }}{{ end }} + {{- if .Arm }}v{{- .Arm }}{{ end }} + format_overrides: + - goos: windows + formats: ["zip"] + files: + - shell/** + - LICENSE + - README.md + - id: installer-archives + ids: + - installer-build + name_template: >- + {{- .ProjectName }}- + {{- .Version }}- + {{- .Os }}- + {{- if eq .Arch "amd64" }}64bit + {{- else if eq .Arch "386" }}32bit + {{- else }}{{- .Arch }}{{ end }} + {{- if .Arm }}v{{- .Arm }}{{ end }} + format_overrides: + - goos: windows + formats: ["zip"] + files: + - shell/** + - LICENSE + - README.md + +checksum: + name_template: "{{ .ProjectName }}_checksums.txt" + ids: + - release-archives + - installer-archives + +signs: + - artifacts: checksum + args: + [ + "--batch", + "-u", + "{{ .Env.GPG_FINGERPRINT }}", + "--output", + "${signature}", + "--detach-sign", + "${artifact}", + ] + +release: + # Repo in which the release will be created. + # Default is extracted from the origin remote URL. + github: + owner: exercism + name: cli + + # If set to true, will not auto-publish the release. + # Default is false. + draft: true + + # If set to auto, will mark the release as not ready for production + # in case there is an indicator for this in the tag e.g. v1.0.0-rc1 + # If set to true, will mark the release as not ready for production. + # Default is false. + prerelease: auto + + # You can change the name of the GitHub release. + # Default is `{{.Tag}}` + name_template: "{{.ProjectName}}-v{{.Version}} {{.Env.USER}}" diff --git a/.release/header.md b/.release/header.md new file mode 100644 index 000000000..c648e2d8b --- /dev/null +++ b/.release/header.md @@ -0,0 +1,3 @@ +To install, follow the interactive installation instructions at https://exercism.org/cli-walkthrough + +--- diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 3ebe5609b..000000000 --- a/.travis.yml +++ /dev/null @@ -1,13 +0,0 @@ -language: go - -go: - - 1.4.1 - - tip - -install: true - -before_install: - go get github.com/codegangsta/cli && go get github.com/stretchr/testify/assert - -script: - - go test ./... diff --git a/CHANGELOG.md b/CHANGELOG.md index 2891f2f8c..9de216f5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,231 +2,608 @@ The exercism CLI follows [semantic versioning](http://semver.org/). ----------------- +--- ## Next Release -* **Your contribution here** +- **Your contribution here** + +## v3.5.8 (2025-09-24) + +- [#1215](https://github.com/exercism/cli/pull/1215) Update the token URL to point to the API settings page [@isaacg] +- [#1210](https://github.com/exercism/cli/pull/1210) Use mode 0700 for the config dir, not 0755; other users should not have access to the config [@isaacg] +- [#1205](https://github.com/exercism/cli/pull/1205) goreleaser: update deprecated archives.builds to new archives.ids [@isaacg] +- [#1213](https://github.com/exercism/cli/pull/1213) Include empty files in downloads [@isaacg] +- [#1206](https://github.com/exercism/cli/pull/1206) Drop team-specific logic from the CLI [@isaacg] + +## v3.5.7 (2025-07-14) + +- [#1202](https://github.com/exercism/cli/pull/1202) Add error decoding support for content type parameters such as charset - [@isaacg] +- [#1201](https://github.com/exercism/cli/pull/1201) Fix goreleaser deprecations - [@erikschierboom] + +## v3.5.6 (2025-07-06) + +- [#1199](https://github.com/exercism/cli/pull/1199) Support for Futhark in exercism test - [@erikschierboom] +- [#1198](https://github.com/exercism/cli/pull/1198) Show a "try again after" message when a response sets a Retry-After header - [@isaacg] +- [#1196](https://github.com/exercism/cli/pull/1196) Check HTTP response content type before trying to decode it as JSON - [@isaacg] + +## v3.5.5 (2025-05-30) + +- [#1192](https://github.com/exercism/cli/pull/1192) Change Idris test command to use slug - [@keiravillekode] + +## v3.5.4 (2024-11-15) + +- [#1183](https://github.com/exercism/cli/pull/1183) Add support for Uiua track to `exercism test` - [@vaeng] + +## v3.5.3 (2024-11-03) + +- [#1178](https://github.com/exercism/cli/pull/1178) Add arm64-assembly test configuration [@keiravillekode] +- [#1177](https://github.com/exercism/cli/pull/1177) refactored exercism.io links to exercism.org [@ladokp] +- [#1165](https://github.com/exercism/cli/pull/1165) Add support for the YAMLScript language [@ingydotnet] + +## v3.5.2 (2024-10-09) + +- [#1174](https://github.com/exercism/cli/pull/1174) Fix an issue with `exercism completion bash` where the command name is not present in the completion output. - [@petrem] +- [#1172](https://github.com/exercism/cli/pull/1172) Fix `exercism test` command for Batch track - [@bnandras] + +## v3.5.1 (2024-08-28) + +- [#1162](https://github.com/exercism/cli/pull/1162) Add support for Roc to `exercism test` - [@ageron] + +## v3.5.0 (2024-08-22) + +- [#1157](https://github.com/exercism/cli/pull/1157) Add support for Batch to `exercism test` - [@GroophyLifefor] +- [#1159](https://github.com/exercism/cli/pull/1159) Fix duplicated `t` alias - + [@muzimuzhi] + +## v3.4.2 (2024-08-20) + +- [#1156](https://github.com/exercism/cli/pull/1156) Add `test` command to Shell completions - + [@muzimuzhi] + +## v3.4.1 (2024-08-15) + +- [#1152](https://github.com/exercism/cli/pull/1152) Add support for Idris to `exercism test` - + [@isberg] +- [#1151](https://github.com/exercism/cli/pull/1151) Add support for Cairo to `exercism test` - [@isberg] +- [#1147](https://github.com/exercism/cli/pull/1147) Add support for Arturo to `exercism test` - [@erikschierboom] + +## v3.4.0 (2024-05-09) + +- [#1126](https://github.com/exercism/cli/pull/1126) Update `exercism test` to use Gradle wrapper to test Java exercise - [@sanderploegsma] +- [#1139](https://github.com/exercism/cli/pull/1139) Add support for Pyret to `exercism test` +- [#1136](https://github.com/exercism/cli/pull/1136) Add support for J to `exercism test` - [@enascimento178] +- [#1070](https://github.com/exercism/cli/pull/1070) `exercism open` does not require specifying the directory (defaults to current directory) - [@halfdan] +- [#1122](https://github.com/exercism/cli/pull/1122) Troubleshoot command suggest to open forum post instead of GitHub issue - [@glennj] +- [#1065](https://github.com/exercism/cli/pull/1065) Update help text for `exercism submit` to indicate specifying files is optional - [@andrerfcsantos] +- [#1140](https://github.com/exercism/cli/pull/1140) Fix release notes link + +## v3.3.0 (2024-02-15) + +- [#1128](https://github.com/exercism/cli/pull/1128) Fix `exercism test` command not working for the `8th` and `emacs-lisp` tracks - [@glennj] +- [#1125](https://github.com/exercism/cli/pull/1125) Simplify root command description +- [#1124](https://github.com/exercism/cli/pull/1124) Use correct domain for FAQ link [@tomasnorre] + +## v3.2.0 (2023-07-28) + +- [#1092](https://github.com/exercism/cli/pull/1092) Add `exercism test` command to run the unit tests for nearly any track (inspired by [universal-test-runner](https://github.com/xavdid/universal-test-runner)) - [@xavdid] +- [#1073](https://github.com/exercism/cli/pull/1073) Add `arm64` build for each OS + +## v3.1.0 (2022-10-04) + +- [#979](https://github.com/exercism/cli/pull/979) Protect existing solutions from being overwritten by 'download' - [@harugo] +- [#981](https://github.com/exercism/cli/pull/981) Check if authorisation header is set before attempting to extract token - [@harugo] +- [#1044](https://github.com/exercism/cli/pull/1044) Submit without specifying files - [@andrerfcsantos] + +## v3.0.13 (2019-10-23) + +- [#866](https://github.com/exercism/cli/pull/866) The API token outputted during verbose will now be masked by default - [@Jrank2013] +- [#873](https://github.com/exercism/cli/pull/873) Make all errors in cmd package checked - [@avegner] +- [#871](https://github.com/exercism/cli/pull/871) Error message returned if the track is locked - [@Jrank2013] +- [#886](https://github.com/exercism/cli/pull/886) Added GoReleaser config, updated docs, made archive naming adjustments - [@ekingery] + +## v3.0.12 (2019-07-07) + +- [#770](https://github.com/exercism/cli/pull/770) Print API error messages in submit command - [@Smarticles101] +- [#763](https://github.com/exercism/cli/pull/763) Add Fish shell tab completions - [@John-Goff] +- [#806](https://github.com/exercism/cli/pull/806) Make Zsh shell tab completions work on $fpath - [@QuLogic] +- [#797](https://github.com/exercism/cli/pull/797) Fix panic when submit command is not given args - [@jdsutherland] +- [#828](https://github.com/exercism/cli/pull/828) Remove duplicate files before submitting - [@larson004] +- [#793](https://github.com/exercism/cli/pull/793) Submit handles non 2xx responses - [@jdsutherland] + +## v3.0.11 (2018-11-18) + +- [#752](https://github.com/exercism/cli/pull/752) Improve error message on upgrade command - [@farisj] +- [#759](https://github.com/exercism/cli/pull/759) Update shell tab completion for bash and zsh - [@nywilken] +- [#762](https://github.com/exercism/cli/pull/762) Improve usage documentation - [@Smarticles101] +- [#766](https://github.com/exercism/cli/pull/766) Tweak messaging to work for teams edition - [@kytrinyx] + +## v3.0.10 (2018-10-03) + +- official release of v3.0.10-alpha.1 - [@nywilken] + +## v3.0.10-alpha.1 (2018-09-21) + +- [#739](https://github.com/exercism/cli/pull/739) update maxFileSize error to include filename - [@nywilken] +- [#736](https://github.com/exercism/cli/pull/736) Metadata file .solution.json renamed to metadata.json - [@jdsutherland] +- [#738](https://github.com/exercism/cli/pull/738) Add missing contributor URLs to CHANGELOG - [@nywilken] +- [#737](https://github.com/exercism/cli/pull/737) Remove unused solutions type - [@jdsutherland] +- [#729](https://github.com/exercism/cli/pull/729) Update Oh My Zsh instructions - [@katrinleinweber] +- [#725](https://github.com/exercism/cli/pull/725) Do not allow submission of enormous files - [@sfairchild] +- [#724](https://github.com/exercism/cli/pull/724) Update submit error message when submitting a directory - [@sfairchild] +- [#723](https://github.com/exercism/cli/pull/720) Move .solution.json to hidden subdirectory - [@jdsutherland] + +## v3.0.9 (2018-08-29) + +- [#720](https://github.com/exercism/cli/pull/720) Make the timeout configurable globally - [@kytrinyx] +- [#721](https://github.com/exercism/cli/pull/721) Handle windows filepaths that accidentally got submitted to the server - [@kytrinyx] +- [#722](https://github.com/exercism/cli/pull/722) Handle exercise directories with numeric suffixes - [@kytrinyx] + +## v3.0.8 (2018-08-22) + +- [#713](https://github.com/exercism/cli/pull/713) Fix broken support for uuid flag on download command - [@nywilken] + +## v3.0.7 (2018-08-21) + +- [#705](https://github.com/exercism/cli/pull/705) Fix confusion about path and filepath - [@kytrinyx] +- [#650](https://github.com/exercism/cli/pull/650) Fix encoding problem in filenames - [@williandrade] + +## v3.0.6 (2018-07-17) + +- [#652](https://github.com/exercism/cli/pull/652) Add support for teams feature - [@kytrinyx] +- [#683](https://github.com/exercism/cli/pull/683) Fix typo in welcome message - [@glebedel] +- [#675](https://github.com/exercism/cli/pull/675) Improve output of troubleshoot command when CLI is unconfigured - [@kytrinyx] +- [#679](https://github.com/exercism/cli/pull/679) Improve error message for failed /ping on configure - [@kytrinyx] +- [#669](https://github.com/exercism/cli/pull/669) Add debug as alias for troubleshoot - [@kytrinyx] +- [#647](https://github.com/exercism/cli/pull/647) Ensure welcome message has full link to settings page - [@kytrinyx] +- [#667](https://github.com/exercism/cli/pull/667) Improve bash completion script - [@cookrn] + +## v3.0.5 (2018-07-17) + +- [#646](https://github.com/exercism/cli/pull/646) Fix issue with upgrading on Windows - [@nywilken] + +## v3.0.4 (2018-07-15) + +- [#644](https://github.com/exercism/cli/pull/644) Add better error messages when solution metadata is missing - [@kytrinyx] + +## v3.0.3 (2018-07-14) + +- [#642](https://github.com/exercism/cli/pull/642) Add better error messages when configuration is needed before download - [@kytrinyx] +- [#641](https://github.com/exercism/cli/pull/641) Fix broken download for uuid flag - [@kytrinyx] +- [#618](https://github.com/exercism/cli/pull/618) Fix broken test in Windows build for relative paths - [@nywilken] +- [#631](https://github.com/exercism/cli/pull/631) Stop accepting token flag on download command - [@kytrinyx] +- [#616](https://github.com/exercism/cli/pull/616) Add shell completion scripts to build artifacts - [@jdsutherland] +- [#624](https://github.com/exercism/cli/pull/624) Tweak command documentation to reflect reality - [@kytrinyx] +- [#625](https://github.com/exercism/cli/pull/625) Fix wildly excessive whitespace in error messages - [@kytrinyx] + +## v3.0.2 (2018-07-13) + +- [#622](https://github.com/exercism/cli/pull/622) Fix bug with multi-file submission - [@kytrinyx] + +## v3.0.1 (2018-07-13) + +- [#619](https://github.com/exercism/cli/pull/619) Improve error message for successful configuration - [@kytrinyx] + +## v3.0.0 (2018-07-13) + +This is a complete rewrite from the ground up to work against the new https://exercism.org site. + +## v2.4.1 (2017-07-01) + +- [#385](https://github.com/exercism/cli/pull/385) Fix broken upgrades for Windows - [@Tonkpils] + +## v2.4.0 (2017-03-24) + +- [#344](https://github.com/exercism/cli/pull/344) Make the CLI config paths more XDG friendly - [@narqo] +- [#346](https://github.com/exercism/cli/pull/346) Fallback to UTF-8 if encoding is uncertain - [@petertseng] +- [#350](https://github.com/exercism/cli/pull/350) Add ARMv8 binaries to CLI releases - [@Tonkpils] +- [#352](https://github.com/exercism/cli/pull/352) Fix case sensitivity on slug and track ID - [@Tonkpils] +- [#353](https://github.com/exercism/cli/pull/353) Print confirmation when fetching --all - [@neslom] +- [#356](https://github.com/exercism/cli/pull/356) Resolve symlinks before attempting to read files - [@lcowell] +- [#358](https://github.com/exercism/cli/pull/358) Redact API key from debug output - [@Tonkpils] +- [#359](https://github.com/exercism/cli/pull/359) Add short flag `-m` for submit comment flag - [@jgsqware] +- [#366](https://github.com/exercism/cli/pull/366) Allow obfuscation on configure command - [@dmmulroy] +- [#367](https://github.com/exercism/cli/pull/367) Use supplied confirmation text from API on submit - [@nilbus] + +## v2.3.0 (2016-08-07) + +- [#339](https://github.com/exercism/cli/pull/339) Don't run status command if API key is missing - [@ests] +- [#336](https://github.com/exercism/cli/pull/336) Add '--all' flag to fetch command - [@neslom] +- [#333](https://github.com/exercism/cli/pull/333) Update references of codegangsta/cli -> urfave/cli - [@manusajith], [@blackerby] +- [#331](https://github.com/exercism/cli/pull/331) Improve usage/help text of submit command - [@manusajith] + +## v2.2.6 (2016-05-30) + +- [#306](https://github.com/exercism/cli/pull/306) Don't use Fatal to print usage - [@broady] +- [#307](https://github.com/exercism/cli/pull/307) Pass API key when fetching individual exercises - [@kytrinyx] +- [#312](https://github.com/exercism/cli/pull/312) Add missing newline on usage string - [@jppunnett] +- [#318](https://github.com/exercism/cli/pull/318) Show activity stream URL after submitting - [@lcowell] +- [4710640](https://github.com/exercism/cli/commit/4710640751c7a01deb1b5bf8a9a65b611b078c05) - [@lcowell] +- Update codegangsta/cli dependency - [@manusajith], [@lcowell] +- [#320](https://github.com/exercism/cli/pull/320) Add missing newlines to usage strings - [@hjljo] +- [#328](https://github.com/exercism/cli/pull/328) Append solution URL path consistently - [@Tonkpils] + +## v2.2.5 (2016-04-02) + +- [#284](https://github.com/exercism/cli/pull/284) Update release instructions - [@kytrinyx] +- [#285](https://github.com/exercism/cli/pull/285) Create a copy/pastable release text - [@kytrinyx] +- [#289](https://github.com/exercism/cli/pull/289) Fix a typo in the usage statement - [@AlexWheeler] +- [#290](https://github.com/exercism/cli/pull/290) Fix upgrade command for Linux systems - [@jbaiter] +- [#292](https://github.com/exercism/cli/pull/292) Vendor dependencies - [@Tonkpils] +- [#293](https://github.com/exercism/cli/pull/293) Remove extraneous/distracting details from README - [@Tonkpils] +- [#294](https://github.com/exercism/cli/pull/294) Improve usage statement: alphabetize commands - [@beanieboi] +- [#297](https://github.com/exercism/cli/pull/297) Improve debug output when API key is unconfigured - [@mrageh] +- [#299](https://github.com/exercism/cli/pull/299) List output uses track ID and problem from list - [@Tonkpils] +- [#301](https://github.com/exercism/cli/pull/301) Return error message for unknown track status - [@neslom] +- [#302](https://github.com/exercism/cli/pull/302) Add helpful error message when user tries to submit a directory - [@alebaffa] + +## v2.2.4 (2016-01-28) + +- [#270](https://github.com/exercism/cli/pull/270) Allow commenting on submission with --comment - [@Tonkpils] +- [#271](https://github.com/exercism/cli/pull/271) Increase timeout to 20 seconds - [@Tonkpils] +- [#273](https://github.com/exercism/cli/pull/273) Guard against submitting spec files and README - [@daveyarwood] +- [#278](https://github.com/exercism/cli/pull/278) Create files with 0644 mode, create missing directories for downloaded solutions - [@petertseng] +- [#281](https://github.com/exercism/cli/pull/281) Create missing directories for downloaded problems - [@petertseng] +- [#282](https://github.com/exercism/cli/pull/282) Remove random encouragement after submitting - [@kytrinyx] +- [#283](https://github.com/exercism/cli/pull/283) Print current configuration after calling configure command - [@kytrinyx] + +## v2.2.3 (2015-12-27) + +- [#264](https://github.com/exercism/cli/pull/264) Fix version flag to use --version and --v - [@Tonkpils] + +## v2.2.2 (2015-12-26) + +- [#212](https://github.com/exercism/cli/pull/212) extract path related code from config - [@lcowell] +- [#215](https://github.com/exercism/cli/pull/215) use $XDG_CONFIG_HOME if available - [@lcowell] +- [#248](https://github.com/exercism/cli/pull/248) [#253](https://github.com/exercism/cli/pull/253) add debugging output - [@lcowell] +- [#256](https://github.com/exercism/cli/pull/256) clean up build scripts - [@lcowell] +- [#258](https://github.com/exercism/cli/pull/258) reduce filesystem noise on fetch [@devonestes] +- [#261](https://github.com/exercism/cli/pull/261) improve error message when track and exercise can't be identified on submit - [@anxiousmodernman] +- [#262](https://github.com/exercism/cli/pull/262) encourage iterating to improve after first submission on an exercise - [@eToThePiIPower] + +## v2.2.1 (2015-08-11) + +- [#200](https://github.com/exercism/cli/pull/200): Add guard to unsubmit command - [@kytrinyx] +- [#204](https://github.com/exercism/cli/pull/204): Improve upgrade failure messages and increase timeout - [@Tonkpils] +- [#206](https://github.com/exercism/cli/pull/207): Fix verbose flag and removed short `-v` - [@zabawaba99] +- [#208](https://github.com/exercism/cli/pull/208): avoid ambiguous or unresolvable exercism paths - [@lcowell] + +## v2.2.0 (2015-06-27) + +- [b3c3d6f](https://github.com/exercism/cli/commit/b3c3d6fe54c622fc0ee07fdd221c8e8e5b73c8cd): Improve error message on Internal Server Error - [@Tonkpils] +- [#196](https://github.com/exercism/cli/pull/196): Add upgrade command - [@Tonkpils] +- [#194](https://github.com/exercism/cli/pull/194): Fix home expansion on configure update - [@Tonkpils] +- [523c5bd](https://github.com/exercism/cli/commit/523c5bdec5ef857f07b39de738a764589660cd5a): Document release process - [@kytrinyx] + +## v2.1.1 (2015-05-13) + +- [#192](https://github.com/exercism/cli/pull/192): Loosen up restrictions on --test flag for submissions - [@Tonkpils] +- [#190](https://github.com/exercism/cli/pull/190): Fix bug in home directory expansion for Windows - [@Tonkpils] + +## v2.1.0 (2015-05-08) + +- [1a2fd1b](https://github.com/exercism/cli/commit/1a2fd1bfb2dba358611a7c3266f935cccaf924b5): Handle config as either directory or file - [@lcowell] +- [#177](https://github.com/exercism/cli/pull/177): Improve JSON error handling and reporting - [@Tonkpils] +- [#178](https://github.com/exercism/cli/pull/178): Add support for $XDG_CONFIG_HOME - [@lcowell] +- [#184](https://github.com/exercism/cli/pull/184): Handle different file encodings in submissions - [@ambroff] +- [#179](https://github.com/exercism/cli/pull/179): Pretty print the JSON config - [@Tonkpils] +- [#181](https://github.com/exercism/cli/pull/181): Fix path issue when downloading problems - [@Tonkpils] +- [#186](https://github.com/exercism/cli/pull/186): Allow people to specify a target directory for the demo - [@Tonkpils] +- [#189](https://github.com/exercism/cli/pull/189): Implement `--test` flag to allow submitting a test file in the solution - [@pminten] + +## v2.0.2 (2015-04-01) + +- [#174](https://github.com/exercism/cli/issues/174): Fix panic during fetch - [@kytrinyx] +- Refactor handling of ENV vars - [@lcowell] + +## v2.0.1 (2015-03-25) + +- [#167](https://github.com/exercism/cli/pull/167): Fixes misspelling of exercism list command - [@queuebit] +- Tweak output from `fetch` so that languages are scannable. +- [#35](https://github.com/exercism/cli/issues/35): Add support for submitting multiple-file solutions +- [#171](https://github.com/exercism/cli/pull/171): Implement `skip` command to bypass individual exercises - [@Tonkpils] ## v2.0.0 (2015-03-05) Added: -* [#154](https://github.com/exercism/cli/pull/154): Add 'list' command to list available exercises for a language - [@lcowell](https://github.com/lcowell) -* [3551884](https://github.com/exercism/cli/commit/3551884e9f38d6e563b99dae7b28a18d4525455d): Add host connectivity status to debug output. - [@lcowell](https://github.com/lcowell) -* [#162](https://github.com/exercism/cli/pull/162): Allow users to open the browser from the terminal. - [@zabawaba99](https://github.com/zabawaba99) +- [#154](https://github.com/exercism/cli/pull/154): Add 'list' command to list available exercises for a language - [@lcowell] +- [3551884](https://github.com/exercism/cli/commit/3551884e9f38d6e563b99dae7b28a18d4525455d): Add host connectivity status to debug output. - [@lcowell] +- [#162](https://github.com/exercism/cli/pull/162): Allow users to open the browser from the terminal. - [@zabawaba99] Removed: -* Stop supporting legacy config files (`~/.exercism.go`) -* Deleted deprecated login/logout commands -* Deleted deprecated key names in config +- Stop supporting legacy config files (`~/.exercism.go`) +- Deleted deprecated login/logout commands +- Deleted deprecated key names in config Fixed: -* [#151](https://github.com/exercism/cli/pull/151): Expand '~' in config path to home directory - [@lcowell](https://github.com/lcowell) -* [#155](https://github.com/exercism/cli/pull/155): Display problems not yet submitted on fetch API - [@Tonkpils](https://github.com/Tonkpils) -* [f999e69](https://github.com/exercism/cli/commit/f999e69e5290cec6c5c9933aecc6fddfad8cf019): Disambiguate debug and verbose flags. - [@lcowell](https://github.com/lcowell) -* Report 'new' at the bottom after fetching, it's going to be more relevant than 'unchanged', which includes all the languages they don't care about. +- [#151](https://github.com/exercism/cli/pull/151): Expand '~' in config path to home directory - [@lcowell] +- [#155](https://github.com/exercism/cli/pull/155): Display problems not yet submitted on fetch API - [@Tonkpils] +- [f999e69](https://github.com/exercism/cli/commit/f999e69e5290cec6c5c9933aecc6fddfad8cf019): Disambiguate debug and verbose flags. - [@lcowell] +- Report 'new' at the bottom after fetching, it's going to be more relevant than 'unchanged', which includes all the languages they don't care about. Tweaked: -* Set environment variable in build script -* [#153](https://github.com/exercism/cli/pull/153): Refactored configuration package - [@kytrinyx](https://github.com/kytrinyx) -* [#157](https://github.com/exercism/cli/pull/157): Refactored API package - [@Tonkpils](https://github.com/Tonkpils) +- Set environment variable in build script +- [#153](https://github.com/exercism/cli/pull/153): Refactored configuration package - [@kytrinyx] +- [#157](https://github.com/exercism/cli/pull/157): Refactored API package - [@Tonkpils] ## v1.9.2 (2015-01-11) -* [exercism.io#2155](https://github.com/exercism/exercism.io/issues/2155): Fixed problem with passed in config file being ignored. -* Added first version of changelog +- [exercism#2155](https://github.com/exercism/exercism/issues/2155): Fixed problem with passed in config file being ignored. +- Added first version of changelog ## v1.9.1 (2015-01-10) -* [#147](https://github.com/exercism/cli/pull/147): added `--api` option to exercism configure - [@morphatic](https://github.com/morphatic) +- [#147](https://github.com/exercism/cli/pull/147): added `--api` option to exercism configure - [@morphatic] ## v1.9.0 (2014-11-27) -* [#143](https://github.com/exercism/cli/pull/143): added command for downloading a specific solution - [@harimp](https://github.com/harimp) -* [#142](https://github.com/exercism/cli/pull/142): fixed command name to be `exercism` rather than `cli` on `go get` - [@Tonkpils](https://github.com/Tonkpils) +- [#143](https://github.com/exercism/cli/pull/143): added command for downloading a specific solution - [@harimp] +- [#142](https://github.com/exercism/cli/pull/142): fixed command name to be `exercism` rather than `cli` on `go get` - [@Tonkpils] ## v1.8.2 (2014-10-24) -* [9cbd069](https://github.com/exercism/cli/commit/9cbd06916cc05bbb165e8c2cb00d5e03cb4dbb99): Made path comparison case insensitive +- [9cbd069](https://github.com/exercism/cli/commit/9cbd06916cc05bbb165e8c2cb00d5e03cb4dbb99): Made path comparison case insensitive ## v1.8.1 (2014-10-23) -* [0ccc7a4](https://github.com/exercism/cli/commit/0ccc7a479940d2d7bb5e12eab41c91105519f135): Implemented debug flag on submit command +- [0ccc7a4](https://github.com/exercism/cli/commit/0ccc7a479940d2d7bb5e12eab41c91105519f135): Implemented debug flag on submit command ## v1.8.0 (2014-10-15) -* [#138](https://github.com/exercism/cli/pull/138): Added conversion to line endings for submissions on Windows - [@rprouse](https://github.com/rprouse) -* [#116](https://github.com/exercism/cli/issues/116): Added support for setting name of config file in an environment variable -* [47d6fd4](https://github.com/exercism/cli/commit/47d6fd407fd0410f5c81d60172e01e8624608f53): Added a `track` command to list the problems in a given language -* [#126](https://github.com/exercism/cli/issues/126): Added explanation in `submit` response about fetching the next problems -* [#133](https://github.com/exercism/cli/pull/133): Changed config command to create the exercism directory, rather than waiting until the first time problems are fetched - [@Tonkpils](https://github.com/Tonkpils) +- [#138](https://github.com/exercism/cli/pull/138): Added conversion to line endings for submissions on Windows - [@rprouse] +- [#116](https://github.com/exercism/cli/issues/116): Added support for setting name of config file in an environment variable +- [47d6fd4](https://github.com/exercism/cli/commit/47d6fd407fd0410f5c81d60172e01e8624608f53): Added a `track` command to list the problems in a given language +- [#126](https://github.com/exercism/cli/issues/126): Added explanation in `submit` response about fetching the next problems +- [#133](https://github.com/exercism/cli/pull/133): Changed config command to create the exercism directory, rather than waiting until the first time problems are fetched - [@Tonkpils] ## v1.7.5 (2014-10-5) -* [88cf1a1fbc884545dfc10e98535f667e4a43e693](https://github.com/exercism/cli/commit/88cf1a1fbc884545dfc10e98535f667e4a43e693): Added ARMv6 to build -* [12672c4](https://github.com/exercism/cli/commit/12672c4f695cfe3891f96467619a3615e6d57c34): Added an error message when people submit a file that is not within the exercism directory tree -* [#128](https://github.com/exercism/cli/pull/128): Made paths os-agnostic in tests - [@ccnp123](https://github.com/ccnp123) +- [88cf1a1fbc884545dfc10e98535f667e4a43e693](https://github.com/exercism/cli/commit/88cf1a1fbc884545dfc10e98535f667e4a43e693): Added ARMv6 to build +- [12672c4](https://github.com/exercism/cli/commit/12672c4f695cfe3891f96467619a3615e6d57c34): Added an error message when people submit a file that is not within the exercism directory tree +- [#128](https://github.com/exercism/cli/pull/128): Made paths os-agnostic in tests - [@ccnp123] ## v1.7.4 (2014-09-27) -* [4ca3e97](https://github.com/exercism/cli/commit/4ca3e9743f6d421903c91dfa27f4747fb1081392): Fixed incorrect HOME directory on Windows -* [8bd1a25](https://github.com/exercism/cli/commit/4ca3e9743f6d421903c91dfa27f4747fb1081392): Added ARMv5 to build -* [#117](https://github.com/exercism/cli/pull/117): Archive windows binaries using zip rather than tar and gzip - [@LegalizeAdulthood](https://github.com/LegalizeAdulthood) +- [4ca3e97](https://github.com/exercism/cli/commit/4ca3e9743f6d421903c91dfa27f4747fb1081392): Fixed incorrect HOME directory on Windows +- [8bd1a25](https://github.com/exercism/cli/commit/4ca3e9743f6d421903c91dfa27f4747fb1081392): Added ARMv5 to build +- [#117](https://github.com/exercism/cli/pull/117): Archive windows binaries using zip rather than tar and gzip - [@LegalizeAdulthood] ## v1.7.3 (2014-09-26) -* [8bec393](https://github.com/exercism/cli/commit/8bec39387094680990af7cf438ada1780cf87129): Fixed submit so it can handle symlinks +- [8bec393](https://github.com/exercism/cli/commit/8bec39387094680990af7cf438ada1780cf87129): Fixed submit so it can handle symlinks ## v1.7.2 (2014-09-24) -* [#111](https://github.com/exercism/cli/pull/111): Don't clobber existing config values when adding more - [@jish](https://github.com/jish) +- [#111](https://github.com/exercism/cli/pull/111): Don't clobber existing config values when adding more - [@jish] ## v1.7.1 (2014-09-19) -* Completely reorganized the code, separating each command into a separate handler -* [17fc164](https://github.com/exercism/cli/commit/17fc1644e9fc9ee5aa4e136de11556e65a7b6036): Fixed paths to be platform-independent -* [8b174e2](https://github.com/exercism/cli/commit/17fc1644e9fc9ee5aa4e136de11556e65a7b6036): Made the output of demo command more helpful -* [8b174e2](https://github.com/exercism/cli/commit/8b174e2fd8c7a545ea5c47c998ac10c5a7ab371f): Deleted the 'current' command +- Completely reorganized the code, separating each command into a separate handler +- [17fc164](https://github.com/exercism/cli/commit/17fc1644e9fc9ee5aa4e136de11556e65a7b6036): Fixed paths to be platform-independent +- [8b174e2](https://github.com/exercism/cli/commit/17fc1644e9fc9ee5aa4e136de11556e65a7b6036): Made the output of demo command more helpful +- [8b174e2](https://github.com/exercism/cli/commit/8b174e2fd8c7a545ea5c47c998ac10c5a7ab371f): Deleted the 'current' command ## v1.7.0 (2014-08-28) -* [ac6dbfd](https://github.com/exercism/cli/commit/ac6dbfd81a86e7a9a5a9b68521b0226c40d8e813): Added os and architecture to the user agent -* [5d58fd1](https://github.com/exercism/cli/commit/5d58fd14b9db84fb752b3bf6112123cd6f04c532): Fixed bug in detecting user's home directory -* [#100](https://github.com/exercism/cli/pull/100): Added 'debug' command, which supersedes the 'info' command - [@Tonkpils](https://github.com/Tonkpils) -* Extracted a couple of commands into separate handlers -* [6ec5876](https://github.com/exercism/cli/commit/6ec5876bde0b02206cacbe685bb8aedcbdba25d4): Added a hack to rename old config files to the new default name -* [bb7d0d6](https://github.com/exercism/cli/commit/bb7d0d6151a950c92590dc771ec3ff5fdd1c83b0): Rename 'home' command to 'info' -* [#95](https://github.com/exercism/cli/issues/95): Added 'home' command -* Deprecate login/logout commands -* [1a39134](https://github.com/exercism/cli/commit/1a391342da93aa32ae398f1500a3981aa65b9f41): Changed demo to write exercises to the default exercism problems directory -* [07cc334](https://github.com/exercism/cli/commit/07cc334739465b21d6eb5d973e16e1c88f67758e): Deleted the whoami command, we weren't using github usernames for anything -* [#97](https://github.com/exercism/cli/pull/97): Changed default exercism directory to ~/exercism - [@lcowell](https://github.com/lcowell) -* [#94](https://github.com/exercism/cli/pull/94): Updated language detection to handle C++ - [@LegalizeAdulthood](https://github.com/LegalizeAdulthood) -* [#92](https://github.com/exercism/cli/pull/92): Renamed config json file to .exercism.json instead of .exercism.go - [@lcowell](https://github.com/lcowell) -* [f55653f](https://github.com/exercism/cli/commit/f55653f35863914086a54375afb0898e142c1638): Deleted go vet from travis build temporarily until the codebase can be cleaned up -* [#91](https://github.com/exercism/cli/pull/91): Replaced temp file usage with encode/decode - [@lcowell](https://github.com/lcowell) -* [#90](https://github.com/exercism/cli/pull/90): Added sanitization to config values to trim whitespace before writing it - [@lcowell](https://github.com/lcowell) -* Did a fair amount of cleanup to make code a bit more idiomatic -* [#86](https://github.com/exercism/cli/pull/86): Triggered interactive login command for commands that require auth - [@Tonkpils](https://github.com/Tonkpils) +- [ac6dbfd](https://github.com/exercism/cli/commit/ac6dbfd81a86e7a9a5a9b68521b0226c40d8e813): Added os and architecture to the user agent +- [5d58fd1](https://github.com/exercism/cli/commit/5d58fd14b9db84fb752b3bf6112123cd6f04c532): Fixed bug in detecting user's home directory +- [#100](https://github.com/exercism/cli/pull/100): Added 'debug' command, which supersedes the 'info' command - [@Tonkpils] +- Extracted a couple of commands into separate handlers +- [6ec5876](https://github.com/exercism/cli/commit/6ec5876bde0b02206cacbe685bb8aedcbdba25d4): Added a hack to rename old config files to the new default name +- [bb7d0d6](https://github.com/exercism/cli/commit/bb7d0d6151a950c92590dc771ec3ff5fdd1c83b0): Rename 'home' command to 'info' +- [#95](https://github.com/exercism/cli/issues/95): Added 'home' command +- Deprecate login/logout commands +- [1a39134](https://github.com/exercism/cli/commit/1a391342da93aa32ae398f1500a3981aa65b9f41): Changed demo to write exercises to the default exercism problems directory +- [07cc334](https://github.com/exercism/cli/commit/07cc334739465b21d6eb5d973e16e1c88f67758e): Deleted the whoami command, we weren't using github usernames for anything +- [#97](https://github.com/exercism/cli/pull/97): Changed default exercism directory to ~/exercism - [@lcowell] +- [#94](https://github.com/exercism/cli/pull/94): Updated language detection to handle C++ - [@LegalizeAdulthood] +- [#92](https://github.com/exercism/cli/pull/92): Renamed config json file to .exercism.json instead of .exercism.go - [@lcowell] +- [f55653f](https://github.com/exercism/cli/commit/f55653f35863914086a54375afb0898e142c1638): Deleted go vet from travis build temporarily until the codebase can be cleaned up +- [#91](https://github.com/exercism/cli/pull/91): Replaced temp file usage with encode/decode - [@lcowell] +- [#90](https://github.com/exercism/cli/pull/90): Added sanitization to config values to trim whitespace before writing it - [@lcowell] +- Did a fair amount of cleanup to make code a bit more idiomatic +- [#86](https://github.com/exercism/cli/pull/86): Triggered interactive login command for commands that require auth - [@Tonkpils] ## v1.6.2 (2014-06-02) -* [a5b7a55](https://github.com/exercism/cli/commit/a5b7a55f52c23ac5ce2c6bd1826ea7767aea38c4): Update login prompt +- [a5b7a55](https://github.com/exercism/cli/commit/a5b7a55f52c23ac5ce2c6bd1826ea7767aea38c4): Update login prompt ## v1.6.1 (2014-05-16) -* [#84](https://github.com/exercism/cli/pull/84): Change hard-coded filepath so that it will work on any platform - [@simonjefford](https://github.com/simonjefford) +- [#84](https://github.com/exercism/cli/pull/84): Change hard-coded filepath so that it will work on any platform - [@simonjefford] ## v1.6.0 (2014-05-10) -* [#82](https://github.com/exercism/cli/pull/82): Fixed typo in tests - [@srt32](https://github.com/srt32) -* [aa7446d](https://github.com/exercism/cli/commit/aa7446d598fc894ef329756555c48ef358baf676): Clarified output to user after they fetch -* [#79](https://github.com/exercism/cli/pull/79): Updated development instructions to fix permissions problem - [@andrewsardone](https://github.com/andrewsardone) -* [#78](https://github.com/exercism/cli/pull/78): Deleted deprecated action `peek` - [@djquan](https://github.com/djquan) -* [#74](https://github.com/exercism/cli/pull/74): Implemented new option on `fetch` to get a single language - [@Tonkpils](https://github.com/Tonkpils) -* [#75](https://github.com/exercism/cli/pull/75): Improved feedback to user after logging in - [@Tonkpils](https://github.com/Tonkpils) -* [#72](https://github.com/exercism/cli/pull/72): Optimized use of temp file - [@Dparker1990](https://github.com/Dparker1990) -* [#70](https://github.com/exercism/cli/pull/70): Fixed a panic - [@Tonkpils](https://github.com/Tonkpils) -* [#68](https://github.com/exercism/cli/pull/68): Fixed how user input is read so that it doesn't stop at the first space - [@Tonkpils](https://github.com/Tonkpils) +- [#82](https://github.com/exercism/cli/pull/82): Fixed typo in tests - [@srt32] +- [aa7446d](https://github.com/exercism/cli/commit/aa7446d598fc894ef329756555c48ef358baf676): Clarified output to user after they fetch +- [#79](https://github.com/exercism/cli/pull/79): Updated development instructions to fix permissions problem - [@andrewsardone] +- [#78](https://github.com/exercism/cli/pull/78): Deleted deprecated action `peek` - [@djquan] +- [#74](https://github.com/exercism/cli/pull/74): Implemented new option on `fetch` to get a single language - [@Tonkpils] +- [#75](https://github.com/exercism/cli/pull/75): Improved feedback to user after logging in - [@Tonkpils] +- [#72](https://github.com/exercism/cli/pull/72): Optimized use of temp file - [@Dparker1990] +- [#70](https://github.com/exercism/cli/pull/70): Fixed a panic - [@Tonkpils] +- [#68](https://github.com/exercism/cli/pull/68): Fixed how user input is read so that it doesn't stop at the first space - [@Tonkpils] ## v1.5.1 (2014-03-14) -* [5b672ee](https://github.com/exercism/cli/commit/5b672ee7bf26859c41de9eed83396b7454286063 ): Provided a visual mark next to new problems that get fetched +- [5b672ee](https://github.com/exercism/cli/commit/5b672ee7bf26859c41de9eed83396b7454286063): Provided a visual mark next to new problems that get fetched ## v1.5.0 (2014-02-28) -* [#63](https://github.com/exercism/cli/pull/63): Implemeted `fetch` for a single language - [@Tonkpils](https://github.com/Tonkpils) -* [#62](https://github.com/exercism/cli/pull/62): Expose error message from API to user on `fetch` - [@Tonkpils](https://github.com/Tonkpils) -* [#59](https://github.com/exercism/cli/pull/59): Added global flag to pass the path to the config file instead of relying on default - [@isbadawi](https://github.com/isbadawi) -* [#57](https://github.com/exercism/cli/pull/57): Added description to the restore command - [@rcode5](https://github.com/rcode5) -* [#56](https://github.com/exercism/cli/pull/56): Updated developer instructions in README based on real-life experience - [@rcode5](https://github.com/rcode5) +- [#63](https://github.com/exercism/cli/pull/63): Implemeted `fetch` for a single language - [@Tonkpils] +- [#62](https://github.com/exercism/cli/pull/62): Expose error message from API to user on `fetch` - [@Tonkpils] +- [#59](https://github.com/exercism/cli/pull/59): Added global flag to pass the path to the config file instead of relying on default - [@isbadawi] +- [#57](https://github.com/exercism/cli/pull/57): Added description to the restore command - [@rcode5] +- [#56](https://github.com/exercism/cli/pull/56): Updated developer instructions in README based on real-life experience - [@rcode5] ## v1.4.0 (2014-01-13) -* [#47](https://github.com/exercism/cli/pull/47): Added 'restore' command to download all of a user's existing solutions with their corresponding problems - [@ebautistabar](https://github.com/ebautistabar) -* Numerous small fixes and cleanup to code and documentation - [@dpritchett](https://github.com/dpritchett), [@TrevorBramble](https://github.com/TrevorBramble), [@elimisteve](https://github.com/elimisteve) +- [#47](https://github.com/exercism/cli/pull/47): Added 'restore' command to download all of a user's existing solutions with their corresponding problems - [@ebautistabar] +- Numerous small fixes and cleanup to code and documentation - [@dpritchett], [@TrevorBramble], [@elimisteve] ## v1.3.2 (2013-12-14) -* [f8dd974](https://github.com/exercism/cli/commit/f8dd9748078b1b191629eae385aaeda8af94305b): Fixed content-type header when posting to API -* Fixed user-agent string +- [f8dd974](https://github.com/exercism/cli/commit/f8dd9748078b1b191629eae385aaeda8af94305b): Fixed content-type header when posting to API +- Fixed user-agent string ## v1.3.1 (2013-12-01) -* [exercism.io#1039](https://github.com/exercism/exercism.io/issues/1039): Stopped clobbering existing files on fetch +- [exercism#1039](https://github.com/exercism/exercism/issues/1039): Stopped clobbering existing files on fetch ## v1.3.0 (2013-11-16) -* [7f39ee4](https://github.com/exercism/cli/commit/7f39ee4802752925466bc2715790dc965026b09d): Allow users to specify a particular problem when fetching. +- [7f39ee4](https://github.com/exercism/cli/commit/7f39ee4802752925466bc2715790dc965026b09d): Allow users to specify a particular problem when fetching. ## v1.2.3 (2013-11-13) -* [exercism.io#998](https://github.com/exercism/exercism.io/issues/998): Fix problem with writing an empty config file under certain circumstances. +- [exercism#998](https://github.com/exercism/exercism/issues/998): Fix problem with writing an empty config file under certain circumstances. ## v1.2.2 (2013-11-12) -* [#28](https://github.com/exercism/cli/issues/28): Create exercism directory immediately upon logging in. -* Upgrade to newer version of [codegangsta/cli](https://github.com/codegansta/cli) library, which returns an error from the main Run() function. +- [#28](https://github.com/exercism/cli/issues/28): Create exercism directory immediately upon logging in. +- Upgrade to newer version of [codegangsta/cli](https://github.com/codegansta/cli) library, which returns an error from the main Run() function. ## v1.2.1 (2013-11-09) -* [371521f](https://github.com/exercism/cli/commit/371521fd97460aa92269831f10dadd467cb06592): Add support for nested directories under the language track directory allowing us to create idiomatic scala, clojure, and other exercises. +- [371521f](https://github.com/exercism/cli/commit/371521fd97460aa92269831f10dadd467cb06592): Add support for nested directories under the language track directory allowing us to create idiomatic scala, clojure, and other exercises. ## v1.2.0 (2013-11-07) -* [371521f](https://github.com/exercism/cli/commit/371521fd97460aa92269831f10dadd467cb06592): Consume the new hash of filename => content that the problem API returns. +- [371521f](https://github.com/exercism/cli/commit/371521fd97460aa92269831f10dadd467cb06592): Consume the new hash of filename => content that the problem API returns. ## v1.1.1 (2013-10-20) -* [371521f](https://github.com/exercism/cli/commit/371521fd97460aa92269831f10dadd467cb06592): Add output when fetching to tell the user where the files where created. +- [371521f](https://github.com/exercism/cli/commit/371521fd97460aa92269831f10dadd467cb06592): Add output when fetching to tell the user where the files where created. ## v1.1.0 (2013-10-24) -* Refactor to extract config package -* Delete stray binary **TODO** we might rewrite history on this one, see [#102](https://github.com/exercism/xgo/issues/102). -* [#22](https://github.com/exercism/cli/pull/22): Display submission url after submitting solution - [@Tonkpils](https://github.com/Tonkpils) -* [#21](https://github.com/exercism/cli/pull/21): Add unsubmit command - [@Tonkpils](https://github.com/Tonkpils) -* [#20](https://github.com/exercism/cli/pull/20): Add current command - [@Tonkpils](https://github.com/Tonkpils) -* Inline refactoring experiment, various cleanup +- Refactor to extract config package +- Delete stray binary **TODO** we might rewrite history on this one, see [#102](https://github.com/exercism/xgo/issues/102). +- [#22](https://github.com/exercism/cli/pull/22): Display submission url after submitting solution - [@Tonkpils] +- [#21](https://github.com/exercism/cli/pull/21): Add unsubmit command - [@Tonkpils] +- [#20](https://github.com/exercism/cli/pull/20): Add current command - [@Tonkpils] +- Inline refactoring experiment, various cleanup ## v1.0.1 (2013-09-27) -* [#11](https://github.com/exercism/cli/pull/11): Don't require authentication for demo - [@msgehard](https://github.com/mshegard) -* [#14](https://github.com/exercism/cli/pull/14): Print out fetched assignments - [@Tonkpils](https://github.com/Tonkpils) -* [#16](https://github.com/exercism/cli/pull/16): Fix broken submit for relative path names - [@nf](https://github.com/nf) -* Create a separate demo directory if there's no configured exercism directory +- [#11](https://github.com/exercism/cli/pull/11): Don't require authentication for demo - [@msgehard] +- [#14](https://github.com/exercism/cli/pull/14): Print out fetched assignments - [@Tonkpils] +- [#16](https://github.com/exercism/cli/pull/16): Fix broken submit for relative path names - [@nf] +- Create a separate demo directory if there's no configured exercism directory ## v1.0.0 (2013-09-22) -* [#7](https://github.com/exercism/cli/pull/7): Recognize haskell test files -* [#5](https://github.com/exercism/cli/pull/5): Fix typo - [@simonjefford](https://github.com/simonjefford) -* [#1](https://github.com/exercism/cli/pull/1): Output the location of the config file - [@msgehard](https://github.com/msgehard) -* Recognize more language test files - [@msgehard](https://github.com/msgehard) +- [#7](https://github.com/exercism/cli/pull/7): Recognize haskell test files +- [#5](https://github.com/exercism/cli/pull/5): Fix typo - [@simonjefford] +- [#1](https://github.com/exercism/cli/pull/1): Output the location of the config file - [@msgehard] +- Recognize more language test files - [@msgehard] ## v0.0.27.beta (2013-08-25) -All changes by [@msgehard](https://github.com/msgehard) +All changes by [@msgehard] -* Clean up homedir -* Add dev instructions to README +- Clean up homedir +- Add dev instructions to README ## v0.0.26.beta (2013-08-24) -All changes by [@msgehard](https://github.com/msgehard) - -* Ensure that ruby gem's config file doesn't get clobbered -* Add cross-compilation -* Set proper User-Agent so server doesn't blow up. -* Implement `submit` -* Implement `demo` -* Implement `peek` -* Expand ~ in config -* Implement `fetch` -* Implement `current` -* Implement `whoami` -* Implement login and logout -* Build on Travis +All changes by [@msgehard] + +- Ensure that ruby gem's config file doesn't get clobbered +- Add cross-compilation +- Set proper User-Agent so server doesn't blow up. +- Implement `submit` +- Implement `demo` +- Implement `peek` +- Expand ~ in config +- Implement `fetch` +- Implement `current` +- Implement `whoami` +- Implement login and logout +- Build on Travis + +[@alexwheeler]: https://github.com/AlexWheeler +[@andrerfcsantos]: https://github.com/andrerfcsantos +[@avegner]: https://github.com/avegner +[@dparker1990]: https://github.com/Dparker1990 +[@john-goff]: https://github.com/John-Goff +[@legalizeadulthood]: https://github.com/LegalizeAdulthood +[@qulogic]: https://github.com/QuLogic +[@smarticles101]: https://github.com/Smarticles101 +[@tonkpils]: https://github.com/Tonkpils +[@trevorbramble]: https://github.com/TrevorBramble +[@alebaffa]: https://github.com/alebaffa +[@ambroff]: https://github.com/ambroff +[@andrewsardone]: https://github.com/andrewsardone +[@anxiousmodernman]: https://github.com/anxiousmodernman +[@beanieboi]: https://github.com/beanieboi +[@blackerby]: https://github.com/blackerby +[@broady]: https://github.com/broady +[@ccnp123]: https://github.com/ccnp123 +[@cookrn]: https://github.com/cookrn +[@daveyarwood]: https://github.com/daveyarwood +[@devonestes]: https://github.com/devonestes +[@djquan]: https://github.com/djquan +[@dmmulroy]: https://github.com/dmmulroy +[@dpritchett]: https://github.com/dpritchett +[@etothepiipower]: https://github.com/eToThePiIPower +[@ebautistabar]: https://github.com/ebautistabar +[@ekingery]: https://github.com/ekingery +[@elimisteve]: https://github.com/elimisteve +[@erikschierboom]: https://github.com/erikschierboom +[@ests]: https://github.com/ests +[@farisj]: https://github.com/farisj +[@glebedel]: https://github.com/glebedel +[@harimp]: https://github.com/harimp +[@harugo]: https://github.com/harugo +[@hjljo]: https://github.com/hjljo +[@isaacg]: https://github.com/isaacg +[@isbadawi]: https://github.com/isbadawi +[@jbaiter]: https://github.com/jbaiter +[@jdsutherland]: https://github.com/jdsutherland +[@jgsqware]: https://github.com/jgsqware +[@jish]: https://github.com/jish +[@jrank2013]: https://github.com/Jrank2013 +[@jppunnett]: https://github.com/jppunnett +[@katrinleinweber]: https://github.com/katrinleinweber +[@kytrinyx]: https://github.com/kytrinyx +[@larson004]: https://github.com/larson004 +[@lcowell]: https://github.com/lcowell +[@manusajith]: https://github.com/manusajith +[@morphatic]: https://github.com/morphatic +[@mrageh]: https://github.com/mrageh +[@msgehard]: https://github.com/msgehard +[@narqo]: https://github.com/narqo +[@neslom]: https://github.com/neslom +[@nf]: https://github.com/nf +[@nilbus]: https://github.com/nilbus +[@nywilken]: https://github.com/nywilken +[@petertseng]: https://github.com/petertseng +[@pminten]: https://github.com/pminten +[@queuebit]: https://github.com/queuebit +[@rcode5]: https://github.com/rcode5 +[@rprouse]: https://github.com/rprouse +[@sfairchild]: https://github.com/sfairchild +[@simonjefford]: https://github.com/simonjefford +[@srt32]: https://github.com/srt32 +[@xavdid]: https://github.com/xavdid +[@williandrade]: https://github.com/williandrade +[@zabawaba99]: https://github.com/zabawaba99 +[@groophylifefor]: https://github.com/GroophyLifefor +[@muzimuzhi]: https://github.com/muzimuzhi +[@isberg]: https://github.com/isberg +[@erikschierboom]: https://github.com/erikschierboom +[@sanderploegsma]: https://github.com/sanderploegsma +[@enascimento178]: https://github.com/enascimento178 +[@halfdan]: https://github.com/halfdan +[@glennj]: https://github.com/glennj +[@tomasnorre]: https://github.com/tomasnorre +[@ageron]: https://github.com/ageron +[@petrem]: https://github.com/petrem +[@bnandras]: https://github.com/bnandras +[@vaeng]: https://github.com/vaeng +[@keiravillekode]: https://github.com/keiravillekode diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..3f7813de1 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,93 @@ +# Code of Conduct + +## Introduction + +Exercism is a platform centered around empathetic conversation. +We have a low tolerance for communication that makes anyone feel unwelcome, unsupported, insulted or discriminated against. + +## Seen or experienced something uncomfortable? + +If you see or experience abuse, harassment, discrimination, or feel unsafe or upset, please email [abuse@exercism.org](mailto:abuse@exercism.org?subject=%5BCoC%5D) and include \[CoC\] in the subject line. +We will follow up with you as a priority. + +## Enforcement + +We actively monitor for Code of Conduct (CoC) violations and take any reports of violations extremely seriously. +We have banned contributors, mentors and users due to violations. + +After we receive a report of a CoC violation, we view that person's conversation history on Exercism and related communication channels and attempt to understand whether someone has deliberately broken the CoC, or accidentally crossed a line. +We generally reach out to the person who has been reported to discuss any concerns we have and warn them that repeated violations will result in a ban. +Sometimes we decide that no violation has occurred and that no action is required and sometimes we will also ban people on a first offense. +We strive to be fair, but will err on the side of protecting the culture of our community. + +Exercism's leadership reserve the right to take whatever action they feel appropriate with regards to CoC violations. + +## The simple version + +- Be empathetic +- Be welcoming +- Be kind +- Be honest +- Be supportive +- Be polite + +## The details + +Exercism should be a safe place for everybody regardless of + +- Gender, gender identity or gender expression +- Sexual orientation +- Disability +- Physical appearance (including but not limited to body size) +- Race +- Age +- Religion +- Anything else you can think of + +As someone who is part of this community, you agree that: + +- We are collectively and individually committed to safety and inclusivity +- We have zero tolerance for abuse, harassment, or discrimination +- We respect people’s boundaries and identities +- We refrain from using language that can be considered offensive or oppressive (systemically or otherwise), eg. sexist, racist, homophobic, transphobic, ableist, classist, etc. + - this includes (but is not limited to) various slurs. +- We avoid using offensive topics as a form of humor + +We actively work towards: + +- Being a safe community +- Cultivating a network of support & encouragement for each other +- Encouraging responsible and varied forms of expression + +We condemn: + +- Stalking, doxxing, or publishing private information +- Violence, threats of violence or violent language +- Anything that compromises people’s safety +- Conduct or speech which might be considered sexist, racist, homophobic, transphobic, ableist or otherwise discriminatory or offensive in nature +- The use of unwelcome, suggestive, derogatory or inappropriate nicknames or terms +- Disrespect towards others (jokes, innuendo, dismissive attitudes) and towards differences of opinion +- Intimidation or harassment (online or in-person). + Please read the [Citizen Code of Conduct](https://github.com/stumpsyn/policies/blob/master/citizen_code_of_conduct.md) for how we interpret harassment +- Inappropriate attention or contact +- Not understanding the differences between constructive criticism and disparagement + +These things are NOT OK. + +Be aware of how your actions affect others. +If it makes someone uncomfortable, stop. + +If you say something that is found offensive, and you are called out on it, try to: + +- Listen without interruption +- Believe what the person is saying & do not attempt to disqualify what they have to say +- Ask for tips / help with avoiding making the offense in the future +- Apologize and ask forgiveness + +## History + +This policy was initially adopted from the Front-end London Slack community and has been modified since. +A version history can be seen on [GitHub](https://github.com/exercism/website-copy/edit/main/pages/code_of_conduct.md). + +_This policy is a "living" document, and subject to refinement and expansion in the future. +This policy applies to the Exercism website, the Exercism GitHub organization, any other Exercism-related communication channels (e.g. Discord, Forum, Twitter, email) and any other Exercism entity or event._ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..471c48688 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,51 @@ +# Contributing Guide + +First, thank you! :tada: +Exercism would be impossible without people like you being willing to spend time and effort making things better. + +## Documentation +* [Exercism Documentation Repository](https://github.com/exercism/docs) + +## Dependencies + +You'll need Go version 1.26 or higher. Follow the directions on http://golang.org/doc/install + +## Development + +This project uses Go's [`modules` dependency management](https://github.com/golang/go/wiki/Modules) system. + +To contribute [fork this repo on the GitHub webpage][fork] and clone your fork. +Make your desired changes and submit a pull request. +Please provide tests for the changes where possible. + +Please note that if your development directory is located inside the `GOPATH`, you need to set the `GO111MODULE=on` environment variable. + +## Running the Tests + +To run the tests locally + +``` +go test ./... +``` + +## Manual Testing against Exercism + +To test your changes while doing everyday Exercism work you +can build using the following instructions. Any name may be used for the +binary (e.g. `testercism`) - by using a name other than `exercism` you +can have different profiles under `~/.config` and avoid possibly +damaging your real Exercism submissions, or test different tokens, etc. + +On Unices: + +- `cd /path/to/the/development/directory/cli && go build -o testercism ./exercism/main.go` +- `./testercism -h` + +On Windows: + +- `cd /d \path\to\the\development\directory\cli` +- `go build -o testercism.exe exercism\main.go` +- `testercism.exe —h` + +### Releasing a new CLI version +Consult the [release documentation](RELEASE.md). diff --git a/LICENSE b/LICENSE index 449be2455..986629c33 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2013 Mike Gehard, Katrina Owen +Copyright (c) 2013 Mike Gehard, Exercism Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/README.md b/README.md index 1b19a34fb..8b0fa92c6 100644 --- a/README.md +++ b/README.md @@ -1,89 +1,19 @@ -[![Build Status](https://travis-ci.org/exercism/cli.png?branch=master)](https://travis-ci.org/exercism/cli) +# Exercism Command-line Interface (CLI) -# Exercism Command-Line Client +[![CI](https://github.com/exercism/cli/actions/workflows/ci.yml/badge.svg)](https://github.com/exercism/cli/actions/workflows/ci.yml) +[![Go Report Card](https://goreportcard.com/badge/github.com/exercism/cli)](https://goreportcard.com/report/github.com/exercism/cli) -The CLI provides a way to do the problems on -[exercism.io](http://exercism.io). +The CLI is the link between the [Exercism][exercism] website and your local work environment. It lets you download exercises and submit your solution to the site. -This CLI ships as a binary with no additional runtime requirements. This means -that if you're doing the Haskell problems on exercism you don't need a working -Python or Ruby environment simply to fetch and submit exercises. +This CLI ships as a binary with no additional runtime requirements. -## Dependencies -Go version 1.3 or higher +## Installing the CLI -## Installing Go +Instructions can be found at [exercism/cli/releases](https://github.com/exercism/cli/releases) -Follow the directions on http://golang.org/doc/install +## Contributing -## Development +If you wish to help improve the CLI, please see the [Contributing guide][contributing]. -1. fork this repo -1. `go get github.com/exercism/cli/exercism` -1. `cd $GOPATH/src/github.com/exercism/cli` -1. `git remote set-url origin https://github.com//cli` -1. `go get` -1. Make sure $GOPATH/bin is on your path (you may need something like `export PATH=$PATH:/projects/goprojects/bin`) -1. Open a separate terminal window to your project directory and run the command `glitch` -1. Make the change. -1. Submit a pull request. - -Please provide tests for the changes where possible. - -At the moment the CLI commands are not tested, so if you're adding a new -command don't worry too hard about tests. - -## Building - -To build the binary for your platform run - -``` -bin/build -``` - -The resulting binary can be found in `out/exercism` (Linux, Mac OS X) or `out/exercism.exe` (Windows). - -In order to cross-compile for all platforms, run `bin/build-all`. The binaries -will be built into the `release` directory. - -## Troubleshooting - -```plain -app.Run(os.Args) used as value -``` - -This error is due to a breaking change between the 0.x version of the `codegangsta/cli` library and the `1.x` version of the library. - -To fix it update the `codegangsta/cli` dependency: - -```plain -$ go get -u github.com/codegangsta/cli -``` - -## Using Glitch - -If you'd like to run lint, vet, and the tests on every change, install Levi Cook's `glitch` library: - -1. `go get github.com/levicook/glitch` -1. `go install github.com/levicook/glitch` -1. Ensure that you have `go vet` -1. Run it with `glitch`. - -### Troubleshooting. - -When you `glitch`, do you get stymied like this? - -```shell -# github.com/exercism/cli -api_test.go:7:2: cannot find package "github.com/stretchr/testify/assert" in any of: ... -FAIL github.com/exercism/cli [setup failed] -``` - -You may need to - -```shell -$ go get github.com/stretchr/testify/assert -$ go install github.com/stretchr/testify/assert -``` - -Now you should be able to run `glitch`. +[exercism]: http://exercism.org +[contributing]: /CONTRIBUTING.md diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 000000000..17182f601 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,54 @@ +# Cutting a CLI Release + +The Exercism CLI uses [GoReleaser](https://goreleaser.com) to automate the release process. + +## Requirements + +1. [Install GoReleaser](https://goreleaser.com/install/) +1. [Setup GitHub token](https://goreleaser.com/scm/github/) +1. Have a gpg key installed on your machine - it is [used for signing the artifacts](https://goreleaser.com/customization/sign/) + +## Bump the version + +1. Create a branch for the new version +1. Bump the `Version` constant in `cmd/version.go` +1. Update the `CHANGELOG.md` file to include a section for the new version and its changes. + Hint: you can view changes using the compare view: https://github.com/exercism/cli/compare/$PREVIOUS_RELEASE...main. +1. Commit the updated files +1. Create a PR + +_Note: It's useful to add the version to the commit message when you bump it: e.g. `Bump version to v3.5.9`._ + +## Cut a release + +Once the version bump PR has been merged, run the following command to cut a release: + +```shell +GPG_FINGERPRINT="" ./bin/release.sh +``` + +## Cut Release on GitHub + +Once the `./bin/release.sh` command finishes, the [release workflow](https://github.com/exercism/cli/actions/workflows/release.yml) will automatically run. +This workflow will create a draft release at https://github.com/exercism/cli/releases/tag/vX.Y.Z. +Once created, go that page to update the release description to: + +``` +To install, follow the interactive installation instructions at https://exercism.org/cli-walkthrough +--- + +[modify the generated release-notes to describe changes in this release] +``` + +Lastly, test and then publish the draft. + +## Homebrew + +Homebrew will automatically bump the version, no manual action is required. + +## Update the docs site + +If there are any significant changes, we should describe them on +[exercism.org/cli](https://exercism.org/cli). + +The codebase lives at [exercism/website-copy](https://github.com/exercism/website-copy) in `pages/cli.md`. diff --git a/api/api.go b/api/api.go index 56d93d056..25b5e9d14 100644 --- a/api/api.go +++ b/api/api.go @@ -2,265 +2,50 @@ package api import ( "bytes" - "encoding/json" - "errors" - "fmt" - "net/http" - "strings" -) + "os" -var ( - // ErrUnknownLanguage represents an error returned when the language requested does not exist - ErrUnknownLanguage = errors.New("the language is unknown") + "golang.org/x/net/html/charset" + "golang.org/x/text/transform" ) -// PayloadError represents an error message from the API. -type PayloadError struct { - Error string `json:"error"` -} - -// PayloadProblems represents a response containing problems. -type PayloadProblems struct { - Problems []*Problem - PayloadError -} - -// PayloadSubmission represents metadata about a successful submission. -type PayloadSubmission struct { - *Submission - PayloadError -} - -// SubmissionInfo contains state information about a submission. -type SubmissionInfo struct { - Slug string `json:"slug"` - State string `json:"state"` -} - -// Fetch retrieves problems from the API. -// In most cases these problems consist of a test suite and a README -// from the x-api, but it is also used when restoring earlier iterations. -func (c *Client) Fetch(args []string) ([]*Problem, error) { - var url string - switch len(args) { - case 0: - url = fmt.Sprintf("%s/v2/exercises?key=%s", c.XAPIHost, c.APIKey) - case 1: - language := args[0] - url = fmt.Sprintf("%s/v2/exercises/%s?key=%s", c.XAPIHost, language, c.APIKey) - case 2: - language := args[0] - problem := args[1] - url = fmt.Sprintf("%s/v2/exercises/%s/%s", c.XAPIHost, language, problem) - default: - return nil, fmt.Errorf("Usage: exercism fetch\n or: exercism fetch LANGUAGE\n or: exercism fetch LANGUAGE PROBLEM") - } - - req, err := c.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } - - payload := &PayloadProblems{} - res, err := c.Do(req, payload) - if err != nil { - return nil, err - } - - if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unable to fetch problems (HTTP: %d) - %s", res.StatusCode, payload.Error) - } - - return payload.Problems, nil -} - -// Restore fetches the latest revision of a solution and writes it to disk. -func (c *Client) Restore() ([]*Problem, error) { - url := fmt.Sprintf("%s/api/v1/iterations/%s/restore", c.APIHost, c.APIKey) - req, err := c.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } - - payload := &PayloadProblems{} - res, err := c.Do(req, payload) - if err != nil { - return nil, err - } - - if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unable to fetch problems (HTTP: %d) - %s", res.StatusCode, payload.Error) - } - - return payload.Problems, nil -} - -// Submissions gets a list of submitted exercises and their current acceptance state. -func (c *Client) Submissions() (map[string][]SubmissionInfo, error) { - url := fmt.Sprintf("%s/api/v1/exercises?key=%s", c.APIHost, c.APIKey) - req, err := c.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } - - var payload map[string][]SubmissionInfo - if _, err := c.Do(req, &payload); err != nil { - return nil, err - } - - return payload, nil -} - -// Submission get the latest submitted exercise for the given language and exercise -func (c *Client) Submission(language, excercise string) (*Submission, error) { - url := fmt.Sprintf("%s/api/v1/submissions/%s/%s?key=%s", c.APIHost, language, excercise, c.APIKey) - req, err := c.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } - - var payload Submission - if _, err := c.Do(req, &payload); err != nil { - return nil, err - } - - return &payload, nil -} - -// Download fetches a solution by submission key and writes it to disk. -func (c *Client) Download(submissionID string) (*Submission, error) { - url := fmt.Sprintf("%s/api/v1/submissions/%s", c.APIHost, submissionID) - - req, err := c.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } - - payload := &PayloadSubmission{} - res, err := c.Do(req, payload) - if err != nil { - return nil, err - } - - if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unable to fetch Submission (HTTP: %d) - %s", res.StatusCode, payload.Error) - } - - return payload.Submission, err -} - -// Demo fetches the first problem in each language track. -func (c *Client) Demo() ([]*Problem, error) { - url := fmt.Sprintf("%s/problems/demo?key=%s", c.XAPIHost, c.APIKey) - req, err := c.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } - - payload := &PayloadProblems{} - res, err := c.Do(req, payload) - if err != nil { - return nil, err - } - - if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unable to fetch problems (HTTP: %d) - %s", res.StatusCode, payload.Error) - } - - return payload.Problems, nil -} +const ( + mimeType = "text/plain" +) -// Submit posts code to the API -func (c *Client) Submit(iter *Iteration) (*Submission, error) { - url := fmt.Sprintf("%s/api/v1/user/assignments", c.APIHost) - payload, err := json.Marshal(iter) - if err != nil { - return nil, err - } +var ( + utf8BOM = []byte{0xef, 0xbb, 0xbf} +) - req, err := c.NewRequest("POST", url, bytes.NewReader(payload)) +func readFileAsUTF8String(filename string) (*string, error) { + b, err := os.ReadFile(filename) if err != nil { return nil, err } - ps := &PayloadSubmission{} - res, err := c.Do(req, ps) - if err != nil { - return nil, fmt.Errorf("unable to submit solution - %s", err) - } - - if res.StatusCode != http.StatusCreated { - return nil, fmt.Errorf("unable to submit (HTTP: %d) - %s", res.StatusCode, ps.Error) - } - - return ps.Submission, nil -} - -// List available problems for a language -func (c *Client) List(language string) ([]string, error) { - url := fmt.Sprintf("%s/tracks/%s", c.XAPIHost, language) - - req, err := c.NewRequest("GET", url, nil) - if err != nil { - return nil, err + encoding, _, certain := charset.DetermineEncoding(b, mimeType) + if !certain { + // We don't want to use an uncertain encoding. + // In particular, doing that may mangle UTF-8 files + // that have only ASCII in their first 1024 bytes. + // See https://github.com/exercism/cli/issues/309. + // So if we're unsure, use UTF-8 (no transformation). + s := string(b) + return &s, nil } - - res, err := c.Do(req, nil) + decoder := encoding.NewDecoder() + decodedBytes, _, err := transform.Bytes(decoder, b) if err != nil { return nil, err } - defer res.Body.Close() - if res.StatusCode != http.StatusOK { - return nil, ErrUnknownLanguage - } - - var payload struct { - Track Track - } - if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { - return nil, err - } - problems := make([]string, len(payload.Track.Problems)) - prefix := language + "/" - - for n, p := range payload.Track.Problems { - problems[n] = strings.TrimPrefix(p, prefix) - } - - return problems, nil -} - -// Unsubmit deletes a submission. -func (c *Client) Unsubmit() error { - url := fmt.Sprintf("%s/api/v1/user/assignments?key=%s", c.APIHost, c.APIKey) - req, err := c.NewRequest("DELETE", url, nil) - if err != nil { - return err - } - - pe := &PayloadError{} - if _, err := c.Do(req, pe); err != nil { - return fmt.Errorf("failed to unsubmit - %s", pe.Error) - } - - return nil -} - -// Tracks gets the current list of active and inactive language tracks. -func (c *Client) Tracks() ([]*Track, error) { - url := fmt.Sprintf("%s/tracks", c.XAPIHost) - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return []*Track{}, err - } - - var payload struct { - Tracks []*Track - } - if _, err := c.Do(req, &payload); err != nil { - return []*Track{}, err - } + // Drop the UTF-8 BOM that may have been added. This isn't necessary, and + // it's going to be written into another UTF-8 buffer anyway once it's JSON + // serialized. + // + // The standard recommends omitting the BOM. See + // http://www.unicode.org/versions/Unicode5.0.0/ch02.pdf + decodedBytes = bytes.TrimPrefix(decodedBytes, utf8BOM) - return payload.Tracks, nil + s := string(decodedBytes) + return &s, nil } diff --git a/api/api_test.go b/api/api_test.go deleted file mode 100644 index f6784cb39..000000000 --- a/api/api_test.go +++ /dev/null @@ -1,158 +0,0 @@ -package api - -import ( - "fmt" - "io" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/exercism/cli/config" - "github.com/stretchr/testify/assert" -) - -func respondWithFixture(w http.ResponseWriter, name string) error { - f, err := os.Open("../fixtures/" + name) - if err != nil { - return err - } - - io.Copy(w, f) - f.Close() - - return nil -} -func TestFetchAllProblem(t *testing.T) { - APIKey := "mykey" - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - allProblemsAPI := fmt.Sprintf("/v2/exercises?key=%s", APIKey) - assert.Equal(t, allProblemsAPI, req.RequestURI) - - if err := respondWithFixture(w, "problems.json"); err != nil { - t.Fatal(err) - } - })) - defer ts.Close() - - client := NewClient(&config.Config{XAPI: ts.URL, APIKey: APIKey}) - - problems, err := client.Fetch([]string{}) - assert.NoError(t, err) - - assert.Equal(t, len(problems), 3) -} - -func TestFetchATrack(t *testing.T) { - var ( - APIKey = "mykey" - language = "go" - ) - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - languageProblemsAPI := fmt.Sprintf("/v2/exercises/%s?key=%s", language, APIKey) - assert.Equal(t, languageProblemsAPI, req.RequestURI) - - if err := respondWithFixture(w, "problems.json"); err != nil { - t.Fatal(err) - } - })) - defer ts.Close() - - client := NewClient(&config.Config{XAPI: ts.URL, APIKey: APIKey}) - - _, err := client.Fetch([]string{language}) - assert.NoError(t, err) -} - -func TestFetchASpecificProblem(t *testing.T) { - var ( - APIKey = "mykey" - language = "go" - problem = "leap" - ) - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - languageProblemsAPI := fmt.Sprintf("/v2/exercises/%s/%s", language, problem) - assert.Equal(t, languageProblemsAPI, req.RequestURI) - - if err := respondWithFixture(w, "problems.json"); err != nil { - t.Fatal(err) - } - })) - defer ts.Close() - - client := NewClient(&config.Config{XAPI: ts.URL, APIKey: APIKey}) - - _, err := client.Fetch([]string{language, problem}) - assert.NoError(t, err) -} - -func TestGetSubmission(t *testing.T) { - var ( - APIKey = "mykey" - language = "go" - problem = "leap" - ) - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - languageProblemsAPI := fmt.Sprintf("/api/v1/submissions/%s/%s?key=%s", language, problem, APIKey) - assert.Equal(t, languageProblemsAPI, req.RequestURI) - - if err := respondWithFixture(w, "submission.json"); err != nil { - t.Fatal(err) - } - })) - defer ts.Close() - - client := NewClient(&config.Config{API: ts.URL, APIKey: APIKey}) - _, err := client.Submission(language, problem) - assert.NoError(t, err) -} - -func TestSubmitAssignment(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - w.WriteHeader(http.StatusCreated) - - if err := respondWithFixture(w, "submit.json"); err != nil { - t.Fatal(err) - } - })) - defer ts.Close() - - client := NewClient(&config.Config{API: ts.URL}) - iter := &Iteration{Key: "123", Code: "456", Path: "/foo/bar/bob/bob.rb", Dir: "/foo/bar"} - sub, err := client.Submit(iter) - assert.NoError(t, err) - - assert.Equal(t, sub.Language, "ruby") -} - -func TestListTrack(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - // check that we correctly built the URI path - assert.Equal(t, "/tracks/clojure", req.RequestURI) - - if err := respondWithFixture(w, "tracks.json"); err != nil { - t.Fatal(err) - } - })) - defer ts.Close() - - client := NewClient(&config.Config{XAPI: ts.URL}) - - problems, err := client.List("clojure") - assert.NoError(t, err) - - assert.Equal(t, len(problems), 34) - assert.Equal(t, problems[0], "bob") -} - -func TestUnknownLanguage(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - http.NotFound(w, req) - })) - defer ts.Close() - - client := NewClient(&config.Config{XAPI: ts.URL}) - - _, err := client.List("rubbbby") - assert.Equal(t, err, ErrUnknownLanguage) -} diff --git a/api/client.go b/api/client.go index 30dbcd3e8..a65cd3235 100644 --- a/api/client.go +++ b/api/client.go @@ -1,68 +1,110 @@ package api import ( - "encoding/json" "fmt" "io" "net/http" + "time" - "github.com/exercism/cli/config" + "github.com/exercism/cli/debug" ) var ( // UserAgent lets the API know where the call is being made from. - // It's set from main() so that we have access to the version. - UserAgent string + // It's overridden from the root command so that we can set the version. + UserAgent = "github.com/exercism/cli" + + // TimeoutInSeconds is the timeout the default HTTP client will use. + TimeoutInSeconds = 60 + // HTTPClient is the client used to make HTTP calls in the cli package. + HTTPClient = &http.Client{Timeout: time.Duration(TimeoutInSeconds) * time.Second} ) -// Client contains the necessary information to contact the Exercism APIs +// Client is an http client that is configured for Exercism. type Client struct { - client *http.Client - APIHost string - XAPIHost string - APIKey string + *http.Client + ContentType string + Token string + APIBaseURL string } -// NewClient returns an Exercism API Client -func NewClient(c *config.Config) *Client { +// NewClient returns an Exercism API client. +func NewClient(token, baseURL string) (*Client, error) { return &Client{ - client: http.DefaultClient, - APIHost: c.API, - XAPIHost: c.XAPI, - APIKey: c.APIKey, - } + Client: HTTPClient, + Token: token, + APIBaseURL: baseURL, + }, nil } -// NewRequest returns an http.Request with information for the Exercism API +// NewRequest returns an http.Request with information for the Exercism API. func (c *Client) NewRequest(method, url string, body io.Reader) (*http.Request, error) { + if c.Client == nil { + c.Client = HTTPClient + } + req, err := http.NewRequest(method, url, body) if err != nil { return nil, err } req.Header.Set("User-Agent", UserAgent) - req.Header.Set("Content-Type", "application/json") + if c.ContentType == "" { + req.Header.Set("Content-Type", "application/json") + } else { + req.Header.Set("Content-Type", c.ContentType) + } + if c.Token != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.Token)) + } return req, nil } -// Do performs an http.Request and optionally parses the response body into the given interface -func (c *Client) Do(req *http.Request, v interface{}) (*http.Response, error) { - res, err := c.client.Do(req) +// Do performs an http.Request and optionally parses the response body into the given interface. +func (c *Client) Do(req *http.Request) (*http.Response, error) { + debug.DumpRequest(req) + + res, err := c.Client.Do(req) if err != nil { return nil, err } - if res.StatusCode == http.StatusNoContent { - return res, nil + debug.DumpResponse(res) + return res, nil +} + +// TokenIsValid calls the API to determine whether the token is valid. +func (c *Client) TokenIsValid() (bool, error) { + url := fmt.Sprintf("%s/validate_token", c.APIBaseURL) + req, err := c.NewRequest("GET", url, nil) + if err != nil { + return false, err + } + resp, err := c.Do(req) + if err != nil { + return false, err } + defer resp.Body.Close() + + return resp.StatusCode == http.StatusOK, nil +} - if v != nil { - defer res.Body.Close() - if err := json.NewDecoder(res.Body).Decode(v); err != nil { - return nil, fmt.Errorf("error parsing API response - %s", err) - } +// IsPingable calls the API /ping to determine whether the API can be reached. +func (c *Client) IsPingable() error { + url := fmt.Sprintf("%s/ping", c.APIBaseURL) + req, err := c.NewRequest("GET", url, nil) + if err != nil { + return err + } + resp, err := c.Do(req) + if err != nil { + return err } + defer resp.Body.Close() - return res, nil + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("API returned %s", resp.Status) + } + return nil } diff --git a/api/client_test.go b/api/client_test.go index a7992543b..efb1c3c14 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -1,57 +1,81 @@ package api import ( + "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" - "github.com/exercism/cli/config" "github.com/stretchr/testify/assert" ) -var ( - mux *http.ServeMux - server *httptest.Server - client *Client - conf = &config.Config{APIKey: "apikey", API: "localhost", XAPI: "xlocalhost"} -) - func TestNewRequestSetsDefaultHeaders(t *testing.T) { - UserAgent = "Test" - client = NewClient(conf) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `ok`) + })) + defer ts.Close() - req, err := client.NewRequest("GET", client.APIHost, nil) - assert.NoError(t, err) + UserAgent = "BogusAgent" - assert.Equal(t, UserAgent, req.Header.Get("User-Agent")) - assert.Equal(t, "application/json", req.Header.Get("Content-Type")) + testCases := []struct { + desc string + client *Client + auth string + contentType string + }{ + { + desc: "User defaults", + client: &Client{}, + auth: "", + contentType: "application/json", + }, + { + desc: "Override defaults", + client: &Client{ + Token: "abc123", + APIBaseURL: "http://example.com", + ContentType: "bogus", + }, + auth: "Bearer abc123", + contentType: "bogus", + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + req, err := tc.client.NewRequest("GET", ts.URL, nil) + assert.NoError(t, err) + assert.Equal(t, "BogusAgent", req.Header.Get("User-Agent")) + assert.Equal(t, tc.contentType, req.Header.Get("Content-Type")) + assert.Equal(t, tc.auth, req.Header.Get("Authorization")) + }) + } } func TestDo(t *testing.T) { - UserAgent = "Exercism Test v1" - mux = http.NewServeMux() - server = httptest.NewServer(mux) - defer server.Close() - url := server.URL - conf = &config.Config{APIKey: "apikey", API: url, XAPI: url} - client = NewClient(conf) - - type test struct { - T string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + + fmt.Fprint(w, `{"hello": "world"}`) + })) + defer ts.Close() + + type payload struct { + Hello string `json:"hello"` } - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "GET", r.Method) - assert.Equal(t, UserAgent, r.Header.Get("User-Agent")) + client := &Client{} - fmt.Fprint(w, `{"T":"world"}`) - }) + req, err := client.NewRequest("GET", ts.URL, nil) + assert.NoError(t, err) - req, _ := client.NewRequest("GET", client.APIHost+"/", nil) + res, err := client.Do(req) + assert.NoError(t, err) - var body test - _, err := client.Do(req, &body) + var body payload + err = json.NewDecoder(res.Body).Decode(&body) assert.NoError(t, err) - assert.Equal(t, test{T: "world"}, body) + + assert.Equal(t, "world", body.Hello) } diff --git a/api/iteration.go b/api/iteration.go deleted file mode 100644 index ddabf0554..000000000 --- a/api/iteration.go +++ /dev/null @@ -1,53 +0,0 @@ -package api - -import ( - "fmt" - "path/filepath" - "strings" -) - -const ( - msgUnidentifiable = "unable to identify track and problem" -) - -// Iteration represents a version of a particular exercise. -// This gets submitted to the API. -type Iteration struct { - Key string `json:"key"` - Code string `json:"code"` - Path string `json:"path"` - Dir string `json:"dir"` - File string `json:"-"` - Language string `json:"-"` - Problem string `json:"-"` -} - -// RelativePath returns the path relative to the exercism dir. -func (iter *Iteration) RelativePath() string { - if iter.Path != "" { - return iter.Path - } - - if len(iter.Dir) > len(iter.File) { - return "" - } - iter.Path = iter.File[len(iter.Dir):] - return iter.Path -} - -// Identify attempts to determine the track and problem of an iteration. -func (iter *Iteration) Identify() error { - if !strings.HasPrefix(strings.ToLower(iter.File), strings.ToLower(iter.Dir)) { - return fmt.Errorf(msgUnidentifiable) - } - - segments := strings.Split(iter.RelativePath(), string(filepath.Separator)) - // file is always the absolute path, so the first segment will be empty - if len(segments) < 4 { - return fmt.Errorf(msgUnidentifiable) - } - - iter.Language = segments[1] - iter.Problem = segments[2] - return nil -} diff --git a/api/iteration_test.go b/api/iteration_test.go deleted file mode 100644 index 3e44f9bad..000000000 --- a/api/iteration_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package api - -import "testing" - -func TestIdentify(t *testing.T) { - testCases := []struct { - file string - ok bool - }{ - { - file: "/Users/me/exercism/ruby/bob/totally/fine/deep/path/src/bob.rb", - ok: true, - }, - { - file: "/Users/me/exercism/ruby/bob/bob.rb", - ok: true, - }, - { - file: "/Users/me/exercism/bob.rb", - ok: false, - }, - { - file: "/users/me/exercism/ruby/bob/bob.rb", - ok: true, - }, - { - file: "/Users/me/bob.rb", - ok: false, - }, - { - file: "/tmp/bob.rb", - ok: false, - }, - } - - for _, tt := range testCases { - iter := &Iteration{ - File: tt.file, - Dir: "/Users/me/exercism", - } - err := iter.Identify() - if !tt.ok && err == nil { - t.Errorf("Expected %s to fail.", tt.file) - } - - if tt.ok && !(iter.Language == "ruby" && iter.Problem == "bob") { - t.Errorf("Language: %s, Problem: %s\nPath: %s\n", iter.Language, iter.Problem, tt.file) - } - } -} diff --git a/api/problem.go b/api/problem.go deleted file mode 100644 index 1657f91de..000000000 --- a/api/problem.go +++ /dev/null @@ -1,18 +0,0 @@ -package api - -import "fmt" - -// Problem represents a specific problem in a given language track. -type Problem struct { - ID string `json:"id"` - TrackID string `json:"track_id"` - Language string `json:"language"` - Slug string `json:"slug"` - Name string `json:"name"` - Files map[string]string `json:"files"` - Submitted bool -} - -func (p *Problem) String() string { - return fmt.Sprintf("%s (%s)", p.Name, p.Language) -} diff --git a/api/submission.go b/api/submission.go deleted file mode 100644 index 01ff12692..000000000 --- a/api/submission.go +++ /dev/null @@ -1,13 +0,0 @@ -package api - -// Submission is an iteration that has been submitted to the API. -type Submission struct { - URL string `json:"url"` - TrackID string `json:"track_id"` - Language string `json:"language"` - Slug string `json:"slug"` - Name string `json:"name"` - Username string `json:"username"` - ProblemFiles map[string]string `json:"problem_files"` - SolutionFiles map[string]string `json:"solution_files"` -} diff --git a/api/track.go b/api/track.go deleted file mode 100644 index b9323f66c..000000000 --- a/api/track.go +++ /dev/null @@ -1,20 +0,0 @@ -package api - -import "fmt" - -// Track is a collection of problems in a given language. -type Track struct { - ID string `json:"id"` - Language string `json:"language"` - Active bool `json:"active"` - Problems []string `json:"problems"` -} - -// Len lists the number of problems a track has. -func (t *Track) Len() int { - return len(t.Problems) -} - -func (t *Track) String() string { - return fmt.Sprintf("%s (%s)", t.Language, t.ID) -} diff --git a/bin/build b/bin/build deleted file mode 100755 index 0efb59e94..000000000 --- a/bin/build +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash - -CGO_ENABLED=0 go build -o out/exercism exercism/main.go diff --git a/bin/build-all b/bin/build-all deleted file mode 100755 index e065c5123..000000000 --- a/bin/build-all +++ /dev/null @@ -1,55 +0,0 @@ -#!/bin/bash - -set -e -x - -echo "Creating release dir..." -mkdir -p release - -echo "Creating darwin/386 binary..." -GOOS=darwin GOARCH=386 go build -o out/exercism exercism/main.go -cd out -tar cvzf ../release/exercism-mac-32bit.tgz exercism -cd .. - -echo "Creating darwin/amd64 binary..." -GOOS=darwin GOARCH=amd64 go build -o out/exercism exercism/main.go -cd out -tar cvzf ../release/exercism-mac-64bit.tgz exercism -cd .. - -echo "Creating linux/386 binary..." -GOOS=linux GOARCH=386 go build -o out/exercism exercism/main.go -cd out -tar cvzf ../release/exercism-linux-32bit.tgz exercism -cd .. - -echo "Creating linux/amd64 binary..." -GOOS=linux GOARCH=amd64 go build -o out/exercism exercism/main.go -cd out -tar cvzf ../release/exercism-linux-64bit.tgz exercism -cd .. - -echo "Creating linux/ARMv5 binary..." -GOOS=linux GOARCH=arm GOARM=5 go build -o out/exercism exercism/main.go -cd out -tar cvzf ../release/exercism-linux-arm-v5.tgz exercism -cd .. - -echo "Creating linux/ARMv6 binary..." -GOOS=linux GOARCH=arm GOARM=6 go build -o out/exercism exercism/main.go -cd out -tar cvzf ../release/exercism-linux-arm-v6.tgz exercism -cd .. - -echo "Creating windows/386 binary..." -GOOS=windows GOARCH=386 go build -o out/exercism.exe exercism/main.go -cd out -zip ../release/exercism-windows-32bit.zip exercism.exe -cd .. - -echo "Creating windows/amd64 binary..." -GOOS=windows GOARCH=amd64 go build -o out/exercism.exe exercism/main.go -cd out -zip ../release/exercism-windows-64bit.zip exercism.exe -cd .. - diff --git a/bin/build.sh b/bin/build.sh new file mode 100755 index 000000000..f2ed29c5d --- /dev/null +++ b/bin/build.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +go build -o testercism ./exercism/main.go diff --git a/bin/env b/bin/env deleted file mode 100755 index 428a17ab4..000000000 --- a/bin/env +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -set -e - -SCRIPT_HOME=$( cd "$( dirname "$0" )" && pwd ) - -base=$SCRIPT_HOME/.. - diff --git a/bin/format.sh b/bin/format.sh new file mode 100755 index 000000000..25c19b2e3 --- /dev/null +++ b/bin/format.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +go fmt ./... diff --git a/bin/go b/bin/go deleted file mode 100755 index 68aa0cbd6..000000000 --- a/bin/go +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -set -e - -exec $(dirname $0)/env go $@ diff --git a/bin/release.sh b/bin/release.sh new file mode 100755 index 000000000..05f228455 --- /dev/null +++ b/bin/release.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -z "${GPG_FINGERPRINT}" ]]; then + echo "GPG_FINGERPRINT environment variable is not set" + exit 1 +fi + +echo "Syncing repo with latest main..." +git checkout main +git pull + +VERSION=$(sed -n -E 's/^const Version = "([0-9]+\.[0-9]+\.[0-9]+)"$/\1/p' cmd/version.go) +TAG_NAME="v${VERSION}" + +echo "Verify release can be built..." +goreleaser --skip=publish --snapshot --clean + +echo "Pushing tag..." +git tag -a "${TAG_NAME}" -m "Release ${TAG_NAME}" +git push origin "${TAG_NAME}" + +echo "Tag pushed" +echo "The release CI workflow will automatically create a draft release." +echo "Once created, edit the release notes and publish it." diff --git a/bin/test.sh b/bin/test.sh new file mode 100755 index 000000000..062c46972 --- /dev/null +++ b/bin/test.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +go test ./... diff --git a/browser/open.go b/browser/open.go new file mode 100644 index 000000000..48221326c --- /dev/null +++ b/browser/open.go @@ -0,0 +1,32 @@ +package browser + +import ( + "os/exec" + "runtime" + "strings" +) + +// Open opens a browser to the given URL. +// The terminal's open command is operating system dependent. +func Open(url string) error { + // Escape characters are not allowed by cmd/bash. + switch runtime.GOOS { + case "windows": + url = strings.Replace(url, "&", `^&`, -1) + default: + url = strings.Replace(url, "&", `\&`, -1) + } + + // The command to open the browser is OS-dependent. + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "freebsd", "linux", "netbsd", "openbsd": + cmd = exec.Command("xdg-open", url) + case "windows": + cmd = exec.Command("cmd", "/c", "start", url) + } + + return cmd.Run() +} diff --git a/cli/asset.go b/cli/asset.go new file mode 100644 index 000000000..10bbab051 --- /dev/null +++ b/cli/asset.go @@ -0,0 +1,37 @@ +package cli + +import ( + "bytes" + "fmt" + "io" + "net/http" +) + +// Asset is a build for a particular system, uploaded to a GitHub release. +type Asset struct { + ID int `json:"id"` + Name string `json:"name"` + ContentType string `json:"content_type"` +} + +func (a *Asset) download() (*bytes.Reader, error) { + downloadURL := fmt.Sprintf("%s/assets/%d", ReleaseURL, a.ID) + req, err := http.NewRequest("GET", downloadURL, nil) + if err != nil { + return nil, err + } + // https://developer.github.com/v3/repos/releases/#get-a-single-release-asset + req.Header.Set("Accept", "application/octet-stream") + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + bs, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + return bytes.NewReader(bs), nil +} diff --git a/cli/cli.go b/cli/cli.go new file mode 100644 index 000000000..d8b9bf4d3 --- /dev/null +++ b/cli/cli.go @@ -0,0 +1,212 @@ +package cli + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "runtime" + "strings" + "time" + + semver "github.com/blang/semver/v4" + "github.com/exercism/cli/debug" + update "github.com/inconshreveable/go-update" +) + +var ( + // BuildOS is the operating system (GOOS) used during the build process. + BuildOS string + // BuildARM is the ARM version (GOARM) used during the build process. + BuildARM string + // BuildARCH is the architecture (GOARCH) used during the build process. + BuildARCH string +) + +var ( + osMap = map[string]string{ + "darwin": "darwin", + "freebsd": "freebsd", + "linux": "linux", + "openbsd": "openbsd", + "windows": "windows", + } + + archMap = map[string]string{ + "386": "i386", + "amd64": "x86_64", + "arm": "arm", + "ppc64": "ppc64", + } +) + +var ( + // TimeoutInSeconds is the timeout the default HTTP client will use. + TimeoutInSeconds = 60 + // HTTPClient is the client used to make HTTP calls in the cli package. + HTTPClient = &http.Client{Timeout: time.Duration(TimeoutInSeconds) * time.Second} + // ReleaseURL is the endpoint that provides information about cli releases. + ReleaseURL = "https://api.github.com/repos/exercism/cli/releases" +) + +// Updater is a simple upgradable file interface. +type Updater interface { + IsUpToDate() (bool, error) + Upgrade() error +} + +// CLI is information about the CLI itself. +type CLI struct { + Version string + LatestRelease *Release +} + +// New creates a CLI, setting it to a particular version. +func New(version string) *CLI { + return &CLI{ + Version: version, + } +} + +// IsUpToDate compares the current version to that of the latest release. +func (c *CLI) IsUpToDate() (bool, error) { + if c.LatestRelease == nil { + if err := c.fetchLatestRelease(); err != nil { + return false, err + } + } + + rv, err := semver.Make(c.LatestRelease.Version()) + if err != nil { + return false, fmt.Errorf("unable to parse latest version (%s): %s", c.LatestRelease.Version(), err) + } + cv, err := semver.Make(c.Version) + if err != nil { + return false, fmt.Errorf("unable to parse current version (%s): %s", c.Version, err) + } + + return cv.GTE(rv), nil +} + +// Upgrade allows the user to upgrade to the latest version of the CLI. +func (c *CLI) Upgrade() error { + var ( + OS = osMap[runtime.GOOS] + ARCH = archMap[runtime.GOARCH] + ) + + if OS == "" || ARCH == "" { + return fmt.Errorf("unable to upgrade: OS %s ARCH %s", OS, ARCH) + } + + buildName := fmt.Sprintf("%s-%s", OS, ARCH) + if BuildARCH == "arm" { + if BuildARM == "" { + return fmt.Errorf("unable to upgrade: arm version not found") + } + buildName = fmt.Sprintf("%s-v%s", buildName, BuildARM) + } + + var downloadRC *bytes.Reader + for _, a := range c.LatestRelease.Assets { + if strings.Contains(a.Name, buildName) { + debug.Printf("Downloading %s\n", a.Name) + var err error + downloadRC, err = a.download() + if err != nil { + return fmt.Errorf("error downloading executable: %s", err) + } + break + } + } + if downloadRC == nil { + return fmt.Errorf("no executable found for %s/%s%s", BuildOS, BuildARCH, BuildARM) + } + + bin, err := extractBinary(downloadRC, OS) + if err != nil { + return err + } + defer bin.Close() + + return update.Apply(bin, update.Options{}) +} + +func (c *CLI) fetchLatestRelease() error { + latestReleaseURL := fmt.Sprintf("%s/%s", ReleaseURL, "latest") + resp, err := HTTPClient.Get(latestReleaseURL) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode > 399 { + msg := "failed to get the latest release\n" + for k, v := range resp.Header { + msg += fmt.Sprintf("\n %s:\n %s", k, v) + } + return errors.New(msg) + } + + var rel Release + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return err + } + c.LatestRelease = &rel + return nil +} + +func extractBinary(source *bytes.Reader, platform string) (binary io.ReadCloser, err error) { + if platform == "windows" { + zr, err := zip.NewReader(source, int64(source.Len())) + if err != nil { + return nil, err + } + + for _, f := range zr.File { + info := f.FileInfo() + if info.IsDir() || !strings.HasSuffix(f.Name, ".exe") { + continue + } + return f.Open() + } + } else { + gr, err := gzip.NewReader(source) + if err != nil { + return nil, err + } + defer gr.Close() + + tr := tar.NewReader(gr) + for { + _, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + tmpfile, err := os.CreateTemp("", "temp-exercism") + if err != nil { + return nil, err + } + + if _, err = io.Copy(tmpfile, tr); err != nil { + return nil, err + } + if _, err := tmpfile.Seek(0, 0); err != nil { + return nil, err + } + + binary = tmpfile + } + } + + return binary, nil +} diff --git a/cli/cli_test.go b/cli/cli_test.go new file mode 100644 index 000000000..c9e71e37c --- /dev/null +++ b/cli/cli_test.go @@ -0,0 +1,80 @@ +package cli + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsUpToDate(t *testing.T) { + testCases := []struct { + desc string + cliVersion string + releaseTag string + ok bool + }{ + { + desc: "It returns false for versions less than release.", + cliVersion: "1.0.0", + releaseTag: "v1.0.1", + ok: false, + }, + { + desc: "It returns false for pre-release versions of release.", + cliVersion: "1.0.1-alpha.1", + releaseTag: "v1.0.1", + ok: false, + }, + { + desc: "It returns true for versions equal to release.", + cliVersion: "2.0.1", + releaseTag: "v2.0.1", + ok: true, + }, + { + desc: "It returns true for versions greater than release.", + cliVersion: "2.0.2", + releaseTag: "v2.0.1", + ok: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + c := &CLI{ + Version: tc.cliVersion, + LatestRelease: &Release{TagName: tc.releaseTag}, + } + + ok, err := c.IsUpToDate() + assert.NoError(t, err) + assert.Equal(t, tc.ok, ok, tc.cliVersion) + }) + } +} + +func TestIsUpToDateWithoutRelease(t *testing.T) { + fakeEndpoint := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Checking for the latest release should call latestReleaseURL endpoint. + // if the code below fails to return the proper response then the URL generation logic in pkg cli has changed. + if r.URL.Path != "/latest" { + fmt.Fprintln(w, "") + } + fmt.Fprintln(w, `{"tag_name": "v2.0.0"}`) + }) + ts := httptest.NewServer(fakeEndpoint) + defer ts.Close() + ReleaseURL = ts.URL + + c := &CLI{ + Version: "1.0.0", + } + + ok, err := c.IsUpToDate() + assert.NoError(t, err) + assert.False(t, ok) + assert.NotNil(t, c.LatestRelease) +} diff --git a/cli/release.go b/cli/release.go new file mode 100644 index 000000000..973057f4e --- /dev/null +++ b/cli/release.go @@ -0,0 +1,15 @@ +package cli + +import "strings" + +// Release is a specific build of the CLI, released on GitHub. +type Release struct { + Location string `json:"html_url"` + TagName string `json:"tag_name"` + Assets []Asset `json:"assets"` +} + +// Version is the CLI version that is built for the release. +func (r *Release) Version() string { + return strings.TrimPrefix(r.TagName, "v") +} diff --git a/cmd/cmd.go b/cmd/cmd.go index f8e107c32..ac28286a7 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -1,24 +1,129 @@ package cmd import ( - "path/filepath" + "encoding/json" + "errors" + "fmt" + "net/http" "regexp" + "strconv" + "strings" + + "io" + + "github.com/exercism/cli/config" + "github.com/spf13/viper" ) -const ( - msgPleaseAuthenticate = "You must be authenticated. Run `exercism configure --key=YOUR_API_KEY`." +var ( + // BinaryName is the name of the app. + // By default this is exercism, but people + // are free to name this however they want. + // The usage examples and help strings should reflect + // the actual name of the binary. + BinaryName string + // Out is used to write to information. + Out io.Writer + // Err is used to write errors. + Err io.Writer + // jsonContentTypeRe is used to match Content-Type which contains JSON. + jsonContentTypeRe = regexp.MustCompile(`^application/([[:alpha:]]+\+)?json($|;)`) ) -func isTest(path string) bool { - ext := filepath.Ext(path) - if ext == ".t" { - return true +const msgWelcomePleaseConfigure = ` + + Welcome to Exercism! + + To get started, you need to configure the tool with your API token. + Find your token at + + %s + + Then run the configure command: + + %s configure --token=YOUR_TOKEN + +` + +// Running configure without any arguments will attempt to +// set the default workspace. If the default workspace directory +// risks clobbering an existing directory, it will print an +// error message that explains how to proceed. +const msgRerunConfigure = ` + + Please re-run the configure command to define where + to download the exercises. + + %s configure +` + +const msgMissingMetadata = ` + + The exercise you are submitting doesn't have the necessary metadata. + Please see https://github.com/exercism/website-copy/blob/main/pages/cli_v1_to_v2.md for instructions on how to fix it. + +` + +// validateUserConfig validates the presence of required user config values +func validateUserConfig(cfg *viper.Viper) error { + if cfg.GetString("token") == "" { + return fmt.Errorf( + msgWelcomePleaseConfigure, + config.TokenURL(cfg.GetString("apibaseurl")), + BinaryName, + ) + } + if cfg.GetString("workspace") == "" || cfg.GetString("apibaseurl") == "" { + return fmt.Errorf(msgRerunConfigure, BinaryName) } + return nil +} - file := filepath.Base(path) - name := file[:len(file)-len(ext)] - if name == "test" { - return true +// decodedAPIError decodes and returns the error message from the API response. +// If the message is blank, it returns a fallback message with the status code. +func decodedAPIError(resp *http.Response) error { + // First and foremost, handle Retry-After headers; if set, show this to the user. + if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" { + // The Retry-After header can be an HTTP Date or delay seconds. + // The date can be used as-is. The delay seconds should have "seconds" appended. + if delay, err := strconv.Atoi(retryAfter); err == nil { + retryAfter = fmt.Sprintf("%d seconds", delay) + } + return fmt.Errorf( + "request failed with status %s; please try again after %s", + resp.Status, + retryAfter, + ) + } + + // Check for JSON data. On non-JSON data, show the status and content type then bail. + // Otherwise, extract the message details from the JSON. + if contentType := resp.Header.Get("Content-Type"); !jsonContentTypeRe.MatchString(contentType) { + return fmt.Errorf( + "expected response with Content-Type \"application/json\" but got status %q with Content-Type %q", + resp.Status, + contentType, + ) + } + var apiError struct { + Error struct { + Type string `json:"type"` + Message string `json:"message"` + PossibleTrackIDs []string `json:"possible_track_ids"` + } `json:"error,omitempty"` + } + if err := json.NewDecoder(resp.Body).Decode(&apiError); err != nil { + return fmt.Errorf("failed to parse API error response: %s", err) + } + if apiError.Error.Message != "" { + if apiError.Error.Type == "track_ambiguous" { + return fmt.Errorf( + "%s: %s", + apiError.Error.Message, + strings.Join(apiError.Error.PossibleTrackIDs, ", "), + ) + } + return errors.New(apiError.Error.Message) } - return regexp.MustCompile(`[\._-]?[tT]est`).MatchString(name) + return fmt.Errorf("unexpected API response: %d", resp.StatusCode) } diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index ef666fe0e..d712e742b 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -1,53 +1,201 @@ package cmd -import "testing" +import ( + "io" + "io/ioutil" + "net/http" + "os" + "strings" + "testing" -func TestIsTest(t *testing.T) { + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +const cfgHomeKey = "EXERCISM_CONFIG_HOME" + +// CommandTest makes it easier to write tests for Cobra commands. +// +// To initialize, give it the three fields Cmd, InitFn, and Args. +// Then call Setup, and defer the Teardown. +// The args are the faked out os.Args. The first two arguments +// in the Args will be ignored. These represent the command (e.g. exercism) +// and the subcommand (e.g. download). +// Pass any interactive responses needed for the test in a single +// String in MockInput, delimited by newlines. +// +// Finally, when you have done whatever other setup you need in your +// test, call the command by calling Execute on the App. +// +// Example: +// +// cmdTest := &CommandTest{ +// Cmd: myCmd, +// InitFn: initMyCmd, +// Args: []string{"fakeapp", "mycommand", "arg1", "--flag", "value"}, +// MockInteractiveResponse: "first-input\nsecond\n", +// } +// +// cmdTest.Setup(t) +// defer cmdTest.Teardown(t) +// ... +// cmdTest.App.Execute() +type CommandTest struct { + App *cobra.Command + Cmd *cobra.Command + InitFn func() + TmpDir string + Args []string + MockInteractiveResponse string + OriginalValues struct { + ConfigHome string + Args []string + } +} + +// Setup does all the prep and initialization for testing a command. +// It creates a fake Cobra app to provide a clean harness for the test, +// and adds the command under test to it as a subcommand. +// It also resets and reconfigures the command under test to +// make sure we're not getting any accidental pollution from the existing +// environment or other tests. Lastly, because we need to override some of +// the global environment settings, the setup method also stores the existing +// values so that Teardown can set them back the way they were when the test +// has completed. +// The method takes a *testing.T as an argument, that way the method can +// fail the test if the creation of the temporary directory fails. +func (test *CommandTest) Setup(t *testing.T) { + dir, err := os.MkdirTemp("", "command-test") + defer os.RemoveAll(dir) + assert.NoError(t, err) + + test.TmpDir = dir + test.OriginalValues.ConfigHome = os.Getenv(cfgHomeKey) + test.OriginalValues.Args = os.Args + + os.Setenv(cfgHomeKey, test.TmpDir) + + os.Args = test.Args + + test.Cmd.ResetFlags() + test.InitFn() + + test.App = &cobra.Command{} + test.App.AddCommand(test.Cmd) + test.App.SetOutput(Err) +} + +// Teardown puts the environment back the way it was before the test. +// The method takes a *testing.T so that it can blow up if it fails to +// clean up after itself. +func (test *CommandTest) Teardown(t *testing.T) { + os.Setenv(cfgHomeKey, test.OriginalValues.ConfigHome) + os.Args = test.OriginalValues.Args + if err := os.RemoveAll(test.TmpDir); err != nil { + t.Fatal(err) + } +} + +// capturedOutput lets us more easily redirect streams in the tests. +type capturedOutput struct { + oldOut, oldErr, newOut, newErr io.Writer +} + +// newCapturedOutput creates a new value to override the streams. +func newCapturedOutput() capturedOutput { + return capturedOutput{ + oldOut: Out, + oldErr: Err, + newOut: io.Discard, + newErr: io.Discard, + } +} + +// override sets the package variables to the fake streams. +func (co capturedOutput) override() { + Out = co.newOut + Err = co.newErr +} + +// reset puts back the original streams for the commands to write to. +func (co capturedOutput) reset() { + Out = co.oldOut + Err = co.oldErr +} + +func errorResponse418(contentType string, body string) *http.Response { + response := &http.Response{ + Status: "418 I'm a teapot", + StatusCode: 418, + Header: make(http.Header), + Body: ioutil.NopCloser(strings.NewReader(body)), + ContentLength: int64(len(body)), + } + response.Header.Set("Content-Type", contentType) + return response +} + +func errorResponse429(retryAfter string) *http.Response { + body := "" + response := &http.Response{ + Status: "429 Too Many Requests", + StatusCode: 429, + Header: make(http.Header), + Body: ioutil.NopCloser(strings.NewReader(body)), + ContentLength: int64(len(body)), + } + response.Header.Set("Content-Type", "text/plain") + response.Header.Set("Retry-After", retryAfter) + return response +} + +func TestDecodeErrorResponse(t *testing.T) { testCases := []struct { - name string - isTest bool + response *http.Response + wantMessage string }{ { - name: "problem/whatever-test.ext", - isTest: true, + response: errorResponse418("text/html", "Time for tea"), + wantMessage: `expected response with Content-Type "application/json" but got status "418 I'm a teapot" with Content-Type "text/html"`, }, { - name: "problem/whatever.ext", - isTest: false, + response: errorResponse418("application/json", `{"error": {"type": "json", "valid": no}}`), + wantMessage: "failed to parse API error response: invalid character 'o' in literal null (expecting 'u')", }, { - name: "problem/whatever_test.spec.ext", - isTest: true, + response: errorResponse418("application/json; charset=utf-8", `{"error": {"type": "track_ambiguous", "message": "message", "possible_track_ids": ["a", "b"]}}`), + wantMessage: "message: a, b", }, { - name: "problem/WhateverTest.ext", - isTest: true, + response: errorResponse418("application/json", `{"error": {"type": "track_ambiguous", "message": "message", "possible_track_ids": ["a", "b"]}}`), + wantMessage: "message: a, b", }, { - name: "problem/Whatever.ext", - isTest: false, + response: errorResponse418("application/json", `{"error": {"message": "message"}}`), + wantMessage: "message", }, { - name: "problem/whatever_test.ext", - isTest: true, + response: errorResponse418("application/problem+json", `{"error": {"message": "new json format"}}`), + wantMessage: "new json format", }, { - name: "problem/whatever.ext", - isTest: false, + response: errorResponse418("application/json", `{"error": {}}`), + wantMessage: "unexpected API response: 418", }, { - name: "problem/test.ext", - isTest: true, + response: errorResponse429("30"), + wantMessage: "request failed with status 429 Too Many Requests; please try again after 30 seconds", }, { - name: "problem/Whatever.t", // perl - isTest: true, + response: errorResponse429("Wed, 21 Oct 2015 07:28:00 GMT"), + wantMessage: "request failed with status 429 Too Many Requests; please try again after Wed, 21 Oct 2015 07:28:00 GMT", }, } - - for _, tt := range testCases { - if isTest(tt.name) != tt.isTest { - t.Fatalf("Expected isTest(%s) to be %t", tt.name, tt.isTest) - } + tc := testCases[0] + got := decodedAPIError(tc.response) + assert.Equal(t, tc.wantMessage, got.Error()) + for _, tc = range testCases { + got := decodedAPIError(tc.response) + assert.Equal(t, tc.wantMessage, got.Error()) } } diff --git a/cmd/configure.go b/cmd/configure.go index e48e92257..57aabc04c 100644 --- a/cmd/configure.go +++ b/cmd/configure.go @@ -2,36 +2,240 @@ package cmd import ( "fmt" - "log" "os" + "strings" + "text/tabwriter" - "github.com/codegangsta/cli" + "github.com/exercism/cli/api" "github.com/exercism/cli/config" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/spf13/viper" ) -// Configure stores settings in a JSON file. -// If a setting is not passed as an argument, default -// values are used. -func Configure(ctx *cli.Context) { - c, err := config.New(ctx.GlobalString("config")) +var ( + viperConfig *viper.Viper +) + +// configureCmd configures the command-line client with user-specific settings. +var configureCmd = &cobra.Command{ + Use: "configure", + Aliases: []string{"c"}, + Short: "Configure the command-line client.", + Long: `Configure the command-line client to customize it to your needs. + +This lets you set up the CLI to talk to the API on your behalf, +and tells the CLI about your setup so it puts things in the right +places. + +You can also override certain default settings to suit your preferences. +`, + RunE: func(cmd *cobra.Command, args []string) error { + configuration := config.NewConfig() + + viperConfig.AddConfigPath(configuration.Dir) + viperConfig.SetConfigName("user") + viperConfig.SetConfigType("json") + // Ignore error. If the file doesn't exist, that is fine. + _ = viperConfig.ReadInConfig() + configuration.UserViperConfig = viperConfig + + return runConfigure(configuration, cmd.Flags()) + }, +} + +func runConfigure(configuration config.Config, flags *pflag.FlagSet) error { + cfg := configuration.UserViperConfig + + // Show the existing configuration and exit. + show, err := flags.GetBool("show") + if err != nil { + return err + } + if show { + printCurrentConfig(configuration) + return nil + } + + // If the command is run 'bare' and we have no token, + // explain how to set the token. + if flags.NFlag() == 0 && cfg.GetString("token") == "" { + tokenURL := config.TokenURL(cfg.GetString("apibaseurl")) + return fmt.Errorf("There is no token configured. Find your token on %s, and call this command again with --token=.", tokenURL) + } + + // Determine the base API URL. + baseURL, err := flags.GetString("api") + if err != nil { + return err + } + if baseURL == "" { + baseURL = cfg.GetString("apibaseurl") + } + if baseURL == "" { + baseURL = configuration.DefaultBaseURL + } + + // By default we verify that + // - the configured API URL is reachable. + // - the configured token is valid. + skipVerification, err := flags.GetBool("no-verify") + if err != nil { + return err + } + + // Is the API URL reachable? + if !skipVerification { + client, err := api.NewClient("", baseURL) + if err != nil { + return err + } + + if err := client.IsPingable(); err != nil { + return fmt.Errorf("The base API URL '%s' cannot be reached.\n\n%s", baseURL, err) + } + } + // Finally, configure the URL. + cfg.Set("apibaseurl", baseURL) + + // Determine the token. + token, err := flags.GetString("token") + if err != nil { + return err + } + if token == "" { + token = cfg.GetString("token") + } + + tokenURL := config.TokenURL(cfg.GetString("apibaseurl")) + + // If we don't have a token then explain how to set it and bail. + if token == "" { + return fmt.Errorf("There is no token configured. Find your token on %s, and call this command again with --token=.", tokenURL) + } + + // Verify that the token is valid. + if !skipVerification { + client, err := api.NewClient(token, baseURL) + if err != nil { + return err + } + ok, err := client.TokenIsValid() + if err != nil { + return err + } + if !ok { + return fmt.Errorf("The token '%s' is invalid. Find your token on %s.", token, tokenURL) + } + } + + // Finally, configure the token. + cfg.Set("token", token) + + // Determine the workspace. + workspace, err := flags.GetString("workspace") if err != nil { - log.Fatal(err) + return err + } + if workspace == "" { + workspace = cfg.GetString("workspace") } + workspace = config.Resolve(workspace, configuration.Home) + + if workspace != "" { + // If there is a non-directory here, then we cannot proceed. + if info, err := os.Lstat(workspace); !os.IsNotExist(err) && !info.IsDir() { + msg := ` + + There is already something at the workspace location you are configuring: - key := ctx.String("key") - host := ctx.String("host") - dir := ctx.String("dir") - api := ctx.String("api") - c.Update(key, host, dir, api) + %s - if err := os.MkdirAll(c.Dir, os.ModePerm); err != nil { - log.Fatalf("Error creating exercism directory %s\n", err) + Please rename it, or set a different workspace location: + + %s configure %s --workspace=PATH_TO_DIFFERENT_FOLDER + ` + + return fmt.Errorf(msg, workspace, BinaryName, commandify(flags)) + } } - if err := c.Write(); err != nil { - log.Fatal(err) + if workspace == "" { + workspace = config.DefaultWorkspaceDir(configuration) + + // If it already exists don't clobber it with the default. + if _, err := os.Lstat(workspace); !os.IsNotExist(err) { + msg := ` + The default Exercism workspace is + + %s + + There is already something there. + If it's a directory, that might be fine. + If it's a file, you will need to move it first, or choose a + different location for the workspace. + + You can choose the workspace location by rerunning this command + with the --workspace flag. + + %s configure %s --workspace=%s + ` + + return fmt.Errorf(msg, workspace, BinaryName, commandify(flags), workspace) + } } + // Configure the workspace. + cfg.Set("workspace", workspace) + + // Persist the new configuration. + if err := configuration.Save("user"); err != nil { + return err + } + fmt.Fprintln(Err, "\nYou have configured the Exercism command-line client:") + printCurrentConfig(configuration) + return nil +} + +func printCurrentConfig(configuration config.Config) { + w := tabwriter.NewWriter(Err, 0, 0, 2, ' ', 0) + defer w.Flush() + + v := configuration.UserViperConfig + + fmt.Fprintln(w, "") + fmt.Fprintln(w, fmt.Sprintf("Config dir:\t\t%s", configuration.Dir)) + fmt.Fprintln(w, fmt.Sprintf("Token:\t(-t, --token)\t%s", v.GetString("token"))) + fmt.Fprintln(w, fmt.Sprintf("Workspace:\t(-w, --workspace)\t%s", v.GetString("workspace"))) + fmt.Fprintln(w, fmt.Sprintf("API Base URL:\t(-a, --api)\t%s", v.GetString("apibaseurl"))) + fmt.Fprintln(w, "") +} + +func commandify(flags *pflag.FlagSet) string { + var cmd string + fn := func(f *pflag.Flag) { + if f.Changed { + cmd = fmt.Sprintf("%s --%s=%s", cmd, f.Name, f.Value.String()) + } + } + flags.VisitAll(fn) + return strings.TrimLeft(cmd, " ") +} + +func initConfigureCmd() { + viperConfig = viper.New() + setupConfigureFlags(configureCmd.Flags()) +} + +func setupConfigureFlags(flags *pflag.FlagSet) { + flags.StringP("token", "t", "", "authentication token used to connect to the site") + flags.StringP("workspace", "w", "", "directory for exercism exercises") + flags.StringP("api", "a", "", "API base url") + flags.BoolP("show", "s", false, "show the current configuration") + flags.BoolP("no-verify", "", false, "skip online token authorization check") +} + +func init() { + RootCmd.AddCommand(configureCmd) - fmt.Printf("The configuration has been written to %s\n", c.File) - fmt.Printf("Your exercism directory can be found at %s\n", c.Dir) + initConfigureCmd() } diff --git a/cmd/configure_test.go b/cmd/configure_test.go new file mode 100644 index 000000000..df3542328 --- /dev/null +++ b/cmd/configure_test.go @@ -0,0 +1,420 @@ +//go:build !windows + +package cmd + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/exercism/cli/config" + "github.com/spf13/pflag" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" +) + +func TestBareConfigure(t *testing.T) { + flags := pflag.NewFlagSet("fake", pflag.PanicOnError) + setupConfigureFlags(flags) + + v := viper.New() + err := flags.Parse([]string{}) + assert.NoError(t, err) + + cfg := config.Config{ + Persister: config.InMemoryPersister{}, + UserViperConfig: v, + DefaultBaseURL: "http://example.com", + } + + err = runConfigure(cfg, flags) + if assert.Error(t, err) { + assert.Regexp(t, "no token configured", err.Error()) + } +} + +func TestConfigureShow(t *testing.T) { + co := newCapturedOutput() + co.newErr = &bytes.Buffer{} + co.override() + defer co.reset() + + flags := pflag.NewFlagSet("fake", pflag.PanicOnError) + setupConfigureFlags(flags) + + v := viper.New() + v.Set("token", "configured-token") + v.Set("workspace", "configured-workspace") + v.Set("apibaseurl", "http://configured.example.com") + + // it will ignore any flags + args := []string{ + "--show", + "--api", "http://override.example.com", + "--token", "token-override", + "--workspace", "workspace-override", + } + err := flags.Parse(args) + assert.NoError(t, err) + + cfg := config.Config{ + Persister: config.InMemoryPersister{}, + UserViperConfig: v, + } + + err = runConfigure(cfg, flags) + assert.NoError(t, err) + + assert.Regexp(t, "configured.example", Err) + assert.NotRegexp(t, "override.example", Err) + + assert.Regexp(t, "configured-token", Err) + assert.NotRegexp(t, "token-override", Err) + + assert.Regexp(t, "configured-workspace", Err) + assert.NotRegexp(t, "workspace-override", Err) +} + +func TestConfigureToken(t *testing.T) { + co := newCapturedOutput() + co.override() + defer co.reset() + + testCases := []struct { + desc string + configured string + args []string + expected string + message string + err bool + }{ + { + desc: "It doesn't lose a configured value", + configured: "existing-token", + args: []string{"--no-verify"}, + expected: "existing-token", + }, + { + desc: "It writes a token when passed as a flag", + configured: "", + args: []string{"--no-verify", "--token", "a-token"}, + expected: "a-token", + }, + { + desc: "It overwrites the token", + configured: "old-token", + args: []string{"--no-verify", "--token", "replacement-token"}, + expected: "replacement-token", + }, + { + desc: "It complains when token is neither configured nor passed", + configured: "", + args: []string{"--no-verify"}, + expected: "", + err: true, + message: "no token configured", + }, + { + desc: "It validates the existing token if we're not skipping validations", + configured: "configured-token", + args: []string{}, + expected: "configured-token", + err: true, + message: "token.*invalid", + }, + { + desc: "It validates the replacement token if we're not skipping validations", + configured: "", + args: []string{"--token", "invalid-token"}, + expected: "", + err: true, + message: "token.*invalid", + }, + } + + endpoint := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/validate_token" { + w.WriteHeader(http.StatusUnauthorized) + } + }) + ts := httptest.NewServer(endpoint) + defer ts.Close() + + for _, tc := range testCases { + flags := pflag.NewFlagSet("fake", pflag.PanicOnError) + setupConfigureFlags(flags) + + v := viper.New() + v.Set("token", tc.configured) + + err := flags.Parse(tc.args) + assert.NoError(t, err) + + cfg := config.Config{ + Persister: config.InMemoryPersister{}, + UserViperConfig: v, + DefaultBaseURL: ts.URL, + } + + err = runConfigure(cfg, flags) + if err != nil || tc.err { + assert.Regexp(t, tc.message, err.Error(), tc.desc) + } + assert.Equal(t, tc.expected, cfg.UserViperConfig.GetString("token"), tc.desc) + } +} + +func TestConfigureAPIBaseURL(t *testing.T) { + co := newCapturedOutput() + co.override() + defer co.reset() + + endpoint := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/ping" { + w.WriteHeader(http.StatusNotFound) + } + }) + ts := httptest.NewServer(endpoint) + defer ts.Close() + + testCases := []struct { + desc string + configured string + args []string + expected string + message string + err bool + }{ + { + desc: "It doesn't lose a configured value", + configured: "http://example.com", + args: []string{"--no-verify"}, + expected: "http://example.com", + }, + { + desc: "It writes a base url when passed as a flag", + configured: "", + args: []string{"--no-verify", "--api", "http://api.example.com"}, + expected: "http://api.example.com", + }, + { + desc: "It overwrites the base url", + configured: "http://old.example.com", + args: []string{"--no-verify", "--api", "http://replacement.example.com"}, + expected: "http://replacement.example.com", + }, + { + desc: "It validates the existing base url if we're not skipping validations", + configured: ts.URL, + args: []string{"--token", "some-token"}, // need to bypass the error message on "bare configure" + expected: ts.URL, + err: true, + message: "API.*cannot be reached", + }, + { + desc: "It validates the replacement base URL if we're not skipping validations", + configured: "", + args: []string{"--api", ts.URL}, + expected: "", + err: true, + message: "API.*cannot be reached", + }, + } + + for _, tc := range testCases { + flags := pflag.NewFlagSet("fake", pflag.PanicOnError) + setupConfigureFlags(flags) + + v := viper.New() + v.Set("apibaseurl", tc.configured) + + err := flags.Parse(tc.args) + assert.NoError(t, err) + + cfg := config.Config{ + Persister: config.InMemoryPersister{}, + UserViperConfig: v, + DefaultBaseURL: ts.URL, + } + + err = runConfigure(cfg, flags) + if err != nil || tc.err { + assert.Regexp(t, tc.message, err.Error(), tc.desc) + } + assert.Equal(t, tc.expected, cfg.UserViperConfig.GetString("apibaseurl"), tc.desc) + } +} + +func TestConfigureWorkspace(t *testing.T) { + co := newCapturedOutput() + co.override() + defer co.reset() + + testCases := []struct { + desc string + configured string + args []string + expected string + message string + err bool + }{ + { + desc: "It doesn't lose a configured value", + configured: "/the-workspace", + args: []string{"--no-verify"}, + expected: "/the-workspace", + }, + { + desc: "It writes a workspace when passed as a flag", + configured: "", + args: []string{"--no-verify", "--workspace", "/new-workspace"}, + expected: "/new-workspace", + }, + { + desc: "It overwrites the configured workspace", + configured: "/configured-workspace", + args: []string{"--no-verify", "--workspace", "/replacement-workspace"}, + expected: "/replacement-workspace", + }, + { + desc: "It gets the default workspace when neither configured nor passed as a flag", + configured: "", + args: []string{"--token", "some-token"}, // need to bypass the error message on "bare configure" + expected: "/home/default-workspace", + }, + { + desc: "It resolves the passed workspace to expand ~", + configured: "", + args: []string{"--workspace", "~/workspace-dir"}, + expected: "/home/workspace-dir", + }, + + { + desc: "It resolves the configured workspace to expand ~", + configured: "~/configured-dir", + args: []string{"--token", "some-token"}, // need to bypass the error message on "bare configure" + expected: "/home/configured-dir", // The configuration object hard-codes the home directory below + }, + } + + endpoint := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 200 OK by default. Ping and TokenAuth will both pass. + }) + ts := httptest.NewServer(endpoint) + defer ts.Close() + + for _, tc := range testCases { + flags := pflag.NewFlagSet("fake", pflag.PanicOnError) + setupConfigureFlags(flags) + + v := viper.New() + v.Set("token", "abc123") // set a token so we get past the no token configured logic + v.Set("workspace", tc.configured) + + err := flags.Parse(tc.args) + assert.NoError(t, err) + + cfg := config.Config{ + Persister: config.InMemoryPersister{}, + UserViperConfig: v, + DefaultBaseURL: ts.URL, + DefaultDirName: "default-workspace", + Home: "/home", + OS: "linux", + } + + err = runConfigure(cfg, flags) + assert.NoError(t, err, tc.desc) + assert.Equal(t, tc.expected, cfg.UserViperConfig.GetString("workspace"), tc.desc) + } +} + +func TestConfigureDefaultWorkspaceWithoutClobbering(t *testing.T) { + co := newCapturedOutput() + co.override() + defer co.reset() + + // Stub server to always be 200 OK + endpoint := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + ts := httptest.NewServer(endpoint) + defer ts.Close() + + tmpDir, err := os.MkdirTemp("", "no-clobber") + defer os.RemoveAll(tmpDir) + assert.NoError(t, err) + + cfg := config.Config{ + OS: "linux", + DefaultDirName: "workspace", + Home: tmpDir, + Dir: tmpDir, + DefaultBaseURL: ts.URL, + UserViperConfig: viper.New(), + Persister: config.InMemoryPersister{}, + } + + // Create a directory at the workspace directory's location + // so that it's already present. + err = os.MkdirAll(config.DefaultWorkspaceDir(cfg), os.FileMode(0755)) + assert.NoError(t, err) + + flags := pflag.NewFlagSet("fake", pflag.PanicOnError) + setupConfigureFlags(flags) + err = flags.Parse([]string{"--token", "abc123"}) + assert.NoError(t, err) + + err = runConfigure(cfg, flags) + if assert.Error(t, err) { + assert.Regexp(t, "already something", err.Error()) + } +} + +func TestConfigureExplicitWorkspaceWithoutClobberingNonDirectory(t *testing.T) { + co := newCapturedOutput() + co.override() + defer co.reset() + + tmpDir, err := os.MkdirTemp("", "no-clobber") + defer os.RemoveAll(tmpDir) + assert.NoError(t, err) + + v := viper.New() + v.Set("token", "abc123") + + cfg := config.Config{ + OS: "linux", + DefaultDirName: "workspace", + Home: tmpDir, + Dir: tmpDir, + UserViperConfig: v, + Persister: config.InMemoryPersister{}, + } + + // Create a file at the workspace directory's location + err = os.WriteFile(filepath.Join(tmpDir, "workspace"), []byte("This is not a directory"), os.FileMode(0755)) + assert.NoError(t, err) + + flags := pflag.NewFlagSet("fake", pflag.PanicOnError) + setupConfigureFlags(flags) + err = flags.Parse([]string{"--no-verify", "--workspace", config.DefaultWorkspaceDir(cfg)}) + assert.NoError(t, err) + + err = runConfigure(cfg, flags) + if assert.Error(t, err) { + assert.Regexp(t, "set a different workspace", err.Error()) + } +} + +func TestCommandifyFlagSet(t *testing.T) { + flags := pflag.NewFlagSet("primitives", pflag.PanicOnError) + flags.StringP("word", "w", "", "a word") + flags.BoolP("yes", "y", false, "just do it") + flags.IntP("number", "n", 1, "count to one") + + err := flags.Parse([]string{"--word", "banana", "--yes"}) + assert.NoError(t, err) + assert.Equal(t, commandify(flags), "--word=banana --yes=true") +} diff --git a/cmd/debug.go b/cmd/debug.go deleted file mode 100644 index 253ecce86..000000000 --- a/cmd/debug.go +++ /dev/null @@ -1,65 +0,0 @@ -package cmd - -import ( - "fmt" - "log" - "net/http" - "os" - "runtime" - "time" - - "github.com/codegangsta/cli" - "github.com/exercism/cli/config" -) - -// Debug provides information about the user's environment and configuration. -func Debug(ctx *cli.Context) { - defer fmt.Printf("\nIf you are having trouble and need to file a GitHub issue (https://github.com/exercism/exercism.io/issues) please include this information (except your API key. Keep that private).\n") - - fmt.Printf("\n**** Debug Information ****\n") - fmt.Printf("Exercism CLI Version: %s\n", ctx.App.Version) - fmt.Printf("OS/Architecture: %s/%s\n", runtime.GOOS, runtime.GOARCH) - - dir, err := config.Home() - if err != nil { - log.Fatal(err) - } - fmt.Printf("Home Dir: %s\n", dir) - - c, err := config.New(ctx.GlobalString("config")) - if err != nil { - log.Fatal(err) - } - - configured := true - if _, err = os.Stat(c.File); err != nil { - if os.IsNotExist(err) { - configured = false - } else { - log.Fatal(err) - } - } - - if configured { - fmt.Printf("Config file: %s\n", c.File) - fmt.Printf("API Key: %s\n", c.APIKey) - } else { - fmt.Println("Config file: ") - fmt.Println("API Key: ") - } - client := http.Client{Timeout: 5 * time.Second} - - fmt.Printf("API: %s [%s]\n", c.API, pingUrl(client, c.API)) - fmt.Printf("XAPI: %s [%s]\n", c.XAPI, pingUrl(client, c.XAPI)) - fmt.Printf("Exercises Directory: %s\n", c.Dir) -} - -func pingUrl(client http.Client, url string) string { - res, err := client.Get(url) - if err != nil { - return err.Error() - } - defer res.Body.Close() - - return "connected" -} diff --git a/cmd/demo.go b/cmd/demo.go deleted file mode 100644 index 4236911ad..000000000 --- a/cmd/demo.go +++ /dev/null @@ -1,35 +0,0 @@ -package cmd - -import ( - "fmt" - "log" - - "github.com/codegangsta/cli" - "github.com/exercism/cli/api" - "github.com/exercism/cli/config" - "github.com/exercism/cli/user" -) - -// Demo returns one problem for each active track. -func Demo(ctx *cli.Context) { - c, err := config.New(ctx.GlobalString("config")) - if err != nil { - log.Fatal(err) - } - - client := api.NewClient(c) - - problems, err := client.Demo() - if err != nil { - log.Fatal(err) - } - - hw := user.NewHomework(problems, c) - if err := hw.Save(); err != nil { - log.Fatal(err) - } - - hw.Report(user.HWAll) - - fmt.Println("Next step: choose a language, read the README, and make the test suite pass.") -} diff --git a/cmd/download.go b/cmd/download.go index 9a4b80ead..0e74c49ed 100644 --- a/cmd/download.go +++ b/cmd/download.go @@ -1,54 +1,357 @@ package cmd import ( + "bytes" + "encoding/json" + "errors" "fmt" - "io/ioutil" - "log" + "io" + "net/http" + netURL "net/url" "os" "path/filepath" + "regexp" + "strings" - "github.com/codegangsta/cli" "github.com/exercism/cli/api" "github.com/exercism/cli/config" + "github.com/exercism/cli/workspace" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/spf13/viper" ) -// Download returns specified submissions and related problem. -func Download(ctx *cli.Context) { - c, err := config.New(ctx.GlobalString("config")) +// downloadCmd represents the download command +var downloadCmd = &cobra.Command{ + Use: "download", + Aliases: []string{"d"}, + Short: "Download an exercise.", + Long: `Download an exercise. + +You may download an exercise to work on. If you've already +started working on it, the command will also download your +latest solution. + +Download other people's solutions by providing the UUID. +`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg := config.NewConfig() + + v := viper.New() + v.AddConfigPath(cfg.Dir) + v.SetConfigName("user") + v.SetConfigType("json") + // Ignore error. If the file doesn't exist, that is fine. + _ = v.ReadInConfig() + cfg.UserViperConfig = v + + return runDownload(cfg, cmd.Flags(), args) + }, +} + +func runDownload(cfg config.Config, flags *pflag.FlagSet, args []string) error { + usrCfg := cfg.UserViperConfig + if err := validateUserConfig(usrCfg); err != nil { + return err + } + + download, err := newDownload(flags, usrCfg) + if err != nil { + return err + } + + metadata := download.payload.metadata() + dir := metadata.Exercise(usrCfg.GetString("workspace")).MetadataDir() + + if _, err = os.Stat(dir); !download.forceoverwrite && err == nil { + return fmt.Errorf("directory '%s' already exists, use --force to overwrite", dir) + } + + if err := os.MkdirAll(dir, os.FileMode(0755)); err != nil { + return err + } + + if err := metadata.Write(dir); err != nil { + return err + } + + client, err := api.NewClient(usrCfg.GetString("token"), usrCfg.GetString("apibaseurl")) + if err != nil { + return err + } + + for _, sf := range download.payload.files() { + url, err := sf.url() + if err != nil { + return err + } + + req, err := client.NewRequest("GET", url, nil) + if err != nil { + return err + } + + res, err := client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + // TODO: deal with it + continue + } + + path := sf.relativePath() + dir := filepath.Join(metadata.Dir, filepath.Dir(path)) + if err = os.MkdirAll(dir, os.FileMode(0755)); err != nil { + return err + } + + f, err := os.Create(filepath.Join(metadata.Dir, path)) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(f, res.Body) + if err != nil { + return err + } + } + fmt.Fprintf(Err, "\nDownloaded to\n") + fmt.Fprintf(Out, "%s\n", metadata.Dir) + return nil +} + +type download struct { + // either/or + slug, uuid string + + // user config + token, apibaseurl, workspace string + + // optional + track string + forceoverwrite bool + + payload *downloadPayload +} + +func newDownload(flags *pflag.FlagSet, usrCfg *viper.Viper) (*download, error) { + var err error + d := &download{} + d.uuid, err = flags.GetString("uuid") + if err != nil { + return nil, err + } + d.slug, err = flags.GetString("exercise") + if err != nil { + return nil, err + } + d.track, err = flags.GetString("track") + if err != nil { + return nil, err + } + if err != nil { + return nil, err + } + + d.forceoverwrite, err = flags.GetBool("force") if err != nil { - log.Fatal(err) + return nil, err } - client := api.NewClient(c) - args := ctx.Args() - if len(args) != 1 { - msg := "Usage: exercism download SUBMISSION_ID" - log.Fatal(msg) + d.token = usrCfg.GetString("token") + d.apibaseurl = usrCfg.GetString("apibaseurl") + d.workspace = usrCfg.GetString("workspace") + + if err = d.needsSlugXorUUID(); err != nil { + return nil, err + } + if err = d.needsUserConfigValues(); err != nil { + return nil, err + } + if err = d.needsSlugWhenGivenTrack(); err != nil { + return nil, err } - submission, err := client.Download(args[0]) + client, err := api.NewClient(d.token, d.apibaseurl) if err != nil { - log.Fatal(err) + return nil, err + } + + req, err := client.NewRequest("GET", d.url(), nil) + if err != nil { + return nil, err + } + d.buildQueryParams(req.URL) + + res, err := client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode < 200 || res.StatusCode > 299 { + return nil, decodedAPIError(res) + } + + body, _ := io.ReadAll(res.Body) + res.Body = io.NopCloser(bytes.NewReader(body)) + + if err := json.Unmarshal(body, &d.payload); err != nil { + return nil, decodedAPIError(res) + } + + return d, nil +} + +func (d download) url() string { + id := "latest" + if d.uuid != "" { + id = d.uuid + } + return fmt.Sprintf("%s/solutions/%s", d.apibaseurl, id) +} + +func (d download) buildQueryParams(url *netURL.URL) { + query := url.Query() + if d.slug != "" { + query.Add("exercise_id", d.slug) + if d.track != "" { + query.Add("track_id", d.track) + } + } + url.RawQuery = query.Encode() +} + +// needsSlugXorUUID checks the presence of slug XOR uuid. +func (d download) needsSlugXorUUID() error { + if d.slug != "" && d.uuid != "" || d.uuid == d.slug { + return errors.New("need an --exercise name or a solution --uuid") } + return nil +} - path := filepath.Join(c.Dir, "solutions", submission.Username, submission.Language, submission.Slug, args[0]) +// needsUserConfigValues checks the presence of required values from the user config. +func (d download) needsUserConfigValues() error { + errMsg := "missing required user config: '%s'" + if d.token == "" { + return fmt.Errorf(errMsg, "token") + } + if d.apibaseurl == "" { + return fmt.Errorf(errMsg, "apibaseurl") + } + if d.workspace == "" { + return fmt.Errorf(errMsg, "workspace") + } + return nil +} - if err := os.MkdirAll(path, 0755); err != nil { - log.Fatal(err) +// needsSlugWhenGivenTrack ensures that track arguments are also given with a slug. +// (track meaningless when given a uuid). +func (d download) needsSlugWhenGivenTrack() error { + if d.track != "" && d.slug == "" { + return errors.New("--track or requires --exercise (not --uuid)") } + return nil +} - for name, contents := range submission.ProblemFiles { - if err := ioutil.WriteFile(fmt.Sprintf("%s/%s", path, name), []byte(contents), 0755); err != nil { - log.Fatal(err) +type downloadPayload struct { + Solution struct { + ID string `json:"id"` + URL string `json:"url"` + User struct { + Handle string `json:"handle"` + IsRequester bool `json:"is_requester"` + } `json:"user"` + Exercise struct { + ID string `json:"id"` + InstructionsURL string `json:"instructions_url"` + AutoApprove bool `json:"auto_approve"` + Track struct { + ID string `json:"id"` + Language string `json:"language"` + } `json:"track"` + } `json:"exercise"` + FileDownloadBaseURL string `json:"file_download_base_url"` + Files []string `json:"files"` + Iteration struct { + SubmittedAt *string `json:"submitted_at"` } + } `json:"solution"` + Error struct { + Type string `json:"type"` + Message string `json:"message"` + PossibleTrackIDs []string `json:"possible_track_ids"` + } `json:"error,omitempty"` +} + +func (dp downloadPayload) metadata() workspace.ExerciseMetadata { + return workspace.ExerciseMetadata{ + AutoApprove: dp.Solution.Exercise.AutoApprove, + Track: dp.Solution.Exercise.Track.ID, + ExerciseSlug: dp.Solution.Exercise.ID, + ID: dp.Solution.ID, + URL: dp.Solution.URL, + Handle: dp.Solution.User.Handle, + IsRequester: dp.Solution.User.IsRequester, } +} - for name, contents := range submission.SolutionFiles { - if err := ioutil.WriteFile(fmt.Sprintf("%s/%s", path, name), []byte(contents), 0755); err != nil { - log.Fatal(err) +func (dp downloadPayload) files() []solutionFile { + fx := make([]solutionFile, 0, len(dp.Solution.Files)) + for _, file := range dp.Solution.Files { + f := solutionFile{ + path: file, + baseURL: dp.Solution.FileDownloadBaseURL, + slug: dp.Solution.Exercise.ID, } + fx = append(fx, f) } + return fx +} + +type solutionFile struct { + path, baseURL, slug string +} + +func (sf solutionFile) url() (string, error) { + url, err := netURL.ParseRequestURI(fmt.Sprintf("%s%s", sf.baseURL, sf.path)) - fmt.Printf("Successfully downloaded submission.\n\nThe submission can be viewed at:\n %s\n\n", path) + if err != nil { + return "", err + } + + return url.String(), nil +} + +func (sf solutionFile) relativePath() string { + file := sf.path + + // Work around a path bug due to an early design decision (later reversed) to + // allow numeric suffixes for exercise directories, letting people have + // multiple parallel versions of an exercise. + pattern := fmt.Sprintf(`\A.*[/\\]%s-\d*/`, sf.slug) + rgxNumericSuffix := regexp.MustCompile(pattern) + if rgxNumericSuffix.MatchString(sf.path) { + file = string(rgxNumericSuffix.ReplaceAll([]byte(sf.path), []byte(""))) + } + + // Rewrite paths submitted with an older, buggy client where the Windows path is being treated as part of the filename. + file = strings.Replace(file, "\\", "/", -1) + + return filepath.FromSlash(file) +} + +func setupDownloadFlags(flags *pflag.FlagSet) { + flags.StringP("uuid", "u", "", "the solution UUID") + flags.StringP("track", "t", "", "the track ID") + flags.StringP("exercise", "e", "", "the exercise slug") + flags.BoolP("force", "F", false, "overwrite existing exercise directory") +} +func init() { + RootCmd.AddCommand(downloadCmd) + setupDownloadFlags(downloadCmd.Flags()) } diff --git a/cmd/download_test.go b/cmd/download_test.go new file mode 100644 index 000000000..fb18cfc86 --- /dev/null +++ b/cmd/download_test.go @@ -0,0 +1,421 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "testing" + + "github.com/exercism/cli/config" + "github.com/exercism/cli/workspace" + "github.com/spf13/pflag" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" +) + +func TestDownloadWithoutToken(t *testing.T) { + cfg := config.Config{ + UserViperConfig: viper.New(), + } + + err := runDownload(cfg, pflag.NewFlagSet("fake", pflag.PanicOnError), []string{}) + if assert.Error(t, err) { + assert.Regexp(t, "Welcome to Exercism", err.Error()) + // It uses the default base API url to infer the host + assert.Regexp(t, "exercism.org/settings", err.Error()) + } +} + +func TestDownloadWithoutWorkspace(t *testing.T) { + v := viper.New() + v.Set("token", "abc123") + cfg := config.Config{ + UserViperConfig: v, + } + + err := runDownload(cfg, pflag.NewFlagSet("fake", pflag.PanicOnError), []string{}) + if assert.Error(t, err) { + assert.Regexp(t, "re-run the configure", err.Error()) + } +} + +func TestDownloadWithoutBaseURL(t *testing.T) { + v := viper.New() + v.Set("token", "abc123") + v.Set("workspace", "/home/whatever") + cfg := config.Config{ + UserViperConfig: v, + } + + err := runDownload(cfg, pflag.NewFlagSet("fake", pflag.PanicOnError), []string{}) + if assert.Error(t, err) { + assert.Regexp(t, "re-run the configure", err.Error()) + } +} + +func TestDownloadWithoutFlags(t *testing.T) { + v := viper.New() + v.Set("token", "abc123") + v.Set("workspace", "/home/username") + v.Set("apibaseurl", "http://example.com") + + cfg := config.Config{ + UserViperConfig: v, + } + + flags := pflag.NewFlagSet("fake", pflag.PanicOnError) + setupDownloadFlags(flags) + + err := runDownload(cfg, flags, []string{}) + if assert.Error(t, err) { + assert.Regexp(t, "need an --exercise name or a solution --uuid", err.Error()) + } +} + +func TestSolutionFile(t *testing.T) { + testCases := []struct { + name, file, expectedPath, expectedURL string + }{ + { + name: "filename with special character", + file: "special-char-filename#.txt", + expectedPath: "special-char-filename#.txt", + expectedURL: "http://www.example.com/special-char-filename%23.txt", + }, + { + name: "filename with leading slash", + file: "/with-leading-slash.txt", + expectedPath: fmt.Sprintf("%cwith-leading-slash.txt", os.PathSeparator), + expectedURL: "http://www.example.com//with-leading-slash.txt", + }, + { + name: "filename with leading backslash", + file: "\\with-leading-backslash.txt", + expectedPath: fmt.Sprintf("%cwith-leading-backslash.txt", os.PathSeparator), + expectedURL: "http://www.example.com/%5Cwith-leading-backslash.txt", + }, + { + name: "filename with backslashes in path", + file: "\\backslashes\\in-path.txt", + expectedPath: fmt.Sprintf("%[1]cbackslashes%[1]cin-path.txt", os.PathSeparator), + expectedURL: "http://www.example.com/%5Cbackslashes%5Cin-path.txt", + }, + { + name: "path with a numeric suffix", + file: "/bogus-exercise-12345/numeric.txt", + expectedPath: fmt.Sprintf("%[1]cbogus-exercise-12345%[1]cnumeric.txt", os.PathSeparator), + expectedURL: "http://www.example.com//bogus-exercise-12345/numeric.txt", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + sf := solutionFile{ + path: tc.file, + baseURL: "http://www.example.com/", + } + + if sf.relativePath() != tc.expectedPath { + t.Fatalf("Expected path '%s', got '%s'", tc.expectedPath, sf.relativePath()) + } + + url, err := sf.url() + if err != nil { + t.Fatal(err) + } + + if url != tc.expectedURL { + t.Fatalf("Expected URL '%s', got '%s'", tc.expectedURL, url) + } + }) + } +} + +func TestDownload(t *testing.T) { + co := newCapturedOutput() + co.override() + defer co.reset() + + testCases := []struct { + requester bool + expectedDir string + flags map[string]string + }{ + { + requester: true, + expectedDir: "", + flags: map[string]string{"exercise": "bogus-exercise"}, + }, + { + requester: true, + expectedDir: "", + flags: map[string]string{"uuid": "bogus-id"}, + }, + { + requester: false, + expectedDir: filepath.Join("users", "alice"), + flags: map[string]string{"uuid": "bogus-id"}, + }, + } + + for _, tc := range testCases { + tmpDir, err := os.MkdirTemp("", "download-cmd") + defer os.RemoveAll(tmpDir) + assert.NoError(t, err) + + ts := fakeDownloadServer(strconv.FormatBool(tc.requester)) + defer ts.Close() + + v := viper.New() + v.Set("workspace", tmpDir) + v.Set("apibaseurl", ts.URL) + v.Set("token", "abc123") + + cfg := config.Config{ + UserViperConfig: v, + } + flags := pflag.NewFlagSet("fake", pflag.PanicOnError) + setupDownloadFlags(flags) + for name, value := range tc.flags { + flags.Set(name, value) + } + + err = runDownload(cfg, flags, []string{}) + assert.NoError(t, err) + + targetDir := filepath.Join(tmpDir, tc.expectedDir) + assertDownloadedCorrectFiles(t, targetDir) + + dir := filepath.Join(targetDir, "bogus-track", "bogus-exercise") + b, err := os.ReadFile(workspace.NewExerciseFromDir(dir).MetadataFilepath()) + assert.NoError(t, err) + var metadata workspace.ExerciseMetadata + err = json.Unmarshal(b, &metadata) + assert.NoError(t, err) + + assert.Equal(t, "bogus-track", metadata.Track) + assert.Equal(t, "bogus-exercise", metadata.ExerciseSlug) + assert.Equal(t, tc.requester, metadata.IsRequester) + } +} + +func TestDownloadToExistingDirectory(t *testing.T) { + co := newCapturedOutput() + co.override() + defer co.reset() + + testCases := []struct { + exerciseDir string + flags map[string]string + }{ + { + exerciseDir: filepath.Join("bogus-track", "bogus-exercise"), + flags: map[string]string{"exercise": "bogus-exercise", "track": "bogus-track"}, + }, + } + + for _, tc := range testCases { + tmpDir, err := os.MkdirTemp("", "download-cmd") + defer os.RemoveAll(tmpDir) + assert.NoError(t, err) + + err = os.MkdirAll(filepath.Join(tmpDir, tc.exerciseDir), os.FileMode(0755)) + assert.NoError(t, err) + + ts := fakeDownloadServer("true") + defer ts.Close() + + v := viper.New() + v.Set("workspace", tmpDir) + v.Set("apibaseurl", ts.URL) + v.Set("token", "abc123") + + cfg := config.Config{ + UserViperConfig: v, + } + flags := pflag.NewFlagSet("fake", pflag.PanicOnError) + setupDownloadFlags(flags) + for name, value := range tc.flags { + flags.Set(name, value) + } + + err = runDownload(cfg, flags, []string{}) + + if assert.Error(t, err) { + assert.Regexp(t, "directory '.+' already exists", err.Error()) + } + } +} + +func TestDownloadToExistingDirectoryWithForce(t *testing.T) { + co := newCapturedOutput() + co.override() + defer co.reset() + + testCases := []struct { + exerciseDir string + flags map[string]string + }{ + { + exerciseDir: filepath.Join("bogus-track", "bogus-exercise"), + flags: map[string]string{"exercise": "bogus-exercise", "track": "bogus-track"}, + }, + } + + for _, tc := range testCases { + tmpDir, err := os.MkdirTemp("", "download-cmd") + defer os.RemoveAll(tmpDir) + assert.NoError(t, err) + + err = os.MkdirAll(filepath.Join(tmpDir, tc.exerciseDir), os.FileMode(0755)) + assert.NoError(t, err) + + ts := fakeDownloadServer("true") + defer ts.Close() + + v := viper.New() + v.Set("workspace", tmpDir) + v.Set("apibaseurl", ts.URL) + v.Set("token", "abc123") + + cfg := config.Config{ + UserViperConfig: v, + } + flags := pflag.NewFlagSet("fake", pflag.PanicOnError) + setupDownloadFlags(flags) + for name, value := range tc.flags { + flags.Set(name, value) + } + flags.Set("force", "true") + + err = runDownload(cfg, flags, []string{}) + assert.NoError(t, err) + } +} + +func fakeDownloadServer(requestor string) *httptest.Server { + mux := http.NewServeMux() + server := httptest.NewServer(mux) + + mux.HandleFunc("/file-1.txt", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "this is file 1") + }) + + mux.HandleFunc("/subdir/file-2.txt", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "this is file 2") + }) + + mux.HandleFunc("/file-3.txt", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "") + }) + + mux.HandleFunc("/solutions/latest", func(w http.ResponseWriter, r *http.Request) { + payloadBody := fmt.Sprintf(payloadTemplate, requestor, server.URL+"/") + fmt.Fprint(w, payloadBody) + }) + mux.HandleFunc("/solutions/bogus-id", func(w http.ResponseWriter, r *http.Request) { + payloadBody := fmt.Sprintf(payloadTemplate, requestor, server.URL+"/") + fmt.Fprint(w, payloadBody) + }) + + return server +} + +func assertDownloadedCorrectFiles(t *testing.T, targetDir string) { + expectedFiles := []struct { + desc string + path string + contents string + }{ + { + desc: "a file in the exercise root directory", + path: filepath.Join(targetDir, "bogus-track", "bogus-exercise", "file-1.txt"), + contents: "this is file 1", + }, + { + desc: "a file in a subdirectory", + path: filepath.Join(targetDir, "bogus-track", "bogus-exercise", "subdir", "file-2.txt"), + contents: "this is file 2", + }, + } + + for _, file := range expectedFiles { + t.Run(file.desc, func(t *testing.T) { + b, err := os.ReadFile(file.path) + assert.NoError(t, err) + assert.Equal(t, file.contents, string(b)) + }) + } + + path := filepath.Join(targetDir, "bogus-track", "bogus-exercise", "file-3.txt") + _, err := os.Lstat(path) + assert.NoError(t, err) +} + +func TestDownloadError(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, `{"error": {"type": "error", "message": "test error"}}`) + }) + + ts := httptest.NewServer(handler) + defer ts.Close() + + tmpDir, err := os.MkdirTemp("", "submit-err-tmp-dir") + defer os.RemoveAll(tmpDir) + assert.NoError(t, err) + + v := viper.New() + v.Set("token", "abc123") + v.Set("workspace", tmpDir) + v.Set("apibaseurl", ts.URL) + + cfg := config.Config{ + Persister: config.InMemoryPersister{}, + UserViperConfig: v, + DefaultBaseURL: "http://example.com", + } + + flags := pflag.NewFlagSet("fake", pflag.PanicOnError) + setupDownloadFlags(flags) + flags.Set("uuid", "value") + + err = runDownload(cfg, flags, []string{}) + + assert.Equal(t, `expected response with Content-Type "application/json" but got status "400 Bad Request" with Content-Type "text/plain; charset=utf-8"`, err.Error()) + +} + +const payloadTemplate = ` +{ + "solution": { + "id": "bogus-id", + "user": { + "handle": "alice", + "is_requester": %s + }, + "exercise": { + "id": "bogus-exercise", + "instructions_url": "http://example.com/bogus-exercise", + "auto_approve": false, + "track": { + "id": "bogus-track", + "language": "Bogus Language" + } + }, + "file_download_base_url": "%s", + "files": [ + "file-1.txt", + "subdir/file-2.txt", + "file-3.txt" + ], + "iteration": { + "submitted_at": "2017-08-21t10:11:12.130z" + } + } +} +` diff --git a/cmd/fetch.go b/cmd/fetch.go deleted file mode 100644 index 60cd04cf3..000000000 --- a/cmd/fetch.go +++ /dev/null @@ -1,53 +0,0 @@ -package cmd - -import ( - "log" - - "github.com/codegangsta/cli" - "github.com/exercism/cli/api" - "github.com/exercism/cli/config" - "github.com/exercism/cli/user" -) - -// Fetch returns exercism problems. -func Fetch(ctx *cli.Context) { - c, err := config.New(ctx.GlobalString("config")) - if err != nil { - log.Fatal(err) - } - client := api.NewClient(c) - - problems, err := client.Fetch(ctx.Args()) - if err != nil { - log.Fatal(err) - } - - submissionInfo, err := client.Submissions() - if err != nil { - log.Fatal(err) - } - - if err := setSubmissionState(problems, submissionInfo); err != nil { - log.Fatal(err) - } - - hw := user.NewHomework(problems, c) - if err := hw.Save(); err != nil { - log.Fatal(err) - } - - hw.Summarize(user.HWAll) -} - -func setSubmissionState(problems []*api.Problem, submissionInfo map[string][]api.SubmissionInfo) error { - for _, problem := range problems { - langSubmissions := submissionInfo[problem.Language] - for _, submission := range langSubmissions { - if submission.Slug == problem.Slug { - problem.Submitted = true - } - } - } - - return nil -} diff --git a/cmd/list.go b/cmd/list.go deleted file mode 100644 index 2ded4dc10..000000000 --- a/cmd/list.go +++ /dev/null @@ -1,41 +0,0 @@ -package cmd - -import ( - "fmt" - "log" - - "github.com/codegangsta/cli" - "github.com/exercism/cli/api" - "github.com/exercism/cli/config" -) - -const msgExplainFetch = "In order to fetch a specific assignment, call the fetch command with a specific assignment.\n\nexercism fetch ruby matrix" - -// List returns the full list of assignments for a given language -func List(ctx *cli.Context) { - c, err := config.New(ctx.GlobalString("config")) - if err != nil { - log.Fatal(err) - } - args := ctx.Args() - - if len(args) != 1 { - msg := "Usage: exercism list LANGUAGE" - log.Fatal(msg) - } - - language := args[0] - client := api.NewClient(c) - problems, err := client.List(language) - if err != nil { - if err == api.ErrUnknownLanguage { - log.Fatalf("The requested language '%s' is unknown", language) - } - log.Fatal(err) - } - - for _, p := range problems { - fmt.Printf("%s\n", p) - } - fmt.Printf("\n%s\n\n", msgExplainFetch) -} diff --git a/cmd/open.go b/cmd/open.go index dfadf5976..627eac639 100644 --- a/cmd/open.go +++ b/cmd/open.go @@ -1,57 +1,34 @@ package cmd import ( - "log" - "os/exec" - "runtime" - "strings" - - "github.com/codegangsta/cli" - "github.com/exercism/cli/api" - "github.com/exercism/cli/config" + "github.com/exercism/cli/browser" + "github.com/exercism/cli/workspace" + "github.com/spf13/cobra" ) -func Open(ctx *cli.Context) { - c, err := config.New(ctx.GlobalString("config")) - if err != nil { - log.Fatal(err) - } - client := api.NewClient(c) - - args := ctx.Args() - if len(args) != 2 { - msg := "Usage: exercism open LANGUAGE EXERCISE" - log.Fatal(msg) - } - - language := args[0] - exercise := args[1] - submission, err := client.Submission(language, exercise) - if err != nil { - log.Fatal(err) - } - - url := submission.URL - // Escape characters not allowed by cmd/bash - switch runtime.GOOS { - case "windows": - url = strings.Replace(url, "&", `^&`, -1) - default: - url = strings.Replace(url, "&", `\&`, -1) - } - - // setup command to open browser - var cmd *exec.Cmd - switch runtime.GOOS { - case "darwin": - cmd = exec.Command("open", url) - case "freebsd", "linux", "netbsd", "openbsd": - cmd = exec.Command("xdg-open", url) - case "windows": - cmd = exec.Command("cmd", "/c", "start", url) - } +// openCmd opens the designated exercise in the browser. +var openCmd = &cobra.Command{ + Use: "open", + Aliases: []string{"o"}, + Short: "Open an exercise on the website.", + Long: `Open the specified exercise to the solution page on the Exercism website. + +Pass the path to the directory that contains the solution you want to see on the website. + `, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + path := "." + if len(args) == 1 { + path = args[0] + } + metadata, err := workspace.NewExerciseMetadata(path) + if err != nil { + return err + } + return browser.Open(metadata.URL) + }, +} - if err := cmd.Run(); err != nil { - log.Fatal(err) - } +func init() { + RootCmd.AddCommand(openCmd) } diff --git a/cmd/prepare.go b/cmd/prepare.go new file mode 100644 index 000000000..6e6a13811 --- /dev/null +++ b/cmd/prepare.go @@ -0,0 +1,19 @@ +package cmd + +import "github.com/spf13/cobra" + +// prepareCmd does necessary setup for Exercism and its tracks. +var prepareCmd = &cobra.Command{ + Use: "prepare", + Aliases: []string{"p"}, + Short: "Prepare does setup for Exercism and its tracks.", + Long: `Prepare downloads settings and dependencies for Exercism and the language tracks. + `, + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, +} + +func init() { + RootCmd.AddCommand(prepareCmd) +} diff --git a/cmd/restore.go b/cmd/restore.go deleted file mode 100644 index 89453ac9c..000000000 --- a/cmd/restore.go +++ /dev/null @@ -1,31 +0,0 @@ -package cmd - -import ( - "log" - - "github.com/codegangsta/cli" - "github.com/exercism/cli/api" - "github.com/exercism/cli/config" - "github.com/exercism/cli/user" -) - -// Restore returns a user's solved problems. -func Restore(ctx *cli.Context) { - c, err := config.New(ctx.GlobalString("config")) - if err != nil { - log.Fatal(err) - } - - client := api.NewClient(c) - - problems, err := client.Restore() - if err != nil { - log.Fatal(err) - } - - hw := user.NewHomework(problems, c) - if err := hw.Save(); err != nil { - log.Fatal(err) - } - hw.Summarize(user.HWNotSubmitted) -} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 000000000..d94d5c847 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "fmt" + "os" + "runtime" + + "github.com/exercism/cli/api" + "github.com/exercism/cli/cli" + "github.com/exercism/cli/config" + "github.com/exercism/cli/debug" + "github.com/spf13/cobra" +) + +// RootCmd represents the base command when called without any subcommands. +var RootCmd = &cobra.Command{ + Use: getCommandName(), + Short: "A friendly command-line interface to Exercism.", + Long: `A command-line interface for Exercism. + +Download exercises and submit your solutions.`, + SilenceUsage: true, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + if verbose, _ := cmd.Flags().GetBool("verbose"); verbose { + debug.Verbose = verbose + } + if unmask, _ := cmd.Flags().GetBool("unmask-token"); unmask { + debug.UnmaskAPIKey = unmask + } + if timeout, _ := cmd.Flags().GetInt("timeout"); timeout > 0 { + cli.TimeoutInSeconds = timeout + api.TimeoutInSeconds = timeout + } + }, +} + +// Execute adds all child commands to the root command. +func Execute() { + if err := RootCmd.Execute(); err != nil { + os.Exit(-1) + } +} + +func getCommandName() string { + return os.Args[0] +} + +func init() { + BinaryName = getCommandName() + config.SetDefaultDirName(BinaryName) + Out = os.Stdout + Err = os.Stderr + api.UserAgent = fmt.Sprintf("github.com/exercism/cli v%s (%s/%s)", Version, runtime.GOOS, runtime.GOARCH) + RootCmd.PersistentFlags().BoolP("verbose", "v", false, "verbose output") + RootCmd.PersistentFlags().IntP("timeout", "", 0, "override the default HTTP timeout (seconds)") + RootCmd.PersistentFlags().BoolP("unmask-token", "", false, "will unmask the API during a request/response dump") +} diff --git a/cmd/submit.go b/cmd/submit.go index c5609e4b7..005cb8cc4 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -1,107 +1,467 @@ package cmd import ( + "bytes" + "errors" "fmt" - "io/ioutil" - "log" + "io" + "mime/multipart" "os" "path/filepath" - "github.com/codegangsta/cli" "github.com/exercism/cli/api" "github.com/exercism/cli/config" + "github.com/exercism/cli/workspace" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/spf13/viper" ) -// Submit posts an iteration to the api -func Submit(ctx *cli.Context) { - if len(ctx.Args()) == 0 { - log.Fatal("Please enter a file name") +// submitCmd lets people upload a solution to the website. +var submitCmd = &cobra.Command{ + Use: "submit [ ...]", + Aliases: []string{"s"}, + Short: "Submit your solution to an exercise.", + Long: `Submit your solution to an Exercism exercise. + + Call the command with the list of files you want to submit. + If you omit the list of files, the CLI will submit the + default solution files for the exercise. +`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg := config.NewConfig() + + usrCfg := viper.New() + usrCfg.AddConfigPath(cfg.Dir) + usrCfg.SetConfigName("user") + usrCfg.SetConfigType("json") + // Ignore error. If the file doesn't exist, that is fine. + _ = usrCfg.ReadInConfig() + cfg.UserViperConfig = usrCfg + + v := viper.New() + v.AddConfigPath(cfg.Dir) + v.SetConfigName("cli") + v.SetConfigType("json") + // Ignore error. If the file doesn't exist, that is fine. + _ = v.ReadInConfig() + + if len(args) == 0 { + files, err := getExerciseSolutionFiles(".") + if err != nil { + return err + } + args = files + } + + return runSubmit(cfg, cmd.Flags(), args) + }, +} + +func runSubmit(cfg config.Config, flags *pflag.FlagSet, args []string) error { + if err := validateUserConfig(cfg.UserViperConfig); err != nil { + return err + } + + ctx := newSubmitCmdContext(cfg.UserViperConfig, flags) + + if err := ctx.validator.filesExistAndNotADir(args); err != nil { + return err } - c, err := config.New(ctx.GlobalString("config")) + submitPaths, err := ctx.evaluatedSymlinks(args) if err != nil { - log.Fatal(err) + return err } - if ctx.GlobalBool("debug") { - log.Printf("Exercises dir: %s", c.Dir) - dir, err := os.Getwd() - if err != nil { - log.Printf("Unable to get current working directory - %s", err) - } else { - log.Printf("Current dir: %s", dir) - } + submitPaths = ctx.removeDuplicatePaths(submitPaths) + + if err = ctx.validator.filesBelongToSameExercise(submitPaths); err != nil { + return err } - if !c.IsAuthenticated() { - log.Fatal(msgPleaseAuthenticate) + exercise, err := ctx.exercise(submitPaths[0]) + if err != nil { + return err + } + + if err = ctx.migrateLegacyMetadata(exercise); err != nil { + return err } - filename := ctx.Args()[0] + if err = ctx.validator.fileSizesWithinMax(submitPaths); err != nil { + return err + } - if ctx.GlobalBool("debug") { - log.Printf("file name: %s", filename) + documents, err := ctx.documents(submitPaths, exercise) + if err != nil { + return err } - if isTest(filename) { - log.Fatal("Please submit the solution, not the test file.") + if err = ctx.validator.submissionNotEmpty(documents); err != nil { + return err } - file, err := filepath.Abs(filename) + metadata, err := ctx.metadata(exercise) if err != nil { - log.Fatal(err) + return err + } + + if err := ctx.validator.metadataMatchesExercise(metadata, exercise); err != nil { + return err + } + + if err := ctx.validator.isRequestor(metadata); err != nil { + return err } - if ctx.GlobalBool("debug") { - log.Printf("absolute path: %s", file) + if err := ctx.submit(metadata, documents); err != nil { + return err } - file, err = filepath.EvalSymlinks(file) + ctx.printResult(metadata) + return nil +} + +func getExerciseSolutionFiles(baseDir string) ([]string, error) { + v := viper.New() + v.AddConfigPath(filepath.Join(baseDir, ".exercism")) + v.SetConfigName("config") + v.SetConfigType("json") + err := v.ReadInConfig() if err != nil { - log.Fatal(err) + return nil, errors.New("no files to submit") } + solutionFiles := v.GetStringSlice("files.solution") + if len(solutionFiles) == 0 { + return nil, errors.New("no files to submit") + } + + return solutionFiles, nil +} + +type submitCmdContext struct { + usrCfg *viper.Viper + flags *pflag.FlagSet + validator submitValidator +} - if ctx.GlobalBool("debug") { - log.Printf("eval symlinks (file): %s", file) +func newSubmitCmdContext(usrCfg *viper.Viper, flags *pflag.FlagSet) *submitCmdContext { + return &submitCmdContext{ + usrCfg: usrCfg, + flags: flags, + validator: submitValidator{usrCfg: usrCfg}, } +} - dir, err := filepath.EvalSymlinks(c.Dir) +// evaluatedSymlinks returns the submit paths with evaluated symlinks. +func (s *submitCmdContext) evaluatedSymlinks(submitPaths []string) ([]string, error) { + evalSymlinkSubmitPaths := make([]string, 0, len(submitPaths)) + for _, path := range submitPaths { + var err error + path, err = filepath.Abs(path) + if err != nil { + return nil, err + } + + src, err := filepath.EvalSymlinks(path) + if err != nil { + return nil, err + } + evalSymlinkSubmitPaths = append(evalSymlinkSubmitPaths, src) + } + return evalSymlinkSubmitPaths, nil +} + +func (s *submitCmdContext) removeDuplicatePaths(submitPaths []string) []string { + seen := make(map[string]bool) + result := make([]string, 0, len(submitPaths)) + + for _, val := range submitPaths { + if _, ok := seen[val]; !ok { + seen[val] = true + result = append(result, val) + } + } + + return result +} + +// exercise creates an exercise using one of the submitted filepaths. +// This assumes prior verification that submit paths belong to the same exercise. +func (s *submitCmdContext) exercise(aSubmitPath string) (workspace.Exercise, error) { + ws, err := workspace.New(s.usrCfg.GetString("workspace")) if err != nil { - log.Fatal(err) + return workspace.Exercise{}, err } - if ctx.GlobalBool("debug") { - log.Printf("eval symlinks (dir): %s", dir) + dir, err := ws.ExerciseDir(aSubmitPath) + if err != nil { + return workspace.Exercise{}, err } + return workspace.NewExerciseFromDir(dir), nil +} - code, err := ioutil.ReadFile(file) +func (s *submitCmdContext) migrateLegacyMetadata(exercise workspace.Exercise) error { + migrationStatus, err := exercise.MigrateLegacyMetadataFile() if err != nil { - log.Fatalf("Cannot read the contents of %s - %s\n", filename, err) + return err + } + if verbose, _ := s.flags.GetBool("verbose"); verbose { + Err.Write([]byte(migrationStatus.String())) } + return nil +} + +// documents builds the documents that get submitted. +// Empty files are skipped, printing a warning. +func (s *submitCmdContext) documents(submitPaths []string, exercise workspace.Exercise) ([]workspace.Document, error) { + docs := make([]workspace.Document, 0, len(submitPaths)) + for _, file := range submitPaths { + // Don't submit empty files + info, err := os.Stat(file) + if err != nil { + return nil, err + } + if info.Size() == 0 { + + msg := ` + + WARNING: Skipping empty file + %s - iteration := &api.Iteration{ - Key: c.APIKey, - Code: string(code), - File: file, - Dir: dir, + ` + fmt.Fprintf(Err, msg, file) + continue + } + doc, err := workspace.NewDocument(exercise.Filepath(), file) + if err != nil { + return nil, err + } + docs = append(docs, doc) } + return docs, nil +} + +func (s *submitCmdContext) metadata(exercise workspace.Exercise) (*workspace.ExerciseMetadata, error) { + metadata, err := workspace.NewExerciseMetadata(exercise.Filepath()) + if err != nil { + return nil, err + } + return metadata, nil +} + +// submit submits the documents to the Exercism API. +func (s *submitCmdContext) submit(metadata *workspace.ExerciseMetadata, docs []workspace.Document) error { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) - if err := iteration.Identify(); err != nil { - msg := `Please leave the solution within the problem directory that was created by 'exercism fetch'` - log.Fatalf("Cannot submit - %s.\n\n%s", err, msg) + for _, doc := range docs { + file, err := os.Open(doc.Filepath()) + if err != nil { + return err + } + defer file.Close() + + part, err := writer.CreateFormFile("files[]", doc.Path()) + if err != nil { + return err + } + _, err = io.Copy(part, file) + if err != nil { + return err + } + } + if err := writer.Close(); err != nil { + return err } - client := api.NewClient(c) - submission, err := client.Submit(iteration) + client, err := api.NewClient(s.usrCfg.GetString("token"), s.usrCfg.GetString("apibaseurl")) if err != nil { - log.Fatal(err) + return err } + url := fmt.Sprintf("%s/solutions/%s", s.usrCfg.GetString("apibaseurl"), metadata.ID) + req, err := client.NewRequest("PATCH", url, body) + if err != nil { + return err + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return decodedAPIError(resp) + } + + bb := &bytes.Buffer{} + _, err = bb.ReadFrom(resp.Body) + if err != nil { + return err + } + return nil +} +func (s *submitCmdContext) printResult(metadata *workspace.ExerciseMetadata) { msg := ` -Submitted %s in %s. -Your submission can be found online at %s -To get the next exercise, run "exercism fetch" again. + Your solution has been submitted successfully. + %s ` - fmt.Printf(msg, submission.Name, submission.Language, submission.URL) + suffix := "View it at:\n\n " + if metadata.AutoApprove { + suffix = "You can complete the exercise and unlock the next core exercise at:\n" + } + fmt.Fprintf(Err, msg, suffix) + fmt.Fprintf(Out, " %s\n\n", metadata.URL) +} + +// submitValidator contains the validation rules for a submission. +type submitValidator struct { + usrCfg *viper.Viper +} + +// filesExistAndNotADir checks that each file exists and is not a directory. +func (s submitValidator) filesExistAndNotADir(submitPaths []string) error { + for _, path := range submitPaths { + path, err := filepath.Abs(path) + if err != nil { + return err + } + + info, err := os.Lstat(path) + if err != nil { + if os.IsNotExist(err) { + msg := ` + + The file you are trying to submit cannot be found. + + %s + + ` + return fmt.Errorf(msg, path) + } + return err + } + if info.IsDir() { + msg := ` + + You are submitting a directory, which is not currently supported. + + %s + + Please change into the directory and provide the path to the file(s) you wish to submit + + %s submit FILENAME + + ` + return fmt.Errorf(msg, path, BinaryName) + } + } + return nil +} + +// filesBelongToSameExercise checks that each file belongs to the same exercise. +func (s submitValidator) filesBelongToSameExercise(submitPaths []string) error { + ws, err := workspace.New(s.usrCfg.GetString("workspace")) + if err != nil { + return err + } + + var exerciseDir string + for _, f := range submitPaths { + dir, err := ws.ExerciseDir(f) + if err != nil { + if workspace.IsMissingMetadata(err) { + return errors.New(msgMissingMetadata) + } + return err + } + if exerciseDir != "" && dir != exerciseDir { + msg := ` + + You are submitting files belonging to different solutions. + Please submit the files for one solution at a time. + + ` + return errors.New(msg) + } + exerciseDir = dir + } + return nil +} + +// fileSizesWithinMax checks that each file does not exceed the max allowed size. +func (s submitValidator) fileSizesWithinMax(submitPaths []string) error { + for _, file := range submitPaths { + info, err := os.Stat(file) + if err != nil { + return err + } + const maxFileSize int64 = 65535 + if info.Size() >= maxFileSize { + msg := ` + + The submitted file '%s' is larger than the max allowed file size of %d bytes. + Please reduce the size of the file and try again. + + ` + return fmt.Errorf(msg, file, maxFileSize) + } + } + return nil +} + +// submissionNotEmpty checks that there is at least one file to submit. +func (s submitValidator) submissionNotEmpty(docs []workspace.Document) error { + if len(docs) == 0 { + msg := ` + + No files found to submit. + + ` + return errors.New(msg) + } + return nil +} + +// metadataMatchesExercise checks that the metadata refers to the exercise being submitted. +func (s submitValidator) metadataMatchesExercise(metadata *workspace.ExerciseMetadata, exercise workspace.Exercise) error { + if metadata.ExerciseSlug != exercise.Slug { + // TODO: error msg should suggest running future doctor command + msg := ` + + The exercise directory does not match exercise slug in metadata: + + expected '%[1]s' but got '%[2]s' + + Please rename the directory '%[1]s' to '%[2]s' and try again. + + ` + return fmt.Errorf(msg, exercise.Slug, metadata.ExerciseSlug) + } + return nil +} + +// isRequestor checks that the submission requestor is listed as the author in the metadata. +func (s submitValidator) isRequestor(metadata *workspace.ExerciseMetadata) error { + if !metadata.IsRequester { + msg := ` + + The solution you are submitting is not connected to your account. + Please re-download the exercise to make sure it has the data it needs. + + %s download --exercise=%s --track=%s + + ` + return fmt.Errorf(msg, BinaryName, metadata.ExerciseSlug, metadata.Track) + } + return nil +} + +func init() { + RootCmd.AddCommand(submitCmd) } diff --git a/cmd/submit_symlink_test.go b/cmd/submit_symlink_test.go new file mode 100644 index 000000000..093895fb3 --- /dev/null +++ b/cmd/submit_symlink_test.go @@ -0,0 +1,62 @@ +//go:build !windows + +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/exercism/cli/config" + "github.com/spf13/pflag" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" +) + +func TestSubmitFilesInSymlinkedPath(t *testing.T) { + co := newCapturedOutput() + co.override() + defer co.reset() + + // The fake endpoint will populate this when it receives the call from the command. + submittedFiles := map[string]string{} + ts := fakeSubmitServer(t, submittedFiles) + defer ts.Close() + + tmpDir, err := os.MkdirTemp("", "symlink-destination") + defer os.RemoveAll(tmpDir) + assert.NoError(t, err) + dstDir := filepath.Join(tmpDir, "workspace") + + srcDir, err := os.MkdirTemp("", "symlink-source") + defer os.RemoveAll(srcDir) + assert.NoError(t, err) + + err = os.Symlink(srcDir, dstDir) + assert.NoError(t, err) + + dir := filepath.Join(dstDir, "bogus-track", "bogus-exercise") + os.MkdirAll(dir, os.FileMode(0755)) + + writeFakeMetadata(t, dir, "bogus-track", "bogus-exercise") + + v := viper.New() + v.Set("token", "abc123") + v.Set("workspace", dstDir) + v.Set("apibaseurl", ts.URL) + + cfg := config.Config{ + Persister: config.InMemoryPersister{}, + UserViperConfig: v, + } + + file := filepath.Join(dir, "file.txt") + err = os.WriteFile(filepath.Join(dir, "file.txt"), []byte("This is a file."), os.FileMode(0755)) + assert.NoError(t, err) + + err = runSubmit(cfg, pflag.NewFlagSet("symlinks", pflag.PanicOnError), []string{file}) + assert.NoError(t, err) + + assert.Equal(t, 1, len(submittedFiles)) + assert.Equal(t, "This is a file.", submittedFiles["file.txt"]) +} diff --git a/cmd/submit_test.go b/cmd/submit_test.go new file mode 100644 index 000000000..38caf9d93 --- /dev/null +++ b/cmd/submit_test.go @@ -0,0 +1,741 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/exercism/cli/config" + "github.com/exercism/cli/workspace" + "github.com/spf13/pflag" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" +) + +func TestSubmitWithoutToken(t *testing.T) { + cfg := config.Config{ + Persister: config.InMemoryPersister{}, + UserViperConfig: viper.New(), + } + + err := runSubmit(cfg, pflag.NewFlagSet("fake", pflag.PanicOnError), []string{}) + if assert.Error(t, err) { + assert.Regexp(t, "Welcome to Exercism", err.Error()) + assert.Regexp(t, "exercism.org/settings", err.Error()) + } +} + +func TestSubmitWithoutWorkspace(t *testing.T) { + v := viper.New() + v.Set("token", "abc123") + + cfg := config.Config{ + Persister: config.InMemoryPersister{}, + UserViperConfig: v, + DefaultBaseURL: "http://example.com", + } + + err := runSubmit(cfg, pflag.NewFlagSet("fake", pflag.PanicOnError), []string{}) + if assert.Error(t, err) { + assert.Regexp(t, "re-run the configure", err.Error()) + } +} + +func TestSubmitNonExistentFile(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "submit-no-such-file") + defer os.RemoveAll(tmpDir) + assert.NoError(t, err) + + v := viper.New() + v.Set("token", "abc123") + v.Set("workspace", tmpDir) + v.Set("apibaseurl", "http://api.example.com") + + cfg := config.Config{ + Persister: config.InMemoryPersister{}, + UserViperConfig: v, + DefaultBaseURL: "http://example.com", + } + + err = os.WriteFile(filepath.Join(tmpDir, "file-1.txt"), []byte("This is file 1"), os.FileMode(0755)) + assert.NoError(t, err) + + err = os.WriteFile(filepath.Join(tmpDir, "file-2.txt"), []byte("This is file 2"), os.FileMode(0755)) + assert.NoError(t, err) + files := []string{ + filepath.Join(tmpDir, "file-1.txt"), + "no-such-file.txt", + filepath.Join(tmpDir, "file-2.txt"), + } + err = runSubmit(cfg, pflag.NewFlagSet("fake", pflag.PanicOnError), files) + if assert.Error(t, err) { + assert.Regexp(t, "cannot be found", err.Error()) + } +} + +func TestSubmitExerciseWithoutMetadataFile(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "no-metadata-file") + defer os.RemoveAll(tmpDir) + assert.NoError(t, err) + + dir := filepath.Join(tmpDir, "bogus-track", "bogus-exercise") + os.MkdirAll(dir, os.FileMode(0755)) + + file := filepath.Join(dir, "file.txt") + err = os.WriteFile(file, []byte("This is a file."), os.FileMode(0755)) + assert.NoError(t, err) + + v := viper.New() + v.Set("token", "abc123") + v.Set("workspace", tmpDir) + v.Set("apibaseurl", "http://api.example.com") + + cfg := config.Config{ + Persister: config.InMemoryPersister{}, + Dir: tmpDir, + UserViperConfig: v, + } + + err = runSubmit(cfg, pflag.NewFlagSet("fake", pflag.PanicOnError), []string{file}) + if assert.Error(t, err) { + assert.Regexp(t, "doesn't have the necessary metadata", err.Error()) + } +} + +func TestGetExerciseSolutionFiles(t *testing.T) { + + tmpDir, err := os.MkdirTemp("", "dir-with-no-metadata") + defer os.RemoveAll(tmpDir) + assert.NoError(t, err) + + _, err = getExerciseSolutionFiles(tmpDir) + if assert.Error(t, err) { + assert.Regexp(t, "no files to submit", err.Error()) + } + + validTmpDir, err := os.MkdirTemp("", "dir-with-valid-metadata") + defer os.RemoveAll(validTmpDir) + assert.NoError(t, err) + + metadataDir := filepath.Join(validTmpDir, ".exercism") + err = os.MkdirAll(metadataDir, os.FileMode(0755)) + assert.NoError(t, err) + + err = os.WriteFile( + filepath.Join(metadataDir, "config.json"), + []byte(` +{ + "files": { + "solution": [ + "expenses.go" + ] + } +} +`), os.FileMode(0755)) + assert.NoError(t, err) + + files, err := getExerciseSolutionFiles(validTmpDir) + assert.NoError(t, err) + if assert.Equal(t, len(files), 1) { + assert.Equal(t, files[0], "expenses.go") + } +} + +func TestSubmitFilesAndDir(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "submit-no-such-file") + defer os.RemoveAll(tmpDir) + assert.NoError(t, err) + + v := viper.New() + v.Set("token", "abc123") + v.Set("workspace", tmpDir) + v.Set("apibaseurl", "http://api.example.com") + + cfg := config.Config{ + Persister: config.InMemoryPersister{}, + UserViperConfig: v, + DefaultBaseURL: "http://example.com", + } + + err = os.WriteFile(filepath.Join(tmpDir, "file-1.txt"), []byte("This is file 1"), os.FileMode(0755)) + assert.NoError(t, err) + + err = os.WriteFile(filepath.Join(tmpDir, "file-2.txt"), []byte("This is file 2"), os.FileMode(0755)) + assert.NoError(t, err) + files := []string{ + filepath.Join(tmpDir, "file-1.txt"), + tmpDir, + filepath.Join(tmpDir, "file-2.txt"), + } + + err = runSubmit(cfg, pflag.NewFlagSet("fake", pflag.PanicOnError), files) + if assert.Error(t, err) { + assert.Regexp(t, "submitting a directory", err.Error()) + assert.Regexp(t, "Please change into the directory and provide the path to the file\\(s\\) you wish to submit", err.Error()) + } +} + +func TestDuplicateFiles(t *testing.T) { + co := newCapturedOutput() + co.override() + defer co.reset() + + // The fake endpoint will populate this when it receives the call from the command. + submittedFiles := map[string]string{} + ts := fakeSubmitServer(t, submittedFiles) + defer ts.Close() + + tmpDir, err := os.MkdirTemp("", "duplicate-files") + defer os.RemoveAll(tmpDir) + assert.NoError(t, err) + + dir := filepath.Join(tmpDir, "bogus-track", "bogus-exercise") + os.MkdirAll(dir, os.FileMode(0755)) + + writeFakeMetadata(t, dir, "bogus-track", "bogus-exercise") + + v := viper.New() + v.Set("token", "abc123") + v.Set("workspace", tmpDir) + v.Set("apibaseurl", ts.URL) + + cfg := config.Config{ + Persister: config.InMemoryPersister{}, + UserViperConfig: v, + } + + file1 := filepath.Join(dir, "file-1.txt") + err = os.WriteFile(file1, []byte("This is file 1."), os.FileMode(0755)) + + err = runSubmit(cfg, pflag.NewFlagSet("fake", pflag.PanicOnError), []string{file1, file1}) + assert.NoError(t, err) + + assert.Equal(t, 1, len(submittedFiles)) + assert.Equal(t, "This is file 1.", submittedFiles["file-1.txt"]) +} + +func TestSubmitFiles(t *testing.T) { + co := newCapturedOutput() + co.override() + defer co.reset() + Err = &bytes.Buffer{} + + // The fake endpoint will populate this when it receives the call from the command. + submittedFiles := map[string]string{} + ts := fakeSubmitServer(t, submittedFiles) + defer ts.Close() + + tmpDir, err := os.MkdirTemp("", "submit-files") + defer os.RemoveAll(tmpDir) + assert.NoError(t, err) + + dir := filepath.Join(tmpDir, "bogus-track", "bogus-exercise") + os.MkdirAll(filepath.Join(dir, "subdir"), os.FileMode(0755)) + writeFakeMetadata(t, dir, "bogus-track", "bogus-exercise") + + file1 := filepath.Join(dir, "file-1.txt") + err = os.WriteFile(file1, []byte("This is file 1."), os.FileMode(0755)) + assert.NoError(t, err) + + file2 := filepath.Join(dir, "subdir", "file-2.txt") + err = os.WriteFile(file2, []byte("This is file 2."), os.FileMode(0755)) + assert.NoError(t, err) + + // We don't filter *.md files if you explicitly pass the file path. + readme := filepath.Join(dir, "README.md") + err = os.WriteFile(readme, []byte("This is the readme."), os.FileMode(0755)) + assert.NoError(t, err) + + v := viper.New() + v.Set("token", "abc123") + v.Set("workspace", tmpDir) + v.Set("apibaseurl", ts.URL) + + cfg := config.Config{ + Persister: config.InMemoryPersister{}, + Dir: tmpDir, + UserViperConfig: v, + } + + files := []string{ + file1, file2, readme, + } + + err = runSubmit(cfg, pflag.NewFlagSet("fake", pflag.PanicOnError), files) + + if assert.NoError(t, err) { + assert.Equal(t, 3, len(submittedFiles)) + assert.Equal(t, "This is file 1.", submittedFiles["file-1.txt"]) + assert.Equal(t, "This is file 2.", submittedFiles["subdir/file-2.txt"]) + assert.Equal(t, "This is the readme.", submittedFiles["README.md"]) + assert.Regexp(t, "submitted successfully", Err) + } +} + +func TestLegacyMetadataMigration(t *testing.T) { + co := newCapturedOutput() + co.newErr = &bytes.Buffer{} + co.override() + defer co.reset() + + submittedFiles := map[string]string{} + ts := fakeSubmitServer(t, submittedFiles) + defer ts.Close() + + tmpDir, err := os.MkdirTemp("", "legacy-metadata-file") + defer os.RemoveAll(tmpDir) + assert.NoError(t, err) + + dir := filepath.Join(tmpDir, "bogus-track", "bogus-exercise") + os.MkdirAll(dir, os.FileMode(0755)) + + metadata := &workspace.ExerciseMetadata{ + ID: "bogus-solution-uuid", + Track: "bogus-track", + ExerciseSlug: "bogus-exercise", + URL: "http://example.com/bogus-url", + IsRequester: true, + } + b, err := json.Marshal(metadata) + assert.NoError(t, err) + exercise := workspace.NewExerciseFromDir(dir) + err = os.WriteFile(exercise.LegacyMetadataFilepath(), b, os.FileMode(0600)) + assert.NoError(t, err) + + file := filepath.Join(dir, "file.txt") + err = os.WriteFile(file, []byte("This is a file."), os.FileMode(0755)) + assert.NoError(t, err) + + v := viper.New() + v.Set("token", "abc123") + v.Set("workspace", tmpDir) + v.Set("apibaseurl", ts.URL) + cfg := config.Config{ + Persister: config.InMemoryPersister{}, + Dir: tmpDir, + UserViperConfig: v, + } + + ok, _ := exercise.HasLegacyMetadata() + assert.True(t, ok) + ok, _ = exercise.HasMetadata() + assert.False(t, ok) + + flags := pflag.NewFlagSet("fake", pflag.PanicOnError) + flags.Bool("verbose", true, "") + + err = runSubmit(cfg, flags, []string{file}) + assert.NoError(t, err) + assert.Equal(t, "This is a file.", submittedFiles["file.txt"]) + + ok, _ = exercise.HasLegacyMetadata() + assert.False(t, ok) + ok, _ = exercise.HasMetadata() + assert.True(t, ok) + assert.Regexp(t, "Migrated metadata", Err) +} + +func TestSubmitWithEmptyFile(t *testing.T) { + co := newCapturedOutput() + co.override() + defer co.reset() + + // The fake endpoint will populate this when it receives the call from the command. + submittedFiles := map[string]string{} + ts := fakeSubmitServer(t, submittedFiles) + defer ts.Close() + + tmpDir, err := os.MkdirTemp("", "empty-file") + defer os.RemoveAll(tmpDir) + assert.NoError(t, err) + + dir := filepath.Join(tmpDir, "bogus-track", "bogus-exercise") + os.MkdirAll(dir, os.FileMode(0755)) + + writeFakeMetadata(t, dir, "bogus-track", "bogus-exercise") + + v := viper.New() + v.Set("token", "abc123") + v.Set("workspace", tmpDir) + v.Set("apibaseurl", ts.URL) + + cfg := config.Config{ + Persister: config.InMemoryPersister{}, + UserViperConfig: v, + } + + file1 := filepath.Join(dir, "file-1.txt") + err = os.WriteFile(file1, []byte(""), os.FileMode(0755)) + file2 := filepath.Join(dir, "file-2.txt") + err = os.WriteFile(file2, []byte("This is file 2."), os.FileMode(0755)) + + err = runSubmit(cfg, pflag.NewFlagSet("fake", pflag.PanicOnError), []string{file1, file2}) + assert.NoError(t, err) + + assert.Equal(t, 1, len(submittedFiles)) + assert.Equal(t, "This is file 2.", submittedFiles["file-2.txt"]) +} + +func TestSubmitWithEnormousFile(t *testing.T) { + co := newCapturedOutput() + co.override() + defer co.reset() + + // The fake endpoint will populate this when it receives the call from the command. + submittedFiles := map[string]string{} + ts := fakeSubmitServer(t, submittedFiles) + defer ts.Close() + + tmpDir, err := os.MkdirTemp("", "enormous-file") + defer os.RemoveAll(tmpDir) + assert.NoError(t, err) + + dir := filepath.Join(tmpDir, "bogus-track", "bogus-exercise") + os.MkdirAll(dir, os.FileMode(0755)) + + writeFakeMetadata(t, dir, "bogus-track", "bogus-exercise") + + v := viper.New() + v.Set("token", "abc123") + v.Set("workspace", tmpDir) + v.Set("apibaseurl", ts.URL) + + cfg := config.Config{ + Persister: config.InMemoryPersister{}, + UserViperConfig: v, + } + + file := filepath.Join(dir, "file.txt") + err = os.WriteFile(file, make([]byte, 65535), os.FileMode(0755)) + if err != nil { + t.Fatal(err) + } + + err = runSubmit(cfg, pflag.NewFlagSet("fake", pflag.PanicOnError), []string{file}) + + if assert.Error(t, err) { + assert.Regexp(t, "Please reduce the size of the file and try again.", err.Error()) + } +} + +func TestSubmitOnlyEmptyFile(t *testing.T) { + co := newCapturedOutput() + co.override() + defer co.reset() + + tmpDir, err := os.MkdirTemp("", "just-an-empty-file") + defer os.RemoveAll(tmpDir) + assert.NoError(t, err) + + dir := filepath.Join(tmpDir, "bogus-track", "bogus-exercise") + os.MkdirAll(dir, os.FileMode(0755)) + + writeFakeMetadata(t, dir, "bogus-track", "bogus-exercise") + + v := viper.New() + v.Set("token", "abc123") + v.Set("workspace", tmpDir) + v.Set("apibaseurl", "http://api.example.com") + + cfg := config.Config{ + Persister: config.InMemoryPersister{}, + UserViperConfig: v, + } + + file := filepath.Join(dir, "file.txt") + err = os.WriteFile(file, []byte(""), os.FileMode(0755)) + + err = runSubmit(cfg, pflag.NewFlagSet("fake", pflag.PanicOnError), []string{file}) + if assert.Error(t, err) { + assert.Regexp(t, "No files found", err.Error()) + } +} + +func TestSubmitFilesFromDifferentSolutions(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dir-1-submit") + defer os.RemoveAll(tmpDir) + assert.NoError(t, err) + + dir1 := filepath.Join(tmpDir, "bogus-track", "bogus-exercise-1") + os.MkdirAll(dir1, os.FileMode(0755)) + writeFakeMetadata(t, dir1, "bogus-track", "bogus-exercise-1") + + dir2 := filepath.Join(tmpDir, "bogus-track", "bogus-exercise-2") + os.MkdirAll(dir2, os.FileMode(0755)) + writeFakeMetadata(t, dir2, "bogus-track", "bogus-exercise-2") + + file1 := filepath.Join(dir1, "file-1.txt") + err = os.WriteFile(file1, []byte("This is file 1."), os.FileMode(0755)) + assert.NoError(t, err) + + file2 := filepath.Join(dir2, "file-2.txt") + err = os.WriteFile(file2, []byte("This is file 2."), os.FileMode(0755)) + assert.NoError(t, err) + + v := viper.New() + v.Set("token", "abc123") + v.Set("workspace", tmpDir) + v.Set("apibaseurl", "http://api.example.com") + + cfg := config.Config{ + Persister: config.InMemoryPersister{}, + Dir: tmpDir, + UserViperConfig: v, + } + + err = runSubmit(cfg, pflag.NewFlagSet("fake", pflag.PanicOnError), []string{file1, file2}) + if assert.Error(t, err) { + assert.Regexp(t, "different solutions", err.Error()) + } +} + +func fakeSubmitServer(t *testing.T, submittedFiles map[string]string) *httptest.Server { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := r.ParseMultipartForm(2 << 10) + if err != nil { + t.Fatal(err) + } + mf := r.MultipartForm + + files := mf.File["files[]"] + for _, fileHeader := range files { + file, err := fileHeader.Open() + if err != nil { + t.Fatal(err) + } + defer file.Close() + body, err := io.ReadAll(file) + if err != nil { + t.Fatal(err) + } + // Following RFC 7578, Go 1.17+ strips the directory information in fileHeader.Filename. + // Validating the submitted files directory tree is important so Content-Disposition is used for + // obtaining the unmodified filename. + v := fileHeader.Header.Get("Content-Disposition") + _, dispositionParams, err := mime.ParseMediaType(v) + if err != nil { + t.Fatalf("failed to obtain submitted filename from multipart header: %s", err.Error()) + } + filename := dispositionParams["filename"] + submittedFiles[filename] = string(body) + } + + fmt.Fprint(w, "{}") + }) + return httptest.NewServer(handler) +} + +func TestSubmitRelativePath(t *testing.T) { + co := newCapturedOutput() + co.override() + defer co.reset() + + // The fake endpoint will populate this when it receives the call from the command. + submittedFiles := map[string]string{} + ts := fakeSubmitServer(t, submittedFiles) + defer ts.Close() + + tmpDir, err := os.MkdirTemp("", "relative-path") + defer os.RemoveAll(tmpDir) + assert.NoError(t, err) + + dir := filepath.Join(tmpDir, "bogus-track", "bogus-exercise") + os.MkdirAll(dir, os.FileMode(0755)) + + writeFakeMetadata(t, dir, "bogus-track", "bogus-exercise") + + v := viper.New() + v.Set("token", "abc123") + v.Set("workspace", tmpDir) + v.Set("apibaseurl", ts.URL) + + cfg := config.Config{ + Persister: config.InMemoryPersister{}, + UserViperConfig: v, + } + + err = os.WriteFile(filepath.Join(dir, "file.txt"), []byte("This is a file."), os.FileMode(0755)) + + err = os.Chdir(dir) + assert.NoError(t, err) + + err = runSubmit(cfg, pflag.NewFlagSet("fake", pflag.PanicOnError), []string{"file.txt"}) + assert.NoError(t, err) + + assert.Equal(t, 1, len(submittedFiles)) + assert.Equal(t, "This is a file.", submittedFiles["file.txt"]) +} + +func TestSubmitServerErr(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, `{"error": {"type": "error", "message": "test error"}}`) + }) + + ts := httptest.NewServer(handler) + defer ts.Close() + + tmpDir, err := os.MkdirTemp("", "submit-err-tmp-dir") + defer os.RemoveAll(tmpDir) + assert.NoError(t, err) + + v := viper.New() + v.Set("token", "abc123") + v.Set("workspace", tmpDir) + v.Set("apibaseurl", ts.URL) + + cfg := config.Config{ + Persister: config.InMemoryPersister{}, + UserViperConfig: v, + DefaultBaseURL: "http://example.com", + } + + dir := filepath.Join(tmpDir, "bogus-track", "bogus-exercise") + os.MkdirAll(filepath.Join(dir, "subdir"), os.FileMode(0755)) + writeFakeMetadata(t, dir, "bogus-track", "bogus-exercise") + + err = os.WriteFile(filepath.Join(dir, "file-1.txt"), []byte("This is file 1"), os.FileMode(0755)) + assert.NoError(t, err) + + files := []string{ + filepath.Join(dir, "file-1.txt"), + } + + err = runSubmit(cfg, pflag.NewFlagSet("fake", pflag.PanicOnError), files) + + assert.Regexp(t, `expected response with Content-Type "application/json" but got status "400 Bad Request" with Content-Type "text/plain; charset=utf-8"`, err.Error()) +} + +func TestHandleErrorResponse(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + }) + + ts := httptest.NewServer(handler) + defer ts.Close() + + tmpDir, err := os.MkdirTemp("", "submit-nonsuccess") + defer os.RemoveAll(tmpDir) + assert.NoError(t, err) + + v := viper.New() + v.Set("token", "abc123") + v.Set("workspace", tmpDir) + v.Set("apibaseurl", ts.URL) + + cfg := config.Config{ + Persister: config.InMemoryPersister{}, + UserViperConfig: v, + DefaultBaseURL: "http://example.com", + } + + dir := filepath.Join(tmpDir, "bogus-track", "bogus-exercise") + os.MkdirAll(filepath.Join(dir, "subdir"), os.FileMode(0755)) + writeFakeMetadata(t, dir, "bogus-track", "bogus-exercise") + + err = os.WriteFile(filepath.Join(dir, "file-1.txt"), []byte("This is file 1"), os.FileMode(0755)) + assert.NoError(t, err) + + files := []string{ + filepath.Join(dir, "file-1.txt"), + } + + err = runSubmit(cfg, pflag.NewFlagSet("fake", pflag.PanicOnError), files) + assert.Error(t, err) +} + +func TestSubmissionNotConnectedToRequesterAccount(t *testing.T) { + submittedFiles := map[string]string{} + ts := fakeSubmitServer(t, submittedFiles) + defer ts.Close() + + tmpDir, err := os.MkdirTemp("", "submit-files") + defer os.RemoveAll(tmpDir) + assert.NoError(t, err) + + dir := filepath.Join(tmpDir, "bogus-track", "bogus-exercise") + os.MkdirAll(filepath.Join(dir, "subdir"), os.FileMode(0755)) + + metadata := &workspace.ExerciseMetadata{ + ID: "bogus-solution-uuid", + Track: "bogus-track", + ExerciseSlug: "bogus-exercise", + URL: "http://example.com/bogus-url", + IsRequester: false, + } + err = metadata.Write(dir) + assert.NoError(t, err) + + file1 := filepath.Join(dir, "file-1.txt") + err = os.WriteFile(file1, []byte("This is file 1."), os.FileMode(0755)) + assert.NoError(t, err) + + v := viper.New() + v.Set("token", "abc123") + v.Set("workspace", tmpDir) + v.Set("apibaseurl", ts.URL) + + cfg := config.Config{ + Persister: config.InMemoryPersister{}, + Dir: tmpDir, + UserViperConfig: v, + } + + err = runSubmit(cfg, pflag.NewFlagSet("fake", pflag.PanicOnError), []string{file1}) + if assert.Error(t, err) { + assert.Regexp(t, "not connected to your account", err.Error()) + } +} + +func TestExerciseDirnameMatchesMetadataSlug(t *testing.T) { + submittedFiles := map[string]string{} + ts := fakeSubmitServer(t, submittedFiles) + defer ts.Close() + + tmpDir, err := os.MkdirTemp("", "submit-files") + defer os.RemoveAll(tmpDir) + assert.NoError(t, err) + + dir := filepath.Join(tmpDir, "bogus-track", "bogus-exercise-doesnt-match-metadata-slug") + os.MkdirAll(filepath.Join(dir, "subdir"), os.FileMode(0755)) + writeFakeMetadata(t, dir, "bogus-track", "bogus-exercise") + + file1 := filepath.Join(dir, "file-1.txt") + err = os.WriteFile(file1, []byte("This is file 1."), os.FileMode(0755)) + assert.NoError(t, err) + + v := viper.New() + v.Set("token", "abc123") + v.Set("workspace", tmpDir) + v.Set("apibaseurl", ts.URL) + + cfg := config.Config{ + Persister: config.InMemoryPersister{}, + Dir: tmpDir, + UserViperConfig: v, + } + + err = runSubmit(cfg, pflag.NewFlagSet("fake", pflag.PanicOnError), []string{file1}) + if assert.Error(t, err) { + assert.Regexp(t, "directory does not match exercise slug", err.Error()) + } +} + +func writeFakeMetadata(t *testing.T, dir, trackID, exerciseSlug string) { + metadata := &workspace.ExerciseMetadata{ + ID: "bogus-solution-uuid", + Track: trackID, + ExerciseSlug: exerciseSlug, + URL: "http://example.com/bogus-url", + IsRequester: true, + } + err := metadata.Write(dir) + assert.NoError(t, err) +} diff --git a/cmd/test.go b/cmd/test.go new file mode 100644 index 000000000..8f5b54e35 --- /dev/null +++ b/cmd/test.go @@ -0,0 +1,84 @@ +package cmd + +import ( + "fmt" + "log" + "os" + "os/exec" + "strings" + + "github.com/exercism/cli/workspace" + "github.com/spf13/cobra" +) + +var testCmd = &cobra.Command{ + Use: "test", + Aliases: []string{"t"}, + Short: "Run the exercise's tests.", + Long: `Run the exercise's tests. + + Run this command in an exercise's root directory.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runTest(args) + }, +} + +func runTest(args []string) error { + track, err := getTrack() + if err != nil { + return err + } + + testConf, ok := workspace.TestConfigurations[track] + + if !ok { + return fmt.Errorf("the \"%s\" track does not yet support running tests using the Exercism CLI. Please see HELP.md for testing instructions", track) + } + + command, err := testConf.GetTestCommand() + if err != nil { + return err + } + cmdParts := strings.Split(command, " ") + + // pass args/flags to this command down to the test handler + if len(args) > 0 { + cmdParts = append(cmdParts, args...) + } + + fmt.Printf("Running tests via `%s`\n\n", strings.Join(cmdParts, " ")) + exerciseTestCmd := exec.Command(cmdParts[0], cmdParts[1:]...) + + // pipe output directly out, preserving any color + exerciseTestCmd.Stdout = os.Stdout + exerciseTestCmd.Stderr = os.Stderr + + err = exerciseTestCmd.Run() + if err != nil { + // unclear what other errors would pop up here, but it pays to be defensive + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode := exitErr.ExitCode() + // if subcommand returned a non-zero exit code, exit with the same + os.Exit(exitCode) + } else { + log.Fatalf("Failed to get error from failed subcommand: %v", err) + } + } + return nil +} + +func getTrack() (string, error) { + metadata, err := workspace.NewExerciseMetadata(".") + if err != nil { + return "", err + } + if metadata.Track == "" { + return "", fmt.Errorf("no track found in exercise metadata") + } + + return metadata.Track, nil +} + +func init() { + RootCmd.AddCommand(testCmd) +} diff --git a/cmd/tracks.go b/cmd/tracks.go deleted file mode 100644 index 48831b76b..000000000 --- a/cmd/tracks.go +++ /dev/null @@ -1,38 +0,0 @@ -package cmd - -import ( - "fmt" - "log" - - "github.com/codegangsta/cli" - "github.com/exercism/cli/api" - "github.com/exercism/cli/config" - "github.com/exercism/cli/user" -) - -// Tracks lists available tracks. -func Tracks(ctx *cli.Context) { - c, err := config.New(ctx.GlobalString("config")) - if err != nil { - log.Fatal(err) - } - client := api.NewClient(c) - - tracks, err := client.Tracks() - if err != nil { - log.Fatal(err) - } - - curr := user.NewCurriculum(tracks) - fmt.Println("\nActive language tracks:") - curr.Report(user.TrackActive) - fmt.Println("\nInactive language tracks:") - curr.Report(user.TrackInactive) - - // TODO: implement `list` command to list problems in a track - msg := ` -Related commands: - exercism fetch (see 'exercism help fetch') - ` - fmt.Println(msg) -} diff --git a/cmd/troubleshoot.go b/cmd/troubleshoot.go new file mode 100644 index 000000000..f83d5cc96 --- /dev/null +++ b/cmd/troubleshoot.go @@ -0,0 +1,268 @@ +package cmd + +import ( + "bytes" + "fmt" + "html/template" + "runtime" + "sync" + "time" + + "github.com/exercism/cli/cli" + "github.com/exercism/cli/config" + "github.com/exercism/cli/debug" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// fullAPIKey flag for troubleshoot command. +var fullAPIKey bool + +// troubleshootCmd does a diagnostic self-check. +var troubleshootCmd = &cobra.Command{ + Use: "troubleshoot", + Aliases: []string{"debug"}, + Short: "Troubleshoot does a diagnostic self-check.", + Long: `Provides output to help with troubleshooting. + +If you're running into trouble, copy and paste the output from the troubleshoot +command into a topic on the Exercism forum so we can help figure out what's going on. +`, + RunE: func(cmd *cobra.Command, args []string) error { + cli.TimeoutInSeconds = cli.TimeoutInSeconds * 2 + c := cli.New(Version) + + cfg := config.NewConfig() + + v := viper.New() + v.AddConfigPath(cfg.Dir) + v.SetConfigName("user") + v.SetConfigType("json") + // Ignore error. If the file doesn't exist, that is fine. + _ = v.ReadInConfig() + + cfg.UserViperConfig = v + + status := newStatus(c, cfg) + status.Censor = !fullAPIKey + s, err := status.check() + if err != nil { + return err + } + + fmt.Printf("%s", s) + return nil + }, +} + +// Status represents the results of a CLI self test. +type Status struct { + Censor bool + Version versionStatus + System systemStatus + Configuration configurationStatus + APIReachability apiReachabilityStatus + cfg config.Config + cli *cli.CLI +} + +type versionStatus struct { + Current string + Latest string + Status string + Error error + UpToDate bool +} + +type systemStatus struct { + OS string + Architecture string + Build string +} + +type configurationStatus struct { + Home string + Workspace string + Dir string + Token string + TokenURL string +} + +type apiReachabilityStatus struct { + Services []*apiPing +} + +type apiPing struct { + Service string + URL string + Status string + Latency time.Duration +} + +// newStatus prepares a value to perform a diagnostic self-check. +func newStatus(cli *cli.CLI, cfg config.Config) Status { + status := Status{ + cfg: cfg, + cli: cli, + } + return status +} + +// check runs the CLI's diagnostic self-check. +func (status *Status) check() (string, error) { + status.Version = newVersionStatus(status.cli) + status.System = newSystemStatus() + status.Configuration = newConfigurationStatus(status) + status.APIReachability = newAPIReachabilityStatus(status.cfg) + + return status.compile() +} +func (status *Status) compile() (string, error) { + t, err := template.New("self-test").Parse(tmplSelfTest) + if err != nil { + return "", err + } + + var bb bytes.Buffer + if err = t.Execute(&bb, status); err != nil { + return "", err + } + return bb.String(), nil +} + +func newAPIReachabilityStatus(cfg config.Config) apiReachabilityStatus { + baseURL := cfg.UserViperConfig.GetString("apibaseurl") + if baseURL == "" { + baseURL = cfg.DefaultBaseURL + } + ar := apiReachabilityStatus{ + Services: []*apiPing{ + {Service: "GitHub", URL: "https://api.github.com"}, + {Service: "Exercism", URL: fmt.Sprintf("%s/ping", baseURL)}, + }, + } + var wg sync.WaitGroup + wg.Add(len(ar.Services)) + for _, service := range ar.Services { + go service.Call(&wg) + } + wg.Wait() + return ar +} + +func newVersionStatus(c *cli.CLI) versionStatus { + vs := versionStatus{ + Current: c.Version, + } + ok, err := c.IsUpToDate() + if err == nil { + vs.Latest = c.LatestRelease.Version() + } else { + vs.Error = fmt.Errorf("Error: %s", err) + } + vs.UpToDate = ok + return vs +} + +func newSystemStatus() systemStatus { + ss := systemStatus{ + OS: runtime.GOOS, + Architecture: runtime.GOARCH, + } + if cli.BuildOS != "" && cli.BuildARCH != "" { + ss.Build = fmt.Sprintf("%s/%s", cli.BuildOS, cli.BuildARCH) + } + if cli.BuildARM != "" { + ss.Build = fmt.Sprintf("%s ARMv%s", ss.Build, cli.BuildARM) + } + return ss +} + +func newConfigurationStatus(status *Status) configurationStatus { + v := status.cfg.UserViperConfig + + workspace := v.GetString("workspace") + if workspace == "" { + workspace = fmt.Sprintf("%s (default)", config.DefaultWorkspaceDir(status.cfg)) + } + + cs := configurationStatus{ + Home: status.cfg.Home, + Workspace: workspace, + Dir: status.cfg.Dir, + Token: v.GetString("token"), + TokenURL: config.TokenURL(v.GetString("apibaseurl")), + } + if status.Censor && cs.Token != "" { + cs.Token = debug.Redact(cs.Token) + } + return cs +} + +func (ping *apiPing) Call(wg *sync.WaitGroup) { + defer wg.Done() + + now := time.Now() + res, err := cli.HTTPClient.Get(ping.URL) + delta := time.Since(now) + ping.Latency = delta + if err != nil { + ping.Status = err.Error() + return + } + res.Body.Close() + ping.Status = "connected" +} + +const tmplSelfTest = ` +Troubleshooting Information +=========================== + +Version +---------------- +Current: {{ .Version.Current }} +Latest: {{ with .Version.Latest }}{{ . }}{{ else }}{{ end }} +{{ with .Version.Error }} +{{ . }} +{{ end -}} +{{ if not .Version.UpToDate }} +Call 'exercism upgrade' to get the latest version. +See the release notes at https://github.com/exercism/cli/releases/tag/v{{ .Version.Latest }} for details. +{{ end }} + +Operating System +---------------- +OS: {{ .System.OS }} +Architecture: {{ .System.Architecture }} +{{ with .System.Build }} +Build: {{ . }} +{{ end }} + +Configuration +---------------- +Home: {{ .Configuration.Home }} +Workspace: {{ .Configuration.Workspace }} +Config: {{ .Configuration.Dir }} +API key: {{ with .Configuration.Token }}{{ . }}{{ else }} +Find your API key at {{ .Configuration.TokenURL }}{{ end }} + +API Reachability +---------------- +{{ range .APIReachability.Services }} +{{ .Service }}: + * {{ .URL }} + * [{{ .Status }}] + * {{ .Latency }} +{{ end }} + +If you are having trouble, please create a new topic in the Exercism forum +at https://forum.exercism.org/c/support/cli/10 and include +this information. +{{ if not .Censor }} +Don't share your API key. Keep that private. +{{ end }}` + +func init() { + RootCmd.AddCommand(troubleshootCmd) + troubleshootCmd.Flags().BoolVarP(&fullAPIKey, "full-api-key", "f", false, "display the user's full API key, censored by default") +} diff --git a/cmd/unsubmit.go b/cmd/unsubmit.go deleted file mode 100644 index 35744ffa0..000000000 --- a/cmd/unsubmit.go +++ /dev/null @@ -1,31 +0,0 @@ -package cmd - -import ( - "fmt" - "log" - - "github.com/codegangsta/cli" - "github.com/exercism/cli/api" - "github.com/exercism/cli/config" -) - -// Unsubmit deletes an iteration from the api. -// If no iteration is specified, the most recent iteration -// is deleted. -func Unsubmit(ctx *cli.Context) { - c, err := config.New(ctx.GlobalString("config")) - if err != nil { - log.Fatal(err) - } - - if !c.IsAuthenticated() { - log.Fatal(msgPleaseAuthenticate) - } - - client := api.NewClient(c) - if err := client.Unsubmit(); err != nil { - log.Fatal(err) - } - - fmt.Println("Your most recent submission was successfully deleted.") -} diff --git a/cmd/upgrade.go b/cmd/upgrade.go new file mode 100644 index 000000000..e4ec04508 --- /dev/null +++ b/cmd/upgrade.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "fmt" + + "github.com/exercism/cli/cli" + "github.com/spf13/cobra" +) + +// upgradeCmd downloads and installs the most recent version of the CLI. +var upgradeCmd = &cobra.Command{ + Use: "upgrade", + Aliases: []string{"u"}, + Short: "Upgrade to the latest version of the CLI.", + Long: `Upgrade to the latest version of the CLI. + +This finds and downloads the latest release, if you don't +already have it. + +On Windows the old CLI will be left on disk, marked as hidden. +The next time you upgrade, the hidden file will be overwritten. +You can always delete this file. + `, + RunE: func(cmd *cobra.Command, args []string) error { + c := cli.New(Version) + err := updateCLI(c) + if err != nil { + return fmt.Errorf(` + +We were not able to upgrade the cli because we encountered an error: +%s + +Please check the FAQ for solutions to common upgrading issues. + +https://exercism.org/faqs`, err) + } + return nil + }, +} + +// updateCLI updates CLI to the latest available version, if it is out of date. +func updateCLI(c cli.Updater) error { + ok, err := c.IsUpToDate() + if err != nil { + return err + } + + if ok { + fmt.Fprintln(Out, "Your CLI version is up to date.") + return nil + } + + return c.Upgrade() +} + +func init() { + RootCmd.AddCommand(upgradeCmd) +} diff --git a/cmd/upgrade_test.go b/cmd/upgrade_test.go new file mode 100644 index 000000000..b2489ea83 --- /dev/null +++ b/cmd/upgrade_test.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +type fakeCLI struct { + UpToDate bool + UpgradeCalled bool +} + +func (fc *fakeCLI) IsUpToDate() (bool, error) { + return fc.UpToDate, nil +} + +func (fc *fakeCLI) Upgrade() error { + fc.UpgradeCalled = true + return nil +} + +func TestUpgrade(t *testing.T) { + co := newCapturedOutput() + co.override() + defer co.reset() + + testCases := []struct { + desc string + upToDate bool + expected bool + }{ + { + desc: "upgrade should be called for an outdated CLI", + upToDate: false, + expected: true, + }, + { + desc: "upgrade should not be called for an already updated CLI", + upToDate: true, + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + fc := &fakeCLI{UpToDate: tc.upToDate} + + err := updateCLI(fc) + assert.NoError(t, err) + assert.Equal(t, tc.expected, fc.UpgradeCalled) + }) + } +} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 000000000..7eec21d45 --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,72 @@ +package cmd + +import ( + "fmt" + + "github.com/exercism/cli/cli" + "github.com/spf13/cobra" +) + +// Version is the version of the current build. +// It follows semantic versioning. +const Version = "3.5.8" + +// checkLatest flag for version command. +var checkLatest bool + +// versionCmd outputs the version of the CLI. +var versionCmd = &cobra.Command{ + Use: "version", + Aliases: []string{"v"}, + Short: "Version outputs the version of CLI.", + Long: `Version outputs the version of the exercism binary that is in use. + +To check for the latest available version, call the command with the +--latest flag. + `, + + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Println(currentVersion()) + + if checkLatest { + c := cli.New(Version) + l, err := checkForUpdate(c) + if err != nil { + return err + } + + fmt.Println(l) + } + + return nil + }, +} + +// currentVersion returns a formatted version string for the Exercism CLI. +func currentVersion() string { + return fmt.Sprintf("exercism version %s", Version) +} + +// checkForUpdate verifies if the CLI is running the latest version. +// If the client is out of date, the function returns upgrade instructions. +func checkForUpdate(c *cli.CLI) (string, error) { + + ok, err := c.IsUpToDate() + if err != nil { + return "", err + } + + if ok { + return "Your CLI version is up to date.", nil + } + + // Anything but ok is out of date. + msg := fmt.Sprintf("A new CLI version is available. Run `exercism upgrade` to update to %s", c.LatestRelease.Version()) + return msg, nil + +} + +func init() { + RootCmd.AddCommand(versionCmd) + versionCmd.Flags().BoolVarP(&checkLatest, "latest", "l", false, "check latest available version") +} diff --git a/cmd/version_test.go b/cmd/version_test.go new file mode 100644 index 000000000..6c8916282 --- /dev/null +++ b/cmd/version_test.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/exercism/cli/cli" + "github.com/stretchr/testify/assert" +) + +func TestCurrentVersion(t *testing.T) { + expected := fmt.Sprintf("exercism version %s", Version) + + actual := currentVersion() + assert.Equal(t, expected, actual) +} + +func TestVersionUpdateCheck(t *testing.T) { + fakeEndpoint := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, `{"tag_name": "v2.0.0"}`) + }) + ts := httptest.NewServer(fakeEndpoint) + defer ts.Close() + cli.ReleaseURL = ts.URL + + testCases := []struct { + desc string + version string + expected string + }{ + { + desc: "It returns new version available for versions older than latest.", + version: "1.0.0", + expected: "A new CLI version is available. Run `exercism upgrade` to update to 2.0.0", + }, + { + desc: "It returns up to date for versions matching latest.", + version: "2.0.0", + expected: "Your CLI version is up to date.", + }, + { + desc: "It returns up to date for versions newer than latest.", + version: "2.0.1", + expected: "Your CLI version is up to date.", + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + c := &cli.CLI{ + Version: tc.version, + } + + actual, err := checkForUpdate(c) + + assert.NoError(t, err) + assert.NotEmpty(t, actual) + assert.Equal(t, tc.expected, actual) + }) + } +} diff --git a/cmd/workspace.go b/cmd/workspace.go new file mode 100644 index 000000000..673ab367e --- /dev/null +++ b/cmd/workspace.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "fmt" + + "github.com/exercism/cli/config" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// workspaceCmd outputs the path to the person's workspace directory. +var workspaceCmd = &cobra.Command{ + Use: "workspace", + Aliases: []string{"w"}, + Short: "Print out the path to your Exercism workspace.", + Long: `Print out the path to your Exercism workspace. + +This command can be used for scripting, or it can be combined with shell +commands to take you to your workspace. + +For example you can run: + + cd $(exercism workspace) + +On Windows, this will work only with Powershell, however you would +need to be on the same drive as your workspace directory. Otherwise +nothing will happen. + `, + RunE: func(cmd *cobra.Command, args []string) error { + cfg := config.NewConfig() + + v := viper.New() + v.AddConfigPath(cfg.Dir) + v.SetConfigName("user") + v.SetConfigType("json") + // Ignore error. If the file doesn't exist, that is fine. + _ = v.ReadInConfig() + + fmt.Fprintf(Out, "%s\n", v.GetString("workspace")) + return nil + }, +} + +func init() { + RootCmd.AddCommand(workspaceCmd) +} diff --git a/config/config.go b/config/config.go index 4fa53bc30..f16b2b6ce 100644 --- a/config/config.go +++ b/config/config.go @@ -1,197 +1,134 @@ package config import ( - "encoding/json" - "errors" + "fmt" "os" "path/filepath" + "regexp" "runtime" "strings" -) - -const ( - fileEnvKey = "EXERCISM_CONFIG_FILE" - // File is the default name of the JSON file where the config written. - // The user can pass an alternate filename when using the CLI. - File = ".exercism.json" - // LegacyFile is the name of the original config file. - // It is a misnomer, since the config was in json, not go. - LegacyFile = ".exercism.go" - // hostAPI is the endpoint to submit solutions to, and to get personalized data - hostAPI = "http://exercism.io" - // hostXAPI is the endpoint to fetch problems from - hostXAPI = "http://x.exercism.io" - - // DirExercises is the default name of the directory for active users. - // Make this non-exported when handlers.Login is deleted. - DirExercises = "exercism" + "github.com/spf13/viper" ) var ( - errHomeNotFound = errors.New("unable to locate home directory") + defaultBaseURL = "https://api.exercism.org/v1" + + // DefaultDirName is the default name used for config and workspace directories. + DefaultDirName string ) -// Config represents the settings for particular user. -// This defines both the auth for talking to the API, as well as -// where to put problems that get downloaded. +// Config lets us inject configuration options into commands. type Config struct { - APIKey string `json:"apiKey"` - Dir string `json:"dir"` - API string `json:"api"` - XAPI string `json:"xapi"` - File string `json:"-"` // full path to config file - home string // cache user's home directory + OS string + Home string + Dir string + DefaultBaseURL string + DefaultDirName string + UserViperConfig *viper.Viper + Persister Persister } -// Home returns the user's canonical home directory. -// See: http://stackoverflow.com/questions/7922270/obtain-users-home-directory -// we can't cross compile using cgo and use user.Current() -func Home() (string, error) { - var dir string - if runtime.GOOS == "windows" { - dir = os.Getenv("USERPROFILE") - if dir == "" { - dir = os.Getenv("HOMEDRIVE") + os.Getenv("HOMEPATH") - } - } else { - dir = os.Getenv("HOME") - } +// NewConfig provides a configuration with default values. +func NewConfig() Config { + home := userHome() + dir := Dir() - if dir == "" { - return dir, errHomeNotFound + return Config{ + OS: runtime.GOOS, + Dir: Dir(), + Home: home, + DefaultBaseURL: defaultBaseURL, + DefaultDirName: DefaultDirName, + Persister: FilePersister{Dir: dir}, } - return dir, nil } -// New returns a configuration struct with content from the exercism.json file -func New(path string) (*Config, error) { - c := &Config{} - err := c.load(path, os.Getenv(fileEnvKey)) - return c, err +// SetDefaultDirName configures the default directory name based on the name of the binary. +func SetDefaultDirName(binaryName string) { + DefaultDirName = strings.Replace(filepath.Base(binaryName), ".exe", "", 1) } -// Update sets new values where given. -func (c *Config) Update(key, host, dir, xapi string) { - key = strings.TrimSpace(key) - if key != "" { - c.APIKey = key - } - - host = strings.TrimSpace(host) - if host != "" { - c.API = host - } - - dir = strings.TrimSpace(dir) - if dir != "" { - c.Dir = dir - } - - xapi = strings.TrimSpace(xapi) - if xapi != "" { - c.XAPI = xapi - } -} - -// Write saves the config as JSON. -func (c *Config) Write() error { - // truncates existing file if it exists - f, err := os.Create(c.File) - if err != nil { - return err - } - defer f.Close() - - e := json.NewEncoder(f) - return e.Encode(c) -} - -func (c *Config) load(argPath, envPath string) error { - path, err := c.resolvePath(argPath, envPath) - if err != nil { - return err - } - c.File = path - - if err := c.read(); err != nil { - return err +// Dir is the configured config home directory. +// All the cli-related config files live in this directory. +func Dir() string { + var dir string + if runtime.GOOS == "windows" { + dir = os.Getenv("APPDATA") + if dir != "" { + return filepath.Join(dir, DefaultDirName) + } + } else { + dir := os.Getenv("EXERCISM_CONFIG_HOME") + if dir != "" { + return dir + } + dir = os.Getenv("XDG_CONFIG_HOME") + if dir == "" { + dir = filepath.Join(os.Getenv("HOME"), ".config") + } + if dir != "" { + return filepath.Join(dir, DefaultDirName) + } } - - // in case people manually update the config file - // with weird formatting - c.APIKey = strings.TrimSpace(c.APIKey) - c.Dir = strings.TrimSpace(c.Dir) - c.API = strings.TrimSpace(c.API) - c.XAPI = strings.TrimSpace(c.XAPI) - - return c.setDefaults() + // If all else fails, use the current directory. + dir, _ = os.Getwd() + return dir } -func (c *Config) read() error { - if _, err := os.Stat(c.File); err != nil { - if os.IsNotExist(err) { - return nil +func userHome() string { + var dir string + if runtime.GOOS == "windows" { + dir = os.Getenv("USERPROFILE") + if dir != "" { + return dir + } + dir = filepath.Join(os.Getenv("HOMEDRIVE"), os.Getenv("HOMEPATH")) + if dir != "" { + return dir + } + } else { + dir = os.Getenv("HOME") + if dir != "" { + return dir } - return err - } - f, err := os.Open(c.File) - if err != nil { - return err } - defer f.Close() - - d := json.NewDecoder(f) - return d.Decode(&c) + // If all else fails, use the current directory. + dir, _ = os.Getwd() + return dir } -// IsAuthenticated returns true if the config contains an API key. -// This does not check whether or not that key is valid. -func (c *Config) IsAuthenticated() bool { - return c.APIKey != "" -} - -// homeDir caches the lookup of the user's home directory. -func (c *Config) homeDir() (string, error) { - if c.home != "" { - return c.home, nil +// DefaultWorkspaceDir provides a sensible default for the Exercism workspace. +// The default is different depending on the platform, in order to best match +// the conventions for that platform. +// It places the directory in the user's home path. +func DefaultWorkspaceDir(cfg Config) string { + dir := cfg.DefaultDirName + if cfg.OS != "linux" { + dir = strings.Title(dir) } - return Home() + return filepath.Join(cfg.Home, dir) } -func (c *Config) resolvePath(argPath, envPath string) (string, error) { - path := argPath - if path == "" { - path = envPath - } - if path == "" { - path = filepath.Join("~", File) - } - h, err := c.homeDir() - if err != nil { - return "", err - } - return strings.Replace(path, "~", h, 1), nil +// Save persists a viper config of the base name. +func (c Config) Save(basename string) error { + return c.Persister.Save(c.UserViperConfig, basename) } -func (c *Config) setDefaults() error { - if c.API == "" { - c.API = hostAPI - } - - if c.XAPI == "" { - c.XAPI = hostXAPI +// InferSiteURL guesses what the website URL is. +// The basis for the guess is which API we're submitting to. +func InferSiteURL(apiURL string) string { + if apiURL == "" { + apiURL = defaultBaseURL } - - h, err := c.homeDir() - if err != nil { - return err + if apiURL == "https://api.exercism.org/v1" { + return "https://exercism.org" } + re := regexp.MustCompile("^(https?://[^/]*).*") + return re.ReplaceAllString(apiURL, "$1") +} - if c.Dir == "" { - c.Dir = filepath.Join(h, DirExercises) - } - c.Dir = strings.Replace(c.Dir, "~", h, 1) - - return nil +// TokenURL provides a link to where the user can find their API token. +func TokenURL(apiURL string) string { + return fmt.Sprintf("%s%s", InferSiteURL(apiURL), "/settings/api_cli") } diff --git a/config/config_notwin_test.go b/config/config_notwin_test.go new file mode 100644 index 000000000..33912cc9b --- /dev/null +++ b/config/config_notwin_test.go @@ -0,0 +1,30 @@ +//go:build !windows + +package config + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDefaultWorkspaceDir(t *testing.T) { + testCases := []struct { + cfg Config + expected string + }{ + { + cfg: Config{OS: "darwin", Home: "/User/charlie", DefaultDirName: "apple"}, + expected: "/User/charlie/Apple", + }, + { + cfg: Config{OS: "linux", Home: "/home/bob", DefaultDirName: "banana"}, + expected: "/home/bob/banana", + }, + } + + for _, tc := range testCases { + assert.Equal(t, tc.expected, DefaultWorkspaceDir(tc.cfg), fmt.Sprintf("Operating System: %s", tc.cfg.OS)) + } +} diff --git a/config/config_test.go b/config/config_test.go index aed6694a7..40ecb0c36 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1,182 +1,24 @@ package config import ( - "fmt" - "io/ioutil" - "os" - "path/filepath" - "runtime" "testing" "github.com/stretchr/testify/assert" ) -func TestLoad(t *testing.T) { - tmpDir, err := ioutil.TempDir("", "") - if err != nil { - t.Fatal(err) - } - configPath := filepath.Join(tmpDir, "config.json") - if err := os.Link(fixturePath(t, "config.json"), configPath); err != nil { - t.Fatal(err) - } - dirtyPath := filepath.Join(tmpDir, "dirty.json") - if err := os.Link(fixturePath(t, "dirty.json"), dirtyPath); err != nil { - t.Fatal(err) - } - +func TestInferSiteURL(t *testing.T) { testCases := []struct { - desc string - in string // the name of the file passed as a command line argument - env string // the name of the file stored in the environment variable - out string // the name of the file that the config will be written to - dir, key, api, xapi string // the actual config values + api, url string }{ - { - desc: "defaults", - in: "", - env: "", - out: filepath.Join(tmpDir, File), - dir: filepath.Join(tmpDir, DirExercises), - key: "", - api: hostAPI, - xapi: hostXAPI, - }, - { - desc: "no such file", - in: filepath.Join(tmpDir, "no-such.json"), - env: "", - out: filepath.Join(tmpDir, "no-such.json"), - dir: filepath.Join(tmpDir, DirExercises), - key: "", - api: hostAPI, - xapi: hostXAPI, - }, - { - desc: "file exists", - in: configPath, - env: "", - out: configPath, - dir: "/a/b/c", - key: "abc123", - api: "http://api.example.com", - xapi: "http://x.example.com", - }, - { - desc: "unexpanded path", - in: "~/config.json", - env: "", - out: configPath, - dir: "/a/b/c", - key: "abc123", - api: "http://api.example.com", - xapi: "http://x.example.com", - }, - { - desc: "file in env", - in: "", - env: configPath, - out: configPath, - dir: "/a/b/c", - key: "abc123", - api: "http://api.example.com", - xapi: "http://x.example.com", - }, - { - desc: "unexpanded path in env", - in: "", - env: "~/env.json", - out: filepath.Join(tmpDir, "env.json"), - dir: filepath.Join(tmpDir, DirExercises), - key: "", - api: hostAPI, - xapi: hostXAPI, - }, - { - desc: "command line argument overrides env", - in: "~/arg.json", - env: "~/env.json", - out: filepath.Join(tmpDir, "arg.json"), - dir: filepath.Join(tmpDir, DirExercises), - key: "", - api: hostAPI, - xapi: hostXAPI, - }, - { - desc: "sanitizes whitespace", - in: "~/dirty.json", - env: "", - out: filepath.Join(tmpDir, "dirty.json"), - dir: "/a/b/c", - key: "abc123", - api: "http://api.example.com", - xapi: "http://x.example.com", - }, + {"https://api.exercism.org/v1", "https://exercism.org"}, + {"https://v2.exercism.org/api/v1", "https://v2.exercism.org"}, + {"https://mentors-beta.exercism.org/api/v1", "https://mentors-beta.exercism.org"}, + {"http://localhost:3000/api/v1", "http://localhost:3000"}, + {"", "https://exercism.org"}, // use the default + {"http://whatever", "http://whatever"}, // you're on your own, pal } for _, tc := range testCases { - c := &Config{home: tmpDir} - - if err := c.load(tc.in, tc.env); err != nil { - t.Fatal(err) - } - assert.Equal(t, tc.out, c.File, tc.desc) - assert.Equal(t, tc.dir, c.Dir, tc.desc) - assert.Equal(t, tc.key, c.APIKey, tc.desc) - assert.Equal(t, tc.api, c.API, tc.desc) - assert.Equal(t, tc.xapi, c.XAPI, tc.desc) + assert.Equal(t, InferSiteURL(tc.api), tc.url) } } - -func TestReadingWritingConfig(t *testing.T) { - tmpDir, err := ioutil.TempDir("", "") - filename := fmt.Sprintf("%s/%s", tmpDir, File) - assert.NoError(t, err) - - c1 := &Config{ - APIKey: "MyKey", - Dir: "/exercism/directory", - API: "localhost", - XAPI: "localhost", - File: filename, - } - - c1.Write() - - c2, err := New(filename) - assert.NoError(t, err) - - assert.Equal(t, c1.APIKey, c2.APIKey) - assert.Equal(t, c1.Dir, c2.Dir) - assert.Equal(t, c1.API, c2.API) - assert.Equal(t, c1.XAPI, c2.XAPI) -} - -func TestUpdateConfig(t *testing.T) { - c := &Config{ - APIKey: "MyKey", - API: "localhost", - Dir: "/exercism/directory", - XAPI: "localhost", - } - - // Test the blank values don't overwrite existing values - c.Update("", "", "", "") - assert.Equal(t, "MyKey", c.APIKey) - assert.Equal(t, "localhost", c.API) - assert.Equal(t, "/exercism/directory", c.Dir) - assert.Equal(t, "localhost", c.XAPI) - - // Test that each value can be overwritten - c.Update("NewKey", "http://example.com", "/tmp/exercism", "http://x.example.org") - assert.Equal(t, "NewKey", c.APIKey) - assert.Equal(t, "http://example.com", c.API) - assert.Equal(t, "/tmp/exercism", c.Dir) - assert.Equal(t, "http://x.example.org", c.XAPI) -} - -func fixturePath(t *testing.T, filename string) string { - _, caller, _, ok := runtime.Caller(0) - assert.True(t, ok) - return filepath.Join(filepath.Dir(caller), "..", "fixtures", filename) -} diff --git a/config/config_windows_test.go b/config/config_windows_test.go new file mode 100644 index 000000000..5757b783f --- /dev/null +++ b/config/config_windows_test.go @@ -0,0 +1,14 @@ +//go:build windows + +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDefaultWindowsWorkspaceDir(t *testing.T) { + cfg := Config{OS: "windows", Home: "C:\\Something", DefaultDirName: "basename"} + assert.Equal(t, "C:\\Something\\Basename", DefaultWorkspaceDir(cfg)) +} diff --git a/config/persister.go b/config/persister.go new file mode 100644 index 000000000..1b29dc720 --- /dev/null +++ b/config/persister.go @@ -0,0 +1,48 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/viper" +) + +// Persister saves viper configs. +type Persister interface { + Save(*viper.Viper, string) error +} + +// FilePersister saves viper configs to the file system. +type FilePersister struct { + Dir string +} + +// Save writes the viper config to the target location on the filesystem. +func (p FilePersister) Save(v *viper.Viper, basename string) error { + v.SetConfigType("json") + v.AddConfigPath(p.Dir) + v.SetConfigName(basename) + + if _, err := os.Stat(p.Dir); os.IsNotExist(err) { + if err := os.MkdirAll(p.Dir, os.FileMode(0700)); err != nil { + return err + } + } + + // WriteConfig is broken. + // Someone proposed a fix in https://github.com/spf13/viper/pull/503, + // but the fix doesn't work yet. + // When it's fixed and merged we can get rid of `path` + // and use viperConfig.WriteConfig() directly. + path := filepath.Join(p.Dir, fmt.Sprintf("%s.json", basename)) + return v.WriteConfigAs(path) +} + +// InMemoryPersister is a noop persister for use in unit tests. +type InMemoryPersister struct{} + +// Save does nothing. +func (p InMemoryPersister) Save(*viper.Viper, string) error { + return nil +} diff --git a/config/resolve.go b/config/resolve.go new file mode 100644 index 000000000..93c1645bc --- /dev/null +++ b/config/resolve.go @@ -0,0 +1,30 @@ +package config + +import ( + "os" + "path/filepath" + "strings" +) + +// Resolve cleans up filesystem paths. +func Resolve(path, home string) string { + if path == "" { + return "" + } + if strings.HasPrefix(path, "~/") { + path = strings.Replace(path, "~/", "", 1) + return filepath.Join(home, path) + } + if filepath.IsAbs(path) { + return filepath.Clean(path) + } + // if using "/dir" on Windows + if strings.HasPrefix(path, "/") { + return filepath.Join(home, filepath.Clean(path)) + } + cwd, err := os.Getwd() + if err != nil { + return path + } + return filepath.Join(cwd, path) +} diff --git a/config/resolve_notwin_test.go b/config/resolve_notwin_test.go new file mode 100644 index 000000000..fb70d5f69 --- /dev/null +++ b/config/resolve_notwin_test.go @@ -0,0 +1,35 @@ +//go:build !windows + +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestResolve(t *testing.T) { + cwd, err := os.Getwd() + assert.NoError(t, err) + + testCases := []struct { + in, out string + }{ + {"", ""}, // don't make wild guesses + {"/home/alice///foobar", "/home/alice/foobar"}, + {"~/foobar", "/home/alice/foobar"}, + {"/foobar/~/noexpand", "/foobar/~/noexpand"}, + {"/no/modification", "/no/modification"}, + {"relative", filepath.Join(cwd, "relative")}, + {"relative///path", filepath.Join(cwd, "relative", "path")}, + } + + for _, tc := range testCases { + testName := "'" + tc.in + "' should be normalized as '" + tc.out + "'" + t.Run(testName, func(t *testing.T) { + assert.Equal(t, tc.out, Resolve(tc.in, "/home/alice"), testName) + }) + } +} diff --git a/config/resolve_windows.go b/config/resolve_windows.go new file mode 100644 index 000000000..20035a312 --- /dev/null +++ b/config/resolve_windows.go @@ -0,0 +1,32 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestResolve(t *testing.T) { + cwd, err := os.Getwd() + assert.NoError(t, err) + + testCases := []struct { + in, out string + }{ + {"", ""}, // don't make wild guesses + {"C:\\alice\\\\foobar", "C:\\alice\\\\foobar"}, + {"\\foobar\\~\\noexpand", "\\foobar\\~\\noexpand"}, + {"\\no\\modification", "\\no\\modification"}, + {"relative", filepath.Join(cwd, "relative")}, + {"relative\\path", filepath.Join(cwd, "relative", "path")}, + } + + for _, tc := range testCases { + t.Run(tc.in, func(t *testing.T) { + desc := "'" + tc.in + "' should be normalized as '" + tc.out + "'" + assert.Equal(t, tc.out, Resolve(tc.in, ""), desc) + }) + } +} diff --git a/debug/debug.go b/debug/debug.go new file mode 100644 index 000000000..a96d1f3bd --- /dev/null +++ b/debug/debug.go @@ -0,0 +1,96 @@ +package debug + +import ( + "bytes" + "fmt" + "io" + "log" + "net/http" + "net/http/httputil" + "os" + "strings" +) + +var ( + // Verbose determines if debugging output is displayed to the user + Verbose bool + output io.Writer = os.Stderr + // UnmaskAPIKey determines if the API key should de displayed during a dump + UnmaskAPIKey bool +) + +// Println conditionally outputs a message to Stderr +func Println(args ...interface{}) { + if Verbose { + fmt.Fprintln(output, args...) + } +} + +// Printf conditionally outputs a formatted message to Stderr +func Printf(format string, args ...interface{}) { + if Verbose { + fmt.Fprintf(output, format, args...) + } +} + +// DumpRequest dumps out the provided http.Request +func DumpRequest(req *http.Request) { + if !Verbose { + return + } + + var bodyCopy bytes.Buffer + body := io.TeeReader(req.Body, &bodyCopy) + req.Body = io.NopCloser(body) + + authHeader := req.Header.Get("Authorization") + + if authParts := strings.Split(authHeader, " "); len(authParts) > 1 && !UnmaskAPIKey { + if token := authParts[1]; token != "" { + req.Header.Set("Authorization", "Bearer "+Redact(token)) + } + } + + dump, err := httputil.DumpRequest(req, req.ContentLength > 0) + if err != nil { + log.Fatal(err) + } + + Println("\n========================= BEGIN DumpRequest =========================") + Println(string(dump)) + Println("========================= END DumpRequest =========================") + Println("") + + req.Header.Set("Authorization", authHeader) + req.Body = io.NopCloser(&bodyCopy) +} + +// DumpResponse dumps out the provided http.Response +func DumpResponse(res *http.Response) { + if !Verbose { + return + } + + var bodyCopy bytes.Buffer + body := io.TeeReader(res.Body, &bodyCopy) + res.Body = io.NopCloser(body) + + dump, err := httputil.DumpResponse(res, res.ContentLength > 0) + if err != nil { + log.Fatal(err) + } + + Println("\n========================= BEGIN DumpResponse =========================") + Println(string(dump)) + Println("========================= END DumpResponse =========================") + Println("") + + res.Body = io.NopCloser(body) +} + +// Redact masks the given token by replacing part of the string with * +func Redact(token string) string { + str := token[4 : len(token)-3] + redaction := strings.Repeat("*", len(str)) + return string(token[:4]) + redaction + string(token[len(token)-3:]) +} diff --git a/debug/debug_test.go b/debug/debug_test.go new file mode 100644 index 000000000..f9be5f864 --- /dev/null +++ b/debug/debug_test.go @@ -0,0 +1,108 @@ +package debug + +import ( + "bytes" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestVerboseEnabled(t *testing.T) { + b := &bytes.Buffer{} + output = b + Verbose = true + + Println("World") + if b.String() != "World\n" { + t.Error("expected 'World' got", b.String()) + } +} + +func TestVerboseDisabled(t *testing.T) { + b := &bytes.Buffer{} + output = b + Verbose = false + + Println("World") + if b.String() != "" { + t.Error("expected '' got", b.String()) + } +} + +func TestDumpRequest(t *testing.T) { + testCases := []struct { + desc string + auth string + verbose bool + unmask bool + }{ + { + desc: "Do not attempt to dump request if 'Verbose' is set to false", + auth: "", + verbose: false, + unmask: false, + }, + { + desc: "Dump request without authorization header", + auth: "", //not set + verbose: true, + unmask: false, + }, + { + desc: "Dump request with malformed 'Authorization' header", + auth: "malformed", + verbose: true, + unmask: true, + }, + { + desc: "Dump request with properly formed 'Authorization' header", + auth: "Bearer abc12-345abcde1234-5abc12", + verbose: true, + unmask: false, + }, + } + + b := &bytes.Buffer{} + output = b + for _, tc := range testCases { + Verbose = tc.verbose + UnmaskAPIKey = tc.unmask + r, _ := http.NewRequest("GET", "https://api.example.com/bogus", nil) + if tc.auth != "" { + r.Header.Set("Authorization", tc.auth) + } + + DumpRequest(r) + if tc.verbose { + assert.Regexp(t, "GET /bogus", b.String(), tc.desc) + assert.Equal(t, tc.auth, r.Header.Get("Authorization"), tc.desc) + if tc.unmask { + assert.Regexp(t, "Authorization: "+tc.auth, b.String(), tc.desc) + } + } else { + assert.NotRegexp(t, "GET /bogus", b.String(), tc.desc) + } + } +} + +func TestDumpResponse(t *testing.T) { + b := &bytes.Buffer{} + output = b + Verbose = true + r := &http.Response{ + StatusCode: 200, + ProtoMajor: 1, + ProtoMinor: 1, + } + + DumpResponse(r) + assert.Regexp(t, "HTTP/1.1 200 OK", b.String()) +} + +func TestRedact(t *testing.T) { + fakeToken := "1a11111aaaa111aa1a11111a11111aa1" + expected := "1a11*************************aa1" + + assert.Equal(t, expected, Redact(fakeToken)) +} diff --git a/exercism/doc.go b/exercism/doc.go new file mode 100644 index 000000000..3e00d9015 --- /dev/null +++ b/exercism/doc.go @@ -0,0 +1,7 @@ +/* +Command exercism allows users to interact with the exercism.org platform. + +The primary actions are to fetch problems to be solved, and submit iterations +of these problems. +*/ +package main diff --git a/exercism/main.go b/exercism/main.go index a52999159..0ac55033d 100644 --- a/exercism/main.go +++ b/exercism/main.go @@ -1,147 +1,7 @@ package main -import ( - "fmt" - "log" - "os" - "runtime" - - "github.com/codegangsta/cli" - "github.com/exercism/cli/api" - "github.com/exercism/cli/cmd" -) - -const ( - // Version is the current release of the command-line app. - // We try to follow Semantic Versioning (http://semver.org), - // but with the http://exercism.io app being a prototype, a - // lot of things get out of hand. - Version = "2.0.0" - - descDebug = "Outputs useful debug information." - descConfigure = "Writes config values to a JSON file." - descDemo = "Fetches a demo problem for each language track on exercism.io." - descFetch = "Fetches your current problems on exercism.io, as well as the next unstarted problem in each language." - descRestore = "Restores completed and current problems on from exercism.io, along with your most recent iteration for each." - descSubmit = "Submits a new iteration to a problem on exercism.io." - descUnsubmit = "Deletes the most recently submitted iteration." - descTracks = "List the available language tracks" - descOpen = "Opens the current submission of the specified exercise" - - descLongRestore = "Restore will pull the latest revisions of exercises that have already been submitted. It will *not* overwrite existing files. If you have made changes to a file and have not submitted it, and you're trying to restore the last submitted version, first move that file out of the way, then call restore." - descDownload = "Downloads and saves a specified submission into the local system" - descList = "Lists all available assignments for a given language" -) +import "github.com/exercism/cli/cmd" func main() { - api.UserAgent = fmt.Sprintf("github.com/exercism/cli v%s (%s/%s)", Version, runtime.GOOS, runtime.GOARCH) - - app := cli.NewApp() - app.Name = "exercism" - app.Usage = "A command line tool to interact with http://exercism.io" - app.Version = Version - app.HideVersion = true - app.Flags = []cli.Flag{ - cli.StringFlag{ - Name: "config, c", - Usage: "path to config file", - }, - cli.BoolFlag{ - Name: "verbose, v", - Usage: "turn on verbose logging", - }, - cli.BoolFlag{ - Name: "version", - Usage: "print the version", - }, - } - app.Commands = []cli.Command{ - { - Name: "debug", - Usage: descDebug, - Action: cmd.Debug, - }, - { - Name: "configure", - Usage: descConfigure, - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "dir, d", - Usage: "path to exercises directory", - }, - cli.StringFlag{ - Name: "host, u", - Usage: "exercism api host", - }, - cli.StringFlag{ - Name: "key, k", - Usage: "exercism.io API key (see http://exercism.io/account)", - }, - cli.StringFlag{ - Name: "api, a", - Usage: "exercism xapi host", - }, - }, - Action: cmd.Configure, - }, - { - Name: "demo", - ShortName: "d", - Usage: descDemo, - Action: cmd.Demo, - }, - { - Name: "fetch", - ShortName: "f", - Usage: descFetch, - Action: cmd.Fetch, - }, - { - Name: "restore", - ShortName: "r", - Usage: descRestore, - Description: descLongRestore, - Action: cmd.Restore, - }, - { - Name: "submit", - ShortName: "s", - Usage: descSubmit, - Action: cmd.Submit, - }, - { - Name: "unsubmit", - ShortName: "u", - Usage: descUnsubmit, - Action: cmd.Unsubmit, - }, - { - Name: "tracks", - ShortName: "t", - Usage: descTracks, - Action: cmd.Tracks, - }, - { - Name: "open", - ShortName: "op", - Usage: descOpen, - Action: cmd.Open, - }, - { - Name: "download", - ShortName: "dl", - Usage: descDownload, - Action: cmd.Download, - }, - { - Name: "list", - ShortName: "li", - Usage: descList, - Action: cmd.List, - }, - } - if err := app.Run(os.Args); err != nil { - - log.Fatal(err) - } + cmd.Execute() } diff --git a/fixtures/config.json b/fixtures/config.json deleted file mode 100644 index 2b7c0c63b..000000000 --- a/fixtures/config.json +++ /dev/null @@ -1 +0,0 @@ -{"apiKey":"abc123","dir":"/a/b/c","api":"http://api.example.com","xapi":"http://x.example.com"} diff --git a/fixtures/detect-path-type/a-dir/.keep b/fixtures/detect-path-type/a-dir/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/fixtures/detect-path-type/a-file.txt b/fixtures/detect-path-type/a-file.txt new file mode 100644 index 000000000..4dd1ef756 --- /dev/null +++ b/fixtures/detect-path-type/a-file.txt @@ -0,0 +1 @@ +This is a file. diff --git a/fixtures/detect-path-type/symlinked-dir b/fixtures/detect-path-type/symlinked-dir new file mode 120000 index 000000000..81398e249 --- /dev/null +++ b/fixtures/detect-path-type/symlinked-dir @@ -0,0 +1 @@ +a-dir \ No newline at end of file diff --git a/fixtures/detect-path-type/symlinked-file.txt b/fixtures/detect-path-type/symlinked-file.txt new file mode 120000 index 000000000..ee26a0518 --- /dev/null +++ b/fixtures/detect-path-type/symlinked-file.txt @@ -0,0 +1 @@ +a-file.txt \ No newline at end of file diff --git a/fixtures/dirty.json b/fixtures/dirty.json deleted file mode 100644 index c77c4ea7c..000000000 --- a/fixtures/dirty.json +++ /dev/null @@ -1 +0,0 @@ -{"apiKey":" abc123 ","dir":"\r\n/a/b/c \r \n","api":" \t\r\nhttp://api.example.com\n \r\n","xapi":" http://x.example.com "} diff --git a/fixtures/is-solution-path/broken/.exercism/metadata.json b/fixtures/is-solution-path/broken/.exercism/metadata.json new file mode 100644 index 000000000..004e1e203 --- /dev/null +++ b/fixtures/is-solution-path/broken/.exercism/metadata.json @@ -0,0 +1 @@ +{,} diff --git a/fixtures/is-solution-path/nope/.keep b/fixtures/is-solution-path/nope/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/fixtures/is-solution-path/yepp/.exercism/metadata.json b/fixtures/is-solution-path/yepp/.exercism/metadata.json new file mode 100644 index 000000000..c2fbc8016 --- /dev/null +++ b/fixtures/is-solution-path/yepp/.exercism/metadata.json @@ -0,0 +1,3 @@ +{ + "id": "abc" +} diff --git a/fixtures/locate-exercise/equipment/bat/.keep b/fixtures/locate-exercise/equipment/bat/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/fixtures/locate-exercise/food/squash/.keep b/fixtures/locate-exercise/food/squash/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/fixtures/locate-exercise/symlinked-workspace b/fixtures/locate-exercise/symlinked-workspace new file mode 120000 index 000000000..f1981605f --- /dev/null +++ b/fixtures/locate-exercise/symlinked-workspace @@ -0,0 +1 @@ +workspace \ No newline at end of file diff --git a/fixtures/locate-exercise/workspace/actions/batten/.keep b/fixtures/locate-exercise/workspace/actions/batten/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/fixtures/locate-exercise/workspace/actions/date/.keep b/fixtures/locate-exercise/workspace/actions/date/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/fixtures/locate-exercise/workspace/actions/squash/.keep b/fixtures/locate-exercise/workspace/actions/squash/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/fixtures/locate-exercise/workspace/creatures/bat/.keep b/fixtures/locate-exercise/workspace/creatures/bat/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/fixtures/locate-exercise/workspace/creatures/crane-2/.keep b/fixtures/locate-exercise/workspace/creatures/crane-2/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/fixtures/locate-exercise/workspace/creatures/crane/.keep b/fixtures/locate-exercise/workspace/creatures/crane/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/fixtures/locate-exercise/workspace/creatures/duck/.keep b/fixtures/locate-exercise/workspace/creatures/duck/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/fixtures/locate-exercise/workspace/creatures/horse/.keep b/fixtures/locate-exercise/workspace/creatures/horse/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/fixtures/locate-exercise/workspace/food/date b/fixtures/locate-exercise/workspace/food/date new file mode 120000 index 000000000..782468922 --- /dev/null +++ b/fixtures/locate-exercise/workspace/food/date @@ -0,0 +1 @@ +../text-files/date \ No newline at end of file diff --git a/fixtures/locate-exercise/workspace/food/squash b/fixtures/locate-exercise/workspace/food/squash new file mode 120000 index 000000000..e6c93335c --- /dev/null +++ b/fixtures/locate-exercise/workspace/food/squash @@ -0,0 +1 @@ +../../food/squash \ No newline at end of file diff --git a/fixtures/locate-exercise/workspace/friends/alice/creatures/bat/.keep b/fixtures/locate-exercise/workspace/friends/alice/creatures/bat/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/fixtures/locate-exercise/workspace/friends/alice/creatures/fly/.keep b/fixtures/locate-exercise/workspace/friends/alice/creatures/fly/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/fixtures/locate-exercise/workspace/text-files/date b/fixtures/locate-exercise/workspace/text-files/date new file mode 100644 index 000000000..f81fce04c --- /dev/null +++ b/fixtures/locate-exercise/workspace/text-files/date @@ -0,0 +1 @@ +this is a file diff --git a/fixtures/locate-exercise/workspace/text-files/duck b/fixtures/locate-exercise/workspace/text-files/duck new file mode 100644 index 000000000..f81fce04c --- /dev/null +++ b/fixtures/locate-exercise/workspace/text-files/duck @@ -0,0 +1 @@ +this is a file diff --git a/fixtures/problems.json b/fixtures/problems.json deleted file mode 100644 index f076c5d60..000000000 --- a/fixtures/problems.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "problems": [ - { - "files": { - "README.md": "# Gigasecond\n\nWrite a program that will calculate the date that someone turned or will celebrate their 1 Gs anniversary.\n\nA gigasecond is one billion (10**9) seconds.\n\n\n## Source\n\nChapter 9 in Chris Pine's online Learn to Program tutorial. [view source](http://pine.fm/LearnToProgram/?Chapter=09)\n", - "cases_test.go": "package gigasecond\n\n// Source: exercism/x-common\n// Commit: f362340 Merge pull request #36 from soniakeys/gigasecond-test-data\n\n// Add one gigasecond to the input.\nvar addCases = []struct {\n\tin string\n\twant string\n}{\n\t{\n\t\t\"2011-04-25\",\n\t\t\"2043-01-01T01:46:40\",\n\t},\n\t{\n\t\t\"1977-06-13\",\n\t\t\"2009-02-19T01:46:40\",\n\t},\n\t{\n\t\t\"1959-07-19\",\n\t\t\"1991-03-27T01:46:40\",\n\t},\n}\n", - "gigasecond_test.go": "package gigasecond\n\n// Write a function AddGigasecond that works with time.Time.\n// Also define a variable Birthday set to your (or someone else's) birthday.\n// Run go test -v to see your gigasecond anniversary.\n\nimport (\n\t\"os\"\n\t\"testing\"\n\t\"time\"\n)\n\nconst testVersion = 1\n\n// Retired testVersions\n// (none) 98807b314216ff27492378a00df60410cc971d32\n\n// date formats used in test data\nconst (\n\tfmtD = \"2006-01-02\"\n\tfmtDT = \"2006-01-02T15:04:05\"\n)\n\nfunc TestAddGigasecond(t *testing.T) {\n\tif TestVersion != testVersion {\n\t\tt.Fatalf(\"Found TestVersion = %v, want %v.\", TestVersion, testVersion)\n\t}\n\tfor _, tc := range addCases {\n\t\tin := parse(tc.in, fmtD, t)\n\t\twant := parse(tc.want, fmtDT, t)\n\t\tgot := AddGigasecond(in)\n\t\tif !got.Equal(want) {\n\t\t\tt.Fatalf(`AddGigasecond(%s)\n = %s\nwant %s`, in, got, want)\n\t\t}\n\t}\n\tt.Log(\"Tested\", len(addCases), \"cases.\")\n}\n\nfunc TestYourAnniversary(t *testing.T) {\n\tt.Logf(`\nYour birthday: %s\nYour gigasecond anniversary: %s`, Birthday, AddGigasecond(Birthday))\n}\n\nfunc parse(s string, f string, t *testing.T) time.Time {\n\ttt, err := time.Parse(f, s)\n\tif err != nil {\n\t\t// can't run tests if input won't parse. if this seems to be a\n\t\t// development or ci environment, raise an error. if this condition\n\t\t// makes it to the solver though, ask for a bug report.\n\t\t_, statErr := os.Stat(\"example_gen.go\")\n\t\tif statErr == nil || os.Getenv(\"TRAVIS_GO_VERSION\") > \"\" {\n\t\t\tt.Fatal(err)\n\t\t} else {\n\t\t\tt.Log(err)\n\t\t\tt.Skip(\"(Not your problem. \" +\n\t\t\t\t\"please file issue at https://github.com/exercism/xgo.)\")\n\t\t}\n\t}\n\treturn tt\n}\n\nfunc BenchmarkAddGigasecond(b *testing.B) {\n\tfor i := 0; i < b.N; i++ {\n\t\tAddGigasecond(time.Time{})\n\t}\n}\n" - }, - "fresh": false, - "id": "go/gigasecond", - "language": "go", - "name": "Gigasecond", - "slug": "gigasecond", - "track_id": "go" - }, - { - "files": { - "README.md": "# Leap\n\nWrite a program that will take a year and report if it is a leap year.\n\nThe tricky thing here is that a leap year occurs:\n\n```plain\non every year that is evenly divisible by 4\n except every year that is evenly divisible by 100\n unless the year is also evenly divisible by 400\n```\n\nFor example, 1997 is not a leap year, but 1996 is. 1900 is not a leap\nyear, but 2000 is.\n\nIf your language provides a method in the standard library that does\nthis look-up, pretend it doesn't exist and implement it yourself.\n\n## Notes\n\nFor a delightful, four minute explanation of the whole leap year\nphenomenon, go watch [this youtube video][video].\n\n[video]: http://www.youtube.com/watch?v=xX96xng7sAE\n\n\n## Source\n\nJavaRanch Cattle Drive, exercise 3 [view source](http://www.javaranch.com/leap.jsp)\n", - "leap_test.go": "package leap\n\nimport (\n\t\"testing\"\n)\n\nvar testCases = []struct {\n\tyear int\n\texpected bool\n\tdescription string\n}{\n\t{1996, true, \"vanilla leap year\"},\n\t{1997, false, \"normal year\"},\n\t{1900, false, \"century\"},\n\t{2400, true, \"exceptional century\"},\n}\n\nfunc TestLeapYears(t *testing.T) {\n\tfor _, test := range testCases {\n\t\tobserved := IsLeapYear(test.year)\n\t\tif observed != test.expected {\n\t\t\tt.Fatalf(\"IsLeapYear(%d) = %t, want %t (%s)\",\n\t\t\t\ttest.year, observed, test.expected, test.description)\n\t\t}\n\t}\n}\n\nfunc BenchmarkLeapYears(b *testing.B) {\n\tfor i := 0; i < b.N; i++ {\n\t\tfor _, test := range testCases {\n\t\t\tIsLeapYear(test.year)\n\t\t}\n\t}\n}\n" - }, - "fresh": false, - "id": "go/leap", - "language": "go", - "name": "Leap", - "slug": "leap", - "track_id": "go" - }, - { - "files": { - "README.md": "# Raindrops\n\nWrite a program that converts a number to a string, the contents of which depends on the number's prime factors.\n\n- If the number contains 3 as a prime factor, output 'Pling'.\n- If the number contains 5 as a prime factor, output 'Plang'.\n- If the number contains 7 as a prime factor, output 'Plong'.\n- If the number does not contain 3, 5, or 7 as a prime factor,\n just pass the number's digits straight through.\n\n## Examples\n\n- 28's prime-factorization is 2, 2, 7.\n - In raindrop-speak, this would be a simple \"Plong\".\n- 1755 prime-factorization is 3, 3, 3, 5, 13.\n - In raindrop-speak, this would be a \"PlingPlang\".\n- The prime factors of 34 are 2 and 17.\n - Raindrop-speak doesn't know what to make of that,\n so it just goes with the straightforward \"34\".\n\n\n## Source\n\nA variation on a famous interview question intended to weed out potential candidates. [view source](http://jumpstartlab.com)\n", - "raindrops_test.go": "package raindrops\n\nimport \"testing\"\n\nvar tests = []struct {\n\tinput int\n\texpected string\n}{\n\t{1, \"1\"},\n\t{3, \"Pling\"},\n\t{5, \"Plang\"},\n\t{7, \"Plong\"},\n\t{6, \"Pling\"},\n\t{9, \"Pling\"},\n\t{10, \"Plang\"},\n\t{14, \"Plong\"},\n\t{15, \"PlingPlang\"},\n\t{21, \"PlingPlong\"},\n\t{25, \"Plang\"},\n\t{35, \"PlangPlong\"},\n\t{49, \"Plong\"},\n\t{52, \"52\"},\n\t{105, \"PlingPlangPlong\"},\n\t{12121, \"12121\"},\n}\n\nfunc TestConvert(t *testing.T) {\n\tfor _, test := range tests {\n\t\tif actual := Convert(test.input); actual != test.expected {\n\t\t\tt.Errorf(\"Convert(%d) = %q, expected %q.\",\n\t\t\t\ttest.input, actual, test.expected)\n\t\t}\n\t}\n}\n\nfunc BenchmarkConvert(b *testing.B) {\n\tfor i := 0; i < b.N; i++ {\n\t\tfor _, test := range tests {\n\t\t\tConvert(test.input)\n\t\t}\n\t}\n}\n" - }, - "fresh": false, - "id": "go/raindrops", - "language": "go", - "name": "Raindrops", - "slug": "raindrops", - "track_id": "go" - } - ] -} diff --git a/fixtures/solution-dir/file.txt b/fixtures/solution-dir/file.txt new file mode 100644 index 000000000..e69de29bb diff --git a/fixtures/solution-dir/workspace/exercise/.exercism/metadata.json b/fixtures/solution-dir/workspace/exercise/.exercism/metadata.json new file mode 100644 index 000000000..e69de29bb diff --git a/fixtures/solution-dir/workspace/exercise/file.txt b/fixtures/solution-dir/workspace/exercise/file.txt new file mode 100644 index 000000000..e69de29bb diff --git a/fixtures/solution-dir/workspace/exercise/in/a/subdir/file.txt b/fixtures/solution-dir/workspace/exercise/in/a/subdir/file.txt new file mode 100644 index 000000000..e69de29bb diff --git a/fixtures/solution-dir/workspace/not-exercise/file.txt b/fixtures/solution-dir/workspace/not-exercise/file.txt new file mode 100644 index 000000000..e69de29bb diff --git a/fixtures/solution-path/creatures/gazelle-2/.exercism/metadata.json b/fixtures/solution-path/creatures/gazelle-2/.exercism/metadata.json new file mode 100644 index 000000000..c6d7de2c7 --- /dev/null +++ b/fixtures/solution-path/creatures/gazelle-2/.exercism/metadata.json @@ -0,0 +1 @@ +{"id": "bbb"} diff --git a/fixtures/solution-path/creatures/gazelle-3/.exercism/metadata.json b/fixtures/solution-path/creatures/gazelle-3/.exercism/metadata.json new file mode 100644 index 000000000..ae9e57c45 --- /dev/null +++ b/fixtures/solution-path/creatures/gazelle-3/.exercism/metadata.json @@ -0,0 +1 @@ +{"id": "ccc"} diff --git a/fixtures/solution-path/creatures/gazelle/.exercism/metadata.json b/fixtures/solution-path/creatures/gazelle/.exercism/metadata.json new file mode 100644 index 000000000..640669c6d --- /dev/null +++ b/fixtures/solution-path/creatures/gazelle/.exercism/metadata.json @@ -0,0 +1 @@ +{"id": "aaa"} diff --git a/fixtures/solutions/alpha/.exercism/metadata.json b/fixtures/solutions/alpha/.exercism/metadata.json new file mode 100644 index 000000000..139f8115b --- /dev/null +++ b/fixtures/solutions/alpha/.exercism/metadata.json @@ -0,0 +1 @@ +{"id": "alpha"} diff --git a/fixtures/solutions/bravo/.exercism/metadata.json b/fixtures/solutions/bravo/.exercism/metadata.json new file mode 100644 index 000000000..c1348f523 --- /dev/null +++ b/fixtures/solutions/bravo/.exercism/metadata.json @@ -0,0 +1 @@ +{"id": "bravo"} diff --git a/fixtures/solutions/charlie/.exercism/metadata.json b/fixtures/solutions/charlie/.exercism/metadata.json new file mode 100644 index 000000000..a9135f4cf --- /dev/null +++ b/fixtures/solutions/charlie/.exercism/metadata.json @@ -0,0 +1 @@ +{"id": "charlie"} diff --git a/fixtures/solutions/delta/.keep b/fixtures/solutions/delta/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/fixtures/submission.json b/fixtures/submission.json deleted file mode 100644 index d618f990e..000000000 --- a/fixtures/submission.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "url": "127.0.0.1/submissions/a185fcdafd094882ae27b2b457857f8f", - "track_id": "go", - "slug": "leap" -} \ No newline at end of file diff --git a/fixtures/submit.json b/fixtures/submit.json deleted file mode 100644 index ffb060e56..000000000 --- a/fixtures/submit.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "error": null, - "url": "a url", - "track_id": "a track_id", - "language": "ruby" -} diff --git a/fixtures/tracks.json b/fixtures/tracks.json deleted file mode 100644 index aae48707d..000000000 --- a/fixtures/tracks.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "track":{ - "language":"Clojure", - "active":true, - "id":"clojure", - "slug":"clojure", - "problems":[ - "clojure/bob", - "clojure/rna-transcription", - "clojure/word-count", - "clojure/anagram", - "clojure/beer-song", - "clojure/nucleotide-count", - "clojure/point-mutations", - "clojure/phone-number", - "clojure/grade-school", - "clojure/robot-name", - "clojure/leap", - "clojure/etl", - "clojure/meetup", - "clojure/space-age", - "clojure/grains", - "clojure/gigasecond", - "clojure/triangle", - "clojure/scrabble-score", - "clojure/roman-numerals", - "clojure/binary", - "clojure/prime-factors", - "clojure/raindrops", - "clojure/allergies", - "clojure/atbash-cipher", - "clojure/bank-account", - "clojure/crypto-square", - "clojure/kindergarten-garden", - "clojure/robot-simulator", - "clojure/queen-attack", - "clojure/accumulate", - "clojure/binary-search-tree", - "clojure/difference-of-squares", - "clojure/hexadecimal", - "clojure/largest-series-product" - ], - "repository":"https://github.com/exercism/xclojure" - } -} diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..41f9d07a5 --- /dev/null +++ b/go.mod @@ -0,0 +1,31 @@ +module github.com/exercism/cli + +go 1.26.0 + +require ( + github.com/blang/semver/v4 v4.0.0 + github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf + github.com/spf13/cobra v1.10.0 + github.com/spf13/pflag v1.0.10 + github.com/spf13/viper v1.21.0 + github.com/stretchr/testify v1.11.1 + golang.org/x/net v0.55.0 + golang.org/x/text v0.37.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sys v0.45.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..0bb204f45 --- /dev/null +++ b/go.sum @@ -0,0 +1,60 @@ +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8= +github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.0 h1:a5/WeUlSDCvV5a45ljW2ZFtV0bTDpkfSAj3uqB6Sc+0= +github.com/spf13/cobra v1.10.0/go.mod h1:9dhySC7dnTtEiqzmqfkLj47BslqLCUPMXjG2lj/NgoE= +github.com/spf13/pflag v1.0.8/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/shell/README.md b/shell/README.md new file mode 100644 index 000000000..beee3c47a --- /dev/null +++ b/shell/README.md @@ -0,0 +1,54 @@ +## Executable +Unpack the archive relevant to your machine and place in $PATH + +## Shell Completion Scripts + +### Bash + + mkdir -p ~/.config/exercism + mv ../shell/exercism_completion.bash ~/.config/exercism/exercism_completion.bash + +Load the completion in your `.bashrc`, `.bash_profile` or `.profile` by +adding the following snippet: + + if [ -f ~/.config/exercism/exercism_completion.bash ]; then + source ~/.config/exercism/exercism_completion.bash + fi + +### Zsh + +Load up the completion by placing the `exercism_completion.zsh` somewhere on +your `$fpath` as `_exercism`. For example: + + mkdir -p ~/.zsh/functions + mv ../shell/exercism_completion.zsh ~/.zsh/functions/_exercism + +and then add the directory to your `$fpath` in your `.zshrc`, `.zsh_profile` or +`.profile` before running `compinit`: + + export fpath=(~/.zsh/functions $fpath) + autoload -U compinit && compinit + + +#### Oh My Zsh + +If you are using the popular [Oh My Zsh][oh-my-zsh] framework to manage your +zsh plugins, you need to move the file `exercism_completion.zsh` to a new +custom plugin: + +[oh-my-zsh]: https://github.com/ohmyzsh/ohmyzsh + + mkdir -p $ZSH_CUSTOM/plugins/exercism + cp exercism_completion.zsh $ZSH_CUSTOM/plugins/exercism/_exercism + +Then edit the file `~/.zshrc` to include `exercism` in the list of plugins. +Completions will be activated the next time you open a new shell. If the +completions do not work, you should update Oh My Zsh to the latest version with +`omz update`. Oh My Zsh now checks whether the plugin list has changed (more +accurately, `$fpath`) and resets the `zcompdump` file. + +### Fish + +Completions must go in the user defined `$fish_complete_path`. By default, this is `~/.config/fish/completions` + + mv ../shell/exercism.fish ~/.config/fish/exercism.fish diff --git a/shell/exercism.fish b/shell/exercism.fish new file mode 100644 index 000000000..4abe9b21e --- /dev/null +++ b/shell/exercism.fish @@ -0,0 +1,57 @@ +# Configure +complete -f -c exercism -n "__fish_use_subcommand" -a "configure" -d "Writes config values to a JSON file." +complete -f -c exercism -n "__fish_seen_subcommand_from configure" -s t -l token -d "Set token" +complete -f -c exercism -n "__fish_seen_subcommand_from configure" -s w -l workspace -d "Set workspace" +complete -f -c exercism -n "__fish_seen_subcommand_from configure" -s a -l api -d "set API base url" +complete -f -c exercism -n "__fish_seen_subcommand_from configure" -s s -l show -d "show settings" + +# Download +complete -f -c exercism -n "__fish_use_subcommand" -a "download" -d "Downloads and saves a specified submission into the local system" +complete -f -c exercism -n "__fish_seen_subcommand_from download" -s e -l exercise -d "the exercise slug" +complete -f -c exercism -n "__fish_seen_subcommand_from download" -s h -l help -d "help for download" +complete -f -c exercism -n "__fish_seen_subcommand_from download" -s t -l track -d "the track ID" +complete -f -c exercism -n "__fish_seen_subcommand_from download" -s u -l uuid -d "the solution UUID" + +# Help +complete -f -c exercism -n "__fish_use_subcommand" -a "help" -d "Shows a list of commands or help for one command" +complete -f -c exercism -n "__fish_seen_subcommand_from help" -a "configure download help open submit test troubleshoot upgrade version workspace" + +# Open +complete -f -c exercism -n "__fish_use_subcommand" -a "open" -d "Opens a browser to exercism.org for the specified submission." +complete -f -c exercism -n "__fish_seen_subcommand_from open" -s h -l help -d "help for open" + +# Submit +complete -f -c exercism -n "__fish_use_subcommand" -a "submit" -d "Submits a new iteration to a problem on exercism.org." +complete -f -c exercism -n "__fish_seen_subcommand_from submit" -s h -l help -d "help for submit" + +# Test +complete -f -c exercism -n "__fish_use_subcommand" -a "test" -d "Run the exercise's tests." +complete -f -c exercism -n "__fish_seen_subcommand_from submit" -s h -l help -d "help for test" + +# Troubleshoot +complete -f -c exercism -n "__fish_use_subcommand" -a "troubleshoot" -d "Outputs useful debug information." +complete -f -c exercism -n "__fish_seen_subcommand_from troubleshoot" -s f -l full-api-key -d "display full API key (censored by default)" +complete -f -c exercism -n "__fish_seen_subcommand_from troubleshoot" -s h -l help -d "help for troubleshoot" + +# Upgrade +complete -f -c exercism -n "__fish_use_subcommand" -a "upgrade" -d "Upgrades to the latest available version." +complete -f -c exercism -n "__fish_seen_subcommand_from help" -s h -l help -d "help for help" + +# Version +complete -f -c exercism -n "__fish_use_subcommand" -a "version" -d "Outputs version information." +complete -f -c exercism -n "__fish_seen_subcommand_from version" -s l -l latest -d "check latest available version" +complete -f -c exercism -n "__fish_seen_subcommand_from version" -s h -l help -d "help for version" + +# Workspace +complete -f -c exercism -n "__fish_use_subcommand" -a "workspace" -d "Outputs the root directory for Exercism exercises." +complete -f -c exercism -n "__fish_seen_subcommand_from workspace" -s h -l help -d "help for workspace" + +# Options +complete -f -c exercism -s h -l help -d "show help" +complete -f -c exercism -l timeout -a "10" -d "10 seconds" +complete -f -c exercism -l timeout -a "30" -d "30 seconds" +complete -f -c exercism -l timeout -a "60" -d "1 minute" +complete -f -c exercism -l timeout -a "300" -d "5 minutes" +complete -f -c exercism -l timeout -a "600" -d "10 minutes" +complete -f -c exercism -l timeout -a "" -d "override default HTTP timeout" +complete -f -c exercism -s v -l verbose -d "turn on verbose logging" diff --git a/shell/exercism_completion.bash b/shell/exercism_completion.bash new file mode 100644 index 000000000..6864ec153 --- /dev/null +++ b/shell/exercism_completion.bash @@ -0,0 +1,51 @@ +_exercism () { + local cur prev + + COMPREPLY=() # Array variable storing the possible completions. + cur=${COMP_WORDS[COMP_CWORD]} + prev=${COMP_WORDS[COMP_CWORD-1]} + opts="--verbose --timeout" + + commands="configure download open + submit test troubleshoot upgrade version workspace help" + config_opts="--show" + version_opts="--latest" + + if [ "${#COMP_WORDS[@]}" -eq 2 ]; then + case "${cur}" in + -*) + COMPREPLY=( $( compgen -W "${opts}" -- "${cur}" ) ) + return 0 + ;; + *) + COMPREPLY=( $( compgen -W "${commands}" "${cur}" ) ) + return 0 + ;; + esac + fi + + if [ "${#COMP_WORDS[@]}" -eq 3 ]; then + case "${prev}" in + configure) + COMPREPLY=( $( compgen -W "${config_opts}" -- "${cur}" ) ) + return 0 + ;; + version) + COMPREPLY=( $( compgen -W "${version_opts}" -- "${cur}" ) ) + return 0 + ;; + help) + COMPREPLY=( $( compgen -W "${commands}" "${cur}" ) ) + return 0 + ;; + *) + return 0 + ;; + esac + fi + + return 0 +} + +complete -o bashdefault -o default -o nospace -F _exercism exercism 2>/dev/null \ + || complete -o default -o nospace -F _exercism exercism diff --git a/shell/exercism_completion.zsh b/shell/exercism_completion.zsh new file mode 100644 index 000000000..542c8a576 --- /dev/null +++ b/shell/exercism_completion.zsh @@ -0,0 +1,35 @@ +#compdef exercism + +local curcontext="$curcontext" state line +typeset -A opt_args + +local -a options +options=(configure:"Writes config values to a JSON file." + download:"Downloads and saves a specified submission into the local system" + open:"Opens a browser to exercism.org for the specified submission." + submit:"Submits a new iteration to a problem on exercism.org." + test:"Run the exercise's tests." + troubleshoot:"Outputs useful debug information." + upgrade:"Upgrades to the latest available version." + version:"Outputs version information." + workspace:"Outputs the root directory for Exercism exercises." + help:"Shows a list of commands or help for one command") + +_arguments -s -S \ + {-h,--help}"[show help]" \ + {-t,--timeout}"[override default HTTP timeout]" \ + {-v,--verbose}"[turn on verbose logging]" \ + '(-): :->command' \ + '(-)*:: :->option-or-argument' \ + && return 0; + +case $state in + (command) + _describe 'commands' options ;; + (option-or-argument) + case $words[1] in + s*) + _files + ;; + esac +esac diff --git a/user/curriculum.go b/user/curriculum.go deleted file mode 100644 index 6b48001e3..000000000 --- a/user/curriculum.go +++ /dev/null @@ -1,77 +0,0 @@ -package user - -import ( - "fmt" - "strings" - - "github.com/exercism/cli/api" -) - -// Status is the status of a track (active/inactive). -type Status bool - -const ( - // TrackActive represents an active track. - // Problems from active tracks will be delivered with the `fetch` command. - TrackActive Status = true - // TrackInactive represents an inactive track. - // It is possible to fetch problems from an inactive track, and - // submit them to the website, but these will not automatically be - // delivered in the global `fetch` command. - TrackInactive Status = false -) - -// Curriculum is a collection of language tracks. -type Curriculum struct { - Tracks []*api.Track - wLang int - wID int -} - -// NewCurriculum returns a collection of language tracks. -func NewCurriculum(tracks []*api.Track) *Curriculum { - return &Curriculum{Tracks: tracks} -} - -// Report creates a table of the tracks that have the requested status. -func (cur *Curriculum) Report(status Status) { - for _, track := range cur.Tracks { - if Status(track.Active) == status { - fmt.Println( - " ", - track.Language, - strings.Repeat(" ", cur.lenLang()-len(track.Language)+1), - track.ID, - strings.Repeat(" ", cur.lenID()-len(track.ID)+1), - track.Len(), - "problems", - ) - } - } -} - -func (cur *Curriculum) lenLang() int { - if cur.wLang > 0 { - return cur.wLang - } - - for _, track := range cur.Tracks { - if len(track.Language) > cur.wLang { - cur.wLang = len(track.Language) - } - } - return cur.wLang -} - -func (cur *Curriculum) lenID() int { - if cur.wID > 0 { - return cur.wID - } - - for _, track := range cur.Tracks { - if len(track.ID) > cur.wID { - cur.wID = len(track.ID) - } - } - return cur.wID -} diff --git a/user/homework.go b/user/homework.go deleted file mode 100644 index fca97f406..000000000 --- a/user/homework.go +++ /dev/null @@ -1,137 +0,0 @@ -package user - -import ( - "fmt" - - "github.com/exercism/cli/api" - "github.com/exercism/cli/config" -) - -// HWFilter is used to categorize homework items. -type HWFilter int - -// SummaryOption is an alias of HWFilter that allows -// selective display of summary items -type SummaryOption HWFilter - -const ( - // HWAll represents all items in the collection. - HWAll = iota - // HWUpdated represents problems that were already on the - // user's filesystem, where one or more new files have been added. - HWUpdated - // HWNew represents problems that did not yet exist on the - // user's filesystem. - HWNew - // HWNotSubmitted represents problems that have not been submitted - // for review. - HWNotSubmitted -) - -// Homework is a collection of problems that were fetched from the APIs. -type Homework struct { - Items []*Item - template string -} - -// NewHomework decorates a problem set with some additional data based on the -// user's system. -func NewHomework(problems []*api.Problem, c *config.Config) *Homework { - hw := Homework{} - for _, problem := range problems { - item := &Item{ - Problem: problem, - dir: c.Dir, - } - hw.Items = append(hw.Items, item) - } - - hw.template = fmt.Sprintf("%%%ds %%s\n", hw.maxTitleWidth()) - return &hw -} - -// Save saves all problems in the problem set. -func (hw *Homework) Save() error { - for _, item := range hw.Items { - if err := item.Save(); err != nil { - return err - } - } - return nil -} - -// ItemsMatching returns a subset of the set of problems. -func (hw *Homework) ItemsMatching(filter HWFilter) []*Item { - items := []*Item{} - for _, item := range hw.Items { - if item.Matches(filter) { - items = append(items, item) - } - } - return items -} - -// Report outputs a list of the problems in the set. -// It prints the track name, the problem name, and the full -// path to the problem on the user's filesystem. -func (hw *Homework) Report(filter HWFilter) { - items := hw.ItemsMatching(filter) - hw.heading(filter, len(items)) - for _, item := range items { - fmt.Printf(hw.template, item.String(), item.Path()) - } -} - -func (hw *Homework) heading(filter HWFilter, count int) { - if count == 0 { - return - } - fmt.Println() - - if filter == HWAll { - return - } - - unit := "problems" - if count == 1 { - unit = "problem" - } - - var status string - switch filter { - case HWUpdated: - status = "Updated:" - case HWNew: - status = "New:" - case HWNotSubmitted: - status = "Not Submitted:" - } - summary := fmt.Sprintf("%d %s", count, unit) - fmt.Printf(hw.template, status, summary) -} - -func (hw *Homework) maxTitleWidth() int { - var max int - for _, item := range hw.Items { - if len(item.String()) > max { - max = len(item.String()) - } - } - return max -} - -// Summarize prints a full report of new and updated items in the set. -func (hw *Homework) Summarize(summaryFilter SummaryOption) { - hw.Report(HWUpdated) - - if summaryFilter != HWNotSubmitted { - hw.Report(HWNotSubmitted) - } - - hw.Report(HWNew) - - fresh := len(hw.ItemsMatching(HWNew)) - updated := len(hw.ItemsMatching(HWUpdated)) - unchanged := len(hw.Items) - updated - fresh - fmt.Printf("\nunchanged: %d, updated: %d, new: %d\n\n", unchanged, updated, fresh) -} diff --git a/user/item.go b/user/item.go deleted file mode 100644 index 91162dc0f..000000000 --- a/user/item.go +++ /dev/null @@ -1,76 +0,0 @@ -package user - -import ( - "io/ioutil" - "os" - "path/filepath" - "runtime" - "strings" - - "github.com/exercism/cli/api" -) - -// Item is a problem that has been fetched from the APIs. -// It contains some data specific to this particular request and user -// in order to give a useful report to the user about what has been fetched. -type Item struct { - *api.Problem - dir string - isNew bool - isUpdated bool -} - -// Path is the location of this item on the user's filesystem. -func (it *Item) Path() string { - return filepath.Join(it.dir, it.TrackID, it.Slug) -} - -// Matches determines whether or not this item matches the given filter. -func (it *Item) Matches(filter HWFilter) bool { - switch filter { - case HWNew: - return it.isNew - case HWUpdated: - return it.isUpdated - case HWNotSubmitted: - return !it.Submitted - } - return true -} - -// Save writes the embedded problem to the filesystem. -func (it *Item) Save() error { - if _, err := os.Stat(it.Path()); err != nil { - if !os.IsNotExist(err) { - return err - } - it.isNew = true - } - - for name, text := range it.Files { - file := filepath.Join(it.Path(), name) - - if err := os.MkdirAll(filepath.Dir(file), 0755); err != nil { - return err - } - - if _, err := os.Stat(file); err != nil { - if !os.IsNotExist(err) { - return err - } - - if !it.isNew { - it.isUpdated = true - } - - if runtime.GOOS == "windows" { - text = strings.Replace(text, "\n", "\r\n", -1) - } - - if err := ioutil.WriteFile(file, []byte(text), 0644); err != nil { - return err - } - } - } - return nil -} diff --git a/workspace/document.go b/workspace/document.go new file mode 100644 index 000000000..52b1264d8 --- /dev/null +++ b/workspace/document.go @@ -0,0 +1,34 @@ +package workspace + +import "path/filepath" + +// Document is a file in a directory. +type Document struct { + Root string + RelativePath string +} + +// NewDocument creates a document from the filepath. +// The root is typically the root of the exercise, and +// path is the absolute path to the file. +func NewDocument(root, path string) (Document, error) { + path, err := filepath.Rel(root, path) + if err != nil { + return Document{}, err + } + return Document{ + Root: root, + RelativePath: path, + }, nil +} + +// Filepath is the absolute path to the document on the filesystem. +func (doc Document) Filepath() string { + return filepath.Join(doc.Root, doc.RelativePath) +} + +// Path is the normalized path. +// It uses forward slashes regardless of the operating system. +func (doc Document) Path() string { + return filepath.ToSlash(doc.RelativePath) +} diff --git a/workspace/document_test.go b/workspace/document_test.go new file mode 100644 index 000000000..50013ee73 --- /dev/null +++ b/workspace/document_test.go @@ -0,0 +1,42 @@ +package workspace + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNormalizedDocumentPath(t *testing.T) { + root, err := os.MkdirTemp("", "docpath") + assert.NoError(t, err) + defer os.RemoveAll(root) + + err = os.MkdirAll(filepath.Join(root, "subdirectory"), os.FileMode(0755)) + assert.NoError(t, err) + + testCases := []struct { + filepath string + path string + }{ + { + filepath: filepath.Join(root, "file.txt"), + path: "file.txt", + }, + { + filepath: filepath.Join(root, "subdirectory", "file.txt"), + path: "subdirectory/file.txt", + }, + } + + for _, tc := range testCases { + err = os.WriteFile(tc.filepath, []byte("a file"), os.FileMode(0600)) + assert.NoError(t, err) + + doc, err := NewDocument(root, tc.filepath) + assert.NoError(t, err) + + assert.Equal(t, doc.Path(), tc.path) + } +} diff --git a/workspace/errors.go b/workspace/errors.go new file mode 100644 index 000000000..72c4bb7d5 --- /dev/null +++ b/workspace/errors.go @@ -0,0 +1,29 @@ +package workspace + +import "fmt" + +// ErrNotInWorkspace signals that the target directory is outside the configured workspace. +type ErrNotInWorkspace string + +// ErrNotExist signals that the target directory could not be located. +type ErrNotExist string + +func (err ErrNotInWorkspace) Error() string { + return fmt.Sprintf("%s not within workspace", string(err)) +} + +func (err ErrNotExist) Error() string { + return fmt.Sprintf("%s not found", string(err)) +} + +// IsNotInWorkspace checks if this is an ErrNotInWorkspace error. +func IsNotInWorkspace(err error) bool { + _, ok := err.(ErrNotInWorkspace) + return ok +} + +// IsNotExist checks if this is an ErrNotExist error. +func IsNotExist(err error) bool { + _, ok := err.(ErrNotExist) + return ok +} diff --git a/workspace/exercise.go b/workspace/exercise.go new file mode 100644 index 000000000..af462832c --- /dev/null +++ b/workspace/exercise.go @@ -0,0 +1,121 @@ +package workspace + +import ( + "os" + "path" + "path/filepath" +) + +// Exercise is an implementation of a problem in a track. +type Exercise struct { + Root string + Track string + Slug string +} + +// NewExerciseFromDir constructs an exercise given the exercise directory. +func NewExerciseFromDir(dir string) Exercise { + slug := filepath.Base(dir) + dir = filepath.Dir(dir) + track := filepath.Base(dir) + root := filepath.Dir(dir) + return Exercise{Root: root, Track: track, Slug: slug} +} + +// Path is the normalized relative path. +// It always has forward slashes, regardless +// of the operating system. +func (e Exercise) Path() string { + return path.Join(e.Track, e.Slug) +} + +// Filepath is the absolute path on the filesystem. +func (e Exercise) Filepath() string { + return filepath.Join(e.Root, e.Track, e.Slug) +} + +// MetadataFilepath is the absolute path to the exercise metadata. +func (e Exercise) MetadataFilepath() string { + return filepath.Join(e.Filepath(), metadataFilepath) +} + +// LegacyMetadataFilepath is the absolute path to the legacy exercise metadata. +func (e Exercise) LegacyMetadataFilepath() string { + return filepath.Join(e.Filepath(), legacyMetadataFilename) +} + +// MetadataDir returns the directory that the exercise metadata lives in. +// For now this is the exercise directory. +func (e Exercise) MetadataDir() string { + return e.Filepath() +} + +// HasMetadata checks for the presence of an exercise metadata file. +// If there is no such file, this may be a legacy exercise. +// It could also be an unrelated directory. +func (e Exercise) HasMetadata() (bool, error) { + _, err := os.Lstat(e.MetadataFilepath()) + if os.IsNotExist(err) { + return false, nil + } + if err == nil { + return true, nil + } + return false, err +} + +// HasLegacyMetadata checks for the presence of a legacy exercise metadata file. +// If there is no such file, it could also be an unrelated directory. +func (e Exercise) HasLegacyMetadata() (bool, error) { + _, err := os.Lstat(e.LegacyMetadataFilepath()) + if os.IsNotExist(err) { + return false, nil + } + if err == nil { + return true, nil + } + return false, err +} + +// MigrationStatus represents the result of migrating a legacy metadata file. +type MigrationStatus int + +// MigrationStatus +const ( + MigrationStatusNoop MigrationStatus = iota + MigrationStatusMigrated + MigrationStatusRemoved +) + +func (m MigrationStatus) String() string { + switch m { + case MigrationStatusMigrated: + return "\nMigrated metadata\n" + case MigrationStatusRemoved: + return "\nRemoved legacy metadata\n" + default: + return "" + } +} + +// MigrateLegacyMetadataFile migrates a legacy metadata file to the modern location. +// This is a noop if the metadata file isn't legacy. +// If both legacy and modern metadata files exist, the legacy file will be deleted. +func (e Exercise) MigrateLegacyMetadataFile() (MigrationStatus, error) { + if ok, _ := e.HasLegacyMetadata(); !ok { + return MigrationStatusNoop, nil + } + if err := os.MkdirAll(filepath.Dir(e.MetadataFilepath()), os.FileMode(0755)); err != nil { + return MigrationStatusNoop, err + } + if ok, _ := e.HasMetadata(); !ok { + if err := os.Rename(e.LegacyMetadataFilepath(), e.MetadataFilepath()); err != nil { + return MigrationStatusNoop, err + } + return MigrationStatusMigrated, nil + } + if err := os.Remove(e.LegacyMetadataFilepath()); err != nil { + return MigrationStatusNoop, err + } + return MigrationStatusRemoved, nil +} diff --git a/workspace/exercise_config.go b/workspace/exercise_config.go new file mode 100644 index 000000000..35ae7545c --- /dev/null +++ b/workspace/exercise_config.go @@ -0,0 +1,57 @@ +package workspace + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" +) + +const configFilename = "config.json" + +var configFilepath = filepath.Join(ignoreSubdir, configFilename) + +// ExerciseConfig contains exercise metadata. +// Note: we only use a subset of its fields +type ExerciseConfig struct { + Files struct { + Solution []string `json:"solution"` + Test []string `json:"test"` + } `json:"files"` +} + +// NewExerciseConfig reads exercise metadata from a file in the given directory. +func NewExerciseConfig(dir string) (*ExerciseConfig, error) { + b, err := os.ReadFile(filepath.Join(dir, configFilepath)) + if err != nil { + return nil, err + } + var config ExerciseConfig + if err := json.Unmarshal(b, &config); err != nil { + return nil, err + } + + return &config, nil +} + +// GetTestFiles finds returns the names of the file(s) that hold unit tests for this exercise, if any +func (c *ExerciseConfig) GetSolutionFiles() ([]string, error) { + result := c.Files.Solution + if result == nil { + // solution file(s) key was missing in config json, which is an error when calling this fuction + return []string{}, errors.New("no `files.solution` key in your `config.json`. Was it removed by mistake?") + } + + return result, nil +} + +// GetTestFiles finds returns the names of the file(s) that hold unit tests for this exercise, if any +func (c *ExerciseConfig) GetTestFiles() ([]string, error) { + result := c.Files.Test + if result == nil { + // test file(s) key was missing in config json, which is an error when calling this fuction + return []string{}, errors.New("no `files.test` key in your `config.json`. Was it removed by mistake?") + } + + return result, nil +} diff --git a/workspace/exercise_config_test.go b/workspace/exercise_config_test.go new file mode 100644 index 000000000..5d9847453 --- /dev/null +++ b/workspace/exercise_config_test.go @@ -0,0 +1,97 @@ +package workspace + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExerciseConfig(t *testing.T) { + dir, err := os.MkdirTemp("", "exercise_config") + assert.NoError(t, err) + defer os.RemoveAll(dir) + + err = os.Mkdir(filepath.Join(dir, ".exercism"), os.ModePerm) + assert.NoError(t, err) + + f, err := os.Create(filepath.Join(dir, ".exercism", "config.json")) + assert.NoError(t, err) + defer f.Close() + + _, err = f.WriteString(`{ "blurb": "Learn about the basics of Ruby by following a lasagna recipe.", "authors": ["iHiD", "pvcarrera"], "files": { "solution": ["lasagna.rb"], "test": ["lasagna_test.rb"], "exemplar": [".meta/exemplar.rb"] } } `) + assert.NoError(t, err) + + ec, err := NewExerciseConfig(dir) + assert.NoError(t, err) + + assert.Equal(t, ec.Files.Solution, []string{"lasagna.rb"}) + solutionFiles, err := ec.GetSolutionFiles() + assert.NoError(t, err) + assert.Equal(t, solutionFiles, []string{"lasagna.rb"}) + + assert.Equal(t, ec.Files.Test, []string{"lasagna_test.rb"}) + testFiles, err := ec.GetTestFiles() + assert.NoError(t, err) + assert.Equal(t, testFiles, []string{"lasagna_test.rb"}) +} + +func TestExerciseConfigNoTestKey(t *testing.T) { + dir, err := os.MkdirTemp("", "exercise_config") + assert.NoError(t, err) + defer os.RemoveAll(dir) + + err = os.Mkdir(filepath.Join(dir, ".exercism"), os.ModePerm) + assert.NoError(t, err) + + f, err := os.Create(filepath.Join(dir, ".exercism", "config.json")) + assert.NoError(t, err) + defer f.Close() + + _, err = f.WriteString(`{ "blurb": "Learn about the basics of Ruby by following a lasagna recipe.", "authors": ["iHiD", "pvcarrera"], "files": { "exemplar": [".meta/exemplar.rb"] } } `) + assert.NoError(t, err) + + ec, err := NewExerciseConfig(dir) + assert.NoError(t, err) + + _, err = ec.GetSolutionFiles() + assert.Error(t, err, "no `files.solution` key in your `config.json`") + _, err = ec.GetTestFiles() + assert.Error(t, err, "no `files.test` key in your `config.json`") +} + +func TestMissingExerciseConfig(t *testing.T) { + dir, err := os.MkdirTemp("", "exercise_config") + assert.NoError(t, err) + defer os.RemoveAll(dir) + + _, err = NewExerciseConfig(dir) + assert.Error(t, err) + // any assertions about this error message have to work across all platforms, so be vague + // unix: ".exercism/config.json: no such file or directory" + // windows: "open .exercism\config.json: The system cannot find the path specified." + assert.Contains(t, err.Error(), filepath.Join(".exercism", "config.json:")) +} + +func TestInvalidExerciseConfig(t *testing.T) { + dir, err := os.MkdirTemp("", "exercise_config") + assert.NoError(t, err) + defer os.RemoveAll(dir) + + err = os.Mkdir(filepath.Join(dir, ".exercism"), os.ModePerm) + assert.NoError(t, err) + + f, err := os.Create(filepath.Join(dir, ".exercism", "config.json")) + assert.NoError(t, err) + defer f.Close() + + // invalid JSON + _, err = f.WriteString(`{ "blurb": "Learn about the basics of Ruby by following a lasagna recipe.", "authors": ["iHiD", "pvcarr `) + assert.NoError(t, err) + + _, err = NewExerciseConfig(dir) + assert.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "unexpected end of JSON input")) +} diff --git a/workspace/exercise_metadata.go b/workspace/exercise_metadata.go new file mode 100644 index 000000000..3ec902c1b --- /dev/null +++ b/workspace/exercise_metadata.go @@ -0,0 +1,104 @@ +package workspace + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +const metadataFilename = "metadata.json" +const legacyMetadataFilename = ".solution.json" +const ignoreSubdir = ".exercism" + +var metadataFilepath = filepath.Join(ignoreSubdir, metadataFilename) + +// ExerciseMetadata contains metadata about a user's exercise. +type ExerciseMetadata struct { + Track string `json:"track"` + ExerciseSlug string `json:"exercise"` + ID string `json:"id"` + URL string `json:"url"` + Handle string `json:"handle"` + IsRequester bool `json:"is_requester"` + SubmittedAt *time.Time `json:"submitted_at,omitempty"` + Dir string `json:"-"` + AutoApprove bool `json:"auto_approve"` +} + +// NewExerciseMetadata reads exercise metadata from a file in the given directory. +func NewExerciseMetadata(dir string) (*ExerciseMetadata, error) { + b, err := os.ReadFile(filepath.Join(dir, metadataFilepath)) + if err != nil { + return nil, err + } + var metadata ExerciseMetadata + if err := json.Unmarshal(b, &metadata); err != nil { + return nil, err + } + metadata.Dir = dir + return &metadata, nil +} + +// Suffix is the serial numeric value appended to an exercise directory. +// This is appended to avoid name conflicts, and does not indicate a particular +// iteration. +func (em *ExerciseMetadata) Suffix() string { + return strings.Trim(strings.Replace(filepath.Base(em.Dir), em.ExerciseSlug, "", 1), "-.") +} + +func (em *ExerciseMetadata) String() string { + str := fmt.Sprintf("%s/%s", em.Track, em.ExerciseSlug) + if em.Suffix() != "" { + str = fmt.Sprintf("%s (%s)", str, em.Suffix()) + } + if !em.IsRequester && em.Handle != "" { + str = fmt.Sprintf("%s by @%s", str, em.Handle) + } + return str +} + +// Write stores exercise metadata to a file. +func (em *ExerciseMetadata) Write(dir string) error { + b, err := json.Marshal(em) + if err != nil { + return err + } + metadataAbsoluteFilepath := filepath.Join(dir, metadataFilepath) + if err = os.MkdirAll(filepath.Dir(metadataAbsoluteFilepath), os.FileMode(0755)); err != nil { + return err + } + if err = os.WriteFile(metadataAbsoluteFilepath, b, os.FileMode(0600)); err != nil { + return err + } + em.Dir = dir + return nil +} + +// PathToParent is the relative path from the workspace to the parent dir. +func (em *ExerciseMetadata) PathToParent() string { + var dir string + if !em.IsRequester { + dir = filepath.Join("users") + } + return filepath.Join(dir, em.Track) +} + +// Exercise is an implementation of a problem on disk. +func (em *ExerciseMetadata) Exercise(workspace string) Exercise { + return Exercise{ + Root: em.root(workspace), + Track: em.Track, + Slug: em.ExerciseSlug, + } +} + +// root represents the root of the exercise. +func (em *ExerciseMetadata) root(workspace string) string { + if !em.IsRequester { + return filepath.Join(workspace, "users", em.Handle) + } + return workspace +} diff --git a/workspace/exercise_metadata_test.go b/workspace/exercise_metadata_test.go new file mode 100644 index 000000000..0c3c7afa7 --- /dev/null +++ b/workspace/exercise_metadata_test.go @@ -0,0 +1,147 @@ +package workspace + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestExerciseMetadata(t *testing.T) { + dir, err := os.MkdirTemp("", "solution") + assert.NoError(t, err) + defer os.RemoveAll(dir) + + em1 := &ExerciseMetadata{ + Track: "a-track", + ExerciseSlug: "bogus-exercise", + ID: "abc", + URL: "http://example.com", + Handle: "alice", + IsRequester: true, + Dir: dir, + } + err = em1.Write(dir) + assert.NoError(t, err) + + em2, err := NewExerciseMetadata(dir) + assert.NoError(t, err) + assert.Nil(t, em2.SubmittedAt) + assert.Equal(t, em1, em2) + + ts := time.Date(2000, 1, 2, 3, 4, 5, 6, time.UTC) + em2.SubmittedAt = &ts + + err = em2.Write(dir) + assert.NoError(t, err) + + em3, err := NewExerciseMetadata(dir) + assert.NoError(t, err) + assert.Equal(t, em2, em3) +} + +func TestSuffix(t *testing.T) { + testCases := []struct { + metadata ExerciseMetadata + suffix string + }{ + { + metadata: ExerciseMetadata{ + ExerciseSlug: "bat", + Dir: "", + }, + suffix: "", + }, + { + metadata: ExerciseMetadata{ + ExerciseSlug: "bat", + Dir: "/path/to/bat", + }, + suffix: "", + }, + { + metadata: ExerciseMetadata{ + ExerciseSlug: "bat", + Dir: "/path/to/bat-2", + }, + suffix: "2", + }, + { + metadata: ExerciseMetadata{ + ExerciseSlug: "bat", + Dir: "/path/to/bat-200", + }, + suffix: "200", + }, + } + + for _, tc := range testCases { + testName := "Suffix of '" + tc.metadata.Dir + "' should be " + tc.suffix + t.Run(testName, func(t *testing.T) { + assert.Equal(t, tc.suffix, tc.metadata.Suffix(), testName) + }) + } +} + +func TestExerciseMetadataString(t *testing.T) { + testCases := []struct { + metadata ExerciseMetadata + desc string + }{ + { + metadata: ExerciseMetadata{ + Track: "elixir", + ExerciseSlug: "secret-handshake", + Handle: "", + Dir: "", + }, + desc: "elixir/secret-handshake", + }, + { + metadata: ExerciseMetadata{ + Track: "cpp", + ExerciseSlug: "clock", + Handle: "alice", + IsRequester: true, + }, + desc: "cpp/clock", + }, + { + metadata: ExerciseMetadata{ + Track: "cpp", + ExerciseSlug: "clock", + Handle: "alice", + IsRequester: true, + Dir: "/path/to/clock-2", + }, + desc: "cpp/clock (2)", + }, + { + metadata: ExerciseMetadata{ + Track: "fsharp", + ExerciseSlug: "hello-world", + Handle: "bob", + IsRequester: false, + }, + desc: "fsharp/hello-world by @bob", + }, + { + metadata: ExerciseMetadata{ + Track: "haskell", + ExerciseSlug: "allergies", + Handle: "charlie", + IsRequester: false, + Dir: "/path/to/allergies-2", + }, + desc: "haskell/allergies (2) by @charlie", + }, + } + + for _, tc := range testCases { + testName := "should stringify to '" + tc.desc + "'" + t.Run(testName, func(t *testing.T) { + assert.Equal(t, tc.desc, tc.metadata.String()) + }) + } +} diff --git a/workspace/exercise_test.go b/workspace/exercise_test.go new file mode 100644 index 000000000..7dd15c583 --- /dev/null +++ b/workspace/exercise_test.go @@ -0,0 +1,164 @@ +package workspace + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHasMetadata(t *testing.T) { + ws, err := os.MkdirTemp("", "fake-workspace") + defer os.RemoveAll(ws) + assert.NoError(t, err) + + exerciseA := Exercise{Root: ws, Track: "bogus-track", Slug: "apple"} + exerciseB := Exercise{Root: ws, Track: "bogus-track", Slug: "banana"} + + err = os.MkdirAll(filepath.Dir(exerciseA.MetadataFilepath()), os.FileMode(0755)) + assert.NoError(t, err) + err = os.MkdirAll(filepath.Dir(exerciseB.MetadataFilepath()), os.FileMode(0755)) + assert.NoError(t, err) + + err = os.WriteFile(exerciseA.MetadataFilepath(), []byte{}, os.FileMode(0600)) + assert.NoError(t, err) + + ok, err := exerciseA.HasMetadata() + assert.NoError(t, err) + assert.True(t, ok) + + ok, err = exerciseB.HasMetadata() + assert.NoError(t, err) + assert.False(t, ok) +} + +func TestHasLegacyMetadata(t *testing.T) { + ws, err := os.MkdirTemp("", "fake-workspace") + defer os.RemoveAll(ws) + assert.NoError(t, err) + + exerciseA := Exercise{Root: ws, Track: "bogus-track", Slug: "apple"} + exerciseB := Exercise{Root: ws, Track: "bogus-track", Slug: "banana"} + + err = os.MkdirAll(filepath.Dir(exerciseA.LegacyMetadataFilepath()), os.FileMode(0755)) + assert.NoError(t, err) + err = os.MkdirAll(filepath.Dir(exerciseB.LegacyMetadataFilepath()), os.FileMode(0755)) + assert.NoError(t, err) + + err = os.WriteFile(exerciseA.LegacyMetadataFilepath(), []byte{}, os.FileMode(0600)) + assert.NoError(t, err) + + ok, err := exerciseA.HasLegacyMetadata() + assert.NoError(t, err) + assert.True(t, ok) + + ok, err = exerciseB.HasLegacyMetadata() + assert.NoError(t, err) + assert.False(t, ok) +} + +func TestNewFromDir(t *testing.T) { + dir := filepath.Join("something", "another", "whatever", "the-track", "the-exercise") + + exercise := NewExerciseFromDir(dir) + assert.Equal(t, filepath.Join("something", "another", "whatever"), exercise.Root) + assert.Equal(t, "the-track", exercise.Track) + assert.Equal(t, "the-exercise", exercise.Slug) +} + +func TestMigrationStatusString(t *testing.T) { + assert.Equal(t, "\nMigrated metadata\n", MigrationStatusMigrated.String()) + assert.Equal(t, "\nRemoved legacy metadata\n", MigrationStatusRemoved.String()) + assert.Equal(t, "", MigrationStatusNoop.String()) + assert.Equal(t, "", MigrationStatus(-1).String()) +} + +func TestMigrateLegacyMetadataFileWithoutLegacy(t *testing.T) { + ws, err := os.MkdirTemp("", "fake-workspace") + defer os.RemoveAll(ws) + assert.NoError(t, err) + + exercise := Exercise{Root: ws, Track: "bogus-track", Slug: "no-legacy"} + metadataFilepath := exercise.MetadataFilepath() + err = os.MkdirAll(filepath.Dir(metadataFilepath), os.FileMode(0755)) + assert.NoError(t, err) + + err = os.WriteFile(metadataFilepath, []byte{}, os.FileMode(0600)) + assert.NoError(t, err) + + ok, _ := exercise.HasLegacyMetadata() + assert.False(t, ok) + ok, _ = exercise.HasMetadata() + assert.True(t, ok) + + status, err := exercise.MigrateLegacyMetadataFile() + assert.Equal(t, MigrationStatusNoop, status) + assert.NoError(t, err) + + ok, _ = exercise.HasLegacyMetadata() + assert.False(t, ok) + ok, _ = exercise.HasMetadata() + assert.True(t, ok) +} + +func TestMigrateLegacyMetadataFileWithLegacy(t *testing.T) { + ws, err := os.MkdirTemp("", "fake-workspace") + defer os.RemoveAll(ws) + assert.NoError(t, err) + + exercise := Exercise{Root: ws, Track: "bogus-track", Slug: "legacy"} + legacyMetadataFilepath := exercise.LegacyMetadataFilepath() + err = os.MkdirAll(filepath.Dir(legacyMetadataFilepath), os.FileMode(0755)) + assert.NoError(t, err) + + err = os.WriteFile(legacyMetadataFilepath, []byte{}, os.FileMode(0600)) + assert.NoError(t, err) + + ok, _ := exercise.HasLegacyMetadata() + assert.True(t, ok) + ok, _ = exercise.HasMetadata() + assert.False(t, ok) + + status, err := exercise.MigrateLegacyMetadataFile() + assert.Equal(t, MigrationStatusMigrated, status) + assert.NoError(t, err) + + ok, _ = exercise.HasLegacyMetadata() + assert.False(t, ok) + ok, _ = exercise.HasMetadata() + assert.True(t, ok) +} + +func TestMigrateLegacyMetadataFileWithLegacyAndModern(t *testing.T) { + ws, err := os.MkdirTemp("", "fake-workspace") + defer os.RemoveAll(ws) + assert.NoError(t, err) + + exercise := Exercise{Root: ws, Track: "bogus-track", Slug: "both-legacy-and-modern"} + metadataFilepath := exercise.MetadataFilepath() + legacyMetadataFilepath := exercise.LegacyMetadataFilepath() + err = os.MkdirAll(filepath.Dir(legacyMetadataFilepath), os.FileMode(0755)) + assert.NoError(t, err) + err = os.MkdirAll(filepath.Dir(metadataFilepath), os.FileMode(0755)) + assert.NoError(t, err) + + err = os.WriteFile(legacyMetadataFilepath, []byte{}, os.FileMode(0600)) + assert.NoError(t, err) + err = os.WriteFile(metadataFilepath, []byte{}, os.FileMode(0600)) + assert.NoError(t, err) + + ok, _ := exercise.HasLegacyMetadata() + assert.True(t, ok) + ok, _ = exercise.HasMetadata() + assert.True(t, ok) + + status, err := exercise.MigrateLegacyMetadataFile() + assert.Equal(t, MigrationStatusRemoved, status) + assert.NoError(t, err) + + ok, _ = exercise.HasLegacyMetadata() + assert.False(t, ok) + ok, _ = exercise.HasMetadata() + assert.True(t, ok) +} diff --git a/workspace/path_type.go b/workspace/path_type.go new file mode 100644 index 000000000..7d140eada --- /dev/null +++ b/workspace/path_type.go @@ -0,0 +1,61 @@ +package workspace + +import ( + "os" + "path/filepath" +) + +// PathType is either a path to a dir or file, or the name of an exercise. +type PathType int + +const ( + // TypeExerciseID is the name of an exercise. + TypeExerciseID PathType = iota + // TypeDir is a relative or absolute path to a directory. + TypeDir + // TypeFile is a relative or absolute path to a file. + TypeFile +) + +// DetectPathType determines whether the given path is a directory, a file, or the name of an exercise. +func DetectPathType(path string) (PathType, error) { + // If it's not an absolute path, make it one. + if !filepath.IsAbs(path) { + var err error + path, err = filepath.Abs(path) + if err != nil { + return -1, err + } + } + + // If it doesn't exist, then it's an exercise name. + // We'll have to walk the workspace to find it. + if _, err := os.Stat(path); err != nil { + return TypeExerciseID, nil + } + + // We found it. It's an actual path of some sort. + info, err := os.Lstat(path) + if err != nil { + return -1, err + } + + // If it's a symlink, resolve it. + if info.Mode()&os.ModeSymlink == os.ModeSymlink { + src, err := filepath.EvalSymlinks(path) + if err != nil { + return -1, err + } + path = src + // Overwrite the symlinked info with the source info. + info, err = os.Lstat(path) + if err != nil { + return -1, err + } + } + + if info.IsDir() { + return TypeDir, nil + } + return TypeFile, nil +} diff --git a/workspace/path_type_symlinks_test.go b/workspace/path_type_symlinks_test.go new file mode 100644 index 000000000..a80b6171a --- /dev/null +++ b/workspace/path_type_symlinks_test.go @@ -0,0 +1,31 @@ +//go:build !windows + +package workspace + +import ( + "path/filepath" + "runtime" + "testing" +) + +func TestDetectPathTypeSymlink(t *testing.T) { + _, cwd, _, _ := runtime.Caller(0) + root := filepath.Join(cwd, "..", "..", "fixtures", "detect-path-type") + + testCases := []detectPathTestCase{ + { + desc: "symlinked dir", + path: filepath.Join(root, "symlinked-dir"), + pt: TypeDir, + }, + { + desc: "symlinked file", + path: filepath.Join(root, "symlinked-file.txt"), + pt: TypeFile, + }, + } + + for _, tc := range testCases { + testDetectPathType(t, tc) + } +} diff --git a/workspace/path_type_test.go b/workspace/path_type_test.go new file mode 100644 index 000000000..28f84c9d3 --- /dev/null +++ b/workspace/path_type_test.go @@ -0,0 +1,58 @@ +package workspace + +import ( + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" +) + +type detectPathTestCase struct { + desc string + path string + pt PathType +} + +func TestDetectPathType(t *testing.T) { + _, cwd, _, _ := runtime.Caller(0) + root := filepath.Join(cwd, "..", "..", "fixtures", "detect-path-type") + + testCases := []detectPathTestCase{ + detectPathTestCase{ + desc: "absolute dir", + path: filepath.Join(root, "a-dir"), + pt: TypeDir, + }, + { + desc: "relative dir", + path: filepath.Join("..", "fixtures", "detect-path-type", "a-dir"), + pt: TypeDir, + }, + { + desc: "absolute file", + path: filepath.Join(root, "a-file.txt"), + pt: TypeFile, + }, + { + desc: "relative file", + path: filepath.Join("..", "fixtures", "detect-path-type", "a-file.txt"), + pt: TypeFile, + }, + { + desc: "exercise ID", + path: "a-file", + pt: TypeExerciseID, + }, + } + + for _, tc := range testCases { + testDetectPathType(t, tc) + } +} + +func testDetectPathType(t *testing.T, tc detectPathTestCase) { + pt, err := DetectPathType(tc.path) + assert.NoError(t, err, tc.desc) + assert.Equal(t, tc.pt, pt, tc.desc) +} diff --git a/workspace/test_configurations.go b/workspace/test_configurations.go new file mode 100644 index 000000000..7d5672f69 --- /dev/null +++ b/workspace/test_configurations.go @@ -0,0 +1,307 @@ +package workspace + +import ( + "fmt" + "runtime" + "strings" +) + +type TestConfiguration struct { + // The static portion of the test Command, which will be run for every test on this track. Examples include `cargo test` or `go test`. + // Might be empty if there are platform-specific versions + Command string + + // Windows-specific test command. Mostly relevant for tests wrapped by shell invocations. Falls back to `Command` if we're not running windows or this is empty. + WindowsCommand string +} + +func (c *TestConfiguration) GetTestCommand() (string, error) { + var cmd string + if runtime.GOOS == "windows" && c.WindowsCommand != "" { + cmd = c.WindowsCommand + } else { + cmd = c.Command + } + + // pre-declare these so we can conditionally initialize them + var exerciseConfig *ExerciseConfig + var err error + + if strings.Contains(cmd, "{{") { + // only read exercise's config.json if we need it + exerciseConfig, err = NewExerciseConfig(".") + if err != nil { + return "", err + } + } + + if strings.Contains(cmd, "{{solution_files}}") { + if exerciseConfig == nil { + return "", fmt.Errorf("exerciseConfig not initialize before use") + } + solutionFiles, err := exerciseConfig.GetSolutionFiles() + if err != nil { + return "", err + } + cmd = strings.ReplaceAll(cmd, "{{solution_files}}", strings.Join(solutionFiles, " ")) + } + if strings.Contains(cmd, "{{test_files}}") { + if exerciseConfig == nil { + return "", fmt.Errorf("exerciseConfig not initialize before use") + } + testFiles, err := exerciseConfig.GetTestFiles() + if err != nil { + return "", err + } + cmd = strings.ReplaceAll(cmd, "{{test_files}}", strings.Join(testFiles, " ")) + } + if strings.Contains(cmd, "{{slug}}") { + metadata, err := NewExerciseMetadata(".") + if err != nil { + return "", err + } + cmd = strings.ReplaceAll(cmd, "{{slug}}", metadata.ExerciseSlug) + } + + return cmd, nil +} + +// some tracks aren't (or won't be) implemented; every track is listed either way +var TestConfigurations = map[string]TestConfiguration{ + "8th": { + Command: "8th -f test.8th", + }, + // abap: tests are run via "ABAP Development Tools", not the CLI + "arm64-assembly": { + Command: "make", + }, + "arturo": { + Command: "arturo tester.art", + }, + "awk": { + Command: "bats {{test_files}}", + }, + "ballerina": { + Command: "bal test", + }, + "batch": { + WindowsCommand: "cmd /c {{test_files}}", + }, + "bash": { + Command: "bats {{test_files}}", + }, + "c": { + Command: "make", + }, + "cairo": { + Command: "scarb cairo-test", + }, + "cfml": { + Command: "box task run TestRunner", + }, + "clojure": { + // chosen because the docs recommend `clj` by default and `lein` as optional + Command: "clj -X:test", + }, + "cobol": { + Command: "bash test.sh", + WindowsCommand: "pwsh test.ps1", + }, + "coffeescript": { + Command: "jasmine-node --coffee {{test_files}}", + }, + // common-lisp: tests are loaded into a "running Lisp implementation", not the CLI directly + "cpp": { + Command: "make", + }, + "crystal": { + Command: "crystal spec", + }, + "csharp": { + Command: "dotnet test", + }, + "d": { + // this always works even if the user installed DUB + Command: "dmd source/*.d -de -w -main -unittest", + }, + "dart": { + Command: "dart test", + }, + // delphi: tests are run via IDE + "elixir": { + Command: "mix test", + }, + "elm": { + Command: "elm-test", + }, + "emacs-lisp": { + Command: "emacs -batch -l ert -l {{test_files}} -f ert-run-tests-batch-and-exit", + }, + "erlang": { + Command: "rebar3 eunit", + }, + "factor": { + Command: "factor -roots=. -run=exercism-tools {{slug}}", + }, + "fortran": { + Command: "make", + }, + "free-pascal": { + Command: "make test=all", + }, + "fsharp": { + Command: "dotnet test", + }, + "futhark": { + Command: "futhark test test.fut", + }, + "gleam": { + Command: "gleam test", + }, + "go": { + Command: "go test", + }, + "groovy": { + Command: "gradle test", + }, + "haskell": { + Command: "stack test", + }, + "idris": { + Command: "pack test {{slug}}", + }, + "j": { + Command: `jconsole -js "exit echo unittest {{test_files}} [ load {{solution_files}}"`, + }, + "java": { + Command: "./gradlew test", + WindowsCommand: "gradlew.bat test", + }, + "javascript": { + Command: "npm run test", + }, + "jq": { + Command: "bats {{test_files}}", + }, + "julia": { + Command: "julia runtests.jl", + }, + "kotlin": { + Command: "./gradlew test", + WindowsCommand: "gradlew.bat test", + }, + "lean": { + Command: "lake test", + }, + "lfe": { + Command: "make test", + }, + "lua": { + Command: "busted", + }, + "mips": { + Command: "java -jar /path/to/mars.jar nc runner.mips impl.mips", + }, + "moonscript": { + Command: "busted", + }, + "nim": { + Command: "nim r {{test_files}}", + }, + // objective-c: tests are run via XCode. There's a CLI option (ruby gem `objc`), but the docs note that this is an inferior experience + "ocaml": { + Command: "make", + }, + "odin": { + Command: "odin test .", + }, + "perl5": { + Command: "prove .", + }, + // pharo-smalltalk: tests are run via IDE + "php": { + Command: "phpunit {{test_files}}", + }, + // plsql: test are run via a "mounted oracle db" + "powershell": { + Command: "Invoke-Pester", + }, + "prolog": { + Command: "swipl -f {{solution_files}} -s {{test_files}} -g run_tests,halt -t 'halt(1)'", + }, + "purescript": { + Command: "spago test", + }, + "pyret": { + Command: "pyret {{test_files}}", + }, + "python": { + Command: "python3 -m pytest -o markers=task {{test_files}}", + }, + "r": { + Command: "Rscript {{test_files}}", + }, + "racket": { + Command: "raco test {{test_files}}", + }, + "raku": { + Command: "prove6 {{test_files}}", + }, + "reasonml": { + Command: "npm run test", + }, + "red": { + Command: "red {{test_files}}", + }, + "roc": { + Command: "roc test {{test_files}}", + }, + "ruby": { + Command: "ruby {{test_files}}", + }, + "rust": { + Command: "cargo test --", + }, + "scala": { + Command: "sbt test", + }, + // scheme: docs present 2 equally valid test methods (`make chez` and `make guile`). So I wasn't sure which to pick + "sml": { + Command: "poly -q --use {{test_files}}", + }, + "swift": { + Command: "swift test", + }, + "tcl": { + Command: "tclsh {{test_files}}", + }, + "typescript": { + Command: "yarn test", + }, + "uiua": { + Command: "uiua test {{test_files}}", + }, + // unison: tests are run from an active UCM session + "vbnet": { + Command: "dotnet test", + }, + // vimscript: tests are run from inside a vim session + "vlang": { + Command: "v -stats test run_test.v", + }, + "wasm": { + Command: "npm run test", + }, + "wren": { + Command: "wrenc {{test_files}}", + }, + "x86-64-assembly": { + Command: "make", + }, + "yamlscript": { + Command: "make test", + }, + "zig": { + Command: "zig test {{test_files}}", + }, +} diff --git a/workspace/test_configurations_test.go b/workspace/test_configurations_test.go new file mode 100644 index 000000000..b8a8097ee --- /dev/null +++ b/workspace/test_configurations_test.go @@ -0,0 +1,143 @@ +package workspace + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetCommand(t *testing.T) { + testConfig, ok := TestConfigurations["elixir"] + assert.True(t, ok, "unexpectedly unable to find elixir test config") + + cmd, err := testConfig.GetTestCommand() + assert.NoError(t, err) + + assert.Equal(t, cmd, "mix test") +} + +func TestWindowsCommands(t *testing.T) { + testConfig, ok := TestConfigurations["cobol"] + assert.True(t, ok, "unexpectedly unable to find cobol test config") + + cmd, err := testConfig.GetTestCommand() + assert.NoError(t, err) + + if runtime.GOOS == "windows" { + assert.Contains(t, cmd, ".ps1") + assert.NotContains(t, cmd, ".sh") + } else { + assert.Contains(t, cmd, ".sh") + assert.NotContains(t, cmd, ".ps1") + } +} + +func TestGetCommandMissingConfig(t *testing.T) { + testConfig, ok := TestConfigurations["ruby"] + assert.True(t, ok, "unexpectedly unable to find ruby test config") + + _, err := testConfig.GetTestCommand() + assert.Error(t, err) + // any assertions about this error message have to work across all platforms, so be vague + // unix: ".exercism/config.json: no such file or directory" + // windows: "open .exercism\config.json: The system cannot find the path specified." + assert.Contains(t, err.Error(), filepath.Join(".exercism", "config.json:")) +} + +func TestIncludesSolutionAndTestFilesInCommand(t *testing.T) { + testConfig, ok := TestConfigurations["prolog"] + assert.True(t, ok, "unexpectedly unable to find prolog test config") + + // this creates a config file in the test directory and removes it + dir := filepath.Join(".", ".exercism") + defer os.RemoveAll(dir) + err := os.Mkdir(dir, os.ModePerm) + assert.NoError(t, err) + + f, err := os.Create(filepath.Join(dir, "config.json")) + assert.NoError(t, err) + defer f.Close() + + _, err = f.WriteString(`{ "blurb": "Learn about the basics of Prolog by following a lasagna recipe.", "authors": ["iHiD", "pvcarrera"], "files": { "solution": ["lasagna.pl"], "test": ["lasagna_tests.plt"] } } `) + assert.NoError(t, err) + + cmd, err := testConfig.GetTestCommand() + assert.NoError(t, err) + assert.Equal(t, cmd, "swipl -f lasagna.pl -s lasagna_tests.plt -g run_tests,halt -t 'halt(1)'") +} + +func TestIncludesTestFilesInCommand(t *testing.T) { + testConfig, ok := TestConfigurations["ruby"] + assert.True(t, ok, "unexpectedly unable to find ruby test config") + + // this creates a config file in the test directory and removes it + dir := filepath.Join(".", ".exercism") + defer os.RemoveAll(dir) + err := os.Mkdir(dir, os.ModePerm) + assert.NoError(t, err) + + f, err := os.Create(filepath.Join(dir, "config.json")) + assert.NoError(t, err) + defer f.Close() + + _, err = f.WriteString(`{ "blurb": "Learn about the basics of Ruby by following a lasagna recipe.", "authors": ["iHiD", "pvcarrera"], "files": { "solution": ["lasagna.rb"], "test": ["lasagna_test.rb", "some_other_file.rb"], "exemplar": [".meta/exemplar.rb"] } } `) + assert.NoError(t, err) + + cmd, err := testConfig.GetTestCommand() + assert.NoError(t, err) + assert.Equal(t, cmd, "ruby lasagna_test.rb some_other_file.rb") +} + +func TestRustHasTrailingDashes(t *testing.T) { + testConfig, ok := TestConfigurations["rust"] + assert.True(t, ok, "unexpectedly unable to find rust test config") + + cmd, err := testConfig.GetTestCommand() + assert.NoError(t, err) + + assert.True(t, strings.HasSuffix(cmd, "--"), "rust's test command should have trailing dashes") +} + +func TestIdrisUsesExerciseSlug(t *testing.T) { + currentDir, err := os.Getwd() + assert.NoError(t, err) + + tmpDir, err := os.MkdirTemp("", "solution") + assert.NoError(t, err) + defer os.RemoveAll(tmpDir) + + em := &ExerciseMetadata{ + Track: "idris", + ExerciseSlug: "bogus-exercise", + ID: "abc", + URL: "http://example.com", + Handle: "alice", + IsRequester: true, + Dir: tmpDir, + } + err = em.Write(tmpDir) + assert.NoError(t, err) + + defer os.Chdir(currentDir) + err = os.Chdir(tmpDir) + assert.NoError(t, err) + + exercismDir := filepath.Join(".", ".exercism") + f, err := os.Create(filepath.Join(exercismDir, "config.json")) + assert.NoError(t, err) + defer f.Close() + + _, err = f.WriteString(`{ "files": { "solution": [ "src/BogusExercise.idr" ], "test": [ "test/src/Main.idr" ] } }`) + assert.NoError(t, err) + + testConfig, ok := TestConfigurations["idris"] + assert.True(t, ok, "unexpectedly unable to find idris test config") + + cmd, err := testConfig.GetTestCommand() + assert.NoError(t, err) + assert.Equal(t, cmd, "pack test bogus-exercise") +} diff --git a/workspace/workspace.go b/workspace/workspace.go new file mode 100644 index 000000000..dbd0604f1 --- /dev/null +++ b/workspace/workspace.go @@ -0,0 +1,127 @@ +package workspace + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" +) + +var errMissingMetadata = errors.New("no exercise metadata file found") + +// IsMissingMetadata verifies the type of error. +func IsMissingMetadata(err error) bool { + return err == errMissingMetadata +} + +// Workspace represents a user's Exercism workspace. +// It may contain a user's own exercises, and other people's +// exercises that they've downloaded to look at or run locally. +type Workspace struct { + Dir string +} + +// New returns a configured workspace. +func New(dir string) (Workspace, error) { + _, err := os.Lstat(dir) + if err != nil { + return Workspace{}, err + } + dir, err = filepath.EvalSymlinks(dir) + if err != nil { + return Workspace{}, err + } + return Workspace{Dir: dir}, nil +} + +// PotentialExercises are a first-level guess at the user's exercises. +// It looks at the workspace structurally, and guesses based on +// the location of the directory. E.g. any top level directory +// within the workspace (except 'users') is assumed to be a +// track, and any directory within there again is assumed to +// be an exercise. +func (ws Workspace) PotentialExercises() ([]Exercise, error) { + exercises := []Exercise{} + + topInfos, err := os.ReadDir(ws.Dir) + if err != nil { + return nil, err + } + for _, topInfo := range topInfos { + if !topInfo.IsDir() { + continue + } + + if topInfo.Name() == "users" { + continue + } + + subInfos, err := os.ReadDir(filepath.Join(ws.Dir, topInfo.Name())) + if err != nil { + return nil, err + } + + for _, subInfo := range subInfos { + if !subInfo.IsDir() { + continue + } + + exercises = append(exercises, Exercise{Track: topInfo.Name(), Slug: subInfo.Name(), Root: ws.Dir}) + } + } + + return exercises, nil +} + +// Exercises returns the user's exercises within the workspace. +// This doesn't find legacy exercises where the metadata is missing. +func (ws Workspace) Exercises() ([]Exercise, error) { + candidates, err := ws.PotentialExercises() + if err != nil { + return nil, err + } + + exercises := make([]Exercise, 0, len(candidates)) + for _, candidate := range candidates { + ok, err := candidate.HasMetadata() + if err != nil { + return nil, err + } + if ok { + exercises = append(exercises, candidate) + } + } + return exercises, nil +} + +// ExerciseDir determines the root directory of an exercise. +// This is the directory that contains the exercise metadata file. +func (ws Workspace) ExerciseDir(s string) (string, error) { + if !strings.HasPrefix(s, ws.Dir) { + var err = fmt.Errorf("not in workspace") + if runtime.GOOS == "darwin" { + err = fmt.Errorf("%w: directory location may be case sensitive: workspace directory: %s, "+ + "submit path: %s", err, ws.Dir, s) + } + return "", err + } + + path := s + for { + if path == ws.Dir { + return "", errMissingMetadata + } + if _, err := os.Lstat(path); os.IsNotExist(err) { + return "", err + } + if _, err := os.Lstat(filepath.Join(path, metadataFilepath)); err == nil { + return path, nil + } + if _, err := os.Lstat(filepath.Join(path, legacyMetadataFilename)); err == nil { + return path, nil + } + path = filepath.Dir(path) + } +} diff --git a/workspace/workspace_darwin_test.go b/workspace/workspace_darwin_test.go new file mode 100644 index 000000000..47bcab7ec --- /dev/null +++ b/workspace/workspace_darwin_test.go @@ -0,0 +1,26 @@ +package workspace + +import ( + "fmt" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExerciseDir_case_insensitive(t *testing.T) { + _, cwd, _, _ := runtime.Caller(0) + root := filepath.Join(cwd, "..", "..", "fixtures", "solution-dir") + // configuration file was set with "workspace" - the directory that exists + configured := Workspace{Dir: filepath.Join(root, "workspace")} + // user changes into directory with "bad" case - "Workspace" + userPath := strings.Replace(configured.Dir, "workspace", "Workspace", 1) + + _, err := configured.ExerciseDir(filepath.Join(userPath, "exercise", "file.txt")) + + assert.Error(t, err) + assert.Equal(t, fmt.Sprintf("not in workspace: directory location may be case sensitive: "+ + "workspace directory: %s, submit path: %s/exercise/file.txt", configured.Dir, userPath), err.Error()) +} diff --git a/workspace/workspace_test.go b/workspace/workspace_test.go new file mode 100644 index 000000000..8614ddc2c --- /dev/null +++ b/workspace/workspace_test.go @@ -0,0 +1,139 @@ +package workspace + +import ( + "os" + "path/filepath" + "runtime" + "sort" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWorkspacePotentialExercises(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "walk") + defer os.RemoveAll(tmpDir) + assert.NoError(t, err) + + a1 := filepath.Join(tmpDir, "track-a", "exercise-one") + b1 := filepath.Join(tmpDir, "track-b", "exercise-one") + b2 := filepath.Join(tmpDir, "track-b", "exercise-two") + + // It should ignore other people's exercises. + alice := filepath.Join(tmpDir, "users", "alice", "track-a", "exercise-one") + + // It should ignore nested dirs within exercises. + nested := filepath.Join(a1, "subdir", "deeper-dir", "another-deep-dir") + + for _, path := range []string{a1, b1, b2, alice, nested} { + err := os.MkdirAll(path, os.FileMode(0755)) + assert.NoError(t, err) + } + + ws, err := New(tmpDir) + assert.NoError(t, err) + + exercises, err := ws.PotentialExercises() + assert.NoError(t, err) + if assert.Equal(t, 3, len(exercises)) { + paths := make([]string, len(exercises)) + for i, e := range exercises { + paths[i] = e.Path() + } + + sort.Strings(paths) + assert.Equal(t, paths[0], "track-a/exercise-one") + assert.Equal(t, paths[1], "track-b/exercise-one") + assert.Equal(t, paths[2], "track-b/exercise-two") + } +} + +func TestWorkspaceExercises(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "walk-with-metadata") + defer os.RemoveAll(tmpDir) + assert.NoError(t, err) + + a1 := filepath.Join(tmpDir, "track-a", "exercise-one") + a2 := filepath.Join(tmpDir, "track-a", "exercise-two") // no metadata + b1 := filepath.Join(tmpDir, "track-b", "exercise-one") + b2 := filepath.Join(tmpDir, "track-b", "exercise-two") + + for _, path := range []string{a1, a2, b1, b2} { + metadataAbsoluteFilepath := filepath.Join(path, metadataFilepath) + err := os.MkdirAll(filepath.Dir(metadataAbsoluteFilepath), os.FileMode(0755)) + assert.NoError(t, err) + + if path != a2 { + err = os.WriteFile(metadataAbsoluteFilepath, []byte{}, os.FileMode(0600)) + assert.NoError(t, err) + } + } + + ws, err := New(tmpDir) + assert.NoError(t, err) + + exercises, err := ws.Exercises() + assert.NoError(t, err) + if assert.Equal(t, 3, len(exercises)) { + paths := make([]string, len(exercises)) + for i, e := range exercises { + paths[i] = e.Path() + } + + sort.Strings(paths) + assert.Equal(t, paths[0], "track-a/exercise-one") + assert.Equal(t, paths[1], "track-b/exercise-one") + assert.Equal(t, paths[2], "track-b/exercise-two") + } +} + +func TestExerciseDir(t *testing.T) { + _, cwd, _, _ := runtime.Caller(0) + root := filepath.Join(cwd, "..", "..", "fixtures", "solution-dir") + + ws, err := New(filepath.Join(root, "workspace")) + assert.NoError(t, err) + + tests := []struct { + path string + ok bool + }{ + { + path: filepath.Join(ws.Dir, "exercise"), + ok: true, + }, + { + path: filepath.Join(ws.Dir, "exercise", "file.txt"), + ok: true, + }, + { + path: filepath.Join(ws.Dir, "exercise", "in", "a", "subdir", "file.txt"), + ok: true, + }, + { + path: filepath.Join(ws.Dir, "exercise", "in", "a"), + ok: true, + }, + { + path: filepath.Join(ws.Dir, "not-exercise", "file.txt"), + ok: false, + }, + { + path: filepath.Join(root, "file.txt"), + ok: false, + }, + { + path: filepath.Join(ws.Dir, "exercise", "no-such-file.txt"), + ok: false, + }, + } + + for _, test := range tests { + dir, err := ws.ExerciseDir(test.path) + if !test.ok { + assert.Error(t, err, test.path) + continue + } + assert.Equal(t, filepath.Join(ws.Dir, "exercise"), dir, test.path) + } +}