From 8c3e63e595c7dc2bd4e6712b486f5e4de2ce748b Mon Sep 17 00:00:00 2001 From: Erik Schierboom Date: Fri, 19 Jan 2024 09:10:53 +0100 Subject: [PATCH] create: allow creating of exercise (#845) Make `configlet create` support creating a practice exercise or concept exercise, using the new syntax: configlet create --practice-exercise configlet create --concept-exercise This also supports the `--offline` flag. Closes: #675 --- README.md | 55 ++++++++------- completions/configlet.bash | 6 ++ completions/configlet.fish | 2 +- completions/configlet.zsh | 2 +- src/cli.nim | 40 ++++++++--- src/create/create.nim | 21 ++++-- src/create/exercises.nim | 120 ++++++++++++++++++++++++++++++++ src/exec.nim | 2 +- src/fmt/track_config.nim | 2 +- src/sync/probspecs.nim | 48 +++++++------ src/sync/sync.nim | 2 +- src/sync/sync_filepaths.nim | 2 +- src/sync/sync_metadata.nim | 13 ++-- tests/binary_helpers.nim | 8 +++ tests/test_binary_create.nim | 131 +++++++++++++++++++++++++++++++++++ tests/test_sync.nim | 3 + 16 files changed, 382 insertions(+), 75 deletions(-) create mode 100644 src/create/exercises.nim create mode 100644 tests/test_binary_create.nim diff --git a/README.md b/README.md index fde41989..3a257c56 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Usage: Commands: completion Output a completion script for a given shell - create Add a new approach or article + create Add a new exercise, approach or article fmt Format the exercise 'config.json' files generate Generate Concept Exercise 'introduction.md' files from 'introduction.md.tpl' files info Print some information about the track @@ -65,43 +65,46 @@ Commands: uuid Output new (version 4) UUIDs, suitable for the value of a 'uuid' key Options for completion: - -s, --shell Choose the shell type (required) - Allowed values: b[ash], f[ish], z[sh] + -s, --shell Choose the shell type (required) + Allowed values: b[ash], f[ish], z[sh] Options for create: - --approach The slug of the approach - --article The slug of the article - -e, --exercise Only operate on this exercise + --approach The slug of the approach + --article The slug of the article + --practice-exercise The slug of the concept exercise + --concept-exercise The slug of the practice exercise + -e, --exercise Only operate on this exercise + -o, --offline Do not update the cached 'problem-specifications' data Options for fmt: - -e, --exercise Only operate on this exercise - -u, --update Prompt to write formatted files - -y, --yes Auto-confirm the prompt from --update + -e, --exercise Only operate on this exercise + -u, --update Prompt to write formatted files + -y, --yes Auto-confirm the prompt from --update Options for info: - -o, --offline Do not update the cached 'problem-specifications' data + -o, --offline Do not update the cached 'problem-specifications' data Options for sync: - -e, --exercise Only operate on this exercise - -o, --offline Do not update the cached 'problem-specifications' data - -u, --update Prompt to update the unsynced track data - -y, --yes Auto-confirm prompts from --update for updating docs, filepaths, and metadata - --docs Sync Practice Exercise '.docs/introduction.md' and '.docs/instructions.md' files - --filepaths Populate empty 'files' values in Concept/Practice exercise '.meta/config.json' files - --metadata Sync Practice Exercise '.meta/config.json' metadata values - --tests [mode] Sync Practice Exercise '.meta/tests.toml' files. - The mode value specifies how missing tests are handled when using --update. - Allowed values: c[hoose], i[nclude], e[xclude] (default: choose) + -e, --exercise Only operate on this exercise + -o, --offline Do not update the cached 'problem-specifications' data + -u, --update Prompt to update the unsynced track data + -y, --yes Auto-confirm prompts from --update for updating docs, filepaths, and metadata + --docs Sync Practice Exercise '.docs/introduction.md' and '.docs/instructions.md' files + --filepaths Populate empty 'files' values in Concept/Practice exercise '.meta/config.json' files + --metadata Sync Practice Exercise '.meta/config.json' metadata values + --tests [mode] Sync Practice Exercise '.meta/tests.toml' files. + The mode value specifies how missing tests are handled when using --update. + Allowed values: c[hoose], i[nclude], e[xclude] (default: choose) Options for uuid: - -n, --num Number of UUIDs to output + -n, --num Number of UUIDs to output Global options: - -h, --help Show this help message and exit - --version Show this tool's version information and exit - -t, --track-dir Specify a track directory to use instead of the current directory - -v, --verbosity The verbosity of output. - Allowed values: q[uiet], n[ormal], d[etailed] (default: normal) + -h, --help Show this help message and exit + --version Show this tool's version information and exit + -t, --track-dir Specify a track directory to use instead of the current directory + -v, --verbosity The verbosity of output. + Allowed values: q[uiet], n[ormal], d[etailed] (default: normal) ``` ## `configlet lint` diff --git a/completions/configlet.bash b/completions/configlet.bash index 72110590..314aab03 100644 --- a/completions/configlet.bash +++ b/completions/configlet.bash @@ -84,6 +84,12 @@ _configlet_complete_create_() { '-e' | '--exercise') _configlet_complete_slugs_ "practice" "concept" ;; + '--concept-exercise') + _configlet_complete_slugs_ "concept" + ;; + '--practice-exercise') + _configlet_complete_slugs_ "practice" + ;; *) _configlet_complete_options_ "--approach --article -e --exercise $global_opts" ;; diff --git a/completions/configlet.fish b/completions/configlet.fish index 041fe2b4..b41ec26e 100644 --- a/completions/configlet.fish +++ b/completions/configlet.fish @@ -11,7 +11,7 @@ complete -c configlet -n "__fish_use_subcommand" -a lint -d "Check the tra # subcommands with options complete -c configlet -n "__fish_use_subcommand" -a completion -d "Output a completion script for a given shell" -complete -c configlet -n "__fish_use_subcommand" -a create -d "Add a new approach or article" +complete -c configlet -n "__fish_use_subcommand" -a create -d "Add a new exercise, approach or article" complete -c configlet -n "__fish_use_subcommand" -a fmt -d "Format the exercise '.meta/config.json' files" complete -c configlet -n "__fish_use_subcommand" -a info -d "Track info" complete -c configlet -n "__fish_use_subcommand" -a sync -d "Check or update Practice Exercise docs, metadata, and tests" diff --git a/completions/configlet.zsh b/completions/configlet.zsh index 2918aceb..c8553f5b 100644 --- a/completions/configlet.zsh +++ b/completions/configlet.zsh @@ -11,7 +11,7 @@ _configlet_commands() { "lint:Check the track configuration for correctness" \ # subcommands with options "completion:Output a completion script for a given shell" \ - "create:Add a new approach or article" \ + "create:Add a new exercise, approach or article" \ "fmt:Format the exercise '.meta/config.json' files" \ "info:Print track information" \ "sync:Check or update Practice Exercise docs, metadata, and tests" \ diff --git a/src/cli.nim b/src/cli.nim index 4e39d81e..9a5c0220 100644 --- a/src/cli.nim +++ b/src/cli.nim @@ -40,10 +40,13 @@ type of actCreate: approachSlug*: string articleSlug*: string + practiceExerciseSlug*: string + conceptExerciseSlug*: string # We can't name this field `exercise` because we use that names # in `actSync`, and Nim doesn't yet support duplicate field names # in object variants. exerciseCreate*: string + offlineCreate*: bool of actFmt: # We can't name these fields `exercise`, `update`, and `yes` because we # use those names in `actSync`, and Nim doesn't yet support duplicate @@ -83,6 +86,8 @@ type # Options for `create` optCreateApproach = "approach" optCreateArticle = "article" + optCreateConceptExercise = "conceptExercise" + optCreatePracticeExercise = "practiceExercise" # Options for `completion` optCompletionShell = "shell" @@ -94,8 +99,8 @@ type optFmtSyncUpdate = "update" optFmtSyncYes = "yes" - # Options for both `info` and `sync` - optInfoSyncOffline = "offline" + # Options for both `info`, `sync` and `create` + optInfoSyncCreateOffline = "offline" # Scope to sync optSyncDocs = "docs" @@ -110,7 +115,8 @@ func genShortKeys: array[Opt, char] = ## Returns a lookup that gives the valid short option key for an `Opt`. for opt in Opt: if opt in {optVersion, optSyncDocs, optSyncFilepaths, optSyncMetadata, - optSyncTests, optCreateApproach, optCreateArticle}: + optSyncTests, optCreateApproach, optCreateArticle, + optCreateConceptExercise, optCreatePracticeExercise}: result[opt] = '_' # No short option for these options. else: result[opt] = ($opt)[0] @@ -119,7 +125,7 @@ const configletVersion = staticRead("../configlet.version").strip() short = genShortKeys() optsNoVal = {optHelp, optVersion, optFmtSyncUpdate, optFmtSyncYes, - optInfoSyncOffline, optSyncDocs, optSyncFilepaths, optSyncMetadata} + optInfoSyncCreateOffline, optSyncDocs, optSyncFilepaths, optSyncMetadata} func generateNoVals: tuple[shortNoVal: set[char], longNoVal: seq[string]] = ## Returns the short and long keys for the options in `optsNoVal`. @@ -179,6 +185,8 @@ func genHelpText: string = of optFmtSyncCreateExercise: "slug" of optCreateApproach: "slug" of optCreateArticle: "slug" + of optCreateConceptExercise: "slug" + of optCreatePracticeExercise: "slug" of optSyncTests: "mode" of optUuidNum: "int" else: "" @@ -206,7 +214,7 @@ func genHelpText: string = const actionDescriptions: array[ActionKind, string] = [ actNil: "", actCompletion: "Output a completion script for a given shell", - actCreate: "Add a new approach or article", + actCreate: "Add a new exercise, approach or article", actFmt: "Format the exercise 'config.json' files", actGenerate: "Generate Concept Exercise 'introduction.md' files from 'introduction.md.tpl' files", actInfo: "Print some information about the track", @@ -234,12 +242,14 @@ func genHelpText: string = &"{paddingOpt}{allowedValues(Verbosity)} (default: normal)", optCreateApproach: "The slug of the approach", optCreateArticle: "The slug of the article", + optCreateConceptExercise: "The slug of the practice exercise", + optCreatePracticeExercise: "The slug of the concept exercise", optCompletionShell: &"Choose the shell type (required)\n" & &"{paddingOpt}{allowedValues(Shell)}", optFmtSyncCreateExercise: "Only operate on this exercise", optFmtSyncUpdate: "Prompt to update the unsynced track data", optFmtSyncYes: &"Auto-confirm prompts from --{$optFmtSyncUpdate} for updating docs, filepaths, and metadata", - optInfoSyncOffline: "Do not update the cached 'problem-specifications' data", + optInfoSyncCreateOffline: "Do not update the cached 'problem-specifications' data", optSyncDocs: "Sync Practice Exercise '.docs/introduction.md' and '.docs/instructions.md' files", optSyncFilepaths: "Populate empty 'files' values in Concept/Practice exercise '.meta/config.json' files", optSyncMetadata: "Sync Practice Exercise '.meta/config.json' metadata values", @@ -291,6 +301,10 @@ func genHelpText: string = optCreateApproach of "articleSlug": optCreateArticle + of "conceptExerciseSlug": + optCreateConceptExercise + of "practiceExerciseSlug": + optCreatePracticeExercise of "exerciseCreate": optFmtSyncCreateExercise of "exerciseFmt": @@ -300,7 +314,9 @@ func genHelpText: string = of "yesFmt": optFmtSyncYes of "offlineInfo": - optInfoSyncOffline + optInfoSyncCreateOffline + of "offlineCreate": + optInfoSyncCreateOffline else: parseEnum[Opt](key) # Set the description for `fmt` options. @@ -530,6 +546,12 @@ proc handleOption(conf: var Conf; kind: CmdLineKind; key, val: string) = setActionOpt(articleSlug, val) of optFmtSyncCreateExercise: setActionOpt(exerciseCreate, val) + of optCreateConceptExercise: + setActionOpt(conceptExerciseSlug, val) + of optCreatePracticeExercise: + setActionOpt(practiceExerciseSlug, val) + of optInfoSyncCreateOffline: + setActionOpt(offlineCreate, true) else: discard of actFmt: @@ -544,7 +566,7 @@ proc handleOption(conf: var Conf; kind: CmdLineKind; key, val: string) = discard of actInfo: case opt - of optInfoSyncOffline: + of optInfoSyncCreateOffline: setActionOpt(offlineInfo, true) else: discard @@ -559,7 +581,7 @@ proc handleOption(conf: var Conf; kind: CmdLineKind; key, val: string) = of optSyncTests: setActionOpt(tests, parseVal[TestsMode](kind, key, val)) conf.action.scope.incl skTests - of optInfoSyncOffline: + of optInfoSyncCreateOffline: setActionOpt(offline, true) of optSyncDocs, optSyncMetadata, optSyncFilepaths: conf.action.scope.incl parseEnum[SyncKind]($opt) diff --git a/src/create/create.nim b/src/create/create.nim index 4803f589..cfcbc951 100644 --- a/src/create/create.nim +++ b/src/create/create.nim @@ -1,14 +1,14 @@ import std/[os, strformat] import ".."/[cli, helpers, sync/sync, types_track_config] -import "."/[approaches, articles] +import "."/[approaches, articles, exercises] proc create*(conf: Conf) = if conf.action.kind == actCreate: - if conf.action.exerciseCreate.len == 0: - let msg = "Please specify an exercise, using --exercise " - stderr.writeLine msg - quit QuitFailure if conf.action.approachSlug.len > 0: + if conf.action.exerciseCreate.len == 0: + let msg = "Please specify an exercise to create an approach for, using --exercise " + stderr.writeLine msg + quit QuitFailure if conf.action.articleSlug.len > 0: let msg = &"Both --approach and --article were provided. Please specify only one." stderr.writeLine msg @@ -32,6 +32,10 @@ proc create*(conf: Conf) = createApproach(Slug(conf.action.approachSlug), userExercise, exerciseDir) elif conf.action.articleSlug.len > 0: + if conf.action.exerciseCreate.len == 0: + let msg = "Please specify an exercise to create an article for, using --exercise " + stderr.writeLine msg + quit QuitFailure let trackConfigPath = conf.trackDir / "config.json" let trackConfig = parseFile(trackConfigPath, TrackConfig) let trackExerciseSlugs = getSlugs(trackConfig.exercises, conf, trackConfigPath) @@ -50,8 +54,13 @@ proc create*(conf: Conf) = quit QuitFailure createArticle(Slug(conf.action.articleSlug), userExercise, exerciseDir) + elif conf.action.conceptExerciseSlug.len > 0: + createConceptExercise(conf) + elif conf.action.practiceExerciseSlug.len > 0: + createPracticeExercise(conf) else: - let msg = "Please specify `--article ` or `--approach `" + let msg = "Please specify `--practice-exercise `, `--concept-exercise `, " & + "`--article ` or `--approach `" stderr.writeLine msg quit QuitFailure else: diff --git a/src/create/exercises.nim b/src/create/exercises.nim new file mode 100644 index 00000000..21bc05f0 --- /dev/null +++ b/src/create/exercises.nim @@ -0,0 +1,120 @@ +import std/[sets, options, os, strformat] +import ".."/[cli, helpers, logger, fmt/track_config, sync/probspecs, sync/sync, + sync/sync_filepaths, sync/sync_metadata, types_exercise_config, + types_track_config, uuid/uuid] + +proc verifyExerciseDoesNotExist(conf: Conf, slug: string): tuple[trackConfig: TrackConfig, trackConfigPath: string, exercise: Slug] = + let trackConfigPath = conf.trackDir / "config.json" + let trackConfig = parseFile(trackConfigPath, TrackConfig) + let trackExerciseSlugs = getSlugs(trackConfig.exercises, conf, trackConfigPath) + let userExercise = Slug(slug) + + if userExercise in trackExerciseSlugs.`concept`: + let msg = &"There already is a concept exercise with `{userExercise}` as the slug " & + &"in the track config:\n{trackConfigPath}" + stderr.writeLine msg + quit QuitFailure + elif userExercise in trackExerciseSlugs.practice: + let msg = &"There already is a practice exercise with `{userExercise}` as the slug " & + &"in the track config:\n{trackConfigPath}" + stderr.writeLine msg + quit QuitFailure + + (trackConfig, trackConfigPath, userExercise) + +proc createEmptyFile(file: string) = + let fileDir = parentDir(file) + if not dirExists(fileDir): + createDir(fileDir) + + writeFile(file, "") + +proc syncFiles(trackConfig: TrackConfig, trackDir: string, exerciseSlug: Slug, exerciseKind: ExerciseKind) = + let exerciseDir = trackDir / "exercises" / $exerciseKind / $exerciseSlug + + let filePatternGroups = [ + trackConfig.files.solution, + trackConfig.files.test, + trackConfig.files.editor, + trackConfig.files.invalidator, + if exerciseKind == ekConcept: trackConfig.files.exemplar else: trackConfig.files.example + ] + + for filePatterns in filePatternGroups: + for filePattern in toFilepaths(filePatterns, exerciseSlug): + createEmptyFile(exerciseDir / filePattern) + +proc syncExercise(conf: Conf, slug: Slug,) = + let syncConf = Conf( + trackDir: conf.trackDir, + action: Action(exercise: $slug, kind: actSync, update: true, yes: true, + offline: conf.action.offlineCreate, + scope: {skDocs, skFilepaths, skMetadata, skTests}, tests: tmInclude) + ) + discard syncImpl(syncConf) + +proc createFiles(conf: Conf, slug: Slug, trackConfig: TrackConfig, trackDir: string, exerciseKind: ExerciseKind) = + withLevel(verQuiet): + syncExercise(conf, slug) + syncFiles(trackConfig, conf.trackDir, slug, exerciseKind) + +proc createConceptExercise*(conf: Conf) = + var (trackConfig, trackConfigPath, userExercise) = verifyExerciseDoesNotExist(conf, conf.action.conceptExerciseSlug) + + let probSpecsDir = ProbSpecsDir.init(conf) + if dirExists(probSpecsDir / "exercises" / $userExercise): + let msg = &"There already is an exercise with `{userExercise}` as the slug " & + "in the problem specifications repo" + stderr.writeLine msg + quit QuitFailure + + let exercise = ConceptExercise( + slug: userExercise, + name: $userExercise, # TODO: Humanize slug + uuid: $genUuid(), + concepts: OrderedSet[string](), + prerequisites: OrderedSet[string](), + status: sMissing + ) + + trackConfig.exercises.`concept`.add(exercise) + writeFile(trackConfigPath, prettyTrackConfig(trackConfig)) + + let docsDir = conf.trackDir / "exercises" / "concept" / $userExercise / ".docs" + createEmptyFile(docsDir / "introduction.md") + createEmptyFile(docsDir / "instructions.md") + + createFiles(conf, userExercise, trackConfig, conf.trackDir, ekConcept) + + logNormal(&"Created concept exercise '{userExercise}'.") + +proc createPracticeExercise*(conf: Conf) = + var (trackConfig, trackConfigPath, userExercise) = verifyExerciseDoesNotExist(conf, conf.action.practiceExerciseSlug) + + let probSpecsDir = ProbSpecsDir.init(conf) + let metadataFile = probSpecsDir / "exercises" / $userExercise / "metadata.toml" + let metadata = + if fileExists(metadataFile): + parseMetadataToml(metadataFile) + else: + UpstreamMetadata(title: $userExercise, blurb: "", source: none(string), source_url: none(string)) + + let exercise = PracticeExercise( + slug: userExercise, + name: metadata.title, + uuid: $genUuid(), + practices: OrderedSet[string](), + prerequisites: OrderedSet[string](), + difficulty: 1, + status: sMissing + ) + + trackConfig.exercises.practice.add(exercise) + writeFile(trackConfigPath, prettyTrackConfig(trackConfig)) + + let docsDir = conf.trackDir / "exercises" / "practice" / $userExercise / ".docs" + createEmptyFile(docsDir / "instructions.md") + + createFiles(conf, userExercise, trackConfig, conf.trackDir, ekPractice) + + logNormal(&"Created practice exercise '{userExercise}'.") diff --git a/src/exec.nim b/src/exec.nim index 32fdb960..58a14ea0 100644 --- a/src/exec.nim +++ b/src/exec.nim @@ -119,7 +119,7 @@ proc fixAverageRunTimeInConfigJson(file, oldValue, newValue: string) = &""""average_run_time": {oldValue}""", &""""average_run_time": {newValue}""") writeFile(file, configJson) - let args = ["-C", parentDir(file), "commit", "-a", "-m", "config: convert `average_run_time` to int"] + let args = ["-C", parentDir(file), "commit", "--allow-empty", "-a", "-m", "config: convert `average_run_time` to int"] discard gitCheck(0, args) proc setupExercismRepo*(repoName, dest, hash: string; shallow = false) = diff --git a/src/fmt/track_config.nim b/src/fmt/track_config.nim index de9d72d1..71d00e8c 100644 --- a/src/fmt/track_config.nim +++ b/src/fmt/track_config.nim @@ -242,7 +242,7 @@ func addConcepts(result: var string; val: Concepts; indentLevel = 1) = result.addNewlineAndIndent(indentLevel) result.add "]," -func prettyTrackConfig(e: TrackConfig): string = +func prettyTrackConfig*(e: TrackConfig): string = ## Serializes `e` as pretty-printed JSON, using the canonical key order. let keys = trackConfigKeyOrderForFmt(e) diff --git a/src/sync/probspecs.nim b/src/sync/probspecs.nim index 68e4b03f..dcecbe9e 100644 --- a/src/sync/probspecs.nim +++ b/src/sync/probspecs.nim @@ -106,7 +106,8 @@ proc getNameOfRemote*(probSpecsDir: ProbSpecsDir; func isOffline(conf: Conf): bool = (conf.action.kind == actSync and conf.action.offline) or - (conf.action.kind == actInfo and conf.action.offlineInfo) + (conf.action.kind == actInfo and conf.action.offlineInfo) or + (conf.action.kind == actCreate and conf.action.offlineCreate) proc validate(probSpecsDir: ProbSpecsDir, conf: Conf) = ## Raises an error if the given `probSpecsDir` is not a valid @@ -156,28 +157,29 @@ proc validate(probSpecsDir: ProbSpecsDir, conf: Conf) = const upstreamLocation = "exercism/problem-specifications" let remoteName = getNameOfRemote(probSpecsDir, upstreamHost, upstreamLocation) - # `fetch` and `merge` separately, for better error messages. - logNormal(&"Updating cached 'problem-specifications' data...") - try: - discard gitCheck(0, ["fetch", "--quiet", remoteName, mainBranchName], - &"failed to fetch '{mainBranchName}' in " & - &"problem-specifications directory: '{probSpecsDir}'") - except OSError: - const msg = """ - Unable to update the problem-specifications cache. - - You can either: - - - ensure that you have network connectivity, and run the same configlet command again - - or add the '--offline' option to skip updating the cache - - The most recent commit in the problem-specifications cache is: - - """.unindent() - const format = "commit %H%nAuthor: %an <%ae>%nCommitDate: %cD%n%n %s" - let mostRecentCommit = gitCheck(0, ["log", "-n1", - &"--format={format}"]).strip().indent(4) - showError(msg & mostRecentCommit, writeHelp = false) + once: + # `fetch` and `merge` separately, for better error messages. + logNormal(&"Updating cached 'problem-specifications' data...") + try: + discard gitCheck(0, ["fetch", "--quiet", remoteName, mainBranchName], + &"failed to fetch '{mainBranchName}' in " & + &"problem-specifications directory: '{probSpecsDir}'") + except OSError: + const msg = """ + Unable to update the problem-specifications cache. + + You can either: + + - ensure that you have network connectivity, and run the same configlet command again + - or add the '--offline' option to skip updating the cache + + The most recent commit in the problem-specifications cache is: + + """.unindent() + const format = "commit %H%nAuthor: %an <%ae>%nCommitDate: %cD%n%n %s" + let mostRecentCommit = gitCheck(0, ["log", "-n1", + &"--format={format}"]).strip().indent(4) + showError(msg & mostRecentCommit, writeHelp = false) discard gitCheck(0, ["merge", "--ff-only", &"{remoteName}/{mainBranchName}"], &"failed to merge '{mainBranchName}' in " & diff --git a/src/sync/sync.nim b/src/sync/sync.nim index a52c79a3..24c85b85 100644 --- a/src/sync/sync.nim +++ b/src/sync/sync.nim @@ -80,7 +80,7 @@ proc getSlugs*(exercises: Exercises, conf: Conf, stderr.writeLine msg quit QuitFailure -proc syncImpl(conf: Conf): set[SyncKind] = +proc syncImpl*(conf: Conf): set[SyncKind] = ## Checks the data specified in `conf.action.scope`, and updates them if ## `--update` was passed and the user confirms. ## diff --git a/src/sync/sync_filepaths.nim b/src/sync/sync_filepaths.nim index 564b4397..96800716 100644 --- a/src/sync/sync_filepaths.nim +++ b/src/sync/sync_filepaths.nim @@ -23,7 +23,7 @@ func kebabToCamel(slug: Slug): string = func kebabToPascal(slug: Slug): string = kebabToCamelOrPascal(slug, capitalizeFirstLetter = true) -func toFilepaths(patterns: seq[string], slug: Slug): seq[string] = +func toFilepaths*(patterns: seq[string], slug: Slug): seq[string] = result = newSeq[string](patterns.len) for i, pattern in patterns: result[i] = pattern.multiReplace( diff --git a/src/sync/sync_metadata.nim b/src/sync/sync_metadata.nim index 49198401..017bd55b 100644 --- a/src/sync/sync_metadata.nim +++ b/src/sync/sync_metadata.nim @@ -7,10 +7,11 @@ import "."/sync_common {.push hint[Name]: off.} type - UpstreamMetadata = object - blurb: string - source: Option[string] - source_url: Option[string] + UpstreamMetadata* = object + title*: string + blurb*: string + source*: Option[string] + source_url*: Option[string] PathAndUpdatedConfig = object path: string @@ -18,11 +19,13 @@ type {.pop.} -proc parseMetadataToml(path: string): UpstreamMetadata = +proc parseMetadataToml*(path: string): UpstreamMetadata = ## Parses the problem-specifications `metadata.toml` file at `path`, and ## returns an object containing the `blurb`, `source`, and `source_url` values. let t = parsetoml.parseFile(path) result = UpstreamMetadata( + title: + if t.hasKey("title"): t["title"].getStr() else: "", blurb: if t.hasKey("blurb"): t["blurb"].getStr() else: "", source: diff --git a/tests/binary_helpers.nim b/tests/binary_helpers.nim index e1a8762d..0abd756b 100644 --- a/tests/binary_helpers.nim +++ b/tests/binary_helpers.nim @@ -40,5 +40,13 @@ template testDiffThenRestore*(dir, expectedDiff, restoreArg: string) = check diff == expectedDiff gitRestore(dir, restoreArg) +proc testStatusThenReset*(dir, expectedStatus: string) = + discard git(["-C", dir, "add", "."]) + + let status = gitCheck(0, ["--no-pager", "-C", dir, "status", "--short"]) + check status == expectedStatus + + discard git(["-C", dir, "reset", "--hard"]) + template checkNoDiff*(trackDir: string) = check gitDiffExitCode(trackDir) == 0 diff --git a/tests/test_binary_create.nim b/tests/test_binary_create.nim new file mode 100644 index 00000000..7e26c1c3 --- /dev/null +++ b/tests/test_binary_create.nim @@ -0,0 +1,131 @@ +import std/[os, osproc, strformat, strutils, unittest] +import exec +import "."/[binary_helpers] + +proc main = + const psDir = getCacheDir() / "exercism" / "configlet" / "problem-specifications" + const trackDir = testsDir / ".test_elixir_track_repo" + + # Setup: clone the problem-specifications repo, and checkout a known state + setupExercismRepo("problem-specifications", psDir, + "daf620d47ed905409564dec5fa9610664e294bde") # 2021-06-18 + + # Setup: clone a track repo, and checkout a known state + setupExercismRepo("elixir", trackDir, + "10a13b6b3b5491511c6e50f3a907d014f263221f") # 2024-01-10 + + const + createBase = &"{binaryPath} -t {trackDir} create" + + suite "create": + test "missing argument to determine what to create (prints the expected output, and exits with 1)": + const expectedOutput = fmt""" + Please specify `--practice-exercise `, `--concept-exercise `, `--article ` or `--approach ` + """.unindent() + execAndCheck(1, &"{createBase}", expectedOutput) + + test "concept exercise slug matches existing concept exercise (prints the expected output, and exits with 1)": + const expectedOutput = fmt""" + There already is a concept exercise with `lasagna` as the slug in the track config: + {trackDir / "config.json"} + """.unindent() + execAndCheck(1, &"{createBase} --concept-exercise=lasagna", expectedOutput) + + test "concept exercise slug matches existing practice exercise (prints the expected output, and exits with 1)": + const expectedOutput = fmt""" + There already is a practice exercise with `leap` as the slug in the track config: + {trackDir / "config.json"} + """.unindent() + execAndCheck(1, &"{createBase} --concept-exercise=leap", expectedOutput) + + test "concept exercise slug matches prob-specs exercise (prints the expected output, and exits with 1)": + const expectedOutput = fmt""" + Updating cached 'problem-specifications' data... + There already is an exercise with `hangman` as the slug in the problem specifications repo + """.unindent() + execAndCheck(1, &"{createBase} --concept-exercise=hangman", expectedOutput) + + test "create concept exercise (creates the exercise files, and exits with 0)": + const expectedOutput = fmt""" + Updating cached 'problem-specifications' data... + Created concept exercise 'foo'. + """.unindent() + execAndCheck(0, &"{createBase} --concept-exercise=foo", expectedOutput) + + const expectedStatus = """ + M config.json + A exercises/concept/foo/.docs/instructions.md + A exercises/concept/foo/.docs/introduction.md + A exercises/concept/foo/.meta/config.json + A exercises/concept/foo/.meta/exemplar.ex + A exercises/concept/foo/lib/foo.ex + A exercises/concept/foo/test/foo_test.exs + """.unindent() + testStatusThenReset(trackDir, expectedStatus) + + test "create concept exercise - offline (creates the exercise files, and exits with 0)": + const expectedOutput = fmt""" + Created concept exercise 'foo'. + """.unindent() + execAndCheck(0, &"{createBase} --concept-exercise=foo --offline", expectedOutput) + + const expectedStatus = """ + M config.json + A exercises/concept/foo/.docs/instructions.md + A exercises/concept/foo/.docs/introduction.md + A exercises/concept/foo/.meta/config.json + A exercises/concept/foo/.meta/exemplar.ex + A exercises/concept/foo/lib/foo.ex + A exercises/concept/foo/test/foo_test.exs + """.unindent() + testStatusThenReset(trackDir, expectedStatus) + + test "practice exercise slug matches existing concept exercise (prints the expected output, and exits with 1)": + const expectedOutput = fmt""" + There already is a concept exercise with `lasagna` as the slug in the track config: + {trackDir / "config.json"} + """.unindent() + execAndCheck(1, &"{createBase} --practice-exercise=lasagna", expectedOutput) + + test "practice exercise slug matches existing practice exercise (prints the expected output, and exits with 1)": + const expectedOutput = fmt""" + There already is a practice exercise with `leap` as the slug in the track config: + {trackDir / "config.json"} + """.unindent() + execAndCheck(1, &"{createBase} --practice-exercise=leap", expectedOutput) + + test "create practice exercise with slug not matching prob-specs exercise (creates the exercise files, and exits with 0)": + const expectedOutput = fmt""" + Updating cached 'problem-specifications' data... + Created practice exercise 'foo'. + """.unindent() + execAndCheck(0, &"{createBase} --practice-exercise=foo", expectedOutput) + + const expectedStatus = """ + M config.json + A exercises/practice/foo/.docs/instructions.md + A exercises/practice/foo/.meta/config.json + A exercises/practice/foo/.meta/example.ex + A exercises/practice/foo/lib/foo.ex + A exercises/practice/foo/test/foo_test.exs + """.unindent() + testStatusThenReset(trackDir, expectedStatus) + + test "create practice exercise with slug not matching prob-specs exercise - offline (creates the exercise files, and exits with 0)": + const expectedOutput = fmt""" + Created practice exercise 'foo'. + """.unindent() + execAndCheck(0, &"{createBase} --practice-exercise=foo --offline", expectedOutput) + + const expectedStatus = """ + M config.json + A exercises/practice/foo/.docs/instructions.md + A exercises/practice/foo/.meta/config.json + A exercises/practice/foo/.meta/example.ex + A exercises/practice/foo/lib/foo.ex + A exercises/practice/foo/test/foo_test.exs + """.unindent() + testStatusThenReset(trackDir, expectedStatus) + +main() +{.used.} diff --git a/tests/test_sync.nim b/tests/test_sync.nim index 368ea5e6..9ee66d8c 100644 --- a/tests/test_sync.nim +++ b/tests/test_sync.nim @@ -491,6 +491,7 @@ proc testSyncMetadata = test "with only `blurb` and `source_url` (and quote escaping)": let metadataPath = joinPath(psExercisesDir, "two-fer", "metadata.toml") const expected = UpstreamMetadata( + title: "Two-fer", blurb: """Create a sentence of the form "One for X, one for me.".""", source: none(string), source_url: some("https://github.com/exercism/problem-specifications/issues/757") @@ -501,6 +502,7 @@ proc testSyncMetadata = test "with `blurb`, `source`, and `source_url`": let metadataPath = joinPath(psExercisesDir, "collatz-conjecture", "metadata.toml") const expected = UpstreamMetadata( + title: "", blurb: "Calculate the number of steps to reach 1 using the Collatz conjecture.", source: some("An unsolved problem in mathematics named after mathematician Lothar Collatz"), source_url: some("https://en.wikipedia.org/wiki/3x_%2B_1_problem") @@ -511,6 +513,7 @@ proc testSyncMetadata = test "with `blurb`, `source`, and `source_url`, and extra `title`": let metadataPath = joinPath(psExercisesDir, "etl", "metadata.toml") const expected = UpstreamMetadata( + title: "ETL", blurb: "We are going to do the `Transform` step of an Extract-Transform-Load.", source: some("The Jumpstart Lab team"), source_url: some("http://jumpstartlab.com")