Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions trainings/api/protobuf/server.proto
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ service Trainings {

rpc GetGoldenSolution(GetGoldenSolutionRequest) returns (GetGoldenSolutionResponse) {};

rpc GetExerciseStartState(GetExerciseStartStateRequest) returns (GetExerciseStartStateResponse) {};

rpc GetAgentInstructions(GetAgentInstructionsRequest) returns (GetAgentInstructionsResponse) {};

rpc Ping(google.protobuf.Empty) returns (google.protobuf.Empty) {};
Expand Down Expand Up @@ -267,6 +269,17 @@ message GetGoldenSolutionResponse {
repeated File files = 2;
}

message GetExerciseStartStateRequest {
string training_name = 1;
string exercise_id = 2;
string token = 3;
}

message GetExerciseStartStateResponse {
string dir = 1;
repeated File files = 2;
}

message GetAgentInstructionsRequest {
string training_name = 1;
string token = 2;
Expand Down
28 changes: 3 additions & 25 deletions trainings/exercise_replace.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,35 +31,13 @@ func replaceExerciseFiles(
return files.NewFilesSilentDeleteUnused().WriteExerciseFiles(replacementFiles, trainingRootFs, exerciseDir)
}

// mergeStartStateFiles returns the starting state of an exercise:
// golden(prev) with scaffold(current) overlaid on top. Scaffold wins on path
// collisions because it represents the delta from end-of-prev to start-of-current.
// Pass nil goldenFiles for the first exercise.
func mergeStartStateFiles(
goldenFiles []*genproto.File,
scaffoldFiles []*genproto.File,
) []*genproto.File {
byPath := make(map[string]*genproto.File, len(goldenFiles)+len(scaffoldFiles))
for _, f := range goldenFiles {
byPath[f.Path] = f
}
for _, f := range scaffoldFiles {
byPath[f.Path] = f // scaffold wins on collisions
}
merged := make([]*genproto.File, 0, len(byPath))
for _, f := range byPath {
merged = append(merged, f)
}
return merged
}

// replaceExerciseFilesAndCommit is the complete orchestration for replacing
// the user's exercise files with a given file list: save backup → write files
// (1:1, deletes extras) → stage → commit. ALL callers that replace the user's
// solution with example / start-state content MUST go through this function:
// - overrideWithGolden ('s' action): files = golden(current)
// - g during next/merge-conflict: files = golden(prev) + scaffold(current)
// - resetCleanFiles: files = golden(prev) + scaffold(current)
// - overrideWithGolden ('s' action): files = golden(current) via GetGoldenSolution
// - g during next/merge-conflict: files = start state via GetExerciseStartState
// - resetCleanFiles: files = start state via GetExerciseStartState
//
// The backup branch is REQUIRED — destructive ops must always be recoverable.
// If saveToBackupBranch returns errBackupAborted, that error is returned directly
Expand Down
52 changes: 0 additions & 52 deletions trainings/exercise_replace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,58 +11,6 @@ import (
"github.com/ThreeDotsLabs/cli/trainings/genproto"
)

func TestMergeStartStateFiles(t *testing.T) {
t.Run("first exercise (nil golden) returns scaffold as-is", func(t *testing.T) {
scaffold := []*genproto.File{
{Path: "a.txt", Content: "A"},
{Path: "b.txt", Content: "B"},
}
merged := mergeStartStateFiles(nil, scaffold)
assertFilesByPath(t, merged, map[string]string{
"a.txt": "A",
"b.txt": "B",
})
})

t.Run("scaffold wins on path collision", func(t *testing.T) {
golden := []*genproto.File{
{Path: "shared.txt", Content: "from golden"},
{Path: "golden-only.txt", Content: "G"},
}
scaffold := []*genproto.File{
{Path: "shared.txt", Content: "from scaffold"}, // overrides golden
{Path: "scaffold-only.txt", Content: "S"},
}
merged := mergeStartStateFiles(golden, scaffold)
assertFilesByPath(t, merged, map[string]string{
"shared.txt": "from scaffold",
"golden-only.txt": "G",
"scaffold-only.txt": "S",
})
})

t.Run("regression: golden with filled-in placeholder is preserved when scaffold does not redeliver it", func(t *testing.T) {
// This is the 0001_init_orders.up.sql scenario.
// Earlier exercises scaffolded the file as empty; the user filled it in.
// By a later exercise, the scaffold no longer includes that file — only
// the prev-exercise golden does. The start state must preserve the
// filled-in content.
golden := []*genproto.File{
{Path: "migrations/0001_init.sql", Content: "CREATE TABLE ..."},
{Path: "common.go", Content: "package common"},
}
scaffold := []*genproto.File{
{Path: "new_for_this_exercise.go", Content: "package new"},
}
merged := mergeStartStateFiles(golden, scaffold)
assertFilesByPath(t, merged, map[string]string{
"migrations/0001_init.sql": "CREATE TABLE ...", // survives from golden
"common.go": "package common",
"new_for_this_exercise.go": "package new",
})
})
}

func TestReplaceExerciseFiles_is1to1(t *testing.T) {
// The invariant: after replaceExerciseFiles, exerciseDir contains exactly
// the replacement files — any extras are deleted. This is load-bearing
Expand Down
45 changes: 0 additions & 45 deletions trainings/exercises.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,48 +24,3 @@ func (h *Handlers) fetchGoldenFiles(
}
return resp.Files, nil
}

// resolvePreviousExercise returns the ID and module/exercise path of the exercise
// immediately preceding currentExerciseID in the training's order. Returns
// ("", "", nil) when currentExerciseID is the first exercise.
//
// One GetExercises call per invocation — callers may cache the result per-command
// if they need it more than once, but the common case (single reset / single 'g')
// only needs it once.
func (h *Handlers) resolvePreviousExercise(
ctx context.Context,
trainingName, token, currentExerciseID string,
) (prevExerciseID, prevModuleExercisePath string, err error) {
resp, err := h.newGrpcClient().GetExercises(ctx, &genproto.GetExercisesRequest{
TrainingName: trainingName,
Token: token,
})
if err != nil {
return "", "", fmt.Errorf("failed to list exercises: %w", err)
}

type flatExercise struct {
id string
moduleExercisePath string
}
var flat []flatExercise
for _, module := range resp.Modules {
for _, exercise := range module.Exercises {
flat = append(flat, flatExercise{
id: exercise.Id,
moduleExercisePath: module.Name + "/" + exercise.Name,
})
}
}

for i, e := range flat {
if e.id == currentExerciseID {
if i == 0 {
return "", "", nil // first exercise — no predecessor
}
return flat[i-1].id, flat[i-1].moduleExercisePath, nil
}
}

return "", "", fmt.Errorf("exercise %s not found in training %s", currentExerciseID, trainingName)
}
Loading