├── .appends └── .github │ └── labels.yml ├── .gha.gofmt.sh ├── .github ├── ISSUE_TEMPLATE.md ├── dependabot.yml ├── labels.yml └── workflows │ ├── ci.yml │ ├── pause-community-contributions.yml │ ├── release.yml │ └── sync-labels.yml ├── .gitignore ├── .goreleaser.yml ├── .release └── header.md ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── RELEASE.md ├── api ├── api.go ├── client.go └── client_test.go ├── bin ├── build.sh ├── format.sh ├── release.sh └── test.sh ├── browser └── open.go ├── cli ├── asset.go ├── cli.go ├── cli_test.go └── release.go ├── cmd ├── cmd.go ├── cmd_test.go ├── configure.go ├── configure_test.go ├── download.go ├── download_test.go ├── open.go ├── prepare.go ├── root.go ├── submit.go ├── submit_symlink_test.go ├── submit_test.go ├── test.go ├── troubleshoot.go ├── upgrade.go ├── upgrade_test.go ├── version.go ├── version_test.go └── workspace.go ├── config ├── config.go ├── config_notwin_test.go ├── config_test.go ├── config_windows_test.go ├── persister.go ├── resolve.go ├── resolve_notwin_test.go └── resolve_windows.go ├── debug ├── debug.go └── debug_test.go ├── exercism ├── doc.go └── main.go ├── fixtures ├── detect-path-type │ ├── a-dir │ │ └── .keep │ ├── a-file.txt │ ├── symlinked-dir │ └── symlinked-file.txt ├── is-solution-path │ ├── broken │ │ └── .exercism │ │ │ └── metadata.json │ ├── nope │ │ └── .keep │ └── yepp │ │ └── .exercism │ │ └── metadata.json ├── locate-exercise │ ├── equipment │ │ └── bat │ │ │ └── .keep │ ├── food │ │ └── squash │ │ │ └── .keep │ ├── symlinked-workspace │ └── workspace │ │ ├── actions │ │ ├── batten │ │ │ └── .keep │ │ ├── date │ │ │ └── .keep │ │ └── squash │ │ │ └── .keep │ │ ├── creatures │ │ ├── bat │ │ │ └── .keep │ │ ├── crane-2 │ │ │ └── .keep │ │ ├── crane │ │ │ └── .keep │ │ ├── duck │ │ │ └── .keep │ │ └── horse │ │ │ └── .keep │ │ ├── food │ │ ├── date │ │ └── squash │ │ ├── friends │ │ └── alice │ │ │ └── creatures │ │ │ ├── bat │ │ │ └── .keep │ │ │ └── fly │ │ │ └── .keep │ │ └── text-files │ │ ├── date │ │ └── duck ├── solution-dir │ ├── file.txt │ └── workspace │ │ ├── exercise │ │ ├── .exercism │ │ │ └── metadata.json │ │ ├── file.txt │ │ └── in │ │ │ └── a │ │ │ └── subdir │ │ │ └── file.txt │ │ └── not-exercise │ │ └── file.txt ├── solution-path │ └── creatures │ │ ├── gazelle-2 │ │ └── .exercism │ │ │ └── metadata.json │ │ ├── gazelle-3 │ │ └── .exercism │ │ │ └── metadata.json │ │ └── gazelle │ │ └── .exercism │ │ └── metadata.json └── solutions │ ├── alpha │ └── .exercism │ │ └── metadata.json │ ├── bravo │ └── .exercism │ │ └── metadata.json │ ├── charlie │ └── .exercism │ │ └── metadata.json │ └── delta │ └── .keep ├── go.mod ├── go.sum ├── shell ├── README.md ├── exercism.fish ├── exercism_completion.bash └── exercism_completion.zsh └── workspace ├── document.go ├── document_test.go ├── errors.go ├── exercise.go ├── exercise_config.go ├── exercise_config_test.go ├── exercise_metadata.go ├── exercise_metadata_test.go ├── exercise_test.go ├── path_type.go ├── path_type_symlinks_test.go ├── path_type_test.go ├── test_configurations.go ├── test_configurations_test.go ├── workspace.go ├── workspace_darwin_test.go └── workspace_test.go /.appends/.github/labels.yml: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------------------- # 2 | # These are the repository-specific labels that augment the Exercise-wide labels defined in # 3 | # https://github.com/exercism/org-wide-files/blob/main/global-files/.github/labels.yml. # 4 | # ----------------------------------------------------------------------------------------- # 5 | 6 | - name: "bug?" 7 | description: "" 8 | color: "eb6420" 9 | -------------------------------------------------------------------------------- /.gha.gofmt.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd "$(dirname $0)" 4 | if [ -n "$(go fmt ./...)" ]; then 5 | echo "Go code is not formatted, run 'go fmt github.com/exercism/cli/...'" >&2 6 | exit 1 7 | fi 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | # Keep dependencies for GitHub Actions up-to-date 5 | - package-ecosystem: 'github-actions' 6 | directory: '/' 7 | schedule: 8 | interval: 'monthly' 9 | labels: 10 | - 'x:size/small' 11 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | # --------------------------------------------------------------- # 2 | # This is an auto-generated file - Do not manually edit this file # 3 | # --------------------------------------------------------------- # 4 | 5 | # This file is automatically generated by concatenating two files: 6 | # 7 | # 1. The Exercism-wide labels: defined in https://github.com/exercism/org-wide-files/blob/main/global-files/.github/labels.yml 8 | # 2. The repository-specific labels: defined in the `.appends/.github/labels.yml` file within this repository. 9 | # 10 | # If any of these two files change, a pull request is automatically created containing a re-generated version of this file. 11 | # Consequently, to change repository-specific labels you should update the `.appends/.github/labels.yml` file and _not_ this file. 12 | # 13 | # When the pull request has been merged, the GitHub labels will be automatically updated by the "Sync labels" workflow. 14 | # This typically takes 5-10 minutes. 15 | 16 | # --------------------------------------------------------------------- # 17 | # These are the Exercism-wide labels which are shared across all repos. # 18 | # --------------------------------------------------------------------- # 19 | 20 | # The following Exercism-wide labels are used to show "tasks" on the website, which will point users to things they can contribute to. 21 | 22 | # The `x:action/` labels describe what sort of work the contributor will be engaged in when working on the issue 23 | - name: "x:action/create" 24 | description: "Work on something from scratch" 25 | color: "ffffff" 26 | 27 | - name: "x:action/fix" 28 | description: "Fix an issue" 29 | color: "ffffff" 30 | 31 | - name: "x:action/improve" 32 | description: "Improve existing functionality/content" 33 | color: "ffffff" 34 | 35 | - name: "x:action/proofread" 36 | description: "Proofread text" 37 | color: "ffffff" 38 | 39 | - name: "x:action/sync" 40 | description: "Sync content with its latest version" 41 | color: "ffffff" 42 | 43 | # The `x:knowledge/` labels describe how much Exercism knowledge is required by the contributor 44 | - name: "x:knowledge/none" 45 | description: "No existing Exercism knowledge required" 46 | color: "ffffff" 47 | 48 | - name: "x:knowledge/elementary" 49 | description: "Little Exercism knowledge required" 50 | color: "ffffff" 51 | 52 | - name: "x:knowledge/intermediate" 53 | description: "Quite a bit of Exercism knowledge required" 54 | color: "ffffff" 55 | 56 | - name: "x:knowledge/advanced" 57 | description: "Comprehensive Exercism knowledge required" 58 | color: "ffffff" 59 | 60 | # The `x:module/` labels indicate what part of Exercism the contributor will be working on 61 | - name: "x:module/analyzer" 62 | description: "Work on Analyzers" 63 | color: "ffffff" 64 | 65 | - name: "x:module/concept" 66 | description: "Work on Concepts" 67 | color: "ffffff" 68 | 69 | - name: "x:module/concept-exercise" 70 | description: "Work on Concept Exercises" 71 | color: "ffffff" 72 | 73 | - name: "x:module/generator" 74 | description: "Work on Exercise generators" 75 | color: "ffffff" 76 | 77 | - name: "x:module/practice-exercise" 78 | description: "Work on Practice Exercises" 79 | color: "ffffff" 80 | 81 | - name: "x:module/representer" 82 | description: "Work on Representers" 83 | color: "ffffff" 84 | 85 | - name: "x:module/test-runner" 86 | description: "Work on Test Runners" 87 | color: "ffffff" 88 | 89 | # The `x:rep/` labels describe the amount of reputation to award 90 | # 91 | # For more information on reputation and how these labels should be used, 92 | # check out https://exercism.org/docs/using/product/reputation 93 | - name: "x:rep/tiny" 94 | description: "Tiny amount of reputation" 95 | color: "ffffff" 96 | 97 | - name: "x:rep/small" 98 | description: "Small amount of reputation" 99 | color: "ffffff" 100 | 101 | - name: "x:rep/medium" 102 | description: "Medium amount of reputation" 103 | color: "ffffff" 104 | 105 | - name: "x:rep/large" 106 | description: "Large amount of reputation" 107 | color: "ffffff" 108 | 109 | - name: "x:rep/massive" 110 | description: "Massive amount of reputation" 111 | color: "ffffff" 112 | 113 | # The `x:size/` labels describe the expected amount of work for a contributor 114 | - name: "x:size/tiny" 115 | description: "Tiny amount of work" 116 | color: "ffffff" 117 | 118 | - name: "x:size/small" 119 | description: "Small amount of work" 120 | color: "ffffff" 121 | 122 | - name: "x:size/medium" 123 | description: "Medium amount of work" 124 | color: "ffffff" 125 | 126 | - name: "x:size/large" 127 | description: "Large amount of work" 128 | color: "ffffff" 129 | 130 | - name: "x:size/massive" 131 | description: "Massive amount of work" 132 | color: "ffffff" 133 | 134 | # The `x:status/` label indicates if there is already someone working on the issue 135 | - name: "x:status/claimed" 136 | description: "Someone is working on this issue" 137 | color: "ffffff" 138 | 139 | # The `x:type/` labels describe what type of work the contributor will be engaged in 140 | - name: "x:type/ci" 141 | description: "Work on Continuous Integration (e.g. GitHub Actions workflows)" 142 | color: "ffffff" 143 | 144 | - name: "x:type/coding" 145 | description: "Write code that is not student-facing content (e.g. test-runners, generators, but not exercises)" 146 | color: "ffffff" 147 | 148 | - name: "x:type/content" 149 | description: "Work on content (e.g. exercises, concepts)" 150 | color: "ffffff" 151 | 152 | - name: "x:type/docker" 153 | description: "Work on Dockerfiles" 154 | color: "ffffff" 155 | 156 | - name: "x:type/docs" 157 | description: "Work on Documentation" 158 | color: "ffffff" 159 | 160 | # This Exercism-wide label is added to all automatically created pull requests that help migrate/prepare a track for Exercism v3 161 | - name: "v3-migration 🤖" 162 | description: "Preparing for Exercism v3" 163 | color: "e99695" 164 | 165 | # This Exercism-wide label can be used to bulk-close issues in preparation for pausing community contributions 166 | - name: "paused" 167 | description: "Work paused until further notice" 168 | color: "e4e669" 169 | 170 | # ----------------------------------------------------------------------------------------- # 171 | # These are the repository-specific labels that augment the Exercise-wide labels defined in # 172 | # https://github.com/exercism/org-wide-files/blob/main/global-files/.github/labels.yml. # 173 | # ----------------------------------------------------------------------------------------- # 174 | 175 | - name: "bug?" 176 | description: "" 177 | color: "eb6420" 178 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | tests: 15 | name: Go ${{ matrix.go-version }} - ${{ matrix.os }} 16 | runs-on: ${{ matrix.os }} 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | go-version: 21 | - '1.20.x' 22 | - '1.21.x' 23 | os: [ubuntu-latest, windows-latest, macOS-latest] 24 | 25 | steps: 26 | - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 27 | 28 | - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 29 | with: 30 | go-version: ${{ matrix.go-version }} 31 | 32 | - name: Run Tests 33 | run: | 34 | go test -cover ./... 35 | shell: bash 36 | 37 | formatting: 38 | name: Go Format 39 | runs-on: ubuntu-latest 40 | 41 | steps: 42 | - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 43 | 44 | - name: Check formatting 45 | run: ./.gha.gofmt.sh 46 | -------------------------------------------------------------------------------- /.github/workflows/pause-community-contributions.yml: -------------------------------------------------------------------------------- 1 | name: Pause Community Contributions 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | pull_request_target: 8 | types: 9 | - opened 10 | 11 | permissions: 12 | issues: write 13 | pull-requests: write 14 | 15 | jobs: 16 | pause: 17 | if: github.repository_owner == 'exercism' # Stops this job from running on forks 18 | uses: exercism/github-actions/.github/workflows/community-contributions.yml@main 19 | with: 20 | forum_category: support 21 | secrets: 22 | github_membership_token: ${{ secrets.COMMUNITY_CONTRIBUTIONS_WORKFLOW_TOKEN }} 23 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' # semver release tags 7 | - 'v*.*.*-*' # pre-release tags for testing 8 | 9 | permissions: 10 | contents: write # needed by goreleaser/goreleaser-action for publishing release artifacts 11 | 12 | jobs: 13 | goreleaser: 14 | runs-on: ubuntu-22.04 15 | steps: 16 | 17 | - name: Checkout code 18 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Set up Go 23 | uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 24 | with: 25 | go-version: '1.20.x' 26 | 27 | - name: Import GPG Key 28 | id: import_gpg 29 | uses: crazy-max/ghaction-import-gpg@01dd5d3ca463c7f10f7f4f7b4f177225ac661ee4 # v6.1.0 30 | with: 31 | gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} 32 | passphrase: ${{ secrets.PASSPHRASE }} 33 | 34 | - name: Cut Release 35 | uses: goreleaser/goreleaser-action@286f3b13b1b49da4ac219696163fb8c1c93e1200 # v6.0.0 36 | with: 37 | version: latest 38 | args: release --clean --release-header .release/header.md --timeout 120m # default time is 30m 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} 42 | -------------------------------------------------------------------------------- /.github/workflows/sync-labels.yml: -------------------------------------------------------------------------------- 1 | name: Tools 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - .github/labels.yml 9 | - .github/workflows/sync-labels.yml 10 | workflow_dispatch: 11 | schedule: 12 | - cron: 0 0 1 * * # First day of each month 13 | 14 | permissions: 15 | issues: write 16 | 17 | jobs: 18 | sync-labels: 19 | uses: exercism/github-actions/.github/workflows/labels.yml@main 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | vendor/ 10 | dist/ 11 | 12 | # Architecture specific extensions/prefixes 13 | *.[568vq] 14 | [568vq].out 15 | 16 | *.cgo1.go 17 | *.cgo2.c 18 | _cgo_defun.c 19 | _cgo_gotypes.go 20 | _cgo_export.* 21 | 22 | _testmain.go 23 | 24 | *.exe 25 | 26 | out/ 27 | release/ 28 | go-exercism 29 | testercism 30 | 31 | # Intellij 32 | /.idea 33 | 34 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # You can find the GoReleaser documentation at http://goreleaser.com 2 | version: 2 3 | project_name: exercism 4 | 5 | env: 6 | - CGO_ENABLED=0 7 | builds: 8 | - id: release-build 9 | main: ./exercism/main.go 10 | mod_timestamp: "{{ .CommitTimestamp }}" 11 | flags: 12 | - -trimpath # removes file system paths from compiled executable 13 | ldflags: 14 | - "-s -w" # strip debug symbols and DWARF debugging info 15 | goos: 16 | - darwin 17 | - linux 18 | - windows 19 | - freebsd 20 | - openbsd 21 | goarch: 22 | - amd64 23 | - 386 24 | - arm 25 | - arm64 26 | - ppc64 27 | goarm: 28 | - 5 29 | - 6 30 | ignore: 31 | - goos: openbsd 32 | goarch: arm 33 | - goos: freebsd 34 | goarch: arm 35 | - id: installer-build 36 | main: ./exercism/main.go 37 | mod_timestamp: "{{ .CommitTimestamp }}" 38 | flags: 39 | - -trimpath # removes file system paths from compiled executable 40 | ldflags: 41 | - "-s -w" # strip debug symbols and DWARF debugging info 42 | goos: 43 | - windows 44 | goarch: 45 | - amd64 46 | - 386 47 | 48 | changelog: 49 | sort: asc 50 | filters: 51 | exclude: 52 | - "^docs:" 53 | - "^test:" 54 | 55 | archives: 56 | - id: release-archives 57 | builds: 58 | - release-build 59 | name_template: >- 60 | {{- .ProjectName }}- 61 | {{- .Version }}- 62 | {{- .Os }}- 63 | {{- if eq .Arch "amd64" }}x86_64 64 | {{- else if eq .Arch "386" }}i386 65 | {{- else }}{{- .Arch }}{{ end }} 66 | {{- if .Arm }}v{{- .Arm }}{{ end }} 67 | format_overrides: 68 | - goos: windows 69 | format: zip 70 | files: 71 | - shell/** 72 | - LICENSE 73 | - README.md 74 | - id: installer-archives 75 | builds: 76 | - installer-build 77 | name_template: >- 78 | {{- .ProjectName }}- 79 | {{- .Version }}- 80 | {{- .Os }}- 81 | {{- if eq .Arch "amd64" }}64bit 82 | {{- else if eq .Arch "386" }}32bit 83 | {{- else }}{{- .Arch }}{{ end }} 84 | {{- if .Arm }}v{{- .Arm }}{{ end }} 85 | format_overrides: 86 | - goos: windows 87 | format: zip 88 | files: 89 | - shell/** 90 | - LICENSE 91 | - README.md 92 | 93 | checksum: 94 | name_template: "{{ .ProjectName }}_checksums.txt" 95 | ids: 96 | - release-archives 97 | - installer-archives 98 | 99 | signs: 100 | - artifacts: checksum 101 | args: 102 | [ 103 | "--batch", 104 | "-u", 105 | "{{ .Env.GPG_FINGERPRINT }}", 106 | "--output", 107 | "${signature}", 108 | "--detach-sign", 109 | "${artifact}", 110 | ] 111 | 112 | release: 113 | # Repo in which the release will be created. 114 | # Default is extracted from the origin remote URL. 115 | github: 116 | owner: exercism 117 | name: cli 118 | 119 | # If set to true, will not auto-publish the release. 120 | # Default is false. 121 | draft: true 122 | 123 | # If set to auto, will mark the release as not ready for production 124 | # in case there is an indicator for this in the tag e.g. v1.0.0-rc1 125 | # If set to true, will mark the release as not ready for production. 126 | # Default is false. 127 | prerelease: auto 128 | 129 | # You can change the name of the GitHub release. 130 | # Default is `{{.Tag}}` 131 | name_template: "{{.ProjectName}}-v{{.Version}} {{.Env.USER}}" 132 | -------------------------------------------------------------------------------- /.release/header.md: -------------------------------------------------------------------------------- 1 | To install, follow the interactive installation instructions at https://exercism.org/cli-walkthrough 2 | 3 | --- 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Introduction 4 | 5 | Exercism is a platform centered around empathetic conversation. 6 | We have a low tolerance for communication that makes anyone feel unwelcome, unsupported, insulted or discriminated against. 7 | 8 | ## Seen or experienced something uncomfortable? 9 | 10 | 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. 11 | We will follow up with you as a priority. 12 | 13 | ## Enforcement 14 | 15 | We actively monitor for Code of Conduct (CoC) violations and take any reports of violations extremely seriously. 16 | We have banned contributors, mentors and users due to violations. 17 | 18 | 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. 19 | 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. 20 | 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. 21 | We strive to be fair, but will err on the side of protecting the culture of our community. 22 | 23 | Exercism's leadership reserve the right to take whatever action they feel appropriate with regards to CoC violations. 24 | 25 | ## The simple version 26 | 27 | - Be empathetic 28 | - Be welcoming 29 | - Be kind 30 | - Be honest 31 | - Be supportive 32 | - Be polite 33 | 34 | ## The details 35 | 36 | Exercism should be a safe place for everybody regardless of 37 | 38 | - Gender, gender identity or gender expression 39 | - Sexual orientation 40 | - Disability 41 | - Physical appearance (including but not limited to body size) 42 | - Race 43 | - Age 44 | - Religion 45 | - Anything else you can think of 46 | 47 | As someone who is part of this community, you agree that: 48 | 49 | - We are collectively and individually committed to safety and inclusivity 50 | - We have zero tolerance for abuse, harassment, or discrimination 51 | - We respect people’s boundaries and identities 52 | - We refrain from using language that can be considered offensive or oppressive (systemically or otherwise), eg. sexist, racist, homophobic, transphobic, ableist, classist, etc. 53 | - this includes (but is not limited to) various slurs. 54 | - We avoid using offensive topics as a form of humor 55 | 56 | We actively work towards: 57 | 58 | - Being a safe community 59 | - Cultivating a network of support & encouragement for each other 60 | - Encouraging responsible and varied forms of expression 61 | 62 | We condemn: 63 | 64 | - Stalking, doxxing, or publishing private information 65 | - Violence, threats of violence or violent language 66 | - Anything that compromises people’s safety 67 | - Conduct or speech which might be considered sexist, racist, homophobic, transphobic, ableist or otherwise discriminatory or offensive in nature 68 | - The use of unwelcome, suggestive, derogatory or inappropriate nicknames or terms 69 | - Disrespect towards others (jokes, innuendo, dismissive attitudes) and towards differences of opinion 70 | - Intimidation or harassment (online or in-person). 71 | Please read the [Citizen Code of Conduct](https://github.com/stumpsyn/policies/blob/master/citizen_code_of_conduct.md) for how we interpret harassment 72 | - Inappropriate attention or contact 73 | - Not understanding the differences between constructive criticism and disparagement 74 | 75 | These things are NOT OK. 76 | 77 | Be aware of how your actions affect others. 78 | If it makes someone uncomfortable, stop. 79 | 80 | If you say something that is found offensive, and you are called out on it, try to: 81 | 82 | - Listen without interruption 83 | - Believe what the person is saying & do not attempt to disqualify what they have to say 84 | - Ask for tips / help with avoiding making the offense in the future 85 | - Apologize and ask forgiveness 86 | 87 | ## History 88 | 89 | This policy was initially adopted from the Front-end London Slack community and has been modified since. 90 | A version history can be seen on [GitHub](https://github.com/exercism/website-copy/edit/main/pages/code_of_conduct.md). 91 | 92 | _This policy is a "living" document, and subject to refinement and expansion in the future. 93 | 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._ 94 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guide 2 | 3 | First, thank you! :tada: 4 | Exercism would be impossible without people like you being willing to spend time and effort making things better. 5 | 6 | ## Documentation 7 | * [Exercism Documentation Repository](https://github.com/exercism/docs) 8 | 9 | ## Dependencies 10 | 11 | You'll need Go version 1.20 or higher. Follow the directions on http://golang.org/doc/install 12 | 13 | ## Development 14 | 15 | This project uses Go's [`modules` dependency management](https://github.com/golang/go/wiki/Modules) system. 16 | 17 | To contribute [fork this repo on the GitHub webpage][fork] and clone your fork. 18 | Make your desired changes and submit a pull request. 19 | Please provide tests for the changes where possible. 20 | 21 | Please note that if your development directory is located inside the `GOPATH`, you need to set the `GO111MODULE=on` environment variable. 22 | 23 | ## Running the Tests 24 | 25 | To run the tests locally 26 | 27 | ``` 28 | go test ./... 29 | ``` 30 | 31 | ## Manual Testing against Exercism 32 | 33 | To test your changes while doing everyday Exercism work you 34 | can build using the following instructions. Any name may be used for the 35 | binary (e.g. `testercism`) - by using a name other than `exercism` you 36 | can have different profiles under `~/.config` and avoid possibly 37 | damaging your real Exercism submissions, or test different tokens, etc. 38 | 39 | On Unices: 40 | 41 | - `cd /path/to/the/development/directory/cli && go build -o testercism ./exercism/main.go` 42 | - `./testercism -h` 43 | 44 | On Windows: 45 | 46 | - `cd /d \path\to\the\development\directory\cli` 47 | - `go build -o testercism.exe exercism\main.go` 48 | - `testercism.exe —h` 49 | 50 | ### Releasing a new CLI version 51 | Consult the [release documentation](RELEASE.md). 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Mike Gehard, Exercism 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Exercism Command-line Interface (CLI) 2 | 3 | [![CI](https://github.com/exercism/cli/actions/workflows/ci.yml/badge.svg)](https://github.com/exercism/cli/actions/workflows/ci.yml) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/exercism/cli)](https://goreportcard.com/report/github.com/exercism/cli) 5 | 6 | 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. 7 | 8 | This CLI ships as a binary with no additional runtime requirements. 9 | 10 | ## Installing the CLI 11 | 12 | Instructions can be found at [exercism/cli/releases](https://github.com/exercism/cli/releases) 13 | 14 | ## Contributing 15 | 16 | If you wish to help improve the CLI, please see the [Contributing guide][contributing]. 17 | 18 | [exercism]: http://exercism.org 19 | [contributing]: /CONTRIBUTING.md 20 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Cutting a CLI Release 2 | 3 | The Exercism CLI uses [GoReleaser](https://goreleaser.com) to automate the release process. 4 | 5 | ## Requirements 6 | 7 | 1. [Install GoReleaser](https://goreleaser.com/install/) 8 | 1. [Setup GitHub token](https://goreleaser.com/scm/github/) 9 | 1. Have a gpg key installed on your machine - it is [used for signing the artifacts](https://goreleaser.com/customization/sign/) 10 | 11 | ## Bump the version 12 | 13 | 1. Create a branch for the new version 14 | 1. Bump the `Version` constant in `cmd/version.go` 15 | 1. Update the `CHANGELOG.md` file to include a section for the new version and its changes. 16 | Hint: you can view changes using the compare view: https://github.com/exercism/cli/compare/$PREVIOUS_RELEASE...main. 17 | 1. Commit the updated files 18 | 1. Create a PR 19 | 20 | _Note: It's useful to add the version to the commit message when you bump it: e.g. `Bump version to v2.3.4`._ 21 | 22 | ## Cut a release 23 | 24 | Once the version bump PR has been merged, run the following command to cut a release: 25 | 26 | ```shell 27 | GPG_FINGERPRINT="" ./bin/release.sh 28 | ``` 29 | 30 | ## Cut Release on GitHub 31 | 32 | Once the `./bin/release.sh` command finishes, the [release workflow](https://github.com/exercism/cli/actions/workflows/release.yml) will automatically run. 33 | This workflow will create a draft release at https://github.com/exercism/cli/releases/tag/vX.Y.Z. 34 | Once created, go that page to update the release description to: 35 | 36 | ``` 37 | To install, follow the interactive installation instructions at https://exercism.org/cli-walkthrough 38 | --- 39 | 40 | [modify the generated release-notes to describe changes in this release] 41 | ``` 42 | 43 | Lastly, test and then publish the draft. 44 | 45 | ## Homebrew 46 | 47 | Homebrew will automatically bump the version, no manual action is required. 48 | 49 | ## Update the docs site 50 | 51 | If there are any significant changes, we should describe them on 52 | [exercism.org/cli](https://exercism.org/cli). 53 | 54 | The codebase lives at [exercism/website-copy](https://github.com/exercism/website-copy) in `pages/cli.md`. 55 | -------------------------------------------------------------------------------- /api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | 7 | "golang.org/x/net/html/charset" 8 | "golang.org/x/text/transform" 9 | ) 10 | 11 | const ( 12 | mimeType = "text/plain" 13 | ) 14 | 15 | var ( 16 | utf8BOM = []byte{0xef, 0xbb, 0xbf} 17 | ) 18 | 19 | func readFileAsUTF8String(filename string) (*string, error) { 20 | b, err := os.ReadFile(filename) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | encoding, _, certain := charset.DetermineEncoding(b, mimeType) 26 | if !certain { 27 | // We don't want to use an uncertain encoding. 28 | // In particular, doing that may mangle UTF-8 files 29 | // that have only ASCII in their first 1024 bytes. 30 | // See https://github.com/exercism/cli/issues/309. 31 | // So if we're unsure, use UTF-8 (no transformation). 32 | s := string(b) 33 | return &s, nil 34 | } 35 | decoder := encoding.NewDecoder() 36 | decodedBytes, _, err := transform.Bytes(decoder, b) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | // Drop the UTF-8 BOM that may have been added. This isn't necessary, and 42 | // it's going to be written into another UTF-8 buffer anyway once it's JSON 43 | // serialized. 44 | // 45 | // The standard recommends omitting the BOM. See 46 | // http://www.unicode.org/versions/Unicode5.0.0/ch02.pdf 47 | decodedBytes = bytes.TrimPrefix(decodedBytes, utf8BOM) 48 | 49 | s := string(decodedBytes) 50 | return &s, nil 51 | } 52 | -------------------------------------------------------------------------------- /api/client.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/exercism/cli/debug" 10 | ) 11 | 12 | var ( 13 | // UserAgent lets the API know where the call is being made from. 14 | // It's overridden from the root command so that we can set the version. 15 | UserAgent = "github.com/exercism/cli" 16 | 17 | // TimeoutInSeconds is the timeout the default HTTP client will use. 18 | TimeoutInSeconds = 60 19 | // HTTPClient is the client used to make HTTP calls in the cli package. 20 | HTTPClient = &http.Client{Timeout: time.Duration(TimeoutInSeconds) * time.Second} 21 | ) 22 | 23 | // Client is an http client that is configured for Exercism. 24 | type Client struct { 25 | *http.Client 26 | ContentType string 27 | Token string 28 | APIBaseURL string 29 | } 30 | 31 | // NewClient returns an Exercism API client. 32 | func NewClient(token, baseURL string) (*Client, error) { 33 | return &Client{ 34 | Client: HTTPClient, 35 | Token: token, 36 | APIBaseURL: baseURL, 37 | }, nil 38 | } 39 | 40 | // NewRequest returns an http.Request with information for the Exercism API. 41 | func (c *Client) NewRequest(method, url string, body io.Reader) (*http.Request, error) { 42 | if c.Client == nil { 43 | c.Client = HTTPClient 44 | } 45 | 46 | req, err := http.NewRequest(method, url, body) 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | req.Header.Set("User-Agent", UserAgent) 52 | if c.ContentType == "" { 53 | req.Header.Set("Content-Type", "application/json") 54 | } else { 55 | req.Header.Set("Content-Type", c.ContentType) 56 | } 57 | if c.Token != "" { 58 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.Token)) 59 | } 60 | 61 | return req, nil 62 | } 63 | 64 | // Do performs an http.Request and optionally parses the response body into the given interface. 65 | func (c *Client) Do(req *http.Request) (*http.Response, error) { 66 | debug.DumpRequest(req) 67 | 68 | res, err := c.Client.Do(req) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | debug.DumpResponse(res) 74 | return res, nil 75 | } 76 | 77 | // TokenIsValid calls the API to determine whether the token is valid. 78 | func (c *Client) TokenIsValid() (bool, error) { 79 | url := fmt.Sprintf("%s/validate_token", c.APIBaseURL) 80 | req, err := c.NewRequest("GET", url, nil) 81 | if err != nil { 82 | return false, err 83 | } 84 | resp, err := c.Do(req) 85 | if err != nil { 86 | return false, err 87 | } 88 | defer resp.Body.Close() 89 | 90 | return resp.StatusCode == http.StatusOK, nil 91 | } 92 | 93 | // IsPingable calls the API /ping to determine whether the API can be reached. 94 | func (c *Client) IsPingable() error { 95 | url := fmt.Sprintf("%s/ping", c.APIBaseURL) 96 | req, err := c.NewRequest("GET", url, nil) 97 | if err != nil { 98 | return err 99 | } 100 | resp, err := c.Do(req) 101 | if err != nil { 102 | return err 103 | } 104 | defer resp.Body.Close() 105 | 106 | if resp.StatusCode != http.StatusOK { 107 | return fmt.Errorf("API returned %s", resp.Status) 108 | } 109 | return nil 110 | } 111 | -------------------------------------------------------------------------------- /api/client_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestNewRequestSetsDefaultHeaders(t *testing.T) { 14 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 15 | fmt.Fprint(w, `ok`) 16 | })) 17 | defer ts.Close() 18 | 19 | UserAgent = "BogusAgent" 20 | 21 | testCases := []struct { 22 | desc string 23 | client *Client 24 | auth string 25 | contentType string 26 | }{ 27 | { 28 | desc: "User defaults", 29 | client: &Client{}, 30 | auth: "", 31 | contentType: "application/json", 32 | }, 33 | { 34 | desc: "Override defaults", 35 | client: &Client{ 36 | Token: "abc123", 37 | APIBaseURL: "http://example.com", 38 | ContentType: "bogus", 39 | }, 40 | auth: "Bearer abc123", 41 | contentType: "bogus", 42 | }, 43 | } 44 | 45 | for _, tc := range testCases { 46 | t.Run(tc.desc, func(t *testing.T) { 47 | req, err := tc.client.NewRequest("GET", ts.URL, nil) 48 | assert.NoError(t, err) 49 | assert.Equal(t, "BogusAgent", req.Header.Get("User-Agent")) 50 | assert.Equal(t, tc.contentType, req.Header.Get("Content-Type")) 51 | assert.Equal(t, tc.auth, req.Header.Get("Authorization")) 52 | }) 53 | } 54 | } 55 | 56 | func TestDo(t *testing.T) { 57 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 58 | assert.Equal(t, "GET", r.Method) 59 | 60 | fmt.Fprint(w, `{"hello": "world"}`) 61 | })) 62 | defer ts.Close() 63 | 64 | type payload struct { 65 | Hello string `json:"hello"` 66 | } 67 | 68 | client := &Client{} 69 | 70 | req, err := client.NewRequest("GET", ts.URL, nil) 71 | assert.NoError(t, err) 72 | 73 | res, err := client.Do(req) 74 | assert.NoError(t, err) 75 | 76 | var body payload 77 | err = json.NewDecoder(res.Body).Decode(&body) 78 | assert.NoError(t, err) 79 | 80 | assert.Equal(t, "world", body.Hello) 81 | } 82 | -------------------------------------------------------------------------------- /bin/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | go build -o testercism ./exercism/main.go 4 | -------------------------------------------------------------------------------- /bin/format.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | go fmt ./... 4 | -------------------------------------------------------------------------------- /bin/release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | if [[ -z "${GPG_FINGERPRINT}" ]]; then 6 | echo "GPG_FINGERPRINT environment variable is not set" 7 | exit 1 8 | fi 9 | 10 | echo "Syncing repo with latest main..." 11 | git checkout main 12 | git pull 13 | 14 | VERSION=$(sed -n -E 's/^const Version = "([0-9]+\.[0-9]+\.[0-9]+)"$/\1/p' cmd/version.go) 15 | TAG_NAME="v${VERSION}" 16 | 17 | echo "Verify release can be built..." 18 | goreleaser --skip=publish --snapshot --clean 19 | 20 | echo "Pushing tag..." 21 | git tag -a "${TAG_NAME}" -m "Release ${TAG_NAME}" 22 | git push origin "${TAG_NAME}" 23 | 24 | echo "Tag pushed" 25 | echo "The release CI workflow will automatically create a draft release." 26 | echo "Once created, edit the release notes and publish it." 27 | -------------------------------------------------------------------------------- /bin/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | go test ./... 4 | -------------------------------------------------------------------------------- /browser/open.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "os/exec" 5 | "runtime" 6 | "strings" 7 | ) 8 | 9 | // Open opens a browser to the given URL. 10 | // The terminal's open command is operating system dependent. 11 | func Open(url string) error { 12 | // Escape characters are not allowed by cmd/bash. 13 | switch runtime.GOOS { 14 | case "windows": 15 | url = strings.Replace(url, "&", `^&`, -1) 16 | default: 17 | url = strings.Replace(url, "&", `\&`, -1) 18 | } 19 | 20 | // The command to open the browser is OS-dependent. 21 | var cmd *exec.Cmd 22 | switch runtime.GOOS { 23 | case "darwin": 24 | cmd = exec.Command("open", url) 25 | case "freebsd", "linux", "netbsd", "openbsd": 26 | cmd = exec.Command("xdg-open", url) 27 | case "windows": 28 | cmd = exec.Command("cmd", "/c", "start", url) 29 | } 30 | 31 | return cmd.Run() 32 | } 33 | -------------------------------------------------------------------------------- /cli/asset.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | ) 9 | 10 | // Asset is a build for a particular system, uploaded to a GitHub release. 11 | type Asset struct { 12 | ID int `json:"id"` 13 | Name string `json:"name"` 14 | ContentType string `json:"content_type"` 15 | } 16 | 17 | func (a *Asset) download() (*bytes.Reader, error) { 18 | downloadURL := fmt.Sprintf("%s/assets/%d", ReleaseURL, a.ID) 19 | req, err := http.NewRequest("GET", downloadURL, nil) 20 | if err != nil { 21 | return nil, err 22 | } 23 | // https://developer.github.com/v3/repos/releases/#get-a-single-release-asset 24 | req.Header.Set("Accept", "application/octet-stream") 25 | res, err := http.DefaultClient.Do(req) 26 | if err != nil { 27 | return nil, err 28 | } 29 | defer res.Body.Close() 30 | 31 | bs, err := io.ReadAll(res.Body) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | return bytes.NewReader(bs), nil 37 | } 38 | -------------------------------------------------------------------------------- /cli/cli.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "archive/tar" 5 | "archive/zip" 6 | "bytes" 7 | "compress/gzip" 8 | "encoding/json" 9 | "fmt" 10 | "io" 11 | "net/http" 12 | "os" 13 | "runtime" 14 | "strings" 15 | "time" 16 | 17 | "github.com/blang/semver" 18 | "github.com/exercism/cli/debug" 19 | update "github.com/inconshreveable/go-update" 20 | ) 21 | 22 | var ( 23 | // BuildOS is the operating system (GOOS) used during the build process. 24 | BuildOS string 25 | // BuildARM is the ARM version (GOARM) used during the build process. 26 | BuildARM string 27 | // BuildARCH is the architecture (GOARCH) used during the build process. 28 | BuildARCH string 29 | ) 30 | 31 | var ( 32 | osMap = map[string]string{ 33 | "darwin": "darwin", 34 | "freebsd": "freebsd", 35 | "linux": "linux", 36 | "openbsd": "openbsd", 37 | "windows": "windows", 38 | } 39 | 40 | archMap = map[string]string{ 41 | "386": "i386", 42 | "amd64": "x86_64", 43 | "arm": "arm", 44 | "ppc64": "ppc64", 45 | } 46 | ) 47 | 48 | var ( 49 | // TimeoutInSeconds is the timeout the default HTTP client will use. 50 | TimeoutInSeconds = 60 51 | // HTTPClient is the client used to make HTTP calls in the cli package. 52 | HTTPClient = &http.Client{Timeout: time.Duration(TimeoutInSeconds) * time.Second} 53 | // ReleaseURL is the endpoint that provides information about cli releases. 54 | ReleaseURL = "https://api.github.com/repos/exercism/cli/releases" 55 | ) 56 | 57 | // Updater is a simple upgradable file interface. 58 | type Updater interface { 59 | IsUpToDate() (bool, error) 60 | Upgrade() error 61 | } 62 | 63 | // CLI is information about the CLI itself. 64 | type CLI struct { 65 | Version string 66 | LatestRelease *Release 67 | } 68 | 69 | // New creates a CLI, setting it to a particular version. 70 | func New(version string) *CLI { 71 | return &CLI{ 72 | Version: version, 73 | } 74 | } 75 | 76 | // IsUpToDate compares the current version to that of the latest release. 77 | func (c *CLI) IsUpToDate() (bool, error) { 78 | if c.LatestRelease == nil { 79 | if err := c.fetchLatestRelease(); err != nil { 80 | return false, err 81 | } 82 | } 83 | 84 | rv, err := semver.Make(c.LatestRelease.Version()) 85 | if err != nil { 86 | return false, fmt.Errorf("unable to parse latest version (%s): %s", c.LatestRelease.Version(), err) 87 | } 88 | cv, err := semver.Make(c.Version) 89 | if err != nil { 90 | return false, fmt.Errorf("unable to parse current version (%s): %s", c.Version, err) 91 | } 92 | 93 | return cv.GTE(rv), nil 94 | } 95 | 96 | // Upgrade allows the user to upgrade to the latest version of the CLI. 97 | func (c *CLI) Upgrade() error { 98 | var ( 99 | OS = osMap[runtime.GOOS] 100 | ARCH = archMap[runtime.GOARCH] 101 | ) 102 | 103 | if OS == "" || ARCH == "" { 104 | return fmt.Errorf("unable to upgrade: OS %s ARCH %s", OS, ARCH) 105 | } 106 | 107 | buildName := fmt.Sprintf("%s-%s", OS, ARCH) 108 | if BuildARCH == "arm" { 109 | if BuildARM == "" { 110 | return fmt.Errorf("unable to upgrade: arm version not found") 111 | } 112 | buildName = fmt.Sprintf("%s-v%s", buildName, BuildARM) 113 | } 114 | 115 | var downloadRC *bytes.Reader 116 | for _, a := range c.LatestRelease.Assets { 117 | if strings.Contains(a.Name, buildName) { 118 | debug.Printf("Downloading %s\n", a.Name) 119 | var err error 120 | downloadRC, err = a.download() 121 | if err != nil { 122 | return fmt.Errorf("error downloading executable: %s", err) 123 | } 124 | break 125 | } 126 | } 127 | if downloadRC == nil { 128 | return fmt.Errorf("no executable found for %s/%s%s", BuildOS, BuildARCH, BuildARM) 129 | } 130 | 131 | bin, err := extractBinary(downloadRC, OS) 132 | if err != nil { 133 | return err 134 | } 135 | defer bin.Close() 136 | 137 | return update.Apply(bin, update.Options{}) 138 | } 139 | 140 | func (c *CLI) fetchLatestRelease() error { 141 | latestReleaseURL := fmt.Sprintf("%s/%s", ReleaseURL, "latest") 142 | resp, err := HTTPClient.Get(latestReleaseURL) 143 | if err != nil { 144 | return err 145 | } 146 | defer resp.Body.Close() 147 | 148 | if resp.StatusCode > 399 { 149 | msg := "failed to get the latest release\n" 150 | for k, v := range resp.Header { 151 | msg += fmt.Sprintf("\n %s:\n %s", k, v) 152 | } 153 | return fmt.Errorf(msg) 154 | } 155 | 156 | var rel Release 157 | if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { 158 | return err 159 | } 160 | c.LatestRelease = &rel 161 | return nil 162 | } 163 | 164 | func extractBinary(source *bytes.Reader, platform string) (binary io.ReadCloser, err error) { 165 | if platform == "windows" { 166 | zr, err := zip.NewReader(source, int64(source.Len())) 167 | if err != nil { 168 | return nil, err 169 | } 170 | 171 | for _, f := range zr.File { 172 | info := f.FileInfo() 173 | if info.IsDir() || !strings.HasSuffix(f.Name, ".exe") { 174 | continue 175 | } 176 | return f.Open() 177 | } 178 | } else { 179 | gr, err := gzip.NewReader(source) 180 | if err != nil { 181 | return nil, err 182 | } 183 | defer gr.Close() 184 | 185 | tr := tar.NewReader(gr) 186 | for { 187 | _, err := tr.Next() 188 | if err == io.EOF { 189 | break 190 | } 191 | if err != nil { 192 | return nil, err 193 | } 194 | tmpfile, err := os.CreateTemp("", "temp-exercism") 195 | if err != nil { 196 | return nil, err 197 | } 198 | 199 | if _, err = io.Copy(tmpfile, tr); err != nil { 200 | return nil, err 201 | } 202 | if _, err := tmpfile.Seek(0, 0); err != nil { 203 | return nil, err 204 | } 205 | 206 | binary = tmpfile 207 | } 208 | } 209 | 210 | return binary, nil 211 | } 212 | -------------------------------------------------------------------------------- /cli/cli_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestIsUpToDate(t *testing.T) { 13 | testCases := []struct { 14 | desc string 15 | cliVersion string 16 | releaseTag string 17 | ok bool 18 | }{ 19 | { 20 | desc: "It returns false for versions less than release.", 21 | cliVersion: "1.0.0", 22 | releaseTag: "v1.0.1", 23 | ok: false, 24 | }, 25 | { 26 | desc: "It returns false for pre-release versions of release.", 27 | cliVersion: "1.0.1-alpha.1", 28 | releaseTag: "v1.0.1", 29 | ok: false, 30 | }, 31 | { 32 | desc: "It returns true for versions equal to release.", 33 | cliVersion: "2.0.1", 34 | releaseTag: "v2.0.1", 35 | ok: true, 36 | }, 37 | { 38 | desc: "It returns true for versions greater than release.", 39 | cliVersion: "2.0.2", 40 | releaseTag: "v2.0.1", 41 | ok: true, 42 | }, 43 | } 44 | 45 | for _, tc := range testCases { 46 | t.Run(tc.desc, func(t *testing.T) { 47 | c := &CLI{ 48 | Version: tc.cliVersion, 49 | LatestRelease: &Release{TagName: tc.releaseTag}, 50 | } 51 | 52 | ok, err := c.IsUpToDate() 53 | assert.NoError(t, err) 54 | assert.Equal(t, tc.ok, ok, tc.cliVersion) 55 | }) 56 | } 57 | } 58 | 59 | func TestIsUpToDateWithoutRelease(t *testing.T) { 60 | fakeEndpoint := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 61 | // Checking for the latest release should call latestReleaseURL endpoint. 62 | // if the code below fails to return the proper response then the URL generation logic in pkg cli has changed. 63 | if r.URL.Path != "/latest" { 64 | fmt.Fprintln(w, "") 65 | } 66 | fmt.Fprintln(w, `{"tag_name": "v2.0.0"}`) 67 | }) 68 | ts := httptest.NewServer(fakeEndpoint) 69 | defer ts.Close() 70 | ReleaseURL = ts.URL 71 | 72 | c := &CLI{ 73 | Version: "1.0.0", 74 | } 75 | 76 | ok, err := c.IsUpToDate() 77 | assert.NoError(t, err) 78 | assert.False(t, ok) 79 | assert.NotNil(t, c.LatestRelease) 80 | } 81 | -------------------------------------------------------------------------------- /cli/release.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import "strings" 4 | 5 | // Release is a specific build of the CLI, released on GitHub. 6 | type Release struct { 7 | Location string `json:"html_url"` 8 | TagName string `json:"tag_name"` 9 | Assets []Asset `json:"assets"` 10 | } 11 | 12 | // Version is the CLI version that is built for the release. 13 | func (r *Release) Version() string { 14 | return strings.TrimPrefix(r.TagName, "v") 15 | } 16 | -------------------------------------------------------------------------------- /cmd/cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | 9 | "io" 10 | 11 | "github.com/exercism/cli/config" 12 | "github.com/spf13/viper" 13 | ) 14 | 15 | var ( 16 | // BinaryName is the name of the app. 17 | // By default this is exercism, but people 18 | // are free to name this however they want. 19 | // The usage examples and help strings should reflect 20 | // the actual name of the binary. 21 | BinaryName string 22 | // Out is used to write to information. 23 | Out io.Writer 24 | // Err is used to write errors. 25 | Err io.Writer 26 | ) 27 | 28 | const msgWelcomePleaseConfigure = ` 29 | 30 | Welcome to Exercism! 31 | 32 | To get started, you need to configure the tool with your API token. 33 | Find your token at 34 | 35 | %s 36 | 37 | Then run the configure command: 38 | 39 | %s configure --token=YOUR_TOKEN 40 | 41 | ` 42 | 43 | // Running configure without any arguments will attempt to 44 | // set the default workspace. If the default workspace directory 45 | // risks clobbering an existing directory, it will print an 46 | // error message that explains how to proceed. 47 | const msgRerunConfigure = ` 48 | 49 | Please re-run the configure command to define where 50 | to download the exercises. 51 | 52 | %s configure 53 | ` 54 | 55 | const msgMissingMetadata = ` 56 | 57 | The exercise you are submitting doesn't have the necessary metadata. 58 | Please see https://github.com/exercism/website-copy/blob/main/pages/cli_v1_to_v2.md for instructions on how to fix it. 59 | 60 | ` 61 | 62 | // validateUserConfig validates the presence of required user config values 63 | func validateUserConfig(cfg *viper.Viper) error { 64 | if cfg.GetString("token") == "" { 65 | return fmt.Errorf( 66 | msgWelcomePleaseConfigure, 67 | config.SettingsURL(cfg.GetString("apibaseurl")), 68 | BinaryName, 69 | ) 70 | } 71 | if cfg.GetString("workspace") == "" || cfg.GetString("apibaseurl") == "" { 72 | return fmt.Errorf(msgRerunConfigure, BinaryName) 73 | } 74 | return nil 75 | } 76 | 77 | // decodedAPIError decodes and returns the error message from the API response. 78 | // If the message is blank, it returns a fallback message with the status code. 79 | func decodedAPIError(resp *http.Response) error { 80 | var apiError struct { 81 | Error struct { 82 | Type string `json:"type"` 83 | Message string `json:"message"` 84 | PossibleTrackIDs []string `json:"possible_track_ids"` 85 | } `json:"error,omitempty"` 86 | } 87 | if err := json.NewDecoder(resp.Body).Decode(&apiError); err != nil { 88 | return fmt.Errorf("failed to parse API error response: %s", err) 89 | } 90 | if apiError.Error.Message != "" { 91 | if apiError.Error.Type == "track_ambiguous" { 92 | return fmt.Errorf( 93 | "%s: %s", 94 | apiError.Error.Message, 95 | strings.Join(apiError.Error.PossibleTrackIDs, ", "), 96 | ) 97 | } 98 | return fmt.Errorf(apiError.Error.Message) 99 | } 100 | return fmt.Errorf("unexpected API response: %d", resp.StatusCode) 101 | } 102 | -------------------------------------------------------------------------------- /cmd/cmd_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "testing" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | const cfgHomeKey = "EXERCISM_CONFIG_HOME" 13 | 14 | // CommandTest makes it easier to write tests for Cobra commands. 15 | // 16 | // To initialize, give it the three fields Cmd, InitFn, and Args. 17 | // Then call Setup, and defer the Teardown. 18 | // The args are the faked out os.Args. The first two arguments 19 | // in the Args will be ignored. These represent the command (e.g. exercism) 20 | // and the subcommand (e.g. download). 21 | // Pass any interactive responses needed for the test in a single 22 | // String in MockInput, delimited by newlines. 23 | // 24 | // Finally, when you have done whatever other setup you need in your 25 | // test, call the command by calling Execute on the App. 26 | // 27 | // Example: 28 | // 29 | // cmdTest := &CommandTest{ 30 | // Cmd: myCmd, 31 | // InitFn: initMyCmd, 32 | // Args: []string{"fakeapp", "mycommand", "arg1", "--flag", "value"}, 33 | // MockInteractiveResponse: "first-input\nsecond\n", 34 | // } 35 | // 36 | // cmdTest.Setup(t) 37 | // defer cmdTest.Teardown(t) 38 | // ... 39 | // cmdTest.App.Execute() 40 | type CommandTest struct { 41 | App *cobra.Command 42 | Cmd *cobra.Command 43 | InitFn func() 44 | TmpDir string 45 | Args []string 46 | MockInteractiveResponse string 47 | OriginalValues struct { 48 | ConfigHome string 49 | Args []string 50 | } 51 | } 52 | 53 | // Setup does all the prep and initialization for testing a command. 54 | // It creates a fake Cobra app to provide a clean harness for the test, 55 | // and adds the command under test to it as a subcommand. 56 | // It also resets and reconfigures the command under test to 57 | // make sure we're not getting any accidental pollution from the existing 58 | // environment or other tests. Lastly, because we need to override some of 59 | // the global environment settings, the setup method also stores the existing 60 | // values so that Teardown can set them back the way they were when the test 61 | // has completed. 62 | // The method takes a *testing.T as an argument, that way the method can 63 | // fail the test if the creation of the temporary directory fails. 64 | func (test *CommandTest) Setup(t *testing.T) { 65 | dir, err := os.MkdirTemp("", "command-test") 66 | defer os.RemoveAll(dir) 67 | assert.NoError(t, err) 68 | 69 | test.TmpDir = dir 70 | test.OriginalValues.ConfigHome = os.Getenv(cfgHomeKey) 71 | test.OriginalValues.Args = os.Args 72 | 73 | os.Setenv(cfgHomeKey, test.TmpDir) 74 | 75 | os.Args = test.Args 76 | 77 | test.Cmd.ResetFlags() 78 | test.InitFn() 79 | 80 | test.App = &cobra.Command{} 81 | test.App.AddCommand(test.Cmd) 82 | test.App.SetOutput(Err) 83 | } 84 | 85 | // Teardown puts the environment back the way it was before the test. 86 | // The method takes a *testing.T so that it can blow up if it fails to 87 | // clean up after itself. 88 | func (test *CommandTest) Teardown(t *testing.T) { 89 | os.Setenv(cfgHomeKey, test.OriginalValues.ConfigHome) 90 | os.Args = test.OriginalValues.Args 91 | if err := os.RemoveAll(test.TmpDir); err != nil { 92 | t.Fatal(err) 93 | } 94 | } 95 | 96 | // capturedOutput lets us more easily redirect streams in the tests. 97 | type capturedOutput struct { 98 | oldOut, oldErr, newOut, newErr io.Writer 99 | } 100 | 101 | // newCapturedOutput creates a new value to override the streams. 102 | func newCapturedOutput() capturedOutput { 103 | return capturedOutput{ 104 | oldOut: Out, 105 | oldErr: Err, 106 | newOut: io.Discard, 107 | newErr: io.Discard, 108 | } 109 | } 110 | 111 | // override sets the package variables to the fake streams. 112 | func (co capturedOutput) override() { 113 | Out = co.newOut 114 | Err = co.newErr 115 | } 116 | 117 | // reset puts back the original streams for the commands to write to. 118 | func (co capturedOutput) reset() { 119 | Out = co.oldOut 120 | Err = co.oldErr 121 | } 122 | -------------------------------------------------------------------------------- /cmd/configure.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | "text/tabwriter" 8 | 9 | "github.com/exercism/cli/api" 10 | "github.com/exercism/cli/config" 11 | "github.com/spf13/cobra" 12 | "github.com/spf13/pflag" 13 | "github.com/spf13/viper" 14 | ) 15 | 16 | var ( 17 | viperConfig *viper.Viper 18 | ) 19 | 20 | // configureCmd configures the command-line client with user-specific settings. 21 | var configureCmd = &cobra.Command{ 22 | Use: "configure", 23 | Aliases: []string{"c"}, 24 | Short: "Configure the command-line client.", 25 | Long: `Configure the command-line client to customize it to your needs. 26 | 27 | This lets you set up the CLI to talk to the API on your behalf, 28 | and tells the CLI about your setup so it puts things in the right 29 | places. 30 | 31 | You can also override certain default settings to suit your preferences. 32 | `, 33 | RunE: func(cmd *cobra.Command, args []string) error { 34 | configuration := config.NewConfig() 35 | 36 | viperConfig.AddConfigPath(configuration.Dir) 37 | viperConfig.SetConfigName("user") 38 | viperConfig.SetConfigType("json") 39 | // Ignore error. If the file doesn't exist, that is fine. 40 | _ = viperConfig.ReadInConfig() 41 | configuration.UserViperConfig = viperConfig 42 | 43 | return runConfigure(configuration, cmd.Flags()) 44 | }, 45 | } 46 | 47 | func runConfigure(configuration config.Config, flags *pflag.FlagSet) error { 48 | cfg := configuration.UserViperConfig 49 | 50 | // Show the existing configuration and exit. 51 | show, err := flags.GetBool("show") 52 | if err != nil { 53 | return err 54 | } 55 | if show { 56 | printCurrentConfig(configuration) 57 | return nil 58 | } 59 | 60 | // If the command is run 'bare' and we have no token, 61 | // explain how to set the token. 62 | if flags.NFlag() == 0 && cfg.GetString("token") == "" { 63 | tokenURL := config.SettingsURL(cfg.GetString("apibaseurl")) 64 | return fmt.Errorf("There is no token configured. Find your token on %s, and call this command again with --token=.", tokenURL) 65 | } 66 | 67 | // Determine the base API URL. 68 | baseURL, err := flags.GetString("api") 69 | if err != nil { 70 | return err 71 | } 72 | if baseURL == "" { 73 | baseURL = cfg.GetString("apibaseurl") 74 | } 75 | if baseURL == "" { 76 | baseURL = configuration.DefaultBaseURL 77 | } 78 | 79 | // By default we verify that 80 | // - the configured API URL is reachable. 81 | // - the configured token is valid. 82 | skipVerification, err := flags.GetBool("no-verify") 83 | if err != nil { 84 | return err 85 | } 86 | 87 | // Is the API URL reachable? 88 | if !skipVerification { 89 | client, err := api.NewClient("", baseURL) 90 | if err != nil { 91 | return err 92 | } 93 | 94 | if err := client.IsPingable(); err != nil { 95 | return fmt.Errorf("The base API URL '%s' cannot be reached.\n\n%s", baseURL, err) 96 | } 97 | } 98 | // Finally, configure the URL. 99 | cfg.Set("apibaseurl", baseURL) 100 | 101 | // Determine the token. 102 | token, err := flags.GetString("token") 103 | if err != nil { 104 | return err 105 | } 106 | if token == "" { 107 | token = cfg.GetString("token") 108 | } 109 | 110 | tokenURL := config.SettingsURL(cfg.GetString("apibaseurl")) 111 | 112 | // If we don't have a token then explain how to set it and bail. 113 | if token == "" { 114 | return fmt.Errorf("There is no token configured. Find your token on %s, and call this command again with --token=.", tokenURL) 115 | } 116 | 117 | // Verify that the token is valid. 118 | if !skipVerification { 119 | client, err := api.NewClient(token, baseURL) 120 | if err != nil { 121 | return err 122 | } 123 | ok, err := client.TokenIsValid() 124 | if err != nil { 125 | return err 126 | } 127 | if !ok { 128 | return fmt.Errorf("The token '%s' is invalid. Find your token on %s.", token, tokenURL) 129 | } 130 | } 131 | 132 | // Finally, configure the token. 133 | cfg.Set("token", token) 134 | 135 | // Determine the workspace. 136 | workspace, err := flags.GetString("workspace") 137 | if err != nil { 138 | return err 139 | } 140 | if workspace == "" { 141 | workspace = cfg.GetString("workspace") 142 | } 143 | workspace = config.Resolve(workspace, configuration.Home) 144 | 145 | if workspace != "" { 146 | // If there is a non-directory here, then we cannot proceed. 147 | if info, err := os.Lstat(workspace); !os.IsNotExist(err) && !info.IsDir() { 148 | msg := ` 149 | 150 | There is already something at the workspace location you are configuring: 151 | 152 | %s 153 | 154 | Please rename it, or set a different workspace location: 155 | 156 | %s configure %s --workspace=PATH_TO_DIFFERENT_FOLDER 157 | ` 158 | 159 | return fmt.Errorf(msg, workspace, BinaryName, commandify(flags)) 160 | } 161 | } 162 | 163 | if workspace == "" { 164 | workspace = config.DefaultWorkspaceDir(configuration) 165 | 166 | // If it already exists don't clobber it with the default. 167 | if _, err := os.Lstat(workspace); !os.IsNotExist(err) { 168 | msg := ` 169 | The default Exercism workspace is 170 | 171 | %s 172 | 173 | There is already something there. 174 | If it's a directory, that might be fine. 175 | If it's a file, you will need to move it first, or choose a 176 | different location for the workspace. 177 | 178 | You can choose the workspace location by rerunning this command 179 | with the --workspace flag. 180 | 181 | %s configure %s --workspace=%s 182 | ` 183 | 184 | return fmt.Errorf(msg, workspace, BinaryName, commandify(flags), workspace) 185 | } 186 | } 187 | // Configure the workspace. 188 | cfg.Set("workspace", workspace) 189 | 190 | // Persist the new configuration. 191 | if err := configuration.Save("user"); err != nil { 192 | return err 193 | } 194 | fmt.Fprintln(Err, "\nYou have configured the Exercism command-line client:") 195 | printCurrentConfig(configuration) 196 | return nil 197 | } 198 | 199 | func printCurrentConfig(configuration config.Config) { 200 | w := tabwriter.NewWriter(Err, 0, 0, 2, ' ', 0) 201 | defer w.Flush() 202 | 203 | v := configuration.UserViperConfig 204 | 205 | fmt.Fprintln(w, "") 206 | fmt.Fprintln(w, fmt.Sprintf("Config dir:\t\t%s", configuration.Dir)) 207 | fmt.Fprintln(w, fmt.Sprintf("Token:\t(-t, --token)\t%s", v.GetString("token"))) 208 | fmt.Fprintln(w, fmt.Sprintf("Workspace:\t(-w, --workspace)\t%s", v.GetString("workspace"))) 209 | fmt.Fprintln(w, fmt.Sprintf("API Base URL:\t(-a, --api)\t%s", v.GetString("apibaseurl"))) 210 | fmt.Fprintln(w, "") 211 | } 212 | 213 | func commandify(flags *pflag.FlagSet) string { 214 | var cmd string 215 | fn := func(f *pflag.Flag) { 216 | if f.Changed { 217 | cmd = fmt.Sprintf("%s --%s=%s", cmd, f.Name, f.Value.String()) 218 | } 219 | } 220 | flags.VisitAll(fn) 221 | return strings.TrimLeft(cmd, " ") 222 | } 223 | 224 | func initConfigureCmd() { 225 | viperConfig = viper.New() 226 | setupConfigureFlags(configureCmd.Flags()) 227 | } 228 | 229 | func setupConfigureFlags(flags *pflag.FlagSet) { 230 | flags.StringP("token", "t", "", "authentication token used to connect to the site") 231 | flags.StringP("workspace", "w", "", "directory for exercism exercises") 232 | flags.StringP("api", "a", "", "API base url") 233 | flags.BoolP("show", "s", false, "show the current configuration") 234 | flags.BoolP("no-verify", "", false, "skip online token authorization check") 235 | } 236 | 237 | func init() { 238 | RootCmd.AddCommand(configureCmd) 239 | 240 | initConfigureCmd() 241 | } 242 | -------------------------------------------------------------------------------- /cmd/configure_test.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package cmd 4 | 5 | import ( 6 | "bytes" 7 | "net/http" 8 | "net/http/httptest" 9 | "os" 10 | "path/filepath" 11 | "testing" 12 | 13 | "github.com/exercism/cli/config" 14 | "github.com/spf13/pflag" 15 | "github.com/spf13/viper" 16 | "github.com/stretchr/testify/assert" 17 | ) 18 | 19 | func TestBareConfigure(t *testing.T) { 20 | flags := pflag.NewFlagSet("fake", pflag.PanicOnError) 21 | setupConfigureFlags(flags) 22 | 23 | v := viper.New() 24 | err := flags.Parse([]string{}) 25 | assert.NoError(t, err) 26 | 27 | cfg := config.Config{ 28 | Persister: config.InMemoryPersister{}, 29 | UserViperConfig: v, 30 | DefaultBaseURL: "http://example.com", 31 | } 32 | 33 | err = runConfigure(cfg, flags) 34 | if assert.Error(t, err) { 35 | assert.Regexp(t, "no token configured", err.Error()) 36 | } 37 | } 38 | 39 | func TestConfigureShow(t *testing.T) { 40 | co := newCapturedOutput() 41 | co.newErr = &bytes.Buffer{} 42 | co.override() 43 | defer co.reset() 44 | 45 | flags := pflag.NewFlagSet("fake", pflag.PanicOnError) 46 | setupConfigureFlags(flags) 47 | 48 | v := viper.New() 49 | v.Set("token", "configured-token") 50 | v.Set("workspace", "configured-workspace") 51 | v.Set("apibaseurl", "http://configured.example.com") 52 | 53 | // it will ignore any flags 54 | args := []string{ 55 | "--show", 56 | "--api", "http://override.example.com", 57 | "--token", "token-override", 58 | "--workspace", "workspace-override", 59 | } 60 | err := flags.Parse(args) 61 | assert.NoError(t, err) 62 | 63 | cfg := config.Config{ 64 | Persister: config.InMemoryPersister{}, 65 | UserViperConfig: v, 66 | } 67 | 68 | err = runConfigure(cfg, flags) 69 | assert.NoError(t, err) 70 | 71 | assert.Regexp(t, "configured.example", Err) 72 | assert.NotRegexp(t, "override.example", Err) 73 | 74 | assert.Regexp(t, "configured-token", Err) 75 | assert.NotRegexp(t, "token-override", Err) 76 | 77 | assert.Regexp(t, "configured-workspace", Err) 78 | assert.NotRegexp(t, "workspace-override", Err) 79 | } 80 | 81 | func TestConfigureToken(t *testing.T) { 82 | co := newCapturedOutput() 83 | co.override() 84 | defer co.reset() 85 | 86 | testCases := []struct { 87 | desc string 88 | configured string 89 | args []string 90 | expected string 91 | message string 92 | err bool 93 | }{ 94 | { 95 | desc: "It doesn't lose a configured value", 96 | configured: "existing-token", 97 | args: []string{"--no-verify"}, 98 | expected: "existing-token", 99 | }, 100 | { 101 | desc: "It writes a token when passed as a flag", 102 | configured: "", 103 | args: []string{"--no-verify", "--token", "a-token"}, 104 | expected: "a-token", 105 | }, 106 | { 107 | desc: "It overwrites the token", 108 | configured: "old-token", 109 | args: []string{"--no-verify", "--token", "replacement-token"}, 110 | expected: "replacement-token", 111 | }, 112 | { 113 | desc: "It complains when token is neither configured nor passed", 114 | configured: "", 115 | args: []string{"--no-verify"}, 116 | expected: "", 117 | err: true, 118 | message: "no token configured", 119 | }, 120 | { 121 | desc: "It validates the existing token if we're not skipping validations", 122 | configured: "configured-token", 123 | args: []string{}, 124 | expected: "configured-token", 125 | err: true, 126 | message: "token.*invalid", 127 | }, 128 | { 129 | desc: "It validates the replacement token if we're not skipping validations", 130 | configured: "", 131 | args: []string{"--token", "invalid-token"}, 132 | expected: "", 133 | err: true, 134 | message: "token.*invalid", 135 | }, 136 | } 137 | 138 | endpoint := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 139 | if r.URL.Path == "/validate_token" { 140 | w.WriteHeader(http.StatusUnauthorized) 141 | } 142 | }) 143 | ts := httptest.NewServer(endpoint) 144 | defer ts.Close() 145 | 146 | for _, tc := range testCases { 147 | flags := pflag.NewFlagSet("fake", pflag.PanicOnError) 148 | setupConfigureFlags(flags) 149 | 150 | v := viper.New() 151 | v.Set("token", tc.configured) 152 | 153 | err := flags.Parse(tc.args) 154 | assert.NoError(t, err) 155 | 156 | cfg := config.Config{ 157 | Persister: config.InMemoryPersister{}, 158 | UserViperConfig: v, 159 | DefaultBaseURL: ts.URL, 160 | } 161 | 162 | err = runConfigure(cfg, flags) 163 | if err != nil || tc.err { 164 | assert.Regexp(t, tc.message, err.Error(), tc.desc) 165 | } 166 | assert.Equal(t, tc.expected, cfg.UserViperConfig.GetString("token"), tc.desc) 167 | } 168 | } 169 | 170 | func TestConfigureAPIBaseURL(t *testing.T) { 171 | co := newCapturedOutput() 172 | co.override() 173 | defer co.reset() 174 | 175 | endpoint := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 176 | if r.URL.Path == "/ping" { 177 | w.WriteHeader(http.StatusNotFound) 178 | } 179 | }) 180 | ts := httptest.NewServer(endpoint) 181 | defer ts.Close() 182 | 183 | testCases := []struct { 184 | desc string 185 | configured string 186 | args []string 187 | expected string 188 | message string 189 | err bool 190 | }{ 191 | { 192 | desc: "It doesn't lose a configured value", 193 | configured: "http://example.com", 194 | args: []string{"--no-verify"}, 195 | expected: "http://example.com", 196 | }, 197 | { 198 | desc: "It writes a base url when passed as a flag", 199 | configured: "", 200 | args: []string{"--no-verify", "--api", "http://api.example.com"}, 201 | expected: "http://api.example.com", 202 | }, 203 | { 204 | desc: "It overwrites the base url", 205 | configured: "http://old.example.com", 206 | args: []string{"--no-verify", "--api", "http://replacement.example.com"}, 207 | expected: "http://replacement.example.com", 208 | }, 209 | { 210 | desc: "It validates the existing base url if we're not skipping validations", 211 | configured: ts.URL, 212 | args: []string{"--token", "some-token"}, // need to bypass the error message on "bare configure" 213 | expected: ts.URL, 214 | err: true, 215 | message: "API.*cannot be reached", 216 | }, 217 | { 218 | desc: "It validates the replacement base URL if we're not skipping validations", 219 | configured: "", 220 | args: []string{"--api", ts.URL}, 221 | expected: "", 222 | err: true, 223 | message: "API.*cannot be reached", 224 | }, 225 | } 226 | 227 | for _, tc := range testCases { 228 | flags := pflag.NewFlagSet("fake", pflag.PanicOnError) 229 | setupConfigureFlags(flags) 230 | 231 | v := viper.New() 232 | v.Set("apibaseurl", tc.configured) 233 | 234 | err := flags.Parse(tc.args) 235 | assert.NoError(t, err) 236 | 237 | cfg := config.Config{ 238 | Persister: config.InMemoryPersister{}, 239 | UserViperConfig: v, 240 | DefaultBaseURL: ts.URL, 241 | } 242 | 243 | err = runConfigure(cfg, flags) 244 | if err != nil || tc.err { 245 | assert.Regexp(t, tc.message, err.Error(), tc.desc) 246 | } 247 | assert.Equal(t, tc.expected, cfg.UserViperConfig.GetString("apibaseurl"), tc.desc) 248 | } 249 | } 250 | 251 | func TestConfigureWorkspace(t *testing.T) { 252 | co := newCapturedOutput() 253 | co.override() 254 | defer co.reset() 255 | 256 | testCases := []struct { 257 | desc string 258 | configured string 259 | args []string 260 | expected string 261 | message string 262 | err bool 263 | }{ 264 | { 265 | desc: "It doesn't lose a configured value", 266 | configured: "/the-workspace", 267 | args: []string{"--no-verify"}, 268 | expected: "/the-workspace", 269 | }, 270 | { 271 | desc: "It writes a workspace when passed as a flag", 272 | configured: "", 273 | args: []string{"--no-verify", "--workspace", "/new-workspace"}, 274 | expected: "/new-workspace", 275 | }, 276 | { 277 | desc: "It overwrites the configured workspace", 278 | configured: "/configured-workspace", 279 | args: []string{"--no-verify", "--workspace", "/replacement-workspace"}, 280 | expected: "/replacement-workspace", 281 | }, 282 | { 283 | desc: "It gets the default workspace when neither configured nor passed as a flag", 284 | configured: "", 285 | args: []string{"--token", "some-token"}, // need to bypass the error message on "bare configure" 286 | expected: "/home/default-workspace", 287 | }, 288 | { 289 | desc: "It resolves the passed workspace to expand ~", 290 | configured: "", 291 | args: []string{"--workspace", "~/workspace-dir"}, 292 | expected: "/home/workspace-dir", 293 | }, 294 | 295 | { 296 | desc: "It resolves the configured workspace to expand ~", 297 | configured: "~/configured-dir", 298 | args: []string{"--token", "some-token"}, // need to bypass the error message on "bare configure" 299 | expected: "/home/configured-dir", // The configuration object hard-codes the home directory below 300 | }, 301 | } 302 | 303 | endpoint := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 304 | // 200 OK by default. Ping and TokenAuth will both pass. 305 | }) 306 | ts := httptest.NewServer(endpoint) 307 | defer ts.Close() 308 | 309 | for _, tc := range testCases { 310 | flags := pflag.NewFlagSet("fake", pflag.PanicOnError) 311 | setupConfigureFlags(flags) 312 | 313 | v := viper.New() 314 | v.Set("token", "abc123") // set a token so we get past the no token configured logic 315 | v.Set("workspace", tc.configured) 316 | 317 | err := flags.Parse(tc.args) 318 | assert.NoError(t, err) 319 | 320 | cfg := config.Config{ 321 | Persister: config.InMemoryPersister{}, 322 | UserViperConfig: v, 323 | DefaultBaseURL: ts.URL, 324 | DefaultDirName: "default-workspace", 325 | Home: "/home", 326 | OS: "linux", 327 | } 328 | 329 | err = runConfigure(cfg, flags) 330 | assert.NoError(t, err, tc.desc) 331 | assert.Equal(t, tc.expected, cfg.UserViperConfig.GetString("workspace"), tc.desc) 332 | } 333 | } 334 | 335 | func TestConfigureDefaultWorkspaceWithoutClobbering(t *testing.T) { 336 | co := newCapturedOutput() 337 | co.override() 338 | defer co.reset() 339 | 340 | // Stub server to always be 200 OK 341 | endpoint := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) 342 | ts := httptest.NewServer(endpoint) 343 | defer ts.Close() 344 | 345 | tmpDir, err := os.MkdirTemp("", "no-clobber") 346 | defer os.RemoveAll(tmpDir) 347 | assert.NoError(t, err) 348 | 349 | cfg := config.Config{ 350 | OS: "linux", 351 | DefaultDirName: "workspace", 352 | Home: tmpDir, 353 | Dir: tmpDir, 354 | DefaultBaseURL: ts.URL, 355 | UserViperConfig: viper.New(), 356 | Persister: config.InMemoryPersister{}, 357 | } 358 | 359 | // Create a directory at the workspace directory's location 360 | // so that it's already present. 361 | err = os.MkdirAll(config.DefaultWorkspaceDir(cfg), os.FileMode(0755)) 362 | assert.NoError(t, err) 363 | 364 | flags := pflag.NewFlagSet("fake", pflag.PanicOnError) 365 | setupConfigureFlags(flags) 366 | err = flags.Parse([]string{"--token", "abc123"}) 367 | assert.NoError(t, err) 368 | 369 | err = runConfigure(cfg, flags) 370 | if assert.Error(t, err) { 371 | assert.Regexp(t, "already something", err.Error()) 372 | } 373 | } 374 | 375 | func TestConfigureExplicitWorkspaceWithoutClobberingNonDirectory(t *testing.T) { 376 | co := newCapturedOutput() 377 | co.override() 378 | defer co.reset() 379 | 380 | tmpDir, err := os.MkdirTemp("", "no-clobber") 381 | defer os.RemoveAll(tmpDir) 382 | assert.NoError(t, err) 383 | 384 | v := viper.New() 385 | v.Set("token", "abc123") 386 | 387 | cfg := config.Config{ 388 | OS: "linux", 389 | DefaultDirName: "workspace", 390 | Home: tmpDir, 391 | Dir: tmpDir, 392 | UserViperConfig: v, 393 | Persister: config.InMemoryPersister{}, 394 | } 395 | 396 | // Create a file at the workspace directory's location 397 | err = os.WriteFile(filepath.Join(tmpDir, "workspace"), []byte("This is not a directory"), os.FileMode(0755)) 398 | assert.NoError(t, err) 399 | 400 | flags := pflag.NewFlagSet("fake", pflag.PanicOnError) 401 | setupConfigureFlags(flags) 402 | err = flags.Parse([]string{"--no-verify", "--workspace", config.DefaultWorkspaceDir(cfg)}) 403 | assert.NoError(t, err) 404 | 405 | err = runConfigure(cfg, flags) 406 | if assert.Error(t, err) { 407 | assert.Regexp(t, "set a different workspace", err.Error()) 408 | } 409 | } 410 | 411 | func TestCommandifyFlagSet(t *testing.T) { 412 | flags := pflag.NewFlagSet("primitives", pflag.PanicOnError) 413 | flags.StringP("word", "w", "", "a word") 414 | flags.BoolP("yes", "y", false, "just do it") 415 | flags.IntP("number", "n", 1, "count to one") 416 | 417 | err := flags.Parse([]string{"--word", "banana", "--yes"}) 418 | assert.NoError(t, err) 419 | assert.Equal(t, commandify(flags), "--word=banana --yes=true") 420 | } 421 | -------------------------------------------------------------------------------- /cmd/download.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | netURL "net/url" 11 | "os" 12 | "path/filepath" 13 | "regexp" 14 | "strings" 15 | 16 | "github.com/exercism/cli/api" 17 | "github.com/exercism/cli/config" 18 | "github.com/exercism/cli/workspace" 19 | "github.com/spf13/cobra" 20 | "github.com/spf13/pflag" 21 | "github.com/spf13/viper" 22 | ) 23 | 24 | // downloadCmd represents the download command 25 | var downloadCmd = &cobra.Command{ 26 | Use: "download", 27 | Aliases: []string{"d"}, 28 | Short: "Download an exercise.", 29 | Long: `Download an exercise. 30 | 31 | You may download an exercise to work on. If you've already 32 | started working on it, the command will also download your 33 | latest solution. 34 | 35 | Download other people's solutions by providing the UUID. 36 | `, 37 | RunE: func(cmd *cobra.Command, args []string) error { 38 | cfg := config.NewConfig() 39 | 40 | v := viper.New() 41 | v.AddConfigPath(cfg.Dir) 42 | v.SetConfigName("user") 43 | v.SetConfigType("json") 44 | // Ignore error. If the file doesn't exist, that is fine. 45 | _ = v.ReadInConfig() 46 | cfg.UserViperConfig = v 47 | 48 | return runDownload(cfg, cmd.Flags(), args) 49 | }, 50 | } 51 | 52 | func runDownload(cfg config.Config, flags *pflag.FlagSet, args []string) error { 53 | usrCfg := cfg.UserViperConfig 54 | if err := validateUserConfig(usrCfg); err != nil { 55 | return err 56 | } 57 | 58 | download, err := newDownload(flags, usrCfg) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | metadata := download.payload.metadata() 64 | dir := metadata.Exercise(usrCfg.GetString("workspace")).MetadataDir() 65 | 66 | if _, err = os.Stat(dir); !download.forceoverwrite && err == nil { 67 | return fmt.Errorf("directory '%s' already exists, use --force to overwrite", dir) 68 | } 69 | 70 | if err := os.MkdirAll(dir, os.FileMode(0755)); err != nil { 71 | return err 72 | } 73 | 74 | if err := metadata.Write(dir); err != nil { 75 | return err 76 | } 77 | 78 | client, err := api.NewClient(usrCfg.GetString("token"), usrCfg.GetString("apibaseurl")) 79 | if err != nil { 80 | return err 81 | } 82 | 83 | for _, sf := range download.payload.files() { 84 | url, err := sf.url() 85 | if err != nil { 86 | return err 87 | } 88 | 89 | req, err := client.NewRequest("GET", url, nil) 90 | if err != nil { 91 | return err 92 | } 93 | 94 | res, err := client.Do(req) 95 | if err != nil { 96 | return err 97 | } 98 | defer res.Body.Close() 99 | 100 | if res.StatusCode != http.StatusOK { 101 | // TODO: deal with it 102 | continue 103 | } 104 | // Don't bother with empty files. 105 | if res.Header.Get("Content-Length") == "0" { 106 | continue 107 | } 108 | 109 | path := sf.relativePath() 110 | dir := filepath.Join(metadata.Dir, filepath.Dir(path)) 111 | if err = os.MkdirAll(dir, os.FileMode(0755)); err != nil { 112 | return err 113 | } 114 | 115 | f, err := os.Create(filepath.Join(metadata.Dir, path)) 116 | if err != nil { 117 | return err 118 | } 119 | defer f.Close() 120 | _, err = io.Copy(f, res.Body) 121 | if err != nil { 122 | return err 123 | } 124 | } 125 | fmt.Fprintf(Err, "\nDownloaded to\n") 126 | fmt.Fprintf(Out, "%s\n", metadata.Dir) 127 | return nil 128 | } 129 | 130 | type download struct { 131 | // either/or 132 | slug, uuid string 133 | 134 | // user config 135 | token, apibaseurl, workspace string 136 | 137 | // optional 138 | track, team string 139 | forceoverwrite bool 140 | 141 | payload *downloadPayload 142 | } 143 | 144 | func newDownload(flags *pflag.FlagSet, usrCfg *viper.Viper) (*download, error) { 145 | var err error 146 | d := &download{} 147 | d.uuid, err = flags.GetString("uuid") 148 | if err != nil { 149 | return nil, err 150 | } 151 | d.slug, err = flags.GetString("exercise") 152 | if err != nil { 153 | return nil, err 154 | } 155 | d.track, err = flags.GetString("track") 156 | if err != nil { 157 | return nil, err 158 | } 159 | d.team, err = flags.GetString("team") 160 | if err != nil { 161 | return nil, err 162 | } 163 | 164 | d.forceoverwrite, err = flags.GetBool("force") 165 | if err != nil { 166 | return nil, err 167 | } 168 | 169 | d.token = usrCfg.GetString("token") 170 | d.apibaseurl = usrCfg.GetString("apibaseurl") 171 | d.workspace = usrCfg.GetString("workspace") 172 | 173 | if err = d.needsSlugXorUUID(); err != nil { 174 | return nil, err 175 | } 176 | if err = d.needsUserConfigValues(); err != nil { 177 | return nil, err 178 | } 179 | if err = d.needsSlugWhenGivenTrackOrTeam(); err != nil { 180 | return nil, err 181 | } 182 | 183 | client, err := api.NewClient(d.token, d.apibaseurl) 184 | if err != nil { 185 | return nil, err 186 | } 187 | 188 | req, err := client.NewRequest("GET", d.url(), nil) 189 | if err != nil { 190 | return nil, err 191 | } 192 | d.buildQueryParams(req.URL) 193 | 194 | res, err := client.Do(req) 195 | if err != nil { 196 | return nil, err 197 | } 198 | defer res.Body.Close() 199 | 200 | if res.StatusCode < 200 || res.StatusCode > 299 { 201 | return nil, decodedAPIError(res) 202 | } 203 | 204 | body, _ := io.ReadAll(res.Body) 205 | res.Body = io.NopCloser(bytes.NewReader(body)) 206 | 207 | if err := json.Unmarshal(body, &d.payload); err != nil { 208 | return nil, decodedAPIError(res) 209 | } 210 | 211 | return d, nil 212 | } 213 | 214 | func (d download) url() string { 215 | id := "latest" 216 | if d.uuid != "" { 217 | id = d.uuid 218 | } 219 | return fmt.Sprintf("%s/solutions/%s", d.apibaseurl, id) 220 | } 221 | 222 | func (d download) buildQueryParams(url *netURL.URL) { 223 | query := url.Query() 224 | if d.slug != "" { 225 | query.Add("exercise_id", d.slug) 226 | if d.track != "" { 227 | query.Add("track_id", d.track) 228 | } 229 | if d.team != "" { 230 | query.Add("team_id", d.team) 231 | } 232 | } 233 | url.RawQuery = query.Encode() 234 | } 235 | 236 | // needsSlugXorUUID checks the presence of slug XOR uuid. 237 | func (d download) needsSlugXorUUID() error { 238 | if d.slug != "" && d.uuid != "" || d.uuid == d.slug { 239 | return errors.New("need an --exercise name or a solution --uuid") 240 | } 241 | return nil 242 | } 243 | 244 | // needsUserConfigValues checks the presence of required values from the user config. 245 | func (d download) needsUserConfigValues() error { 246 | errMsg := "missing required user config: '%s'" 247 | if d.token == "" { 248 | return fmt.Errorf(errMsg, "token") 249 | } 250 | if d.apibaseurl == "" { 251 | return fmt.Errorf(errMsg, "apibaseurl") 252 | } 253 | if d.workspace == "" { 254 | return fmt.Errorf(errMsg, "workspace") 255 | } 256 | return nil 257 | } 258 | 259 | // needsSlugWhenGivenTrackOrTeam ensures that track/team arguments are also given with a slug. 260 | // (track/team meaningless when given a uuid). 261 | func (d download) needsSlugWhenGivenTrackOrTeam() error { 262 | if (d.team != "" || d.track != "") && d.slug == "" { 263 | return errors.New("--track or --team requires --exercise (not --uuid)") 264 | } 265 | return nil 266 | } 267 | 268 | type downloadPayload struct { 269 | Solution struct { 270 | ID string `json:"id"` 271 | URL string `json:"url"` 272 | Team struct { 273 | Name string `json:"name"` 274 | Slug string `json:"slug"` 275 | } `json:"team"` 276 | User struct { 277 | Handle string `json:"handle"` 278 | IsRequester bool `json:"is_requester"` 279 | } `json:"user"` 280 | Exercise struct { 281 | ID string `json:"id"` 282 | InstructionsURL string `json:"instructions_url"` 283 | AutoApprove bool `json:"auto_approve"` 284 | Track struct { 285 | ID string `json:"id"` 286 | Language string `json:"language"` 287 | } `json:"track"` 288 | } `json:"exercise"` 289 | FileDownloadBaseURL string `json:"file_download_base_url"` 290 | Files []string `json:"files"` 291 | Iteration struct { 292 | SubmittedAt *string `json:"submitted_at"` 293 | } 294 | } `json:"solution"` 295 | Error struct { 296 | Type string `json:"type"` 297 | Message string `json:"message"` 298 | PossibleTrackIDs []string `json:"possible_track_ids"` 299 | } `json:"error,omitempty"` 300 | } 301 | 302 | func (dp downloadPayload) metadata() workspace.ExerciseMetadata { 303 | return workspace.ExerciseMetadata{ 304 | AutoApprove: dp.Solution.Exercise.AutoApprove, 305 | Track: dp.Solution.Exercise.Track.ID, 306 | Team: dp.Solution.Team.Slug, 307 | ExerciseSlug: dp.Solution.Exercise.ID, 308 | ID: dp.Solution.ID, 309 | URL: dp.Solution.URL, 310 | Handle: dp.Solution.User.Handle, 311 | IsRequester: dp.Solution.User.IsRequester, 312 | } 313 | } 314 | 315 | func (dp downloadPayload) files() []solutionFile { 316 | fx := make([]solutionFile, 0, len(dp.Solution.Files)) 317 | for _, file := range dp.Solution.Files { 318 | f := solutionFile{ 319 | path: file, 320 | baseURL: dp.Solution.FileDownloadBaseURL, 321 | slug: dp.Solution.Exercise.ID, 322 | } 323 | fx = append(fx, f) 324 | } 325 | return fx 326 | } 327 | 328 | type solutionFile struct { 329 | path, baseURL, slug string 330 | } 331 | 332 | func (sf solutionFile) url() (string, error) { 333 | url, err := netURL.ParseRequestURI(fmt.Sprintf("%s%s", sf.baseURL, sf.path)) 334 | 335 | if err != nil { 336 | return "", err 337 | } 338 | 339 | return url.String(), nil 340 | } 341 | 342 | func (sf solutionFile) relativePath() string { 343 | file := sf.path 344 | 345 | // Work around a path bug due to an early design decision (later reversed) to 346 | // allow numeric suffixes for exercise directories, letting people have 347 | // multiple parallel versions of an exercise. 348 | pattern := fmt.Sprintf(`\A.*[/\\]%s-\d*/`, sf.slug) 349 | rgxNumericSuffix := regexp.MustCompile(pattern) 350 | if rgxNumericSuffix.MatchString(sf.path) { 351 | file = string(rgxNumericSuffix.ReplaceAll([]byte(sf.path), []byte(""))) 352 | } 353 | 354 | // Rewrite paths submitted with an older, buggy client where the Windows path is being treated as part of the filename. 355 | file = strings.Replace(file, "\\", "/", -1) 356 | 357 | return filepath.FromSlash(file) 358 | } 359 | 360 | func setupDownloadFlags(flags *pflag.FlagSet) { 361 | flags.StringP("uuid", "u", "", "the solution UUID") 362 | flags.StringP("track", "t", "", "the track ID") 363 | flags.StringP("exercise", "e", "", "the exercise slug") 364 | flags.StringP("team", "T", "", "the team slug") 365 | flags.BoolP("force", "F", false, "overwrite existing exercise directory") 366 | } 367 | 368 | func init() { 369 | RootCmd.AddCommand(downloadCmd) 370 | setupDownloadFlags(downloadCmd.Flags()) 371 | } 372 | -------------------------------------------------------------------------------- /cmd/download_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "os" 9 | "path/filepath" 10 | "strconv" 11 | "testing" 12 | 13 | "github.com/exercism/cli/config" 14 | "github.com/exercism/cli/workspace" 15 | "github.com/spf13/pflag" 16 | "github.com/spf13/viper" 17 | "github.com/stretchr/testify/assert" 18 | ) 19 | 20 | func TestDownloadWithoutToken(t *testing.T) { 21 | cfg := config.Config{ 22 | UserViperConfig: viper.New(), 23 | } 24 | 25 | err := runDownload(cfg, pflag.NewFlagSet("fake", pflag.PanicOnError), []string{}) 26 | if assert.Error(t, err) { 27 | assert.Regexp(t, "Welcome to Exercism", err.Error()) 28 | // It uses the default base API url to infer the host 29 | assert.Regexp(t, "exercism.org/my/settings", err.Error()) 30 | } 31 | } 32 | 33 | func TestDownloadWithoutWorkspace(t *testing.T) { 34 | v := viper.New() 35 | v.Set("token", "abc123") 36 | cfg := config.Config{ 37 | UserViperConfig: v, 38 | } 39 | 40 | err := runDownload(cfg, pflag.NewFlagSet("fake", pflag.PanicOnError), []string{}) 41 | if assert.Error(t, err) { 42 | assert.Regexp(t, "re-run the configure", err.Error()) 43 | } 44 | } 45 | 46 | func TestDownloadWithoutBaseURL(t *testing.T) { 47 | v := viper.New() 48 | v.Set("token", "abc123") 49 | v.Set("workspace", "/home/whatever") 50 | cfg := config.Config{ 51 | UserViperConfig: v, 52 | } 53 | 54 | err := runDownload(cfg, pflag.NewFlagSet("fake", pflag.PanicOnError), []string{}) 55 | if assert.Error(t, err) { 56 | assert.Regexp(t, "re-run the configure", err.Error()) 57 | } 58 | } 59 | 60 | func TestDownloadWithoutFlags(t *testing.T) { 61 | v := viper.New() 62 | v.Set("token", "abc123") 63 | v.Set("workspace", "/home/username") 64 | v.Set("apibaseurl", "http://example.com") 65 | 66 | cfg := config.Config{ 67 | UserViperConfig: v, 68 | } 69 | 70 | flags := pflag.NewFlagSet("fake", pflag.PanicOnError) 71 | setupDownloadFlags(flags) 72 | 73 | err := runDownload(cfg, flags, []string{}) 74 | if assert.Error(t, err) { 75 | assert.Regexp(t, "need an --exercise name or a solution --uuid", err.Error()) 76 | } 77 | } 78 | 79 | func TestSolutionFile(t *testing.T) { 80 | testCases := []struct { 81 | name, file, expectedPath, expectedURL string 82 | }{ 83 | { 84 | name: "filename with special character", 85 | file: "special-char-filename#.txt", 86 | expectedPath: "special-char-filename#.txt", 87 | expectedURL: "http://www.example.com/special-char-filename%23.txt", 88 | }, 89 | { 90 | name: "filename with leading slash", 91 | file: "/with-leading-slash.txt", 92 | expectedPath: fmt.Sprintf("%cwith-leading-slash.txt", os.PathSeparator), 93 | expectedURL: "http://www.example.com//with-leading-slash.txt", 94 | }, 95 | { 96 | name: "filename with leading backslash", 97 | file: "\\with-leading-backslash.txt", 98 | expectedPath: fmt.Sprintf("%cwith-leading-backslash.txt", os.PathSeparator), 99 | expectedURL: "http://www.example.com/%5Cwith-leading-backslash.txt", 100 | }, 101 | { 102 | name: "filename with backslashes in path", 103 | file: "\\backslashes\\in-path.txt", 104 | expectedPath: fmt.Sprintf("%[1]cbackslashes%[1]cin-path.txt", os.PathSeparator), 105 | expectedURL: "http://www.example.com/%5Cbackslashes%5Cin-path.txt", 106 | }, 107 | { 108 | name: "path with a numeric suffix", 109 | file: "/bogus-exercise-12345/numeric.txt", 110 | expectedPath: fmt.Sprintf("%[1]cbogus-exercise-12345%[1]cnumeric.txt", os.PathSeparator), 111 | expectedURL: "http://www.example.com//bogus-exercise-12345/numeric.txt", 112 | }, 113 | } 114 | 115 | for _, tc := range testCases { 116 | t.Run(tc.name, func(t *testing.T) { 117 | sf := solutionFile{ 118 | path: tc.file, 119 | baseURL: "http://www.example.com/", 120 | } 121 | 122 | if sf.relativePath() != tc.expectedPath { 123 | t.Fatalf("Expected path '%s', got '%s'", tc.expectedPath, sf.relativePath()) 124 | } 125 | 126 | url, err := sf.url() 127 | if err != nil { 128 | t.Fatal(err) 129 | } 130 | 131 | if url != tc.expectedURL { 132 | t.Fatalf("Expected URL '%s', got '%s'", tc.expectedURL, url) 133 | } 134 | }) 135 | } 136 | } 137 | 138 | func TestDownload(t *testing.T) { 139 | co := newCapturedOutput() 140 | co.override() 141 | defer co.reset() 142 | 143 | testCases := []struct { 144 | requester bool 145 | expectedDir string 146 | flags map[string]string 147 | }{ 148 | { 149 | requester: true, 150 | expectedDir: "", 151 | flags: map[string]string{"exercise": "bogus-exercise"}, 152 | }, 153 | { 154 | requester: true, 155 | expectedDir: "", 156 | flags: map[string]string{"uuid": "bogus-id"}, 157 | }, 158 | { 159 | requester: false, 160 | expectedDir: filepath.Join("users", "alice"), 161 | flags: map[string]string{"uuid": "bogus-id"}, 162 | }, 163 | { 164 | requester: true, 165 | expectedDir: filepath.Join("teams", "bogus-team"), 166 | flags: map[string]string{"exercise": "bogus-exercise", "track": "bogus-track", "team": "bogus-team"}, 167 | }, 168 | } 169 | 170 | for _, tc := range testCases { 171 | tmpDir, err := os.MkdirTemp("", "download-cmd") 172 | defer os.RemoveAll(tmpDir) 173 | assert.NoError(t, err) 174 | 175 | ts := fakeDownloadServer(strconv.FormatBool(tc.requester), tc.flags["team"]) 176 | defer ts.Close() 177 | 178 | v := viper.New() 179 | v.Set("workspace", tmpDir) 180 | v.Set("apibaseurl", ts.URL) 181 | v.Set("token", "abc123") 182 | 183 | cfg := config.Config{ 184 | UserViperConfig: v, 185 | } 186 | flags := pflag.NewFlagSet("fake", pflag.PanicOnError) 187 | setupDownloadFlags(flags) 188 | for name, value := range tc.flags { 189 | flags.Set(name, value) 190 | } 191 | 192 | err = runDownload(cfg, flags, []string{}) 193 | assert.NoError(t, err) 194 | 195 | targetDir := filepath.Join(tmpDir, tc.expectedDir) 196 | assertDownloadedCorrectFiles(t, targetDir) 197 | 198 | dir := filepath.Join(targetDir, "bogus-track", "bogus-exercise") 199 | b, err := os.ReadFile(workspace.NewExerciseFromDir(dir).MetadataFilepath()) 200 | assert.NoError(t, err) 201 | var metadata workspace.ExerciseMetadata 202 | err = json.Unmarshal(b, &metadata) 203 | assert.NoError(t, err) 204 | 205 | assert.Equal(t, "bogus-track", metadata.Track) 206 | assert.Equal(t, "bogus-exercise", metadata.ExerciseSlug) 207 | assert.Equal(t, tc.requester, metadata.IsRequester) 208 | } 209 | } 210 | 211 | func TestDownloadToExistingDirectory(t *testing.T) { 212 | co := newCapturedOutput() 213 | co.override() 214 | defer co.reset() 215 | 216 | testCases := []struct { 217 | exerciseDir string 218 | flags map[string]string 219 | }{ 220 | { 221 | exerciseDir: filepath.Join("bogus-track", "bogus-exercise"), 222 | flags: map[string]string{"exercise": "bogus-exercise", "track": "bogus-track"}, 223 | }, 224 | { 225 | exerciseDir: filepath.Join("teams", "bogus-team", "bogus-track", "bogus-exercise"), 226 | flags: map[string]string{"exercise": "bogus-exercise", "track": "bogus-track", "team": "bogus-team"}, 227 | }, 228 | } 229 | 230 | for _, tc := range testCases { 231 | tmpDir, err := os.MkdirTemp("", "download-cmd") 232 | defer os.RemoveAll(tmpDir) 233 | assert.NoError(t, err) 234 | 235 | err = os.MkdirAll(filepath.Join(tmpDir, tc.exerciseDir), os.FileMode(0755)) 236 | assert.NoError(t, err) 237 | 238 | ts := fakeDownloadServer("true", "") 239 | defer ts.Close() 240 | 241 | v := viper.New() 242 | v.Set("workspace", tmpDir) 243 | v.Set("apibaseurl", ts.URL) 244 | v.Set("token", "abc123") 245 | 246 | cfg := config.Config{ 247 | UserViperConfig: v, 248 | } 249 | flags := pflag.NewFlagSet("fake", pflag.PanicOnError) 250 | setupDownloadFlags(flags) 251 | for name, value := range tc.flags { 252 | flags.Set(name, value) 253 | } 254 | 255 | err = runDownload(cfg, flags, []string{}) 256 | 257 | if assert.Error(t, err) { 258 | assert.Regexp(t, "directory '.+' already exists", err.Error()) 259 | } 260 | } 261 | } 262 | 263 | func TestDownloadToExistingDirectoryWithForce(t *testing.T) { 264 | co := newCapturedOutput() 265 | co.override() 266 | defer co.reset() 267 | 268 | testCases := []struct { 269 | exerciseDir string 270 | flags map[string]string 271 | }{ 272 | { 273 | exerciseDir: filepath.Join("bogus-track", "bogus-exercise"), 274 | flags: map[string]string{"exercise": "bogus-exercise", "track": "bogus-track"}, 275 | }, 276 | { 277 | exerciseDir: filepath.Join("teams", "bogus-team", "bogus-track", "bogus-exercise"), 278 | flags: map[string]string{"exercise": "bogus-exercise", "track": "bogus-track", "team": "bogus-team"}, 279 | }, 280 | } 281 | 282 | for _, tc := range testCases { 283 | tmpDir, err := os.MkdirTemp("", "download-cmd") 284 | defer os.RemoveAll(tmpDir) 285 | assert.NoError(t, err) 286 | 287 | err = os.MkdirAll(filepath.Join(tmpDir, tc.exerciseDir), os.FileMode(0755)) 288 | assert.NoError(t, err) 289 | 290 | ts := fakeDownloadServer("true", "") 291 | defer ts.Close() 292 | 293 | v := viper.New() 294 | v.Set("workspace", tmpDir) 295 | v.Set("apibaseurl", ts.URL) 296 | v.Set("token", "abc123") 297 | 298 | cfg := config.Config{ 299 | UserViperConfig: v, 300 | } 301 | flags := pflag.NewFlagSet("fake", pflag.PanicOnError) 302 | setupDownloadFlags(flags) 303 | for name, value := range tc.flags { 304 | flags.Set(name, value) 305 | } 306 | flags.Set("force", "true") 307 | 308 | err = runDownload(cfg, flags, []string{}) 309 | assert.NoError(t, err) 310 | } 311 | } 312 | 313 | func fakeDownloadServer(requestor, teamSlug string) *httptest.Server { 314 | mux := http.NewServeMux() 315 | server := httptest.NewServer(mux) 316 | 317 | mux.HandleFunc("/file-1.txt", func(w http.ResponseWriter, r *http.Request) { 318 | fmt.Fprint(w, "this is file 1") 319 | }) 320 | 321 | mux.HandleFunc("/subdir/file-2.txt", func(w http.ResponseWriter, r *http.Request) { 322 | fmt.Fprint(w, "this is file 2") 323 | }) 324 | 325 | mux.HandleFunc("/file-3.txt", func(w http.ResponseWriter, r *http.Request) { 326 | fmt.Fprint(w, "") 327 | }) 328 | 329 | mux.HandleFunc("/solutions/latest", func(w http.ResponseWriter, r *http.Request) { 330 | team := "null" 331 | if teamSlug := r.FormValue("team_id"); teamSlug != "" { 332 | team = fmt.Sprintf(`{"name": "Bogus Team", "slug": "%s"}`, teamSlug) 333 | } 334 | payloadBody := fmt.Sprintf(payloadTemplate, requestor, team, server.URL+"/") 335 | fmt.Fprint(w, payloadBody) 336 | }) 337 | mux.HandleFunc("/solutions/bogus-id", func(w http.ResponseWriter, r *http.Request) { 338 | payloadBody := fmt.Sprintf(payloadTemplate, requestor, "null", server.URL+"/") 339 | fmt.Fprint(w, payloadBody) 340 | }) 341 | 342 | return server 343 | } 344 | 345 | func assertDownloadedCorrectFiles(t *testing.T, targetDir string) { 346 | expectedFiles := []struct { 347 | desc string 348 | path string 349 | contents string 350 | }{ 351 | { 352 | desc: "a file in the exercise root directory", 353 | path: filepath.Join(targetDir, "bogus-track", "bogus-exercise", "file-1.txt"), 354 | contents: "this is file 1", 355 | }, 356 | { 357 | desc: "a file in a subdirectory", 358 | path: filepath.Join(targetDir, "bogus-track", "bogus-exercise", "subdir", "file-2.txt"), 359 | contents: "this is file 2", 360 | }, 361 | } 362 | 363 | for _, file := range expectedFiles { 364 | t.Run(file.desc, func(t *testing.T) { 365 | b, err := os.ReadFile(file.path) 366 | assert.NoError(t, err) 367 | assert.Equal(t, file.contents, string(b)) 368 | }) 369 | } 370 | 371 | path := filepath.Join(targetDir, "bogus-track", "bogus-exercise", "file-3.txt") 372 | _, err := os.Lstat(path) 373 | assert.True(t, os.IsNotExist(err), "It should not write the file if empty.") 374 | } 375 | 376 | func TestDownloadError(t *testing.T) { 377 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 378 | w.WriteHeader(http.StatusBadRequest) 379 | fmt.Fprintf(w, `{"error": {"type": "error", "message": "test error"}}`) 380 | }) 381 | 382 | ts := httptest.NewServer(handler) 383 | defer ts.Close() 384 | 385 | tmpDir, err := os.MkdirTemp("", "submit-err-tmp-dir") 386 | defer os.RemoveAll(tmpDir) 387 | assert.NoError(t, err) 388 | 389 | v := viper.New() 390 | v.Set("token", "abc123") 391 | v.Set("workspace", tmpDir) 392 | v.Set("apibaseurl", ts.URL) 393 | 394 | cfg := config.Config{ 395 | Persister: config.InMemoryPersister{}, 396 | UserViperConfig: v, 397 | DefaultBaseURL: "http://example.com", 398 | } 399 | 400 | flags := pflag.NewFlagSet("fake", pflag.PanicOnError) 401 | setupDownloadFlags(flags) 402 | flags.Set("uuid", "value") 403 | 404 | err = runDownload(cfg, flags, []string{}) 405 | 406 | assert.Equal(t, "test error", err.Error()) 407 | 408 | } 409 | 410 | const payloadTemplate = ` 411 | { 412 | "solution": { 413 | "id": "bogus-id", 414 | "user": { 415 | "handle": "alice", 416 | "is_requester": %s 417 | }, 418 | "team": %s, 419 | "exercise": { 420 | "id": "bogus-exercise", 421 | "instructions_url": "http://example.com/bogus-exercise", 422 | "auto_approve": false, 423 | "track": { 424 | "id": "bogus-track", 425 | "language": "Bogus Language" 426 | } 427 | }, 428 | "file_download_base_url": "%s", 429 | "files": [ 430 | "file-1.txt", 431 | "subdir/file-2.txt", 432 | "file-3.txt" 433 | ], 434 | "iteration": { 435 | "submitted_at": "2017-08-21t10:11:12.130z" 436 | } 437 | } 438 | } 439 | ` 440 | -------------------------------------------------------------------------------- /cmd/open.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/exercism/cli/browser" 5 | "github.com/exercism/cli/workspace" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // openCmd opens the designated exercise in the browser. 10 | var openCmd = &cobra.Command{ 11 | Use: "open", 12 | Aliases: []string{"o"}, 13 | Short: "Open an exercise on the website.", 14 | Long: `Open the specified exercise to the solution page on the Exercism website. 15 | 16 | Pass the path to the directory that contains the solution you want to see on the website. 17 | `, 18 | Args: cobra.MaximumNArgs(1), 19 | RunE: func(cmd *cobra.Command, args []string) error { 20 | path := "." 21 | if len(args) == 1 { 22 | path = args[0] 23 | } 24 | metadata, err := workspace.NewExerciseMetadata(path) 25 | if err != nil { 26 | return err 27 | } 28 | return browser.Open(metadata.URL) 29 | }, 30 | } 31 | 32 | func init() { 33 | RootCmd.AddCommand(openCmd) 34 | } 35 | -------------------------------------------------------------------------------- /cmd/prepare.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "github.com/spf13/cobra" 4 | 5 | // prepareCmd does necessary setup for Exercism and its tracks. 6 | var prepareCmd = &cobra.Command{ 7 | Use: "prepare", 8 | Aliases: []string{"p"}, 9 | Short: "Prepare does setup for Exercism and its tracks.", 10 | Long: `Prepare downloads settings and dependencies for Exercism and the language tracks. 11 | `, 12 | RunE: func(cmd *cobra.Command, args []string) error { 13 | return nil 14 | }, 15 | } 16 | 17 | func init() { 18 | RootCmd.AddCommand(prepareCmd) 19 | } 20 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "runtime" 7 | 8 | "github.com/exercism/cli/api" 9 | "github.com/exercism/cli/cli" 10 | "github.com/exercism/cli/config" 11 | "github.com/exercism/cli/debug" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | // RootCmd represents the base command when called without any subcommands. 16 | var RootCmd = &cobra.Command{ 17 | Use: getCommandName(), 18 | Short: "A friendly command-line interface to Exercism.", 19 | Long: `A command-line interface for Exercism. 20 | 21 | Download exercises and submit your solutions.`, 22 | SilenceUsage: true, 23 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 24 | if verbose, _ := cmd.Flags().GetBool("verbose"); verbose { 25 | debug.Verbose = verbose 26 | } 27 | if unmask, _ := cmd.Flags().GetBool("unmask-token"); unmask { 28 | debug.UnmaskAPIKey = unmask 29 | } 30 | if timeout, _ := cmd.Flags().GetInt("timeout"); timeout > 0 { 31 | cli.TimeoutInSeconds = timeout 32 | api.TimeoutInSeconds = timeout 33 | } 34 | }, 35 | } 36 | 37 | // Execute adds all child commands to the root command. 38 | func Execute() { 39 | if err := RootCmd.Execute(); err != nil { 40 | os.Exit(-1) 41 | } 42 | } 43 | 44 | func getCommandName() string { 45 | return os.Args[0] 46 | } 47 | 48 | func init() { 49 | BinaryName = getCommandName() 50 | config.SetDefaultDirName(BinaryName) 51 | Out = os.Stdout 52 | Err = os.Stderr 53 | api.UserAgent = fmt.Sprintf("github.com/exercism/cli v%s (%s/%s)", Version, runtime.GOOS, runtime.GOARCH) 54 | RootCmd.PersistentFlags().BoolP("verbose", "v", false, "verbose output") 55 | RootCmd.PersistentFlags().IntP("timeout", "", 0, "override the default HTTP timeout (seconds)") 56 | RootCmd.PersistentFlags().BoolP("unmask-token", "", false, "will unmask the API during a request/response dump") 57 | } 58 | -------------------------------------------------------------------------------- /cmd/submit.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "mime/multipart" 9 | "os" 10 | "path/filepath" 11 | 12 | "github.com/exercism/cli/api" 13 | "github.com/exercism/cli/config" 14 | "github.com/exercism/cli/workspace" 15 | "github.com/spf13/cobra" 16 | "github.com/spf13/pflag" 17 | "github.com/spf13/viper" 18 | ) 19 | 20 | // submitCmd lets people upload a solution to the website. 21 | var submitCmd = &cobra.Command{ 22 | Use: "submit [ ...]", 23 | Aliases: []string{"s"}, 24 | Short: "Submit your solution to an exercise.", 25 | Long: `Submit your solution to an Exercism exercise. 26 | 27 | Call the command with the list of files you want to submit. 28 | If you omit the list of files, the CLI will submit the 29 | default solution files for the exercise. 30 | `, 31 | RunE: func(cmd *cobra.Command, args []string) error { 32 | cfg := config.NewConfig() 33 | 34 | usrCfg := viper.New() 35 | usrCfg.AddConfigPath(cfg.Dir) 36 | usrCfg.SetConfigName("user") 37 | usrCfg.SetConfigType("json") 38 | // Ignore error. If the file doesn't exist, that is fine. 39 | _ = usrCfg.ReadInConfig() 40 | cfg.UserViperConfig = usrCfg 41 | 42 | v := viper.New() 43 | v.AddConfigPath(cfg.Dir) 44 | v.SetConfigName("cli") 45 | v.SetConfigType("json") 46 | // Ignore error. If the file doesn't exist, that is fine. 47 | _ = v.ReadInConfig() 48 | 49 | if len(args) == 0 { 50 | files, err := getExerciseSolutionFiles(".") 51 | if err != nil { 52 | return err 53 | } 54 | args = files 55 | } 56 | 57 | return runSubmit(cfg, cmd.Flags(), args) 58 | }, 59 | } 60 | 61 | func runSubmit(cfg config.Config, flags *pflag.FlagSet, args []string) error { 62 | if err := validateUserConfig(cfg.UserViperConfig); err != nil { 63 | return err 64 | } 65 | 66 | ctx := newSubmitCmdContext(cfg.UserViperConfig, flags) 67 | 68 | if err := ctx.validator.filesExistAndNotADir(args); err != nil { 69 | return err 70 | } 71 | 72 | submitPaths, err := ctx.evaluatedSymlinks(args) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | submitPaths = ctx.removeDuplicatePaths(submitPaths) 78 | 79 | if err = ctx.validator.filesBelongToSameExercise(submitPaths); err != nil { 80 | return err 81 | } 82 | 83 | exercise, err := ctx.exercise(submitPaths[0]) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | if err = ctx.migrateLegacyMetadata(exercise); err != nil { 89 | return err 90 | } 91 | 92 | if err = ctx.validator.fileSizesWithinMax(submitPaths); err != nil { 93 | return err 94 | } 95 | 96 | documents, err := ctx.documents(submitPaths, exercise) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | if err = ctx.validator.submissionNotEmpty(documents); err != nil { 102 | return err 103 | } 104 | 105 | metadata, err := ctx.metadata(exercise) 106 | if err != nil { 107 | return err 108 | } 109 | 110 | if err := ctx.validator.metadataMatchesExercise(metadata, exercise); err != nil { 111 | return err 112 | } 113 | 114 | if err := ctx.validator.isRequestor(metadata); err != nil { 115 | return err 116 | } 117 | 118 | if err := ctx.submit(metadata, documents); err != nil { 119 | return err 120 | } 121 | 122 | ctx.printResult(metadata) 123 | return nil 124 | } 125 | 126 | func getExerciseSolutionFiles(baseDir string) ([]string, error) { 127 | v := viper.New() 128 | v.AddConfigPath(filepath.Join(baseDir, ".exercism")) 129 | v.SetConfigName("config") 130 | v.SetConfigType("json") 131 | err := v.ReadInConfig() 132 | if err != nil { 133 | return nil, errors.New("no files to submit") 134 | } 135 | solutionFiles := v.GetStringSlice("files.solution") 136 | if len(solutionFiles) == 0 { 137 | return nil, errors.New("no files to submit") 138 | } 139 | 140 | return solutionFiles, nil 141 | } 142 | 143 | type submitCmdContext struct { 144 | usrCfg *viper.Viper 145 | flags *pflag.FlagSet 146 | validator submitValidator 147 | } 148 | 149 | func newSubmitCmdContext(usrCfg *viper.Viper, flags *pflag.FlagSet) *submitCmdContext { 150 | return &submitCmdContext{ 151 | usrCfg: usrCfg, 152 | flags: flags, 153 | validator: submitValidator{usrCfg: usrCfg}, 154 | } 155 | } 156 | 157 | // evaluatedSymlinks returns the submit paths with evaluated symlinks. 158 | func (s *submitCmdContext) evaluatedSymlinks(submitPaths []string) ([]string, error) { 159 | evalSymlinkSubmitPaths := make([]string, 0, len(submitPaths)) 160 | for _, path := range submitPaths { 161 | var err error 162 | path, err = filepath.Abs(path) 163 | if err != nil { 164 | return nil, err 165 | } 166 | 167 | src, err := filepath.EvalSymlinks(path) 168 | if err != nil { 169 | return nil, err 170 | } 171 | evalSymlinkSubmitPaths = append(evalSymlinkSubmitPaths, src) 172 | } 173 | return evalSymlinkSubmitPaths, nil 174 | } 175 | 176 | func (s *submitCmdContext) removeDuplicatePaths(submitPaths []string) []string { 177 | seen := make(map[string]bool) 178 | result := make([]string, 0, len(submitPaths)) 179 | 180 | for _, val := range submitPaths { 181 | if _, ok := seen[val]; !ok { 182 | seen[val] = true 183 | result = append(result, val) 184 | } 185 | } 186 | 187 | return result 188 | } 189 | 190 | // exercise creates an exercise using one of the submitted filepaths. 191 | // This assumes prior verification that submit paths belong to the same exercise. 192 | func (s *submitCmdContext) exercise(aSubmitPath string) (workspace.Exercise, error) { 193 | ws, err := workspace.New(s.usrCfg.GetString("workspace")) 194 | if err != nil { 195 | return workspace.Exercise{}, err 196 | } 197 | 198 | dir, err := ws.ExerciseDir(aSubmitPath) 199 | if err != nil { 200 | return workspace.Exercise{}, err 201 | } 202 | return workspace.NewExerciseFromDir(dir), nil 203 | } 204 | 205 | func (s *submitCmdContext) migrateLegacyMetadata(exercise workspace.Exercise) error { 206 | migrationStatus, err := exercise.MigrateLegacyMetadataFile() 207 | if err != nil { 208 | return err 209 | } 210 | if verbose, _ := s.flags.GetBool("verbose"); verbose { 211 | fmt.Fprintf(Err, migrationStatus.String()) 212 | } 213 | return nil 214 | } 215 | 216 | // documents builds the documents that get submitted. 217 | // Empty files are skipped, printing a warning. 218 | func (s *submitCmdContext) documents(submitPaths []string, exercise workspace.Exercise) ([]workspace.Document, error) { 219 | docs := make([]workspace.Document, 0, len(submitPaths)) 220 | for _, file := range submitPaths { 221 | // Don't submit empty files 222 | info, err := os.Stat(file) 223 | if err != nil { 224 | return nil, err 225 | } 226 | if info.Size() == 0 { 227 | 228 | msg := ` 229 | 230 | WARNING: Skipping empty file 231 | %s 232 | 233 | ` 234 | fmt.Fprintf(Err, msg, file) 235 | continue 236 | } 237 | doc, err := workspace.NewDocument(exercise.Filepath(), file) 238 | if err != nil { 239 | return nil, err 240 | } 241 | docs = append(docs, doc) 242 | } 243 | return docs, nil 244 | } 245 | 246 | func (s *submitCmdContext) metadata(exercise workspace.Exercise) (*workspace.ExerciseMetadata, error) { 247 | metadata, err := workspace.NewExerciseMetadata(exercise.Filepath()) 248 | if err != nil { 249 | return nil, err 250 | } 251 | return metadata, nil 252 | } 253 | 254 | // submit submits the documents to the Exercism API. 255 | func (s *submitCmdContext) submit(metadata *workspace.ExerciseMetadata, docs []workspace.Document) error { 256 | body := &bytes.Buffer{} 257 | writer := multipart.NewWriter(body) 258 | 259 | for _, doc := range docs { 260 | file, err := os.Open(doc.Filepath()) 261 | if err != nil { 262 | return err 263 | } 264 | defer file.Close() 265 | 266 | part, err := writer.CreateFormFile("files[]", doc.Path()) 267 | if err != nil { 268 | return err 269 | } 270 | _, err = io.Copy(part, file) 271 | if err != nil { 272 | return err 273 | } 274 | } 275 | if err := writer.Close(); err != nil { 276 | return err 277 | } 278 | 279 | client, err := api.NewClient(s.usrCfg.GetString("token"), s.usrCfg.GetString("apibaseurl")) 280 | if err != nil { 281 | return err 282 | } 283 | url := fmt.Sprintf("%s/solutions/%s", s.usrCfg.GetString("apibaseurl"), metadata.ID) 284 | req, err := client.NewRequest("PATCH", url, body) 285 | if err != nil { 286 | return err 287 | } 288 | req.Header.Set("Content-Type", writer.FormDataContentType()) 289 | 290 | resp, err := client.Do(req) 291 | if err != nil { 292 | return err 293 | } 294 | defer resp.Body.Close() 295 | 296 | if resp.StatusCode < 200 || resp.StatusCode > 299 { 297 | return decodedAPIError(resp) 298 | } 299 | 300 | bb := &bytes.Buffer{} 301 | _, err = bb.ReadFrom(resp.Body) 302 | if err != nil { 303 | return err 304 | } 305 | return nil 306 | } 307 | 308 | func (s *submitCmdContext) printResult(metadata *workspace.ExerciseMetadata) { 309 | msg := ` 310 | 311 | Your solution has been submitted successfully. 312 | %s 313 | ` 314 | suffix := "View it at:\n\n " 315 | if metadata.AutoApprove && metadata.Team == "" { 316 | suffix = "You can complete the exercise and unlock the next core exercise at:\n" 317 | } 318 | fmt.Fprintf(Err, msg, suffix) 319 | fmt.Fprintf(Out, " %s\n\n", metadata.URL) 320 | } 321 | 322 | // submitValidator contains the validation rules for a submission. 323 | type submitValidator struct { 324 | usrCfg *viper.Viper 325 | } 326 | 327 | // filesExistAndNotADir checks that each file exists and is not a directory. 328 | func (s submitValidator) filesExistAndNotADir(submitPaths []string) error { 329 | for _, path := range submitPaths { 330 | path, err := filepath.Abs(path) 331 | if err != nil { 332 | return err 333 | } 334 | 335 | info, err := os.Lstat(path) 336 | if err != nil { 337 | if os.IsNotExist(err) { 338 | msg := ` 339 | 340 | The file you are trying to submit cannot be found. 341 | 342 | %s 343 | 344 | ` 345 | return fmt.Errorf(msg, path) 346 | } 347 | return err 348 | } 349 | if info.IsDir() { 350 | msg := ` 351 | 352 | You are submitting a directory, which is not currently supported. 353 | 354 | %s 355 | 356 | Please change into the directory and provide the path to the file(s) you wish to submit 357 | 358 | %s submit FILENAME 359 | 360 | ` 361 | return fmt.Errorf(msg, path, BinaryName) 362 | } 363 | } 364 | return nil 365 | } 366 | 367 | // filesBelongToSameExercise checks that each file belongs to the same exercise. 368 | func (s submitValidator) filesBelongToSameExercise(submitPaths []string) error { 369 | ws, err := workspace.New(s.usrCfg.GetString("workspace")) 370 | if err != nil { 371 | return err 372 | } 373 | 374 | var exerciseDir string 375 | for _, f := range submitPaths { 376 | dir, err := ws.ExerciseDir(f) 377 | if err != nil { 378 | if workspace.IsMissingMetadata(err) { 379 | return errors.New(msgMissingMetadata) 380 | } 381 | return err 382 | } 383 | if exerciseDir != "" && dir != exerciseDir { 384 | msg := ` 385 | 386 | You are submitting files belonging to different solutions. 387 | Please submit the files for one solution at a time. 388 | 389 | ` 390 | return errors.New(msg) 391 | } 392 | exerciseDir = dir 393 | } 394 | return nil 395 | } 396 | 397 | // fileSizesWithinMax checks that each file does not exceed the max allowed size. 398 | func (s submitValidator) fileSizesWithinMax(submitPaths []string) error { 399 | for _, file := range submitPaths { 400 | info, err := os.Stat(file) 401 | if err != nil { 402 | return err 403 | } 404 | const maxFileSize int64 = 65535 405 | if info.Size() >= maxFileSize { 406 | msg := ` 407 | 408 | The submitted file '%s' is larger than the max allowed file size of %d bytes. 409 | Please reduce the size of the file and try again. 410 | 411 | ` 412 | return fmt.Errorf(msg, file, maxFileSize) 413 | } 414 | } 415 | return nil 416 | } 417 | 418 | // submissionNotEmpty checks that there is at least one file to submit. 419 | func (s submitValidator) submissionNotEmpty(docs []workspace.Document) error { 420 | if len(docs) == 0 { 421 | msg := ` 422 | 423 | No files found to submit. 424 | 425 | ` 426 | return errors.New(msg) 427 | } 428 | return nil 429 | } 430 | 431 | // metadataMatchesExercise checks that the metadata refers to the exercise being submitted. 432 | func (s submitValidator) metadataMatchesExercise(metadata *workspace.ExerciseMetadata, exercise workspace.Exercise) error { 433 | if metadata.ExerciseSlug != exercise.Slug { 434 | // TODO: error msg should suggest running future doctor command 435 | msg := ` 436 | 437 | The exercise directory does not match exercise slug in metadata: 438 | 439 | expected '%[1]s' but got '%[2]s' 440 | 441 | Please rename the directory '%[1]s' to '%[2]s' and try again. 442 | 443 | ` 444 | return fmt.Errorf(msg, exercise.Slug, metadata.ExerciseSlug) 445 | } 446 | return nil 447 | } 448 | 449 | // isRequestor checks that the submission requestor is listed as the author in the metadata. 450 | func (s submitValidator) isRequestor(metadata *workspace.ExerciseMetadata) error { 451 | if !metadata.IsRequester { 452 | msg := ` 453 | 454 | The solution you are submitting is not connected to your account. 455 | Please re-download the exercise to make sure it has the data it needs. 456 | 457 | %s download --exercise=%s --track=%s 458 | 459 | ` 460 | return fmt.Errorf(msg, BinaryName, metadata.ExerciseSlug, metadata.Track) 461 | } 462 | return nil 463 | } 464 | 465 | func init() { 466 | RootCmd.AddCommand(submitCmd) 467 | } 468 | -------------------------------------------------------------------------------- /cmd/submit_symlink_test.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package cmd 4 | 5 | import ( 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | 10 | "github.com/exercism/cli/config" 11 | "github.com/spf13/pflag" 12 | "github.com/spf13/viper" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func TestSubmitFilesInSymlinkedPath(t *testing.T) { 17 | co := newCapturedOutput() 18 | co.override() 19 | defer co.reset() 20 | 21 | // The fake endpoint will populate this when it receives the call from the command. 22 | submittedFiles := map[string]string{} 23 | ts := fakeSubmitServer(t, submittedFiles) 24 | defer ts.Close() 25 | 26 | tmpDir, err := os.MkdirTemp("", "symlink-destination") 27 | defer os.RemoveAll(tmpDir) 28 | assert.NoError(t, err) 29 | dstDir := filepath.Join(tmpDir, "workspace") 30 | 31 | srcDir, err := os.MkdirTemp("", "symlink-source") 32 | defer os.RemoveAll(srcDir) 33 | assert.NoError(t, err) 34 | 35 | err = os.Symlink(srcDir, dstDir) 36 | assert.NoError(t, err) 37 | 38 | dir := filepath.Join(dstDir, "bogus-track", "bogus-exercise") 39 | os.MkdirAll(dir, os.FileMode(0755)) 40 | 41 | writeFakeMetadata(t, dir, "bogus-track", "bogus-exercise") 42 | 43 | v := viper.New() 44 | v.Set("token", "abc123") 45 | v.Set("workspace", dstDir) 46 | v.Set("apibaseurl", ts.URL) 47 | 48 | cfg := config.Config{ 49 | Persister: config.InMemoryPersister{}, 50 | UserViperConfig: v, 51 | } 52 | 53 | file := filepath.Join(dir, "file.txt") 54 | err = os.WriteFile(filepath.Join(dir, "file.txt"), []byte("This is a file."), os.FileMode(0755)) 55 | assert.NoError(t, err) 56 | 57 | err = runSubmit(cfg, pflag.NewFlagSet("symlinks", pflag.PanicOnError), []string{file}) 58 | assert.NoError(t, err) 59 | 60 | assert.Equal(t, 1, len(submittedFiles)) 61 | assert.Equal(t, "This is a file.", submittedFiles["file.txt"]) 62 | } 63 | -------------------------------------------------------------------------------- /cmd/test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "os/exec" 8 | "strings" 9 | 10 | "github.com/exercism/cli/workspace" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | var testCmd = &cobra.Command{ 15 | Use: "test", 16 | Aliases: []string{"t"}, 17 | Short: "Run the exercise's tests.", 18 | Long: `Run the exercise's tests. 19 | 20 | Run this command in an exercise's root directory.`, 21 | RunE: func(cmd *cobra.Command, args []string) error { 22 | return runTest(args) 23 | }, 24 | } 25 | 26 | func runTest(args []string) error { 27 | track, err := getTrack() 28 | if err != nil { 29 | return err 30 | } 31 | 32 | testConf, ok := workspace.TestConfigurations[track] 33 | 34 | if !ok { 35 | return fmt.Errorf("the \"%s\" track does not yet support running tests using the Exercism CLI. Please see HELP.md for testing instructions", track) 36 | } 37 | 38 | command, err := testConf.GetTestCommand() 39 | if err != nil { 40 | return err 41 | } 42 | cmdParts := strings.Split(command, " ") 43 | 44 | // pass args/flags to this command down to the test handler 45 | if len(args) > 0 { 46 | cmdParts = append(cmdParts, args...) 47 | } 48 | 49 | fmt.Printf("Running tests via `%s`\n\n", strings.Join(cmdParts, " ")) 50 | exerciseTestCmd := exec.Command(cmdParts[0], cmdParts[1:]...) 51 | 52 | // pipe output directly out, preserving any color 53 | exerciseTestCmd.Stdout = os.Stdout 54 | exerciseTestCmd.Stderr = os.Stderr 55 | 56 | err = exerciseTestCmd.Run() 57 | if err != nil { 58 | // unclear what other errors would pop up here, but it pays to be defensive 59 | if exitErr, ok := err.(*exec.ExitError); ok { 60 | exitCode := exitErr.ExitCode() 61 | // if subcommand returned a non-zero exit code, exit with the same 62 | os.Exit(exitCode) 63 | } else { 64 | log.Fatalf("Failed to get error from failed subcommand: %v", err) 65 | } 66 | } 67 | return nil 68 | } 69 | 70 | func getTrack() (string, error) { 71 | metadata, err := workspace.NewExerciseMetadata(".") 72 | if err != nil { 73 | return "", err 74 | } 75 | if metadata.Track == "" { 76 | return "", fmt.Errorf("no track found in exercise metadata") 77 | } 78 | 79 | return metadata.Track, nil 80 | } 81 | 82 | func init() { 83 | RootCmd.AddCommand(testCmd) 84 | } 85 | -------------------------------------------------------------------------------- /cmd/troubleshoot.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "html/template" 7 | "runtime" 8 | "sync" 9 | "time" 10 | 11 | "github.com/exercism/cli/cli" 12 | "github.com/exercism/cli/config" 13 | "github.com/exercism/cli/debug" 14 | "github.com/spf13/cobra" 15 | "github.com/spf13/viper" 16 | ) 17 | 18 | // fullAPIKey flag for troubleshoot command. 19 | var fullAPIKey bool 20 | 21 | // troubleshootCmd does a diagnostic self-check. 22 | var troubleshootCmd = &cobra.Command{ 23 | Use: "troubleshoot", 24 | Aliases: []string{"debug"}, 25 | Short: "Troubleshoot does a diagnostic self-check.", 26 | Long: `Provides output to help with troubleshooting. 27 | 28 | If you're running into trouble, copy and paste the output from the troubleshoot 29 | command into a topic on the Exercism forum so we can help figure out what's going on. 30 | `, 31 | RunE: func(cmd *cobra.Command, args []string) error { 32 | cli.TimeoutInSeconds = cli.TimeoutInSeconds * 2 33 | c := cli.New(Version) 34 | 35 | cfg := config.NewConfig() 36 | 37 | v := viper.New() 38 | v.AddConfigPath(cfg.Dir) 39 | v.SetConfigName("user") 40 | v.SetConfigType("json") 41 | // Ignore error. If the file doesn't exist, that is fine. 42 | _ = v.ReadInConfig() 43 | 44 | cfg.UserViperConfig = v 45 | 46 | status := newStatus(c, cfg) 47 | status.Censor = !fullAPIKey 48 | s, err := status.check() 49 | if err != nil { 50 | return err 51 | } 52 | 53 | fmt.Printf("%s", s) 54 | return nil 55 | }, 56 | } 57 | 58 | // Status represents the results of a CLI self test. 59 | type Status struct { 60 | Censor bool 61 | Version versionStatus 62 | System systemStatus 63 | Configuration configurationStatus 64 | APIReachability apiReachabilityStatus 65 | cfg config.Config 66 | cli *cli.CLI 67 | } 68 | 69 | type versionStatus struct { 70 | Current string 71 | Latest string 72 | Status string 73 | Error error 74 | UpToDate bool 75 | } 76 | 77 | type systemStatus struct { 78 | OS string 79 | Architecture string 80 | Build string 81 | } 82 | 83 | type configurationStatus struct { 84 | Home string 85 | Workspace string 86 | Dir string 87 | Token string 88 | TokenURL string 89 | } 90 | 91 | type apiReachabilityStatus struct { 92 | Services []*apiPing 93 | } 94 | 95 | type apiPing struct { 96 | Service string 97 | URL string 98 | Status string 99 | Latency time.Duration 100 | } 101 | 102 | // newStatus prepares a value to perform a diagnostic self-check. 103 | func newStatus(cli *cli.CLI, cfg config.Config) Status { 104 | status := Status{ 105 | cfg: cfg, 106 | cli: cli, 107 | } 108 | return status 109 | } 110 | 111 | // check runs the CLI's diagnostic self-check. 112 | func (status *Status) check() (string, error) { 113 | status.Version = newVersionStatus(status.cli) 114 | status.System = newSystemStatus() 115 | status.Configuration = newConfigurationStatus(status) 116 | status.APIReachability = newAPIReachabilityStatus(status.cfg) 117 | 118 | return status.compile() 119 | } 120 | func (status *Status) compile() (string, error) { 121 | t, err := template.New("self-test").Parse(tmplSelfTest) 122 | if err != nil { 123 | return "", err 124 | } 125 | 126 | var bb bytes.Buffer 127 | if err = t.Execute(&bb, status); err != nil { 128 | return "", err 129 | } 130 | return bb.String(), nil 131 | } 132 | 133 | func newAPIReachabilityStatus(cfg config.Config) apiReachabilityStatus { 134 | baseURL := cfg.UserViperConfig.GetString("apibaseurl") 135 | if baseURL == "" { 136 | baseURL = cfg.DefaultBaseURL 137 | } 138 | ar := apiReachabilityStatus{ 139 | Services: []*apiPing{ 140 | {Service: "GitHub", URL: "https://api.github.com"}, 141 | {Service: "Exercism", URL: fmt.Sprintf("%s/ping", baseURL)}, 142 | }, 143 | } 144 | var wg sync.WaitGroup 145 | wg.Add(len(ar.Services)) 146 | for _, service := range ar.Services { 147 | go service.Call(&wg) 148 | } 149 | wg.Wait() 150 | return ar 151 | } 152 | 153 | func newVersionStatus(c *cli.CLI) versionStatus { 154 | vs := versionStatus{ 155 | Current: c.Version, 156 | } 157 | ok, err := c.IsUpToDate() 158 | if err == nil { 159 | vs.Latest = c.LatestRelease.Version() 160 | } else { 161 | vs.Error = fmt.Errorf("Error: %s", err) 162 | } 163 | vs.UpToDate = ok 164 | return vs 165 | } 166 | 167 | func newSystemStatus() systemStatus { 168 | ss := systemStatus{ 169 | OS: runtime.GOOS, 170 | Architecture: runtime.GOARCH, 171 | } 172 | if cli.BuildOS != "" && cli.BuildARCH != "" { 173 | ss.Build = fmt.Sprintf("%s/%s", cli.BuildOS, cli.BuildARCH) 174 | } 175 | if cli.BuildARM != "" { 176 | ss.Build = fmt.Sprintf("%s ARMv%s", ss.Build, cli.BuildARM) 177 | } 178 | return ss 179 | } 180 | 181 | func newConfigurationStatus(status *Status) configurationStatus { 182 | v := status.cfg.UserViperConfig 183 | 184 | workspace := v.GetString("workspace") 185 | if workspace == "" { 186 | workspace = fmt.Sprintf("%s (default)", config.DefaultWorkspaceDir(status.cfg)) 187 | } 188 | 189 | cs := configurationStatus{ 190 | Home: status.cfg.Home, 191 | Workspace: workspace, 192 | Dir: status.cfg.Dir, 193 | Token: v.GetString("token"), 194 | TokenURL: config.SettingsURL(v.GetString("apibaseurl")), 195 | } 196 | if status.Censor && cs.Token != "" { 197 | cs.Token = debug.Redact(cs.Token) 198 | } 199 | return cs 200 | } 201 | 202 | func (ping *apiPing) Call(wg *sync.WaitGroup) { 203 | defer wg.Done() 204 | 205 | now := time.Now() 206 | res, err := cli.HTTPClient.Get(ping.URL) 207 | delta := time.Since(now) 208 | ping.Latency = delta 209 | if err != nil { 210 | ping.Status = err.Error() 211 | return 212 | } 213 | res.Body.Close() 214 | ping.Status = "connected" 215 | } 216 | 217 | const tmplSelfTest = ` 218 | Troubleshooting Information 219 | =========================== 220 | 221 | Version 222 | ---------------- 223 | Current: {{ .Version.Current }} 224 | Latest: {{ with .Version.Latest }}{{ . }}{{ else }}{{ end }} 225 | {{ with .Version.Error }} 226 | {{ . }} 227 | {{ end -}} 228 | {{ if not .Version.UpToDate }} 229 | Call 'exercism upgrade' to get the latest version. 230 | See the release notes at https://github.com/exercism/cli/releases/tag/v{{ .Version.Latest }} for details. 231 | {{ end }} 232 | 233 | Operating System 234 | ---------------- 235 | OS: {{ .System.OS }} 236 | Architecture: {{ .System.Architecture }} 237 | {{ with .System.Build }} 238 | Build: {{ . }} 239 | {{ end }} 240 | 241 | Configuration 242 | ---------------- 243 | Home: {{ .Configuration.Home }} 244 | Workspace: {{ .Configuration.Workspace }} 245 | Config: {{ .Configuration.Dir }} 246 | API key: {{ with .Configuration.Token }}{{ . }}{{ else }} 247 | Find your API key at {{ .Configuration.TokenURL }}{{ end }} 248 | 249 | API Reachability 250 | ---------------- 251 | {{ range .APIReachability.Services }} 252 | {{ .Service }}: 253 | * {{ .URL }} 254 | * [{{ .Status }}] 255 | * {{ .Latency }} 256 | {{ end }} 257 | 258 | If you are having trouble, please create a new topic in the Exercism forum 259 | at https://forum.exercism.org/c/support/cli/10 and include 260 | this information. 261 | {{ if not .Censor }} 262 | Don't share your API key. Keep that private. 263 | {{ end }}` 264 | 265 | func init() { 266 | RootCmd.AddCommand(troubleshootCmd) 267 | troubleshootCmd.Flags().BoolVarP(&fullAPIKey, "full-api-key", "f", false, "display the user's full API key, censored by default") 268 | } 269 | -------------------------------------------------------------------------------- /cmd/upgrade.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/exercism/cli/cli" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // upgradeCmd downloads and installs the most recent version of the CLI. 11 | var upgradeCmd = &cobra.Command{ 12 | Use: "upgrade", 13 | Aliases: []string{"u"}, 14 | Short: "Upgrade to the latest version of the CLI.", 15 | Long: `Upgrade to the latest version of the CLI. 16 | 17 | This finds and downloads the latest release, if you don't 18 | already have it. 19 | 20 | On Windows the old CLI will be left on disk, marked as hidden. 21 | The next time you upgrade, the hidden file will be overwritten. 22 | You can always delete this file. 23 | `, 24 | RunE: func(cmd *cobra.Command, args []string) error { 25 | c := cli.New(Version) 26 | err := updateCLI(c) 27 | if err != nil { 28 | return fmt.Errorf(` 29 | 30 | We were not able to upgrade the cli because we encountered an error: 31 | %s 32 | 33 | Please check the FAQ for solutions to common upgrading issues. 34 | 35 | https://exercism.org/faqs`, err) 36 | } 37 | return nil 38 | }, 39 | } 40 | 41 | // updateCLI updates CLI to the latest available version, if it is out of date. 42 | func updateCLI(c cli.Updater) error { 43 | ok, err := c.IsUpToDate() 44 | if err != nil { 45 | return err 46 | } 47 | 48 | if ok { 49 | fmt.Fprintln(Out, "Your CLI version is up to date.") 50 | return nil 51 | } 52 | 53 | return c.Upgrade() 54 | } 55 | 56 | func init() { 57 | RootCmd.AddCommand(upgradeCmd) 58 | } 59 | -------------------------------------------------------------------------------- /cmd/upgrade_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | type fakeCLI struct { 10 | UpToDate bool 11 | UpgradeCalled bool 12 | } 13 | 14 | func (fc *fakeCLI) IsUpToDate() (bool, error) { 15 | return fc.UpToDate, nil 16 | } 17 | 18 | func (fc *fakeCLI) Upgrade() error { 19 | fc.UpgradeCalled = true 20 | return nil 21 | } 22 | 23 | func TestUpgrade(t *testing.T) { 24 | co := newCapturedOutput() 25 | co.override() 26 | defer co.reset() 27 | 28 | testCases := []struct { 29 | desc string 30 | upToDate bool 31 | expected bool 32 | }{ 33 | { 34 | desc: "upgrade should be called for an outdated CLI", 35 | upToDate: false, 36 | expected: true, 37 | }, 38 | { 39 | desc: "upgrade should not be called for an already updated CLI", 40 | upToDate: true, 41 | expected: false, 42 | }, 43 | } 44 | 45 | for _, tc := range testCases { 46 | t.Run(tc.desc, func(t *testing.T) { 47 | fc := &fakeCLI{UpToDate: tc.upToDate} 48 | 49 | err := updateCLI(fc) 50 | assert.NoError(t, err) 51 | assert.Equal(t, tc.expected, fc.UpgradeCalled) 52 | }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/exercism/cli/cli" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // Version is the version of the current build. 11 | // It follows semantic versioning. 12 | const Version = "3.5.5" 13 | 14 | // checkLatest flag for version command. 15 | var checkLatest bool 16 | 17 | // versionCmd outputs the version of the CLI. 18 | var versionCmd = &cobra.Command{ 19 | Use: "version", 20 | Aliases: []string{"v"}, 21 | Short: "Version outputs the version of CLI.", 22 | Long: `Version outputs the version of the exercism binary that is in use. 23 | 24 | To check for the latest available version, call the command with the 25 | --latest flag. 26 | `, 27 | 28 | RunE: func(cmd *cobra.Command, args []string) error { 29 | fmt.Println(currentVersion()) 30 | 31 | if checkLatest { 32 | c := cli.New(Version) 33 | l, err := checkForUpdate(c) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | fmt.Println(l) 39 | } 40 | 41 | return nil 42 | }, 43 | } 44 | 45 | // currentVersion returns a formatted version string for the Exercism CLI. 46 | func currentVersion() string { 47 | return fmt.Sprintf("exercism version %s", Version) 48 | } 49 | 50 | // checkForUpdate verifies if the CLI is running the latest version. 51 | // If the client is out of date, the function returns upgrade instructions. 52 | func checkForUpdate(c *cli.CLI) (string, error) { 53 | 54 | ok, err := c.IsUpToDate() 55 | if err != nil { 56 | return "", err 57 | } 58 | 59 | if ok { 60 | return "Your CLI version is up to date.", nil 61 | } 62 | 63 | // Anything but ok is out of date. 64 | msg := fmt.Sprintf("A new CLI version is available. Run `exercism upgrade` to update to %s", c.LatestRelease.Version()) 65 | return msg, nil 66 | 67 | } 68 | 69 | func init() { 70 | RootCmd.AddCommand(versionCmd) 71 | versionCmd.Flags().BoolVarP(&checkLatest, "latest", "l", false, "check latest available version") 72 | } 73 | -------------------------------------------------------------------------------- /cmd/version_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/exercism/cli/cli" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestCurrentVersion(t *testing.T) { 14 | expected := fmt.Sprintf("exercism version %s", Version) 15 | 16 | actual := currentVersion() 17 | assert.Equal(t, expected, actual) 18 | } 19 | 20 | func TestVersionUpdateCheck(t *testing.T) { 21 | fakeEndpoint := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 22 | fmt.Fprintln(w, `{"tag_name": "v2.0.0"}`) 23 | }) 24 | ts := httptest.NewServer(fakeEndpoint) 25 | defer ts.Close() 26 | cli.ReleaseURL = ts.URL 27 | 28 | testCases := []struct { 29 | desc string 30 | version string 31 | expected string 32 | }{ 33 | { 34 | desc: "It returns new version available for versions older than latest.", 35 | version: "1.0.0", 36 | expected: "A new CLI version is available. Run `exercism upgrade` to update to 2.0.0", 37 | }, 38 | { 39 | desc: "It returns up to date for versions matching latest.", 40 | version: "2.0.0", 41 | expected: "Your CLI version is up to date.", 42 | }, 43 | { 44 | desc: "It returns up to date for versions newer than latest.", 45 | version: "2.0.1", 46 | expected: "Your CLI version is up to date.", 47 | }, 48 | } 49 | 50 | for _, tc := range testCases { 51 | t.Run(tc.desc, func(t *testing.T) { 52 | c := &cli.CLI{ 53 | Version: tc.version, 54 | } 55 | 56 | actual, err := checkForUpdate(c) 57 | 58 | assert.NoError(t, err) 59 | assert.NotEmpty(t, actual) 60 | assert.Equal(t, tc.expected, actual) 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /cmd/workspace.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/exercism/cli/config" 7 | "github.com/spf13/cobra" 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | // workspaceCmd outputs the path to the person's workspace directory. 12 | var workspaceCmd = &cobra.Command{ 13 | Use: "workspace", 14 | Aliases: []string{"w"}, 15 | Short: "Print out the path to your Exercism workspace.", 16 | Long: `Print out the path to your Exercism workspace. 17 | 18 | This command can be used for scripting, or it can be combined with shell 19 | commands to take you to your workspace. 20 | 21 | For example you can run: 22 | 23 | cd $(exercism workspace) 24 | 25 | On Windows, this will work only with Powershell, however you would 26 | need to be on the same drive as your workspace directory. Otherwise 27 | nothing will happen. 28 | `, 29 | RunE: func(cmd *cobra.Command, args []string) error { 30 | cfg := config.NewConfig() 31 | 32 | v := viper.New() 33 | v.AddConfigPath(cfg.Dir) 34 | v.SetConfigName("user") 35 | v.SetConfigType("json") 36 | // Ignore error. If the file doesn't exist, that is fine. 37 | _ = v.ReadInConfig() 38 | 39 | fmt.Fprintf(Out, "%s\n", v.GetString("workspace")) 40 | return nil 41 | }, 42 | } 43 | 44 | func init() { 45 | RootCmd.AddCommand(workspaceCmd) 46 | } 47 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "regexp" 8 | "runtime" 9 | "strings" 10 | 11 | "github.com/spf13/viper" 12 | ) 13 | 14 | var ( 15 | defaultBaseURL = "https://api.exercism.org/v1" 16 | 17 | // DefaultDirName is the default name used for config and workspace directories. 18 | DefaultDirName string 19 | ) 20 | 21 | // Config lets us inject configuration options into commands. 22 | type Config struct { 23 | OS string 24 | Home string 25 | Dir string 26 | DefaultBaseURL string 27 | DefaultDirName string 28 | UserViperConfig *viper.Viper 29 | Persister Persister 30 | } 31 | 32 | // NewConfig provides a configuration with default values. 33 | func NewConfig() Config { 34 | home := userHome() 35 | dir := Dir() 36 | 37 | return Config{ 38 | OS: runtime.GOOS, 39 | Dir: Dir(), 40 | Home: home, 41 | DefaultBaseURL: defaultBaseURL, 42 | DefaultDirName: DefaultDirName, 43 | Persister: FilePersister{Dir: dir}, 44 | } 45 | } 46 | 47 | // SetDefaultDirName configures the default directory name based on the name of the binary. 48 | func SetDefaultDirName(binaryName string) { 49 | DefaultDirName = strings.Replace(filepath.Base(binaryName), ".exe", "", 1) 50 | } 51 | 52 | // Dir is the configured config home directory. 53 | // All the cli-related config files live in this directory. 54 | func Dir() string { 55 | var dir string 56 | if runtime.GOOS == "windows" { 57 | dir = os.Getenv("APPDATA") 58 | if dir != "" { 59 | return filepath.Join(dir, DefaultDirName) 60 | } 61 | } else { 62 | dir := os.Getenv("EXERCISM_CONFIG_HOME") 63 | if dir != "" { 64 | return dir 65 | } 66 | dir = os.Getenv("XDG_CONFIG_HOME") 67 | if dir == "" { 68 | dir = filepath.Join(os.Getenv("HOME"), ".config") 69 | } 70 | if dir != "" { 71 | return filepath.Join(dir, DefaultDirName) 72 | } 73 | } 74 | // If all else fails, use the current directory. 75 | dir, _ = os.Getwd() 76 | return dir 77 | } 78 | 79 | func userHome() string { 80 | var dir string 81 | if runtime.GOOS == "windows" { 82 | dir = os.Getenv("USERPROFILE") 83 | if dir != "" { 84 | return dir 85 | } 86 | dir = filepath.Join(os.Getenv("HOMEDRIVE"), os.Getenv("HOMEPATH")) 87 | if dir != "" { 88 | return dir 89 | } 90 | } else { 91 | dir = os.Getenv("HOME") 92 | if dir != "" { 93 | return dir 94 | } 95 | } 96 | // If all else fails, use the current directory. 97 | dir, _ = os.Getwd() 98 | return dir 99 | } 100 | 101 | // DefaultWorkspaceDir provides a sensible default for the Exercism workspace. 102 | // The default is different depending on the platform, in order to best match 103 | // the conventions for that platform. 104 | // It places the directory in the user's home path. 105 | func DefaultWorkspaceDir(cfg Config) string { 106 | dir := cfg.DefaultDirName 107 | if cfg.OS != "linux" { 108 | dir = strings.Title(dir) 109 | } 110 | return filepath.Join(cfg.Home, dir) 111 | } 112 | 113 | // Save persists a viper config of the base name. 114 | func (c Config) Save(basename string) error { 115 | return c.Persister.Save(c.UserViperConfig, basename) 116 | } 117 | 118 | // InferSiteURL guesses what the website URL is. 119 | // The basis for the guess is which API we're submitting to. 120 | func InferSiteURL(apiURL string) string { 121 | if apiURL == "" { 122 | apiURL = defaultBaseURL 123 | } 124 | if apiURL == "https://api.exercism.org/v1" { 125 | return "https://exercism.org" 126 | } 127 | re := regexp.MustCompile("^(https?://[^/]*).*") 128 | return re.ReplaceAllString(apiURL, "$1") 129 | } 130 | 131 | // SettingsURL provides a link to where the user can find their API token. 132 | func SettingsURL(apiURL string) string { 133 | return fmt.Sprintf("%s%s", InferSiteURL(apiURL), "/my/settings") 134 | } 135 | -------------------------------------------------------------------------------- /config/config_notwin_test.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package config 4 | 5 | import ( 6 | "fmt" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestDefaultWorkspaceDir(t *testing.T) { 13 | testCases := []struct { 14 | cfg Config 15 | expected string 16 | }{ 17 | { 18 | cfg: Config{OS: "darwin", Home: "/User/charlie", DefaultDirName: "apple"}, 19 | expected: "/User/charlie/Apple", 20 | }, 21 | { 22 | cfg: Config{OS: "linux", Home: "/home/bob", DefaultDirName: "banana"}, 23 | expected: "/home/bob/banana", 24 | }, 25 | } 26 | 27 | for _, tc := range testCases { 28 | assert.Equal(t, tc.expected, DefaultWorkspaceDir(tc.cfg), fmt.Sprintf("Operating System: %s", tc.cfg.OS)) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestInferSiteURL(t *testing.T) { 10 | testCases := []struct { 11 | api, url string 12 | }{ 13 | {"https://api.exercism.org/v1", "https://exercism.org"}, 14 | {"https://v2.exercism.org/api/v1", "https://v2.exercism.org"}, 15 | {"https://mentors-beta.exercism.org/api/v1", "https://mentors-beta.exercism.org"}, 16 | {"http://localhost:3000/api/v1", "http://localhost:3000"}, 17 | {"", "https://exercism.org"}, // use the default 18 | {"http://whatever", "http://whatever"}, // you're on your own, pal 19 | } 20 | 21 | for _, tc := range testCases { 22 | assert.Equal(t, InferSiteURL(tc.api), tc.url) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /config/config_windows_test.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package config 4 | 5 | import ( 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestDefaultWindowsWorkspaceDir(t *testing.T) { 12 | cfg := Config{OS: "windows", Home: "C:\\Something", DefaultDirName: "basename"} 13 | assert.Equal(t, "C:\\Something\\Basename", DefaultWorkspaceDir(cfg)) 14 | } 15 | -------------------------------------------------------------------------------- /config/persister.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | // Persister saves viper configs. 12 | type Persister interface { 13 | Save(*viper.Viper, string) error 14 | } 15 | 16 | // FilePersister saves viper configs to the file system. 17 | type FilePersister struct { 18 | Dir string 19 | } 20 | 21 | // Save writes the viper config to the target location on the filesystem. 22 | func (p FilePersister) Save(v *viper.Viper, basename string) error { 23 | v.SetConfigType("json") 24 | v.AddConfigPath(p.Dir) 25 | v.SetConfigName(basename) 26 | 27 | if _, err := os.Stat(p.Dir); os.IsNotExist(err) { 28 | if err := os.MkdirAll(p.Dir, os.FileMode(0755)); err != nil { 29 | return err 30 | } 31 | } 32 | 33 | // WriteConfig is broken. 34 | // Someone proposed a fix in https://github.com/spf13/viper/pull/503, 35 | // but the fix doesn't work yet. 36 | // When it's fixed and merged we can get rid of `path` 37 | // and use viperConfig.WriteConfig() directly. 38 | path := filepath.Join(p.Dir, fmt.Sprintf("%s.json", basename)) 39 | return v.WriteConfigAs(path) 40 | } 41 | 42 | // InMemoryPersister is a noop persister for use in unit tests. 43 | type InMemoryPersister struct{} 44 | 45 | // Save does nothing. 46 | func (p InMemoryPersister) Save(*viper.Viper, string) error { 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /config/resolve.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | ) 8 | 9 | // Resolve cleans up filesystem paths. 10 | func Resolve(path, home string) string { 11 | if path == "" { 12 | return "" 13 | } 14 | if strings.HasPrefix(path, "~/") { 15 | path = strings.Replace(path, "~/", "", 1) 16 | return filepath.Join(home, path) 17 | } 18 | if filepath.IsAbs(path) { 19 | return filepath.Clean(path) 20 | } 21 | // if using "/dir" on Windows 22 | if strings.HasPrefix(path, "/") { 23 | return filepath.Join(home, filepath.Clean(path)) 24 | } 25 | cwd, err := os.Getwd() 26 | if err != nil { 27 | return path 28 | } 29 | return filepath.Join(cwd, path) 30 | } 31 | -------------------------------------------------------------------------------- /config/resolve_notwin_test.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package config 4 | 5 | import ( 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestResolve(t *testing.T) { 14 | cwd, err := os.Getwd() 15 | assert.NoError(t, err) 16 | 17 | testCases := []struct { 18 | in, out string 19 | }{ 20 | {"", ""}, // don't make wild guesses 21 | {"/home/alice///foobar", "/home/alice/foobar"}, 22 | {"~/foobar", "/home/alice/foobar"}, 23 | {"/foobar/~/noexpand", "/foobar/~/noexpand"}, 24 | {"/no/modification", "/no/modification"}, 25 | {"relative", filepath.Join(cwd, "relative")}, 26 | {"relative///path", filepath.Join(cwd, "relative", "path")}, 27 | } 28 | 29 | for _, tc := range testCases { 30 | testName := "'" + tc.in + "' should be normalized as '" + tc.out + "'" 31 | t.Run(testName, func(t *testing.T) { 32 | assert.Equal(t, tc.out, Resolve(tc.in, "/home/alice"), testName) 33 | }) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /config/resolve_windows.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestResolve(t *testing.T) { 12 | cwd, err := os.Getwd() 13 | assert.NoError(t, err) 14 | 15 | testCases := []struct { 16 | in, out string 17 | }{ 18 | {"", ""}, // don't make wild guesses 19 | {"C:\\alice\\\\foobar", "C:\\alice\\\\foobar"}, 20 | {"\\foobar\\~\\noexpand", "\\foobar\\~\\noexpand"}, 21 | {"\\no\\modification", "\\no\\modification"}, 22 | {"relative", filepath.Join(cwd, "relative")}, 23 | {"relative\\path", filepath.Join(cwd, "relative", "path")}, 24 | } 25 | 26 | for _, tc := range testCases { 27 | t.Run(tc.in, func(t *testing.T) { 28 | desc := "'" + tc.in + "' should be normalized as '" + tc.out + "'" 29 | assert.Equal(t, tc.out, Resolve(tc.in, ""), desc) 30 | }) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /debug/debug.go: -------------------------------------------------------------------------------- 1 | package debug 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/http" 9 | "net/http/httputil" 10 | "os" 11 | "strings" 12 | ) 13 | 14 | var ( 15 | // Verbose determines if debugging output is displayed to the user 16 | Verbose bool 17 | output io.Writer = os.Stderr 18 | // UnmaskAPIKey determines if the API key should de displayed during a dump 19 | UnmaskAPIKey bool 20 | ) 21 | 22 | // Println conditionally outputs a message to Stderr 23 | func Println(args ...interface{}) { 24 | if Verbose { 25 | fmt.Fprintln(output, args...) 26 | } 27 | } 28 | 29 | // Printf conditionally outputs a formatted message to Stderr 30 | func Printf(format string, args ...interface{}) { 31 | if Verbose { 32 | fmt.Fprintf(output, format, args...) 33 | } 34 | } 35 | 36 | // DumpRequest dumps out the provided http.Request 37 | func DumpRequest(req *http.Request) { 38 | if !Verbose { 39 | return 40 | } 41 | 42 | var bodyCopy bytes.Buffer 43 | body := io.TeeReader(req.Body, &bodyCopy) 44 | req.Body = io.NopCloser(body) 45 | 46 | authHeader := req.Header.Get("Authorization") 47 | 48 | if authParts := strings.Split(authHeader, " "); len(authParts) > 1 && !UnmaskAPIKey { 49 | if token := authParts[1]; token != "" { 50 | req.Header.Set("Authorization", "Bearer "+Redact(token)) 51 | } 52 | } 53 | 54 | dump, err := httputil.DumpRequest(req, req.ContentLength > 0) 55 | if err != nil { 56 | log.Fatal(err) 57 | } 58 | 59 | Println("\n========================= BEGIN DumpRequest =========================") 60 | Println(string(dump)) 61 | Println("========================= END DumpRequest =========================") 62 | Println("") 63 | 64 | req.Header.Set("Authorization", authHeader) 65 | req.Body = io.NopCloser(&bodyCopy) 66 | } 67 | 68 | // DumpResponse dumps out the provided http.Response 69 | func DumpResponse(res *http.Response) { 70 | if !Verbose { 71 | return 72 | } 73 | 74 | var bodyCopy bytes.Buffer 75 | body := io.TeeReader(res.Body, &bodyCopy) 76 | res.Body = io.NopCloser(body) 77 | 78 | dump, err := httputil.DumpResponse(res, res.ContentLength > 0) 79 | if err != nil { 80 | log.Fatal(err) 81 | } 82 | 83 | Println("\n========================= BEGIN DumpResponse =========================") 84 | Println(string(dump)) 85 | Println("========================= END DumpResponse =========================") 86 | Println("") 87 | 88 | res.Body = io.NopCloser(body) 89 | } 90 | 91 | // Redact masks the given token by replacing part of the string with * 92 | func Redact(token string) string { 93 | str := token[4 : len(token)-3] 94 | redaction := strings.Repeat("*", len(str)) 95 | return string(token[:4]) + redaction + string(token[len(token)-3:]) 96 | } 97 | -------------------------------------------------------------------------------- /debug/debug_test.go: -------------------------------------------------------------------------------- 1 | package debug 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestVerboseEnabled(t *testing.T) { 12 | b := &bytes.Buffer{} 13 | output = b 14 | Verbose = true 15 | 16 | Println("World") 17 | if b.String() != "World\n" { 18 | t.Error("expected 'World' got", b.String()) 19 | } 20 | } 21 | 22 | func TestVerboseDisabled(t *testing.T) { 23 | b := &bytes.Buffer{} 24 | output = b 25 | Verbose = false 26 | 27 | Println("World") 28 | if b.String() != "" { 29 | t.Error("expected '' got", b.String()) 30 | } 31 | } 32 | 33 | func TestDumpRequest(t *testing.T) { 34 | testCases := []struct { 35 | desc string 36 | auth string 37 | verbose bool 38 | unmask bool 39 | }{ 40 | { 41 | desc: "Do not attempt to dump request if 'Verbose' is set to false", 42 | auth: "", 43 | verbose: false, 44 | unmask: false, 45 | }, 46 | { 47 | desc: "Dump request without authorization header", 48 | auth: "", //not set 49 | verbose: true, 50 | unmask: false, 51 | }, 52 | { 53 | desc: "Dump request with malformed 'Authorization' header", 54 | auth: "malformed", 55 | verbose: true, 56 | unmask: true, 57 | }, 58 | { 59 | desc: "Dump request with properly formed 'Authorization' header", 60 | auth: "Bearer abc12-345abcde1234-5abc12", 61 | verbose: true, 62 | unmask: false, 63 | }, 64 | } 65 | 66 | b := &bytes.Buffer{} 67 | output = b 68 | for _, tc := range testCases { 69 | Verbose = tc.verbose 70 | UnmaskAPIKey = tc.unmask 71 | r, _ := http.NewRequest("GET", "https://api.example.com/bogus", nil) 72 | if tc.auth != "" { 73 | r.Header.Set("Authorization", tc.auth) 74 | } 75 | 76 | DumpRequest(r) 77 | if tc.verbose { 78 | assert.Regexp(t, "GET /bogus", b.String(), tc.desc) 79 | assert.Equal(t, tc.auth, r.Header.Get("Authorization"), tc.desc) 80 | if tc.unmask { 81 | assert.Regexp(t, "Authorization: "+tc.auth, b.String(), tc.desc) 82 | } 83 | } else { 84 | assert.NotRegexp(t, "GET /bogus", b.String(), tc.desc) 85 | } 86 | } 87 | } 88 | 89 | func TestDumpResponse(t *testing.T) { 90 | b := &bytes.Buffer{} 91 | output = b 92 | Verbose = true 93 | r := &http.Response{ 94 | StatusCode: 200, 95 | ProtoMajor: 1, 96 | ProtoMinor: 1, 97 | } 98 | 99 | DumpResponse(r) 100 | assert.Regexp(t, "HTTP/1.1 200 OK", b.String()) 101 | } 102 | 103 | func TestRedact(t *testing.T) { 104 | fakeToken := "1a11111aaaa111aa1a11111a11111aa1" 105 | expected := "1a11*************************aa1" 106 | 107 | assert.Equal(t, expected, Redact(fakeToken)) 108 | } 109 | -------------------------------------------------------------------------------- /exercism/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Command exercism allows users to interact with the exercism.org platform. 3 | 4 | The primary actions are to fetch problems to be solved, and submit iterations 5 | of these problems. 6 | */ 7 | package main 8 | -------------------------------------------------------------------------------- /exercism/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/exercism/cli/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /fixtures/detect-path-type/a-dir/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exercism/cli/14f2349edc8cd1e2ad7393c14e17ceca06969ef9/fixtures/detect-path-type/a-dir/.keep -------------------------------------------------------------------------------- /fixtures/detect-path-type/a-file.txt: -------------------------------------------------------------------------------- 1 | This is a file. 2 | -------------------------------------------------------------------------------- /fixtures/detect-path-type/symlinked-dir: -------------------------------------------------------------------------------- 1 | a-dir -------------------------------------------------------------------------------- /fixtures/detect-path-type/symlinked-file.txt: -------------------------------------------------------------------------------- 1 | a-file.txt -------------------------------------------------------------------------------- /fixtures/is-solution-path/broken/.exercism/metadata.json: -------------------------------------------------------------------------------- 1 | {,} 2 | -------------------------------------------------------------------------------- /fixtures/is-solution-path/nope/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exercism/cli/14f2349edc8cd1e2ad7393c14e17ceca06969ef9/fixtures/is-solution-path/nope/.keep -------------------------------------------------------------------------------- /fixtures/is-solution-path/yepp/.exercism/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "abc" 3 | } 4 | -------------------------------------------------------------------------------- /fixtures/locate-exercise/equipment/bat/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exercism/cli/14f2349edc8cd1e2ad7393c14e17ceca06969ef9/fixtures/locate-exercise/equipment/bat/.keep -------------------------------------------------------------------------------- /fixtures/locate-exercise/food/squash/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exercism/cli/14f2349edc8cd1e2ad7393c14e17ceca06969ef9/fixtures/locate-exercise/food/squash/.keep -------------------------------------------------------------------------------- /fixtures/locate-exercise/symlinked-workspace: -------------------------------------------------------------------------------- 1 | workspace -------------------------------------------------------------------------------- /fixtures/locate-exercise/workspace/actions/batten/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exercism/cli/14f2349edc8cd1e2ad7393c14e17ceca06969ef9/fixtures/locate-exercise/workspace/actions/batten/.keep -------------------------------------------------------------------------------- /fixtures/locate-exercise/workspace/actions/date/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exercism/cli/14f2349edc8cd1e2ad7393c14e17ceca06969ef9/fixtures/locate-exercise/workspace/actions/date/.keep -------------------------------------------------------------------------------- /fixtures/locate-exercise/workspace/actions/squash/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exercism/cli/14f2349edc8cd1e2ad7393c14e17ceca06969ef9/fixtures/locate-exercise/workspace/actions/squash/.keep -------------------------------------------------------------------------------- /fixtures/locate-exercise/workspace/creatures/bat/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exercism/cli/14f2349edc8cd1e2ad7393c14e17ceca06969ef9/fixtures/locate-exercise/workspace/creatures/bat/.keep -------------------------------------------------------------------------------- /fixtures/locate-exercise/workspace/creatures/crane-2/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exercism/cli/14f2349edc8cd1e2ad7393c14e17ceca06969ef9/fixtures/locate-exercise/workspace/creatures/crane-2/.keep -------------------------------------------------------------------------------- /fixtures/locate-exercise/workspace/creatures/crane/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exercism/cli/14f2349edc8cd1e2ad7393c14e17ceca06969ef9/fixtures/locate-exercise/workspace/creatures/crane/.keep -------------------------------------------------------------------------------- /fixtures/locate-exercise/workspace/creatures/duck/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exercism/cli/14f2349edc8cd1e2ad7393c14e17ceca06969ef9/fixtures/locate-exercise/workspace/creatures/duck/.keep -------------------------------------------------------------------------------- /fixtures/locate-exercise/workspace/creatures/horse/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exercism/cli/14f2349edc8cd1e2ad7393c14e17ceca06969ef9/fixtures/locate-exercise/workspace/creatures/horse/.keep -------------------------------------------------------------------------------- /fixtures/locate-exercise/workspace/food/date: -------------------------------------------------------------------------------- 1 | ../text-files/date -------------------------------------------------------------------------------- /fixtures/locate-exercise/workspace/food/squash: -------------------------------------------------------------------------------- 1 | ../../food/squash -------------------------------------------------------------------------------- /fixtures/locate-exercise/workspace/friends/alice/creatures/bat/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exercism/cli/14f2349edc8cd1e2ad7393c14e17ceca06969ef9/fixtures/locate-exercise/workspace/friends/alice/creatures/bat/.keep -------------------------------------------------------------------------------- /fixtures/locate-exercise/workspace/friends/alice/creatures/fly/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exercism/cli/14f2349edc8cd1e2ad7393c14e17ceca06969ef9/fixtures/locate-exercise/workspace/friends/alice/creatures/fly/.keep -------------------------------------------------------------------------------- /fixtures/locate-exercise/workspace/text-files/date: -------------------------------------------------------------------------------- 1 | this is a file 2 | -------------------------------------------------------------------------------- /fixtures/locate-exercise/workspace/text-files/duck: -------------------------------------------------------------------------------- 1 | this is a file 2 | -------------------------------------------------------------------------------- /fixtures/solution-dir/file.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exercism/cli/14f2349edc8cd1e2ad7393c14e17ceca06969ef9/fixtures/solution-dir/file.txt -------------------------------------------------------------------------------- /fixtures/solution-dir/workspace/exercise/.exercism/metadata.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exercism/cli/14f2349edc8cd1e2ad7393c14e17ceca06969ef9/fixtures/solution-dir/workspace/exercise/.exercism/metadata.json -------------------------------------------------------------------------------- /fixtures/solution-dir/workspace/exercise/file.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exercism/cli/14f2349edc8cd1e2ad7393c14e17ceca06969ef9/fixtures/solution-dir/workspace/exercise/file.txt -------------------------------------------------------------------------------- /fixtures/solution-dir/workspace/exercise/in/a/subdir/file.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exercism/cli/14f2349edc8cd1e2ad7393c14e17ceca06969ef9/fixtures/solution-dir/workspace/exercise/in/a/subdir/file.txt -------------------------------------------------------------------------------- /fixtures/solution-dir/workspace/not-exercise/file.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exercism/cli/14f2349edc8cd1e2ad7393c14e17ceca06969ef9/fixtures/solution-dir/workspace/not-exercise/file.txt -------------------------------------------------------------------------------- /fixtures/solution-path/creatures/gazelle-2/.exercism/metadata.json: -------------------------------------------------------------------------------- 1 | {"id": "bbb"} 2 | -------------------------------------------------------------------------------- /fixtures/solution-path/creatures/gazelle-3/.exercism/metadata.json: -------------------------------------------------------------------------------- 1 | {"id": "ccc"} 2 | -------------------------------------------------------------------------------- /fixtures/solution-path/creatures/gazelle/.exercism/metadata.json: -------------------------------------------------------------------------------- 1 | {"id": "aaa"} 2 | -------------------------------------------------------------------------------- /fixtures/solutions/alpha/.exercism/metadata.json: -------------------------------------------------------------------------------- 1 | {"id": "alpha"} 2 | -------------------------------------------------------------------------------- /fixtures/solutions/bravo/.exercism/metadata.json: -------------------------------------------------------------------------------- 1 | {"id": "bravo"} 2 | -------------------------------------------------------------------------------- /fixtures/solutions/charlie/.exercism/metadata.json: -------------------------------------------------------------------------------- 1 | {"id": "charlie"} 2 | -------------------------------------------------------------------------------- /fixtures/solutions/delta/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exercism/cli/14f2349edc8cd1e2ad7393c14e17ceca06969ef9/fixtures/solutions/delta/.keep -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/exercism/cli 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/blang/semver v3.5.1+incompatible 7 | github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf 8 | github.com/spf13/cobra v1.7.0 9 | github.com/spf13/pflag v1.0.5 10 | github.com/spf13/viper v1.15.0 11 | github.com/stretchr/testify v1.8.4 12 | golang.org/x/net v0.23.0 13 | golang.org/x/text v0.14.0 14 | ) 15 | 16 | require ( 17 | github.com/davecgh/go-spew v1.1.1 // indirect 18 | github.com/fsnotify/fsnotify v1.6.0 // indirect 19 | github.com/hashicorp/hcl v1.0.0 // indirect 20 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 21 | github.com/magiconair/properties v1.8.7 // indirect 22 | github.com/mitchellh/mapstructure v1.5.0 // indirect 23 | github.com/pelletier/go-toml/v2 v2.0.6 // indirect 24 | github.com/pmezard/go-difflib v1.0.0 // indirect 25 | github.com/spf13/afero v1.9.3 // indirect 26 | github.com/spf13/cast v1.5.0 // indirect 27 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 28 | github.com/subosito/gotenv v1.4.2 // indirect 29 | golang.org/x/sys v0.18.0 // indirect 30 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 31 | gopkg.in/ini.v1 v1.67.0 // indirect 32 | gopkg.in/yaml.v3 v3.0.1 // indirect 33 | ) 34 | -------------------------------------------------------------------------------- /shell/README.md: -------------------------------------------------------------------------------- 1 | ## Executable 2 | Unpack the archive relevant to your machine and place in $PATH 3 | 4 | ## Shell Completion Scripts 5 | 6 | ### Bash 7 | 8 | mkdir -p ~/.config/exercism 9 | mv ../shell/exercism_completion.bash ~/.config/exercism/exercism_completion.bash 10 | 11 | Load the completion in your `.bashrc`, `.bash_profile` or `.profile` by 12 | adding the following snippet: 13 | 14 | if [ -f ~/.config/exercism/exercism_completion.bash ]; then 15 | source ~/.config/exercism/exercism_completion.bash 16 | fi 17 | 18 | ### Zsh 19 | 20 | Load up the completion by placing the `exercism_completion.zsh` somewhere on 21 | your `$fpath` as `_exercism`. For example: 22 | 23 | mkdir -p ~/.zsh/functions 24 | mv ../shell/exercism_completion.zsh ~/.zsh/functions/_exercism 25 | 26 | and then add the directory to your `$fpath` in your `.zshrc`, `.zsh_profile` or 27 | `.profile` before running `compinit`: 28 | 29 | export fpath=(~/.zsh/functions $fpath) 30 | autoload -U compinit && compinit 31 | 32 | 33 | #### Oh My Zsh 34 | 35 | If you are using the popular [Oh My Zsh][oh-my-zsh] framework to manage your 36 | zsh plugins, you need to move the file `exercism_completion.zsh` to a new 37 | custom plugin: 38 | 39 | [oh-my-zsh]: https://github.com/ohmyzsh/ohmyzsh 40 | 41 | mkdir -p $ZSH_CUSTOM/plugins/exercism 42 | cp exercism_completion.zsh $ZSH_CUSTOM/plugins/exercism/_exercism 43 | 44 | Then edit the file `~/.zshrc` to include `exercism` in the list of plugins. 45 | Completions will be activated the next time you open a new shell. If the 46 | completions do not work, you should update Oh My Zsh to the latest version with 47 | `omz update`. Oh My Zsh now checks whether the plugin list has changed (more 48 | accurately, `$fpath`) and resets the `zcompdump` file. 49 | 50 | ### Fish 51 | 52 | Completions must go in the user defined `$fish_complete_path`. By default, this is `~/.config/fish/completions` 53 | 54 | mv ../shell/exercism.fish ~/.config/fish/exercism.fish 55 | -------------------------------------------------------------------------------- /shell/exercism.fish: -------------------------------------------------------------------------------- 1 | # Configure 2 | complete -f -c exercism -n "__fish_use_subcommand" -a "configure" -d "Writes config values to a JSON file." 3 | complete -f -c exercism -n "__fish_seen_subcommand_from configure" -s t -l token -d "Set token" 4 | complete -f -c exercism -n "__fish_seen_subcommand_from configure" -s w -l workspace -d "Set workspace" 5 | complete -f -c exercism -n "__fish_seen_subcommand_from configure" -s a -l api -d "set API base url" 6 | complete -f -c exercism -n "__fish_seen_subcommand_from configure" -s s -l show -d "show settings" 7 | 8 | # Download 9 | complete -f -c exercism -n "__fish_use_subcommand" -a "download" -d "Downloads and saves a specified submission into the local system" 10 | complete -f -c exercism -n "__fish_seen_subcommand_from download" -s e -l exercise -d "the exercise slug" 11 | complete -f -c exercism -n "__fish_seen_subcommand_from download" -s h -l help -d "help for download" 12 | complete -f -c exercism -n "__fish_seen_subcommand_from download" -s T -l team -d "the team slug" 13 | complete -f -c exercism -n "__fish_seen_subcommand_from download" -s t -l track -d "the track ID" 14 | complete -f -c exercism -n "__fish_seen_subcommand_from download" -s u -l uuid -d "the solution UUID" 15 | 16 | # Help 17 | complete -f -c exercism -n "__fish_use_subcommand" -a "help" -d "Shows a list of commands or help for one command" 18 | complete -f -c exercism -n "__fish_seen_subcommand_from help" -a "configure download help open submit test troubleshoot upgrade version workspace" 19 | 20 | # Open 21 | complete -f -c exercism -n "__fish_use_subcommand" -a "open" -d "Opens a browser to exercism.org for the specified submission." 22 | complete -f -c exercism -n "__fish_seen_subcommand_from open" -s h -l help -d "help for open" 23 | 24 | # Submit 25 | complete -f -c exercism -n "__fish_use_subcommand" -a "submit" -d "Submits a new iteration to a problem on exercism.org." 26 | complete -f -c exercism -n "__fish_seen_subcommand_from submit" -s h -l help -d "help for submit" 27 | 28 | # Test 29 | complete -f -c exercism -n "__fish_use_subcommand" -a "test" -d "Run the exercise's tests." 30 | complete -f -c exercism -n "__fish_seen_subcommand_from submit" -s h -l help -d "help for test" 31 | 32 | # Troubleshoot 33 | complete -f -c exercism -n "__fish_use_subcommand" -a "troubleshoot" -d "Outputs useful debug information." 34 | complete -f -c exercism -n "__fish_seen_subcommand_from troubleshoot" -s f -l full-api-key -d "display full API key (censored by default)" 35 | complete -f -c exercism -n "__fish_seen_subcommand_from troubleshoot" -s h -l help -d "help for troubleshoot" 36 | 37 | # Upgrade 38 | complete -f -c exercism -n "__fish_use_subcommand" -a "upgrade" -d "Upgrades to the latest available version." 39 | complete -f -c exercism -n "__fish_seen_subcommand_from help" -s h -l help -d "help for help" 40 | 41 | # Version 42 | complete -f -c exercism -n "__fish_use_subcommand" -a "version" -d "Outputs version information." 43 | complete -f -c exercism -n "__fish_seen_subcommand_from version" -s l -l latest -d "check latest available version" 44 | complete -f -c exercism -n "__fish_seen_subcommand_from version" -s h -l help -d "help for version" 45 | 46 | # Workspace 47 | complete -f -c exercism -n "__fish_use_subcommand" -a "workspace" -d "Outputs the root directory for Exercism exercises." 48 | complete -f -c exercism -n "__fish_seen_subcommand_from workspace" -s h -l help -d "help for workspace" 49 | 50 | # Options 51 | complete -f -c exercism -s h -l help -d "show help" 52 | complete -f -c exercism -l timeout -a "10" -d "10 seconds" 53 | complete -f -c exercism -l timeout -a "30" -d "30 seconds" 54 | complete -f -c exercism -l timeout -a "60" -d "1 minute" 55 | complete -f -c exercism -l timeout -a "300" -d "5 minutes" 56 | complete -f -c exercism -l timeout -a "600" -d "10 minutes" 57 | complete -f -c exercism -l timeout -a "" -d "override default HTTP timeout" 58 | complete -f -c exercism -s v -l verbose -d "turn on verbose logging" 59 | -------------------------------------------------------------------------------- /shell/exercism_completion.bash: -------------------------------------------------------------------------------- 1 | _exercism () { 2 | local cur prev 3 | 4 | COMPREPLY=() # Array variable storing the possible completions. 5 | cur=${COMP_WORDS[COMP_CWORD]} 6 | prev=${COMP_WORDS[COMP_CWORD-1]} 7 | opts="--verbose --timeout" 8 | 9 | commands="configure download open 10 | submit test troubleshoot upgrade version workspace help" 11 | config_opts="--show" 12 | version_opts="--latest" 13 | 14 | if [ "${#COMP_WORDS[@]}" -eq 2 ]; then 15 | case "${cur}" in 16 | -*) 17 | COMPREPLY=( $( compgen -W "${opts}" -- "${cur}" ) ) 18 | return 0 19 | ;; 20 | *) 21 | COMPREPLY=( $( compgen -W "${commands}" "${cur}" ) ) 22 | return 0 23 | ;; 24 | esac 25 | fi 26 | 27 | if [ "${#COMP_WORDS[@]}" -eq 3 ]; then 28 | case "${prev}" in 29 | configure) 30 | COMPREPLY=( $( compgen -W "${config_opts}" -- "${cur}" ) ) 31 | return 0 32 | ;; 33 | version) 34 | COMPREPLY=( $( compgen -W "${version_opts}" -- "${cur}" ) ) 35 | return 0 36 | ;; 37 | help) 38 | COMPREPLY=( $( compgen -W "${commands}" "${cur}" ) ) 39 | return 0 40 | ;; 41 | *) 42 | return 0 43 | ;; 44 | esac 45 | fi 46 | 47 | return 0 48 | } 49 | 50 | complete -o bashdefault -o default -o nospace -F _exercism exercism 2>/dev/null \ 51 | || complete -o default -o nospace -F _exercism exercism 52 | -------------------------------------------------------------------------------- /shell/exercism_completion.zsh: -------------------------------------------------------------------------------- 1 | #compdef exercism 2 | 3 | local curcontext="$curcontext" state line 4 | typeset -A opt_args 5 | 6 | local -a options 7 | options=(configure:"Writes config values to a JSON file." 8 | download:"Downloads and saves a specified submission into the local system" 9 | open:"Opens a browser to exercism.org for the specified submission." 10 | submit:"Submits a new iteration to a problem on exercism.org." 11 | test:"Run the exercise's tests." 12 | troubleshoot:"Outputs useful debug information." 13 | upgrade:"Upgrades to the latest available version." 14 | version:"Outputs version information." 15 | workspace:"Outputs the root directory for Exercism exercises." 16 | help:"Shows a list of commands or help for one command") 17 | 18 | _arguments -s -S \ 19 | {-h,--help}"[show help]" \ 20 | {-t,--timeout}"[override default HTTP timeout]" \ 21 | {-v,--verbose}"[turn on verbose logging]" \ 22 | '(-): :->command' \ 23 | '(-)*:: :->option-or-argument' \ 24 | && return 0; 25 | 26 | case $state in 27 | (command) 28 | _describe 'commands' options ;; 29 | (option-or-argument) 30 | case $words[1] in 31 | s*) 32 | _files 33 | ;; 34 | esac 35 | esac 36 | -------------------------------------------------------------------------------- /workspace/document.go: -------------------------------------------------------------------------------- 1 | package workspace 2 | 3 | import "path/filepath" 4 | 5 | // Document is a file in a directory. 6 | type Document struct { 7 | Root string 8 | RelativePath string 9 | } 10 | 11 | // NewDocument creates a document from the filepath. 12 | // The root is typically the root of the exercise, and 13 | // path is the absolute path to the file. 14 | func NewDocument(root, path string) (Document, error) { 15 | path, err := filepath.Rel(root, path) 16 | if err != nil { 17 | return Document{}, err 18 | } 19 | return Document{ 20 | Root: root, 21 | RelativePath: path, 22 | }, nil 23 | } 24 | 25 | // Filepath is the absolute path to the document on the filesystem. 26 | func (doc Document) Filepath() string { 27 | return filepath.Join(doc.Root, doc.RelativePath) 28 | } 29 | 30 | // Path is the normalized path. 31 | // It uses forward slashes regardless of the operating system. 32 | func (doc Document) Path() string { 33 | return filepath.ToSlash(doc.RelativePath) 34 | } 35 | -------------------------------------------------------------------------------- /workspace/document_test.go: -------------------------------------------------------------------------------- 1 | package workspace 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestNormalizedDocumentPath(t *testing.T) { 12 | root, err := os.MkdirTemp("", "docpath") 13 | assert.NoError(t, err) 14 | defer os.RemoveAll(root) 15 | 16 | err = os.MkdirAll(filepath.Join(root, "subdirectory"), os.FileMode(0755)) 17 | assert.NoError(t, err) 18 | 19 | testCases := []struct { 20 | filepath string 21 | path string 22 | }{ 23 | { 24 | filepath: filepath.Join(root, "file.txt"), 25 | path: "file.txt", 26 | }, 27 | { 28 | filepath: filepath.Join(root, "subdirectory", "file.txt"), 29 | path: "subdirectory/file.txt", 30 | }, 31 | } 32 | 33 | for _, tc := range testCases { 34 | err = os.WriteFile(tc.filepath, []byte("a file"), os.FileMode(0600)) 35 | assert.NoError(t, err) 36 | 37 | doc, err := NewDocument(root, tc.filepath) 38 | assert.NoError(t, err) 39 | 40 | assert.Equal(t, doc.Path(), tc.path) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /workspace/errors.go: -------------------------------------------------------------------------------- 1 | package workspace 2 | 3 | import "fmt" 4 | 5 | // ErrNotInWorkspace signals that the target directory is outside the configured workspace. 6 | type ErrNotInWorkspace string 7 | 8 | // ErrNotExist signals that the target directory could not be located. 9 | type ErrNotExist string 10 | 11 | func (err ErrNotInWorkspace) Error() string { 12 | return fmt.Sprintf("%s not within workspace", string(err)) 13 | } 14 | 15 | func (err ErrNotExist) Error() string { 16 | return fmt.Sprintf("%s not found", string(err)) 17 | } 18 | 19 | // IsNotInWorkspace checks if this is an ErrNotInWorkspace error. 20 | func IsNotInWorkspace(err error) bool { 21 | _, ok := err.(ErrNotInWorkspace) 22 | return ok 23 | } 24 | 25 | // IsNotExist checks if this is an ErrNotExist error. 26 | func IsNotExist(err error) bool { 27 | _, ok := err.(ErrNotExist) 28 | return ok 29 | } 30 | -------------------------------------------------------------------------------- /workspace/exercise.go: -------------------------------------------------------------------------------- 1 | package workspace 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "path/filepath" 7 | ) 8 | 9 | // Exercise is an implementation of a problem in a track. 10 | type Exercise struct { 11 | Root string 12 | Track string 13 | Slug string 14 | } 15 | 16 | // NewExerciseFromDir constructs an exercise given the exercise directory. 17 | func NewExerciseFromDir(dir string) Exercise { 18 | slug := filepath.Base(dir) 19 | dir = filepath.Dir(dir) 20 | track := filepath.Base(dir) 21 | root := filepath.Dir(dir) 22 | return Exercise{Root: root, Track: track, Slug: slug} 23 | } 24 | 25 | // Path is the normalized relative path. 26 | // It always has forward slashes, regardless 27 | // of the operating system. 28 | func (e Exercise) Path() string { 29 | return path.Join(e.Track, e.Slug) 30 | } 31 | 32 | // Filepath is the absolute path on the filesystem. 33 | func (e Exercise) Filepath() string { 34 | return filepath.Join(e.Root, e.Track, e.Slug) 35 | } 36 | 37 | // MetadataFilepath is the absolute path to the exercise metadata. 38 | func (e Exercise) MetadataFilepath() string { 39 | return filepath.Join(e.Filepath(), metadataFilepath) 40 | } 41 | 42 | // LegacyMetadataFilepath is the absolute path to the legacy exercise metadata. 43 | func (e Exercise) LegacyMetadataFilepath() string { 44 | return filepath.Join(e.Filepath(), legacyMetadataFilename) 45 | } 46 | 47 | // MetadataDir returns the directory that the exercise metadata lives in. 48 | // For now this is the exercise directory. 49 | func (e Exercise) MetadataDir() string { 50 | return e.Filepath() 51 | } 52 | 53 | // HasMetadata checks for the presence of an exercise metadata file. 54 | // If there is no such file, this may be a legacy exercise. 55 | // It could also be an unrelated directory. 56 | func (e Exercise) HasMetadata() (bool, error) { 57 | _, err := os.Lstat(e.MetadataFilepath()) 58 | if os.IsNotExist(err) { 59 | return false, nil 60 | } 61 | if err == nil { 62 | return true, nil 63 | } 64 | return false, err 65 | } 66 | 67 | // HasLegacyMetadata checks for the presence of a legacy exercise metadata file. 68 | // If there is no such file, it could also be an unrelated directory. 69 | func (e Exercise) HasLegacyMetadata() (bool, error) { 70 | _, err := os.Lstat(e.LegacyMetadataFilepath()) 71 | if os.IsNotExist(err) { 72 | return false, nil 73 | } 74 | if err == nil { 75 | return true, nil 76 | } 77 | return false, err 78 | } 79 | 80 | // MigrationStatus represents the result of migrating a legacy metadata file. 81 | type MigrationStatus int 82 | 83 | // MigrationStatus 84 | const ( 85 | MigrationStatusNoop MigrationStatus = iota 86 | MigrationStatusMigrated 87 | MigrationStatusRemoved 88 | ) 89 | 90 | func (m MigrationStatus) String() string { 91 | switch m { 92 | case MigrationStatusMigrated: 93 | return "\nMigrated metadata\n" 94 | case MigrationStatusRemoved: 95 | return "\nRemoved legacy metadata\n" 96 | default: 97 | return "" 98 | } 99 | } 100 | 101 | // MigrateLegacyMetadataFile migrates a legacy metadata file to the modern location. 102 | // This is a noop if the metadata file isn't legacy. 103 | // If both legacy and modern metadata files exist, the legacy file will be deleted. 104 | func (e Exercise) MigrateLegacyMetadataFile() (MigrationStatus, error) { 105 | if ok, _ := e.HasLegacyMetadata(); !ok { 106 | return MigrationStatusNoop, nil 107 | } 108 | if err := os.MkdirAll(filepath.Dir(e.MetadataFilepath()), os.FileMode(0755)); err != nil { 109 | return MigrationStatusNoop, err 110 | } 111 | if ok, _ := e.HasMetadata(); !ok { 112 | if err := os.Rename(e.LegacyMetadataFilepath(), e.MetadataFilepath()); err != nil { 113 | return MigrationStatusNoop, err 114 | } 115 | return MigrationStatusMigrated, nil 116 | } 117 | if err := os.Remove(e.LegacyMetadataFilepath()); err != nil { 118 | return MigrationStatusNoop, err 119 | } 120 | return MigrationStatusRemoved, nil 121 | } 122 | -------------------------------------------------------------------------------- /workspace/exercise_config.go: -------------------------------------------------------------------------------- 1 | package workspace 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | const configFilename = "config.json" 11 | 12 | var configFilepath = filepath.Join(ignoreSubdir, configFilename) 13 | 14 | // ExerciseConfig contains exercise metadata. 15 | // Note: we only use a subset of its fields 16 | type ExerciseConfig struct { 17 | Files struct { 18 | Solution []string `json:"solution"` 19 | Test []string `json:"test"` 20 | } `json:"files"` 21 | } 22 | 23 | // NewExerciseConfig reads exercise metadata from a file in the given directory. 24 | func NewExerciseConfig(dir string) (*ExerciseConfig, error) { 25 | b, err := os.ReadFile(filepath.Join(dir, configFilepath)) 26 | if err != nil { 27 | return nil, err 28 | } 29 | var config ExerciseConfig 30 | if err := json.Unmarshal(b, &config); err != nil { 31 | return nil, err 32 | } 33 | 34 | return &config, nil 35 | } 36 | 37 | // GetTestFiles finds returns the names of the file(s) that hold unit tests for this exercise, if any 38 | func (c *ExerciseConfig) GetSolutionFiles() ([]string, error) { 39 | result := c.Files.Solution 40 | if result == nil { 41 | // solution file(s) key was missing in config json, which is an error when calling this fuction 42 | return []string{}, errors.New("no `files.solution` key in your `config.json`. Was it removed by mistake?") 43 | } 44 | 45 | return result, nil 46 | } 47 | 48 | // GetTestFiles finds returns the names of the file(s) that hold unit tests for this exercise, if any 49 | func (c *ExerciseConfig) GetTestFiles() ([]string, error) { 50 | result := c.Files.Test 51 | if result == nil { 52 | // test file(s) key was missing in config json, which is an error when calling this fuction 53 | return []string{}, errors.New("no `files.test` key in your `config.json`. Was it removed by mistake?") 54 | } 55 | 56 | return result, nil 57 | } 58 | -------------------------------------------------------------------------------- /workspace/exercise_config_test.go: -------------------------------------------------------------------------------- 1 | package workspace 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestExerciseConfig(t *testing.T) { 13 | dir, err := os.MkdirTemp("", "exercise_config") 14 | assert.NoError(t, err) 15 | defer os.RemoveAll(dir) 16 | 17 | err = os.Mkdir(filepath.Join(dir, ".exercism"), os.ModePerm) 18 | assert.NoError(t, err) 19 | 20 | f, err := os.Create(filepath.Join(dir, ".exercism", "config.json")) 21 | assert.NoError(t, err) 22 | defer f.Close() 23 | 24 | _, 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"] } } `) 25 | assert.NoError(t, err) 26 | 27 | ec, err := NewExerciseConfig(dir) 28 | assert.NoError(t, err) 29 | 30 | assert.Equal(t, ec.Files.Solution, []string{"lasagna.rb"}) 31 | solutionFiles, err := ec.GetSolutionFiles() 32 | assert.NoError(t, err) 33 | assert.Equal(t, solutionFiles, []string{"lasagna.rb"}) 34 | 35 | assert.Equal(t, ec.Files.Test, []string{"lasagna_test.rb"}) 36 | testFiles, err := ec.GetTestFiles() 37 | assert.NoError(t, err) 38 | assert.Equal(t, testFiles, []string{"lasagna_test.rb"}) 39 | } 40 | 41 | func TestExerciseConfigNoTestKey(t *testing.T) { 42 | dir, err := os.MkdirTemp("", "exercise_config") 43 | assert.NoError(t, err) 44 | defer os.RemoveAll(dir) 45 | 46 | err = os.Mkdir(filepath.Join(dir, ".exercism"), os.ModePerm) 47 | assert.NoError(t, err) 48 | 49 | f, err := os.Create(filepath.Join(dir, ".exercism", "config.json")) 50 | assert.NoError(t, err) 51 | defer f.Close() 52 | 53 | _, err = f.WriteString(`{ "blurb": "Learn about the basics of Ruby by following a lasagna recipe.", "authors": ["iHiD", "pvcarrera"], "files": { "exemplar": [".meta/exemplar.rb"] } } `) 54 | assert.NoError(t, err) 55 | 56 | ec, err := NewExerciseConfig(dir) 57 | assert.NoError(t, err) 58 | 59 | _, err = ec.GetSolutionFiles() 60 | assert.Error(t, err, "no `files.solution` key in your `config.json`") 61 | _, err = ec.GetTestFiles() 62 | assert.Error(t, err, "no `files.test` key in your `config.json`") 63 | } 64 | 65 | func TestMissingExerciseConfig(t *testing.T) { 66 | dir, err := os.MkdirTemp("", "exercise_config") 67 | assert.NoError(t, err) 68 | defer os.RemoveAll(dir) 69 | 70 | _, err = NewExerciseConfig(dir) 71 | assert.Error(t, err) 72 | // any assertions about this error message have to work across all platforms, so be vague 73 | // unix: ".exercism/config.json: no such file or directory" 74 | // windows: "open .exercism\config.json: The system cannot find the path specified." 75 | assert.Contains(t, err.Error(), filepath.Join(".exercism", "config.json:")) 76 | } 77 | 78 | func TestInvalidExerciseConfig(t *testing.T) { 79 | dir, err := os.MkdirTemp("", "exercise_config") 80 | assert.NoError(t, err) 81 | defer os.RemoveAll(dir) 82 | 83 | err = os.Mkdir(filepath.Join(dir, ".exercism"), os.ModePerm) 84 | assert.NoError(t, err) 85 | 86 | f, err := os.Create(filepath.Join(dir, ".exercism", "config.json")) 87 | assert.NoError(t, err) 88 | defer f.Close() 89 | 90 | // invalid JSON 91 | _, err = f.WriteString(`{ "blurb": "Learn about the basics of Ruby by following a lasagna recipe.", "authors": ["iHiD", "pvcarr `) 92 | assert.NoError(t, err) 93 | 94 | _, err = NewExerciseConfig(dir) 95 | assert.Error(t, err) 96 | assert.True(t, strings.Contains(err.Error(), "unexpected end of JSON input")) 97 | } 98 | -------------------------------------------------------------------------------- /workspace/exercise_metadata.go: -------------------------------------------------------------------------------- 1 | package workspace 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | const metadataFilename = "metadata.json" 13 | const legacyMetadataFilename = ".solution.json" 14 | const ignoreSubdir = ".exercism" 15 | 16 | var metadataFilepath = filepath.Join(ignoreSubdir, metadataFilename) 17 | 18 | // ExerciseMetadata contains metadata about a user's exercise. 19 | type ExerciseMetadata struct { 20 | Track string `json:"track"` 21 | ExerciseSlug string `json:"exercise"` 22 | ID string `json:"id"` 23 | Team string `json:"team,omitempty"` 24 | URL string `json:"url"` 25 | Handle string `json:"handle"` 26 | IsRequester bool `json:"is_requester"` 27 | SubmittedAt *time.Time `json:"submitted_at,omitempty"` 28 | Dir string `json:"-"` 29 | AutoApprove bool `json:"auto_approve"` 30 | } 31 | 32 | // NewExerciseMetadata reads exercise metadata from a file in the given directory. 33 | func NewExerciseMetadata(dir string) (*ExerciseMetadata, error) { 34 | b, err := os.ReadFile(filepath.Join(dir, metadataFilepath)) 35 | if err != nil { 36 | return nil, err 37 | } 38 | var metadata ExerciseMetadata 39 | if err := json.Unmarshal(b, &metadata); err != nil { 40 | return nil, err 41 | } 42 | metadata.Dir = dir 43 | return &metadata, nil 44 | } 45 | 46 | // Suffix is the serial numeric value appended to an exercise directory. 47 | // This is appended to avoid name conflicts, and does not indicate a particular 48 | // iteration. 49 | func (em *ExerciseMetadata) Suffix() string { 50 | return strings.Trim(strings.Replace(filepath.Base(em.Dir), em.ExerciseSlug, "", 1), "-.") 51 | } 52 | 53 | func (em *ExerciseMetadata) String() string { 54 | str := fmt.Sprintf("%s/%s", em.Track, em.ExerciseSlug) 55 | if em.Suffix() != "" { 56 | str = fmt.Sprintf("%s (%s)", str, em.Suffix()) 57 | } 58 | if !em.IsRequester && em.Handle != "" { 59 | str = fmt.Sprintf("%s by @%s", str, em.Handle) 60 | } 61 | return str 62 | } 63 | 64 | // Write stores exercise metadata to a file. 65 | func (em *ExerciseMetadata) Write(dir string) error { 66 | b, err := json.Marshal(em) 67 | if err != nil { 68 | return err 69 | } 70 | metadataAbsoluteFilepath := filepath.Join(dir, metadataFilepath) 71 | if err = os.MkdirAll(filepath.Dir(metadataAbsoluteFilepath), os.FileMode(0755)); err != nil { 72 | return err 73 | } 74 | if err = os.WriteFile(metadataAbsoluteFilepath, b, os.FileMode(0600)); err != nil { 75 | return err 76 | } 77 | em.Dir = dir 78 | return nil 79 | } 80 | 81 | // PathToParent is the relative path from the workspace to the parent dir. 82 | func (em *ExerciseMetadata) PathToParent() string { 83 | var dir string 84 | if !em.IsRequester { 85 | dir = filepath.Join("users") 86 | } 87 | return filepath.Join(dir, em.Track) 88 | } 89 | 90 | // Exercise is an implementation of a problem on disk. 91 | func (em *ExerciseMetadata) Exercise(workspace string) Exercise { 92 | return Exercise{ 93 | Root: em.root(workspace), 94 | Track: em.Track, 95 | Slug: em.ExerciseSlug, 96 | } 97 | } 98 | 99 | // root represents the root of the exercise. 100 | func (em *ExerciseMetadata) root(workspace string) string { 101 | if em.Team != "" { 102 | return filepath.Join(workspace, "teams", em.Team) 103 | } 104 | if !em.IsRequester { 105 | return filepath.Join(workspace, "users", em.Handle) 106 | } 107 | return workspace 108 | } 109 | -------------------------------------------------------------------------------- /workspace/exercise_metadata_test.go: -------------------------------------------------------------------------------- 1 | package workspace 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestExerciseMetadata(t *testing.T) { 12 | dir, err := os.MkdirTemp("", "solution") 13 | assert.NoError(t, err) 14 | defer os.RemoveAll(dir) 15 | 16 | em1 := &ExerciseMetadata{ 17 | Track: "a-track", 18 | ExerciseSlug: "bogus-exercise", 19 | ID: "abc", 20 | URL: "http://example.com", 21 | Handle: "alice", 22 | IsRequester: true, 23 | Dir: dir, 24 | } 25 | err = em1.Write(dir) 26 | assert.NoError(t, err) 27 | 28 | em2, err := NewExerciseMetadata(dir) 29 | assert.NoError(t, err) 30 | assert.Nil(t, em2.SubmittedAt) 31 | assert.Equal(t, em1, em2) 32 | 33 | ts := time.Date(2000, 1, 2, 3, 4, 5, 6, time.UTC) 34 | em2.SubmittedAt = &ts 35 | 36 | err = em2.Write(dir) 37 | assert.NoError(t, err) 38 | 39 | em3, err := NewExerciseMetadata(dir) 40 | assert.NoError(t, err) 41 | assert.Equal(t, em2, em3) 42 | } 43 | 44 | func TestSuffix(t *testing.T) { 45 | testCases := []struct { 46 | metadata ExerciseMetadata 47 | suffix string 48 | }{ 49 | { 50 | metadata: ExerciseMetadata{ 51 | ExerciseSlug: "bat", 52 | Dir: "", 53 | }, 54 | suffix: "", 55 | }, 56 | { 57 | metadata: ExerciseMetadata{ 58 | ExerciseSlug: "bat", 59 | Dir: "/path/to/bat", 60 | }, 61 | suffix: "", 62 | }, 63 | { 64 | metadata: ExerciseMetadata{ 65 | ExerciseSlug: "bat", 66 | Dir: "/path/to/bat-2", 67 | }, 68 | suffix: "2", 69 | }, 70 | { 71 | metadata: ExerciseMetadata{ 72 | ExerciseSlug: "bat", 73 | Dir: "/path/to/bat-200", 74 | }, 75 | suffix: "200", 76 | }, 77 | } 78 | 79 | for _, tc := range testCases { 80 | testName := "Suffix of '" + tc.metadata.Dir + "' should be " + tc.suffix 81 | t.Run(testName, func(t *testing.T) { 82 | assert.Equal(t, tc.suffix, tc.metadata.Suffix(), testName) 83 | }) 84 | } 85 | } 86 | 87 | func TestExerciseMetadataString(t *testing.T) { 88 | testCases := []struct { 89 | metadata ExerciseMetadata 90 | desc string 91 | }{ 92 | { 93 | metadata: ExerciseMetadata{ 94 | Track: "elixir", 95 | ExerciseSlug: "secret-handshake", 96 | Handle: "", 97 | Dir: "", 98 | }, 99 | desc: "elixir/secret-handshake", 100 | }, 101 | { 102 | metadata: ExerciseMetadata{ 103 | Track: "cpp", 104 | ExerciseSlug: "clock", 105 | Handle: "alice", 106 | IsRequester: true, 107 | }, 108 | desc: "cpp/clock", 109 | }, 110 | { 111 | metadata: ExerciseMetadata{ 112 | Track: "cpp", 113 | ExerciseSlug: "clock", 114 | Handle: "alice", 115 | IsRequester: true, 116 | Dir: "/path/to/clock-2", 117 | }, 118 | desc: "cpp/clock (2)", 119 | }, 120 | { 121 | metadata: ExerciseMetadata{ 122 | Track: "fsharp", 123 | ExerciseSlug: "hello-world", 124 | Handle: "bob", 125 | IsRequester: false, 126 | }, 127 | desc: "fsharp/hello-world by @bob", 128 | }, 129 | { 130 | metadata: ExerciseMetadata{ 131 | Track: "haskell", 132 | ExerciseSlug: "allergies", 133 | Handle: "charlie", 134 | IsRequester: false, 135 | Dir: "/path/to/allergies-2", 136 | }, 137 | desc: "haskell/allergies (2) by @charlie", 138 | }, 139 | } 140 | 141 | for _, tc := range testCases { 142 | testName := "should stringify to '" + tc.desc + "'" 143 | t.Run(testName, func(t *testing.T) { 144 | assert.Equal(t, tc.desc, tc.metadata.String()) 145 | }) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /workspace/exercise_test.go: -------------------------------------------------------------------------------- 1 | package workspace 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestHasMetadata(t *testing.T) { 12 | ws, err := os.MkdirTemp("", "fake-workspace") 13 | defer os.RemoveAll(ws) 14 | assert.NoError(t, err) 15 | 16 | exerciseA := Exercise{Root: ws, Track: "bogus-track", Slug: "apple"} 17 | exerciseB := Exercise{Root: ws, Track: "bogus-track", Slug: "banana"} 18 | 19 | err = os.MkdirAll(filepath.Dir(exerciseA.MetadataFilepath()), os.FileMode(0755)) 20 | assert.NoError(t, err) 21 | err = os.MkdirAll(filepath.Dir(exerciseB.MetadataFilepath()), os.FileMode(0755)) 22 | assert.NoError(t, err) 23 | 24 | err = os.WriteFile(exerciseA.MetadataFilepath(), []byte{}, os.FileMode(0600)) 25 | assert.NoError(t, err) 26 | 27 | ok, err := exerciseA.HasMetadata() 28 | assert.NoError(t, err) 29 | assert.True(t, ok) 30 | 31 | ok, err = exerciseB.HasMetadata() 32 | assert.NoError(t, err) 33 | assert.False(t, ok) 34 | } 35 | 36 | func TestHasLegacyMetadata(t *testing.T) { 37 | ws, err := os.MkdirTemp("", "fake-workspace") 38 | defer os.RemoveAll(ws) 39 | assert.NoError(t, err) 40 | 41 | exerciseA := Exercise{Root: ws, Track: "bogus-track", Slug: "apple"} 42 | exerciseB := Exercise{Root: ws, Track: "bogus-track", Slug: "banana"} 43 | 44 | err = os.MkdirAll(filepath.Dir(exerciseA.LegacyMetadataFilepath()), os.FileMode(0755)) 45 | assert.NoError(t, err) 46 | err = os.MkdirAll(filepath.Dir(exerciseB.LegacyMetadataFilepath()), os.FileMode(0755)) 47 | assert.NoError(t, err) 48 | 49 | err = os.WriteFile(exerciseA.LegacyMetadataFilepath(), []byte{}, os.FileMode(0600)) 50 | assert.NoError(t, err) 51 | 52 | ok, err := exerciseA.HasLegacyMetadata() 53 | assert.NoError(t, err) 54 | assert.True(t, ok) 55 | 56 | ok, err = exerciseB.HasLegacyMetadata() 57 | assert.NoError(t, err) 58 | assert.False(t, ok) 59 | } 60 | 61 | func TestNewFromDir(t *testing.T) { 62 | dir := filepath.Join("something", "another", "whatever", "the-track", "the-exercise") 63 | 64 | exercise := NewExerciseFromDir(dir) 65 | assert.Equal(t, filepath.Join("something", "another", "whatever"), exercise.Root) 66 | assert.Equal(t, "the-track", exercise.Track) 67 | assert.Equal(t, "the-exercise", exercise.Slug) 68 | } 69 | 70 | func TestMigrationStatusString(t *testing.T) { 71 | assert.Equal(t, "\nMigrated metadata\n", MigrationStatusMigrated.String()) 72 | assert.Equal(t, "\nRemoved legacy metadata\n", MigrationStatusRemoved.String()) 73 | assert.Equal(t, "", MigrationStatusNoop.String()) 74 | assert.Equal(t, "", MigrationStatus(-1).String()) 75 | } 76 | 77 | func TestMigrateLegacyMetadataFileWithoutLegacy(t *testing.T) { 78 | ws, err := os.MkdirTemp("", "fake-workspace") 79 | defer os.RemoveAll(ws) 80 | assert.NoError(t, err) 81 | 82 | exercise := Exercise{Root: ws, Track: "bogus-track", Slug: "no-legacy"} 83 | metadataFilepath := exercise.MetadataFilepath() 84 | err = os.MkdirAll(filepath.Dir(metadataFilepath), os.FileMode(0755)) 85 | assert.NoError(t, err) 86 | 87 | err = os.WriteFile(metadataFilepath, []byte{}, os.FileMode(0600)) 88 | assert.NoError(t, err) 89 | 90 | ok, _ := exercise.HasLegacyMetadata() 91 | assert.False(t, ok) 92 | ok, _ = exercise.HasMetadata() 93 | assert.True(t, ok) 94 | 95 | status, err := exercise.MigrateLegacyMetadataFile() 96 | assert.Equal(t, MigrationStatusNoop, status) 97 | assert.NoError(t, err) 98 | 99 | ok, _ = exercise.HasLegacyMetadata() 100 | assert.False(t, ok) 101 | ok, _ = exercise.HasMetadata() 102 | assert.True(t, ok) 103 | } 104 | 105 | func TestMigrateLegacyMetadataFileWithLegacy(t *testing.T) { 106 | ws, err := os.MkdirTemp("", "fake-workspace") 107 | defer os.RemoveAll(ws) 108 | assert.NoError(t, err) 109 | 110 | exercise := Exercise{Root: ws, Track: "bogus-track", Slug: "legacy"} 111 | legacyMetadataFilepath := exercise.LegacyMetadataFilepath() 112 | err = os.MkdirAll(filepath.Dir(legacyMetadataFilepath), os.FileMode(0755)) 113 | assert.NoError(t, err) 114 | 115 | err = os.WriteFile(legacyMetadataFilepath, []byte{}, os.FileMode(0600)) 116 | assert.NoError(t, err) 117 | 118 | ok, _ := exercise.HasLegacyMetadata() 119 | assert.True(t, ok) 120 | ok, _ = exercise.HasMetadata() 121 | assert.False(t, ok) 122 | 123 | status, err := exercise.MigrateLegacyMetadataFile() 124 | assert.Equal(t, MigrationStatusMigrated, status) 125 | assert.NoError(t, err) 126 | 127 | ok, _ = exercise.HasLegacyMetadata() 128 | assert.False(t, ok) 129 | ok, _ = exercise.HasMetadata() 130 | assert.True(t, ok) 131 | } 132 | 133 | func TestMigrateLegacyMetadataFileWithLegacyAndModern(t *testing.T) { 134 | ws, err := os.MkdirTemp("", "fake-workspace") 135 | defer os.RemoveAll(ws) 136 | assert.NoError(t, err) 137 | 138 | exercise := Exercise{Root: ws, Track: "bogus-track", Slug: "both-legacy-and-modern"} 139 | metadataFilepath := exercise.MetadataFilepath() 140 | legacyMetadataFilepath := exercise.LegacyMetadataFilepath() 141 | err = os.MkdirAll(filepath.Dir(legacyMetadataFilepath), os.FileMode(0755)) 142 | assert.NoError(t, err) 143 | err = os.MkdirAll(filepath.Dir(metadataFilepath), os.FileMode(0755)) 144 | assert.NoError(t, err) 145 | 146 | err = os.WriteFile(legacyMetadataFilepath, []byte{}, os.FileMode(0600)) 147 | assert.NoError(t, err) 148 | err = os.WriteFile(metadataFilepath, []byte{}, os.FileMode(0600)) 149 | assert.NoError(t, err) 150 | 151 | ok, _ := exercise.HasLegacyMetadata() 152 | assert.True(t, ok) 153 | ok, _ = exercise.HasMetadata() 154 | assert.True(t, ok) 155 | 156 | status, err := exercise.MigrateLegacyMetadataFile() 157 | assert.Equal(t, MigrationStatusRemoved, status) 158 | assert.NoError(t, err) 159 | 160 | ok, _ = exercise.HasLegacyMetadata() 161 | assert.False(t, ok) 162 | ok, _ = exercise.HasMetadata() 163 | assert.True(t, ok) 164 | } 165 | -------------------------------------------------------------------------------- /workspace/path_type.go: -------------------------------------------------------------------------------- 1 | package workspace 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | // PathType is either a path to a dir or file, or the name of an exercise. 9 | type PathType int 10 | 11 | const ( 12 | // TypeExerciseID is the name of an exercise. 13 | TypeExerciseID PathType = iota 14 | // TypeDir is a relative or absolute path to a directory. 15 | TypeDir 16 | // TypeFile is a relative or absolute path to a file. 17 | TypeFile 18 | ) 19 | 20 | // DetectPathType determines whether the given path is a directory, a file, or the name of an exercise. 21 | func DetectPathType(path string) (PathType, error) { 22 | // If it's not an absolute path, make it one. 23 | if !filepath.IsAbs(path) { 24 | var err error 25 | path, err = filepath.Abs(path) 26 | if err != nil { 27 | return -1, err 28 | } 29 | } 30 | 31 | // If it doesn't exist, then it's an exercise name. 32 | // We'll have to walk the workspace to find it. 33 | if _, err := os.Stat(path); err != nil { 34 | return TypeExerciseID, nil 35 | } 36 | 37 | // We found it. It's an actual path of some sort. 38 | info, err := os.Lstat(path) 39 | if err != nil { 40 | return -1, err 41 | } 42 | 43 | // If it's a symlink, resolve it. 44 | if info.Mode()&os.ModeSymlink == os.ModeSymlink { 45 | src, err := filepath.EvalSymlinks(path) 46 | if err != nil { 47 | return -1, err 48 | } 49 | path = src 50 | // Overwrite the symlinked info with the source info. 51 | info, err = os.Lstat(path) 52 | if err != nil { 53 | return -1, err 54 | } 55 | } 56 | 57 | if info.IsDir() { 58 | return TypeDir, nil 59 | } 60 | return TypeFile, nil 61 | } 62 | -------------------------------------------------------------------------------- /workspace/path_type_symlinks_test.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package workspace 4 | 5 | import ( 6 | "path/filepath" 7 | "runtime" 8 | "testing" 9 | ) 10 | 11 | func TestDetectPathTypeSymlink(t *testing.T) { 12 | _, cwd, _, _ := runtime.Caller(0) 13 | root := filepath.Join(cwd, "..", "..", "fixtures", "detect-path-type") 14 | 15 | testCases := []detectPathTestCase{ 16 | { 17 | desc: "symlinked dir", 18 | path: filepath.Join(root, "symlinked-dir"), 19 | pt: TypeDir, 20 | }, 21 | { 22 | desc: "symlinked file", 23 | path: filepath.Join(root, "symlinked-file.txt"), 24 | pt: TypeFile, 25 | }, 26 | } 27 | 28 | for _, tc := range testCases { 29 | testDetectPathType(t, tc) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /workspace/path_type_test.go: -------------------------------------------------------------------------------- 1 | package workspace 2 | 3 | import ( 4 | "path/filepath" 5 | "runtime" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | type detectPathTestCase struct { 12 | desc string 13 | path string 14 | pt PathType 15 | } 16 | 17 | func TestDetectPathType(t *testing.T) { 18 | _, cwd, _, _ := runtime.Caller(0) 19 | root := filepath.Join(cwd, "..", "..", "fixtures", "detect-path-type") 20 | 21 | testCases := []detectPathTestCase{ 22 | detectPathTestCase{ 23 | desc: "absolute dir", 24 | path: filepath.Join(root, "a-dir"), 25 | pt: TypeDir, 26 | }, 27 | { 28 | desc: "relative dir", 29 | path: filepath.Join("..", "fixtures", "detect-path-type", "a-dir"), 30 | pt: TypeDir, 31 | }, 32 | { 33 | desc: "absolute file", 34 | path: filepath.Join(root, "a-file.txt"), 35 | pt: TypeFile, 36 | }, 37 | { 38 | desc: "relative file", 39 | path: filepath.Join("..", "fixtures", "detect-path-type", "a-file.txt"), 40 | pt: TypeFile, 41 | }, 42 | { 43 | desc: "exercise ID", 44 | path: "a-file", 45 | pt: TypeExerciseID, 46 | }, 47 | } 48 | 49 | for _, tc := range testCases { 50 | testDetectPathType(t, tc) 51 | } 52 | } 53 | 54 | func testDetectPathType(t *testing.T, tc detectPathTestCase) { 55 | pt, err := DetectPathType(tc.path) 56 | assert.NoError(t, err, tc.desc) 57 | assert.Equal(t, tc.pt, pt, tc.desc) 58 | } 59 | -------------------------------------------------------------------------------- /workspace/test_configurations.go: -------------------------------------------------------------------------------- 1 | package workspace 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | "strings" 7 | ) 8 | 9 | type TestConfiguration struct { 10 | // The static portion of the test Command, which will be run for every test on this track. Examples include `cargo test` or `go test`. 11 | // Might be empty if there are platform-specific versions 12 | Command string 13 | 14 | // 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. 15 | WindowsCommand string 16 | } 17 | 18 | func (c *TestConfiguration) GetTestCommand() (string, error) { 19 | var cmd string 20 | if runtime.GOOS == "windows" && c.WindowsCommand != "" { 21 | cmd = c.WindowsCommand 22 | } else { 23 | cmd = c.Command 24 | } 25 | 26 | // pre-declare these so we can conditionally initialize them 27 | var exerciseConfig *ExerciseConfig 28 | var err error 29 | 30 | if strings.Contains(cmd, "{{") { 31 | // only read exercise's config.json if we need it 32 | exerciseConfig, err = NewExerciseConfig(".") 33 | if err != nil { 34 | return "", err 35 | } 36 | } 37 | 38 | if strings.Contains(cmd, "{{solution_files}}") { 39 | if exerciseConfig == nil { 40 | return "", fmt.Errorf("exerciseConfig not initialize before use") 41 | } 42 | solutionFiles, err := exerciseConfig.GetSolutionFiles() 43 | if err != nil { 44 | return "", err 45 | } 46 | cmd = strings.ReplaceAll(cmd, "{{solution_files}}", strings.Join(solutionFiles, " ")) 47 | } 48 | if strings.Contains(cmd, "{{test_files}}") { 49 | if exerciseConfig == nil { 50 | return "", fmt.Errorf("exerciseConfig not initialize before use") 51 | } 52 | testFiles, err := exerciseConfig.GetTestFiles() 53 | if err != nil { 54 | return "", err 55 | } 56 | cmd = strings.ReplaceAll(cmd, "{{test_files}}", strings.Join(testFiles, " ")) 57 | } 58 | if strings.Contains(cmd, "{{slug}}") { 59 | metadata, err := NewExerciseMetadata(".") 60 | if err != nil { 61 | return "", err 62 | } 63 | cmd = strings.ReplaceAll(cmd, "{{slug}}", metadata.ExerciseSlug) 64 | } 65 | 66 | return cmd, nil 67 | } 68 | 69 | // some tracks aren't (or won't be) implemented; every track is listed either way 70 | var TestConfigurations = map[string]TestConfiguration{ 71 | "8th": { 72 | Command: "8th -f test.8th", 73 | }, 74 | // abap: tests are run via "ABAP Development Tools", not the CLI 75 | "arm64-assembly": { 76 | Command: "make", 77 | }, 78 | "arturo": { 79 | Command: "arturo tester.art", 80 | }, 81 | "awk": { 82 | Command: "bats {{test_files}}", 83 | }, 84 | "ballerina": { 85 | Command: "bal test", 86 | }, 87 | "batch": { 88 | WindowsCommand: "cmd /c {{test_files}}", 89 | }, 90 | "bash": { 91 | Command: "bats {{test_files}}", 92 | }, 93 | "c": { 94 | Command: "make", 95 | }, 96 | "cairo": { 97 | Command: "scarb cairo-test", 98 | }, 99 | "cfml": { 100 | Command: "box task run TestRunner", 101 | }, 102 | "clojure": { 103 | // chosen because the docs recommend `clj` by default and `lein` as optional 104 | Command: "clj -X:test", 105 | }, 106 | "cobol": { 107 | Command: "bash test.sh", 108 | WindowsCommand: "pwsh test.ps1", 109 | }, 110 | "coffeescript": { 111 | Command: "jasmine-node --coffee {{test_files}}", 112 | }, 113 | // common-lisp: tests are loaded into a "running Lisp implementation", not the CLI directly 114 | "cpp": { 115 | Command: "make", 116 | }, 117 | "crystal": { 118 | Command: "crystal spec", 119 | }, 120 | "csharp": { 121 | Command: "dotnet test", 122 | }, 123 | "d": { 124 | // this always works even if the user installed DUB 125 | Command: "dmd source/*.d -de -w -main -unittest", 126 | }, 127 | "dart": { 128 | Command: "dart test", 129 | }, 130 | // delphi: tests are run via IDE 131 | "elixir": { 132 | Command: "mix test", 133 | }, 134 | "elm": { 135 | Command: "elm-test", 136 | }, 137 | "emacs-lisp": { 138 | Command: "emacs -batch -l ert -l {{test_files}} -f ert-run-tests-batch-and-exit", 139 | }, 140 | "erlang": { 141 | Command: "rebar3 eunit", 142 | }, 143 | "fortran": { 144 | Command: "make", 145 | }, 146 | "fsharp": { 147 | Command: "dotnet test", 148 | }, 149 | "gleam": { 150 | Command: "gleam test", 151 | }, 152 | "go": { 153 | Command: "go test", 154 | }, 155 | "groovy": { 156 | Command: "gradle test", 157 | }, 158 | "haskell": { 159 | Command: "stack test", 160 | }, 161 | "idris": { 162 | Command: "pack test {{slug}}", 163 | }, 164 | "j": { 165 | Command: `jconsole -js "exit echo unittest {{test_files}} [ load {{solution_files}}"`, 166 | }, 167 | "java": { 168 | Command: "./gradlew test", 169 | WindowsCommand: "gradlew.bat test", 170 | }, 171 | "javascript": { 172 | Command: "npm run test", 173 | }, 174 | "jq": { 175 | Command: "bats {{test_files}}", 176 | }, 177 | "julia": { 178 | Command: "julia runtests.jl", 179 | }, 180 | "kotlin": { 181 | Command: "./gradlew test", 182 | WindowsCommand: "gradlew.bat test", 183 | }, 184 | "lfe": { 185 | Command: "make test", 186 | }, 187 | "lua": { 188 | Command: "busted", 189 | }, 190 | "mips": { 191 | Command: "java -jar /path/to/mars.jar nc runner.mips impl.mips", 192 | }, 193 | "nim": { 194 | Command: "nim r {{test_files}}", 195 | }, 196 | // 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 197 | "ocaml": { 198 | Command: "make", 199 | }, 200 | "perl5": { 201 | Command: "prove .", 202 | }, 203 | // pharo-smalltalk: tests are run via IDE 204 | "php": { 205 | Command: "phpunit {{test_files}}", 206 | }, 207 | // plsql: test are run via a "mounted oracle db" 208 | "powershell": { 209 | Command: "Invoke-Pester", 210 | }, 211 | "prolog": { 212 | Command: "swipl -f {{solution_files}} -s {{test_files}} -g run_tests,halt -t 'halt(1)'", 213 | }, 214 | "purescript": { 215 | Command: "spago test", 216 | }, 217 | "pyret": { 218 | Command: "pyret {{test_files}}", 219 | }, 220 | "python": { 221 | Command: "python3 -m pytest -o markers=task {{test_files}}", 222 | }, 223 | "r": { 224 | Command: "Rscript {{test_files}}", 225 | }, 226 | "racket": { 227 | Command: "raco test {{test_files}}", 228 | }, 229 | "raku": { 230 | Command: "prove6 {{test_files}}", 231 | }, 232 | "reasonml": { 233 | Command: "npm run test", 234 | }, 235 | "red": { 236 | Command: "red {{test_files}}", 237 | }, 238 | "roc": { 239 | Command: "roc test {{test_files}}", 240 | }, 241 | "ruby": { 242 | Command: "ruby {{test_files}}", 243 | }, 244 | "rust": { 245 | Command: "cargo test --", 246 | }, 247 | "scala": { 248 | Command: "sbt test", 249 | }, 250 | // scheme: docs present 2 equally valid test methods (`make chez` and `make guile`). So I wasn't sure which to pick 251 | "sml": { 252 | Command: "poly -q --use {{test_files}}", 253 | }, 254 | "swift": { 255 | Command: "swift test", 256 | }, 257 | "tcl": { 258 | Command: "tclsh {{test_files}}", 259 | }, 260 | "typescript": { 261 | Command: "yarn test", 262 | }, 263 | "uiua": { 264 | Command: "uiua test {{test_files}}", 265 | }, 266 | // unison: tests are run from an active UCM session 267 | "vbnet": { 268 | Command: "dotnet test", 269 | }, 270 | // vimscript: tests are run from inside a vim session 271 | "vlang": { 272 | Command: "v -stats test run_test.v", 273 | }, 274 | "wasm": { 275 | Command: "npm run test", 276 | }, 277 | "wren": { 278 | Command: "wrenc {{test_files}}", 279 | }, 280 | "x86-64-assembly": { 281 | Command: "make", 282 | }, 283 | "yamlscript": { 284 | Command: "make test", 285 | }, 286 | "zig": { 287 | Command: "zig test {{test_files}}", 288 | }, 289 | } 290 | -------------------------------------------------------------------------------- /workspace/test_configurations_test.go: -------------------------------------------------------------------------------- 1 | package workspace 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "runtime" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestGetCommand(t *testing.T) { 14 | testConfig, ok := TestConfigurations["elixir"] 15 | assert.True(t, ok, "unexpectedly unable to find elixir test config") 16 | 17 | cmd, err := testConfig.GetTestCommand() 18 | assert.NoError(t, err) 19 | 20 | assert.Equal(t, cmd, "mix test") 21 | } 22 | 23 | func TestWindowsCommands(t *testing.T) { 24 | testConfig, ok := TestConfigurations["cobol"] 25 | assert.True(t, ok, "unexpectedly unable to find cobol test config") 26 | 27 | cmd, err := testConfig.GetTestCommand() 28 | assert.NoError(t, err) 29 | 30 | if runtime.GOOS == "windows" { 31 | assert.Contains(t, cmd, ".ps1") 32 | assert.NotContains(t, cmd, ".sh") 33 | } else { 34 | assert.Contains(t, cmd, ".sh") 35 | assert.NotContains(t, cmd, ".ps1") 36 | } 37 | } 38 | 39 | func TestGetCommandMissingConfig(t *testing.T) { 40 | testConfig, ok := TestConfigurations["ruby"] 41 | assert.True(t, ok, "unexpectedly unable to find ruby test config") 42 | 43 | _, err := testConfig.GetTestCommand() 44 | assert.Error(t, err) 45 | // any assertions about this error message have to work across all platforms, so be vague 46 | // unix: ".exercism/config.json: no such file or directory" 47 | // windows: "open .exercism\config.json: The system cannot find the path specified." 48 | assert.Contains(t, err.Error(), filepath.Join(".exercism", "config.json:")) 49 | } 50 | 51 | func TestIncludesSolutionAndTestFilesInCommand(t *testing.T) { 52 | testConfig, ok := TestConfigurations["prolog"] 53 | assert.True(t, ok, "unexpectedly unable to find prolog test config") 54 | 55 | // this creates a config file in the test directory and removes it 56 | dir := filepath.Join(".", ".exercism") 57 | defer os.RemoveAll(dir) 58 | err := os.Mkdir(dir, os.ModePerm) 59 | assert.NoError(t, err) 60 | 61 | f, err := os.Create(filepath.Join(dir, "config.json")) 62 | assert.NoError(t, err) 63 | defer f.Close() 64 | 65 | _, 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"] } } `) 66 | assert.NoError(t, err) 67 | 68 | cmd, err := testConfig.GetTestCommand() 69 | assert.NoError(t, err) 70 | assert.Equal(t, cmd, "swipl -f lasagna.pl -s lasagna_tests.plt -g run_tests,halt -t 'halt(1)'") 71 | } 72 | 73 | func TestIncludesTestFilesInCommand(t *testing.T) { 74 | testConfig, ok := TestConfigurations["ruby"] 75 | assert.True(t, ok, "unexpectedly unable to find ruby test config") 76 | 77 | // this creates a config file in the test directory and removes it 78 | dir := filepath.Join(".", ".exercism") 79 | defer os.RemoveAll(dir) 80 | err := os.Mkdir(dir, os.ModePerm) 81 | assert.NoError(t, err) 82 | 83 | f, err := os.Create(filepath.Join(dir, "config.json")) 84 | assert.NoError(t, err) 85 | defer f.Close() 86 | 87 | _, 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"] } } `) 88 | assert.NoError(t, err) 89 | 90 | cmd, err := testConfig.GetTestCommand() 91 | assert.NoError(t, err) 92 | assert.Equal(t, cmd, "ruby lasagna_test.rb some_other_file.rb") 93 | } 94 | 95 | func TestRustHasTrailingDashes(t *testing.T) { 96 | testConfig, ok := TestConfigurations["rust"] 97 | assert.True(t, ok, "unexpectedly unable to find rust test config") 98 | 99 | cmd, err := testConfig.GetTestCommand() 100 | assert.NoError(t, err) 101 | 102 | assert.True(t, strings.HasSuffix(cmd, "--"), "rust's test command should have trailing dashes") 103 | } 104 | 105 | func TestIdrisUsesExerciseSlug(t *testing.T) { 106 | currentDir, err := os.Getwd() 107 | assert.NoError(t, err) 108 | 109 | tmpDir, err := os.MkdirTemp("", "solution") 110 | assert.NoError(t, err) 111 | defer os.RemoveAll(tmpDir) 112 | 113 | em := &ExerciseMetadata{ 114 | Track: "idris", 115 | ExerciseSlug: "bogus-exercise", 116 | ID: "abc", 117 | URL: "http://example.com", 118 | Handle: "alice", 119 | IsRequester: true, 120 | Dir: tmpDir, 121 | } 122 | err = em.Write(tmpDir) 123 | assert.NoError(t, err) 124 | 125 | defer os.Chdir(currentDir) 126 | err = os.Chdir(tmpDir) 127 | assert.NoError(t, err) 128 | 129 | exercismDir := filepath.Join(".", ".exercism") 130 | f, err := os.Create(filepath.Join(exercismDir, "config.json")) 131 | assert.NoError(t, err) 132 | defer f.Close() 133 | 134 | _, err = f.WriteString(`{ "files": { "solution": [ "src/BogusExercise.idr" ], "test": [ "test/src/Main.idr" ] } }`) 135 | assert.NoError(t, err) 136 | 137 | testConfig, ok := TestConfigurations["idris"] 138 | assert.True(t, ok, "unexpectedly unable to find idris test config") 139 | 140 | cmd, err := testConfig.GetTestCommand() 141 | assert.NoError(t, err) 142 | assert.Equal(t, cmd, "pack test bogus-exercise") 143 | } 144 | -------------------------------------------------------------------------------- /workspace/workspace.go: -------------------------------------------------------------------------------- 1 | package workspace 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "runtime" 9 | "strings" 10 | ) 11 | 12 | var errMissingMetadata = errors.New("no exercise metadata file found") 13 | 14 | // IsMissingMetadata verifies the type of error. 15 | func IsMissingMetadata(err error) bool { 16 | return err == errMissingMetadata 17 | } 18 | 19 | // Workspace represents a user's Exercism workspace. 20 | // It may contain a user's own exercises, and other people's 21 | // exercises that they've downloaded to look at or run locally. 22 | type Workspace struct { 23 | Dir string 24 | } 25 | 26 | // New returns a configured workspace. 27 | func New(dir string) (Workspace, error) { 28 | _, err := os.Lstat(dir) 29 | if err != nil { 30 | return Workspace{}, err 31 | } 32 | dir, err = filepath.EvalSymlinks(dir) 33 | if err != nil { 34 | return Workspace{}, err 35 | } 36 | return Workspace{Dir: dir}, nil 37 | } 38 | 39 | // PotentialExercises are a first-level guess at the user's exercises. 40 | // It looks at the workspace structurally, and guesses based on 41 | // the location of the directory. E.g. any top level directory 42 | // within the workspace (except 'users') is assumed to be a 43 | // track, and any directory within there again is assumed to 44 | // be an exercise. 45 | func (ws Workspace) PotentialExercises() ([]Exercise, error) { 46 | exercises := []Exercise{} 47 | 48 | topInfos, err := os.ReadDir(ws.Dir) 49 | if err != nil { 50 | return nil, err 51 | } 52 | for _, topInfo := range topInfos { 53 | if !topInfo.IsDir() { 54 | continue 55 | } 56 | 57 | if topInfo.Name() == "users" { 58 | continue 59 | } 60 | 61 | if topInfo.Name() == "teams" { 62 | subInfos, err := os.ReadDir(filepath.Join(ws.Dir, "teams")) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | for _, subInfo := range subInfos { 68 | teamWs, err := New(filepath.Join(ws.Dir, "teams", subInfo.Name())) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | teamExercises, err := teamWs.PotentialExercises() 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | exercises = append(exercises, teamExercises...) 79 | } 80 | continue 81 | } 82 | 83 | subInfos, err := os.ReadDir(filepath.Join(ws.Dir, topInfo.Name())) 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | for _, subInfo := range subInfos { 89 | if !subInfo.IsDir() { 90 | continue 91 | } 92 | 93 | exercises = append(exercises, Exercise{Track: topInfo.Name(), Slug: subInfo.Name(), Root: ws.Dir}) 94 | } 95 | } 96 | 97 | return exercises, nil 98 | } 99 | 100 | // Exercises returns the user's exercises within the workspace. 101 | // This doesn't find legacy exercises where the metadata is missing. 102 | func (ws Workspace) Exercises() ([]Exercise, error) { 103 | candidates, err := ws.PotentialExercises() 104 | if err != nil { 105 | return nil, err 106 | } 107 | 108 | exercises := make([]Exercise, 0, len(candidates)) 109 | for _, candidate := range candidates { 110 | ok, err := candidate.HasMetadata() 111 | if err != nil { 112 | return nil, err 113 | } 114 | if ok { 115 | exercises = append(exercises, candidate) 116 | } 117 | } 118 | return exercises, nil 119 | } 120 | 121 | // ExerciseDir determines the root directory of an exercise. 122 | // This is the directory that contains the exercise metadata file. 123 | func (ws Workspace) ExerciseDir(s string) (string, error) { 124 | if !strings.HasPrefix(s, ws.Dir) { 125 | var err = fmt.Errorf("not in workspace") 126 | if runtime.GOOS == "darwin" { 127 | err = fmt.Errorf("%w: directory location may be case sensitive: workspace directory: %s, "+ 128 | "submit path: %s", err, ws.Dir, s) 129 | } 130 | return "", err 131 | } 132 | 133 | path := s 134 | for { 135 | if path == ws.Dir { 136 | return "", errMissingMetadata 137 | } 138 | if _, err := os.Lstat(path); os.IsNotExist(err) { 139 | return "", err 140 | } 141 | if _, err := os.Lstat(filepath.Join(path, metadataFilepath)); err == nil { 142 | return path, nil 143 | } 144 | if _, err := os.Lstat(filepath.Join(path, legacyMetadataFilename)); err == nil { 145 | return path, nil 146 | } 147 | path = filepath.Dir(path) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /workspace/workspace_darwin_test.go: -------------------------------------------------------------------------------- 1 | package workspace 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "runtime" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestExerciseDir_case_insensitive(t *testing.T) { 14 | _, cwd, _, _ := runtime.Caller(0) 15 | root := filepath.Join(cwd, "..", "..", "fixtures", "solution-dir") 16 | // configuration file was set with "workspace" - the directory that exists 17 | configured := Workspace{Dir: filepath.Join(root, "workspace")} 18 | // user changes into directory with "bad" case - "Workspace" 19 | userPath := strings.Replace(configured.Dir, "workspace", "Workspace", 1) 20 | 21 | _, err := configured.ExerciseDir(filepath.Join(userPath, "exercise", "file.txt")) 22 | 23 | assert.Error(t, err) 24 | assert.Equal(t, fmt.Sprintf("not in workspace: directory location may be case sensitive: "+ 25 | "workspace directory: %s, submit path: %s/exercise/file.txt", configured.Dir, userPath), err.Error()) 26 | } 27 | -------------------------------------------------------------------------------- /workspace/workspace_test.go: -------------------------------------------------------------------------------- 1 | package workspace 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "runtime" 7 | "sort" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestWorkspacePotentialExercises(t *testing.T) { 14 | tmpDir, err := os.MkdirTemp("", "walk") 15 | defer os.RemoveAll(tmpDir) 16 | assert.NoError(t, err) 17 | 18 | a1 := filepath.Join(tmpDir, "track-a", "exercise-one") 19 | b1 := filepath.Join(tmpDir, "track-b", "exercise-one") 20 | b2 := filepath.Join(tmpDir, "track-b", "exercise-two") 21 | 22 | // It should find teams exercises 23 | team := filepath.Join(tmpDir, "teams", "some-team", "track-c", "exercise-one") 24 | 25 | // It should ignore other people's exercises. 26 | alice := filepath.Join(tmpDir, "users", "alice", "track-a", "exercise-one") 27 | 28 | // It should ignore nested dirs within exercises. 29 | nested := filepath.Join(a1, "subdir", "deeper-dir", "another-deep-dir") 30 | 31 | for _, path := range []string{a1, b1, b2, team, alice, nested} { 32 | err := os.MkdirAll(path, os.FileMode(0755)) 33 | assert.NoError(t, err) 34 | } 35 | 36 | ws, err := New(tmpDir) 37 | assert.NoError(t, err) 38 | 39 | exercises, err := ws.PotentialExercises() 40 | assert.NoError(t, err) 41 | if assert.Equal(t, 4, len(exercises)) { 42 | paths := make([]string, len(exercises)) 43 | for i, e := range exercises { 44 | paths[i] = e.Path() 45 | } 46 | 47 | sort.Strings(paths) 48 | assert.Equal(t, paths[0], "track-a/exercise-one") 49 | assert.Equal(t, paths[1], "track-b/exercise-one") 50 | assert.Equal(t, paths[2], "track-b/exercise-two") 51 | assert.Equal(t, paths[3], "track-c/exercise-one") 52 | } 53 | } 54 | 55 | func TestWorkspaceExercises(t *testing.T) { 56 | tmpDir, err := os.MkdirTemp("", "walk-with-metadata") 57 | defer os.RemoveAll(tmpDir) 58 | assert.NoError(t, err) 59 | 60 | a1 := filepath.Join(tmpDir, "track-a", "exercise-one") 61 | a2 := filepath.Join(tmpDir, "track-a", "exercise-two") // no metadata 62 | b1 := filepath.Join(tmpDir, "track-b", "exercise-one") 63 | b2 := filepath.Join(tmpDir, "track-b", "exercise-two") 64 | 65 | for _, path := range []string{a1, a2, b1, b2} { 66 | metadataAbsoluteFilepath := filepath.Join(path, metadataFilepath) 67 | err := os.MkdirAll(filepath.Dir(metadataAbsoluteFilepath), os.FileMode(0755)) 68 | assert.NoError(t, err) 69 | 70 | if path != a2 { 71 | err = os.WriteFile(metadataAbsoluteFilepath, []byte{}, os.FileMode(0600)) 72 | assert.NoError(t, err) 73 | } 74 | } 75 | 76 | ws, err := New(tmpDir) 77 | assert.NoError(t, err) 78 | 79 | exercises, err := ws.Exercises() 80 | assert.NoError(t, err) 81 | if assert.Equal(t, 3, len(exercises)) { 82 | paths := make([]string, len(exercises)) 83 | for i, e := range exercises { 84 | paths[i] = e.Path() 85 | } 86 | 87 | sort.Strings(paths) 88 | assert.Equal(t, paths[0], "track-a/exercise-one") 89 | assert.Equal(t, paths[1], "track-b/exercise-one") 90 | assert.Equal(t, paths[2], "track-b/exercise-two") 91 | } 92 | } 93 | 94 | func TestExerciseDir(t *testing.T) { 95 | _, cwd, _, _ := runtime.Caller(0) 96 | root := filepath.Join(cwd, "..", "..", "fixtures", "solution-dir") 97 | 98 | ws, err := New(filepath.Join(root, "workspace")) 99 | assert.NoError(t, err) 100 | 101 | tests := []struct { 102 | path string 103 | ok bool 104 | }{ 105 | { 106 | path: filepath.Join(ws.Dir, "exercise"), 107 | ok: true, 108 | }, 109 | { 110 | path: filepath.Join(ws.Dir, "exercise", "file.txt"), 111 | ok: true, 112 | }, 113 | { 114 | path: filepath.Join(ws.Dir, "exercise", "in", "a", "subdir", "file.txt"), 115 | ok: true, 116 | }, 117 | { 118 | path: filepath.Join(ws.Dir, "exercise", "in", "a"), 119 | ok: true, 120 | }, 121 | { 122 | path: filepath.Join(ws.Dir, "not-exercise", "file.txt"), 123 | ok: false, 124 | }, 125 | { 126 | path: filepath.Join(root, "file.txt"), 127 | ok: false, 128 | }, 129 | { 130 | path: filepath.Join(ws.Dir, "exercise", "no-such-file.txt"), 131 | ok: false, 132 | }, 133 | } 134 | 135 | for _, test := range tests { 136 | dir, err := ws.ExerciseDir(test.path) 137 | if !test.ok { 138 | assert.Error(t, err, test.path) 139 | continue 140 | } 141 | assert.Equal(t, filepath.Join(ws.Dir, "exercise"), dir, test.path) 142 | } 143 | } 144 | --------------------------------------------------------------------------------