diff --git a/internal/artifacts/previous_requirements.go b/internal/artifacts/previous_requirements.go new file mode 100644 index 0000000..9db6496 --- /dev/null +++ b/internal/artifacts/previous_requirements.go @@ -0,0 +1,112 @@ +package artifacts + +import ( + "fmt" + "sort" + "strings" + + "gitea.maximumdirect.net/eric/narratio/internal/config" +) + +// PreviousArtifactRequirement describes one previous-session artifact dependency. +type PreviousArtifactRequirement struct { + Name string + Required bool + Sources []string +} + +// CollectPreviousArtifactRequirements scans enabled Scriptorium artifacts and returns +// deduplicated previous-session artifact requirements in deterministic order. +func CollectPreviousArtifactRequirements( + artifactsCfg map[string]config.ScriptoriumArtifactConfig, +) []PreviousArtifactRequirement { + if len(artifactsCfg) == 0 { + return nil + } + + artifactNames := sortedScriptoriumArtifactNames(artifactsCfg) + byName := map[string]PreviousArtifactRequirement{} + + for _, artifactName := range artifactNames { + artifactCfg := artifactsCfg[artifactName] + if !artifactCfg.Enabled { + continue + } + + inputNames := sortedScriptoriumInputKeys(artifactCfg.Inputs) + for _, inputName := range inputNames { + inputCfg := artifactCfg.Inputs[inputName] + previousName, ok := PreviousSessionArtifactName(inputCfg.Source) + if !ok { + continue + } + + location := fmt.Sprintf( + "pipeline.scriptorium.artifacts.%s.inputs.%s.source", + artifactName, + inputName, + ) + requirement := byName[previousName] + requirement.Name = previousName + requirement.Required = requirement.Required || inputCfg.Required + requirement.Sources = append(requirement.Sources, location) + byName[previousName] = requirement + } + } + + if len(byName) == 0 { + return nil + } + + requirements := make([]PreviousArtifactRequirement, 0, len(byName)) + for _, requirement := range byName { + requirement.Sources = dedupeAndSortStrings(requirement.Sources) + requirements = append(requirements, requirement) + } + sort.Slice(requirements, func(i, j int) bool { + return requirements[i].Name < requirements[j].Name + }) + return requirements +} + +func sortedScriptoriumArtifactNames(artifactsCfg map[string]config.ScriptoriumArtifactConfig) []string { + names := make([]string, 0, len(artifactsCfg)) + for name := range artifactsCfg { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func sortedScriptoriumInputKeys(inputs map[string]config.ScriptoriumInputConfig) []string { + if len(inputs) == 0 { + return nil + } + names := make([]string, 0, len(inputs)) + for name := range inputs { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func dedupeAndSortStrings(values []string) []string { + if len(values) == 0 { + return nil + } + seen := map[string]struct{}{} + out := make([]string, 0, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + out = append(out, trimmed) + } + sort.Strings(out) + return out +} diff --git a/internal/artifacts/previous_requirements_test.go b/internal/artifacts/previous_requirements_test.go new file mode 100644 index 0000000..d6b8acf --- /dev/null +++ b/internal/artifacts/previous_requirements_test.go @@ -0,0 +1,190 @@ +package artifacts + +import ( + "reflect" + "testing" + + "gitea.maximumdirect.net/eric/narratio/internal/config" +) + +func TestCollectPreviousArtifactRequirements(t *testing.T) { + tests := []struct { + name string + artifactsCfg map[string]config.ScriptoriumArtifactConfig + want []PreviousArtifactRequirement + }{ + { + name: "no artifacts", + artifactsCfg: nil, + want: nil, + }, + { + name: "no previous inputs", + artifactsCfg: map[string]config.ScriptoriumArtifactConfig{ + "session_recap": { + Enabled: true, + Inputs: map[string]config.ScriptoriumInputConfig{ + "transcript": {Source: "narratio.transcript.trimmed", Required: true}, + }, + }, + }, + want: nil, + }, + { + name: "one optional previous input", + artifactsCfg: map[string]config.ScriptoriumArtifactConfig{ + "session_recap": { + Enabled: true, + Inputs: map[string]config.ScriptoriumInputConfig{ + "previous_recap": {Source: "narratio.previous_session.artifact.session_recap", Required: false}, + }, + }, + }, + want: []PreviousArtifactRequirement{ + { + Name: "session_recap", + Required: false, + Sources: []string{"pipeline.scriptorium.artifacts.session_recap.inputs.previous_recap.source"}, + }, + }, + }, + { + name: "one required previous input", + artifactsCfg: map[string]config.ScriptoriumArtifactConfig{ + "quest_log": { + Enabled: true, + Inputs: map[string]config.ScriptoriumInputConfig{ + "previous_quest_log": {Source: "narratio.previous_session.artifact.quest_log", Required: true}, + }, + }, + }, + want: []PreviousArtifactRequirement{ + { + Name: "quest_log", + Required: true, + Sources: []string{"pipeline.scriptorium.artifacts.quest_log.inputs.previous_quest_log.source"}, + }, + }, + }, + { + name: "duplicate references are deduped", + artifactsCfg: map[string]config.ScriptoriumArtifactConfig{ + "a": { + Enabled: true, + Inputs: map[string]config.ScriptoriumInputConfig{ + "x": {Source: "narratio.previous_session.artifact.session_recap", Required: false}, + }, + }, + "b": { + Enabled: true, + Inputs: map[string]config.ScriptoriumInputConfig{ + "y": {Source: "narratio.previous_session.artifact.session_recap", Required: false}, + }, + }, + }, + want: []PreviousArtifactRequirement{ + { + Name: "session_recap", + Required: false, + Sources: []string{ + "pipeline.scriptorium.artifacts.a.inputs.x.source", + "pipeline.scriptorium.artifacts.b.inputs.y.source", + }, + }, + }, + }, + { + name: "required plus optional reference becomes required", + artifactsCfg: map[string]config.ScriptoriumArtifactConfig{ + "a": { + Enabled: true, + Inputs: map[string]config.ScriptoriumInputConfig{ + "x": {Source: "narratio.previous_session.artifact.session_recap", Required: false}, + }, + }, + "b": { + Enabled: true, + Inputs: map[string]config.ScriptoriumInputConfig{ + "y": {Source: "narratio.previous_session.artifact.session_recap", Required: true}, + }, + }, + }, + want: []PreviousArtifactRequirement{ + { + Name: "session_recap", + Required: true, + Sources: []string{ + "pipeline.scriptorium.artifacts.a.inputs.x.source", + "pipeline.scriptorium.artifacts.b.inputs.y.source", + }, + }, + }, + }, + { + name: "disabled artifact references are ignored", + artifactsCfg: map[string]config.ScriptoriumArtifactConfig{ + "disabled_artifact": { + Enabled: false, + Inputs: map[string]config.ScriptoriumInputConfig{ + "x": {Source: "narratio.previous_session.artifact.session_recap", Required: true}, + }, + }, + "enabled_artifact": { + Enabled: true, + Inputs: map[string]config.ScriptoriumInputConfig{ + "y": {Source: "narratio.previous_session.artifact.quest_log", Required: false}, + }, + }, + }, + want: []PreviousArtifactRequirement{ + { + Name: "quest_log", + Required: false, + Sources: []string{"pipeline.scriptorium.artifacts.enabled_artifact.inputs.y.source"}, + }, + }, + }, + { + name: "deterministic ordering", + artifactsCfg: map[string]config.ScriptoriumArtifactConfig{ + "zz": { + Enabled: true, + Inputs: map[string]config.ScriptoriumInputConfig{ + "b_input": {Source: "narratio.previous_session.artifact.quest_log", Required: false}, + "a_input": {Source: "narratio.previous_session.artifact.session_recap", Required: false}, + }, + }, + "aa": { + Enabled: true, + Inputs: map[string]config.ScriptoriumInputConfig{ + "c_input": {Source: "narratio.previous_session.artifact.session_recap", Required: false}, + }, + }, + }, + want: []PreviousArtifactRequirement{ + { + Name: "quest_log", + Required: false, + Sources: []string{"pipeline.scriptorium.artifacts.zz.inputs.b_input.source"}, + }, + { + Name: "session_recap", + Required: false, + Sources: []string{ + "pipeline.scriptorium.artifacts.aa.inputs.c_input.source", + "pipeline.scriptorium.artifacts.zz.inputs.a_input.source", + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CollectPreviousArtifactRequirements(tt.artifactsCfg) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("CollectPreviousArtifactRequirements() = %#v, want %#v", got, tt.want) + } + }) + } +}