package artifactpolicy import ( "fmt" "regexp" "strings" "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel" "gitea.maximumdirect.net/eric/narratio/internal/pathsafe" ) const ( SourceBoundsSession = "narratio.bounds.session" configuredSourcePrefix = "narratio.artifact." previousConfiguredSrcPrefix = "narratio.previous_session.artifact." ) var configuredSourceRE = regexp.MustCompile(`^narratio\.artifact\.([a-z][a-z0-9_]*)$`) var previousSourceRE = regexp.MustCompile(`^narratio\.previous_session\.artifact\.([a-z][a-z0-9_]*)$`) type SourceKind string const ( SourceKindBuiltIn SourceKind = "built_in" SourceKindConfiguredArtifact SourceKind = "configured_artifact" SourceKindPreviousArtifact SourceKind = "previous_session_configured_artifact" ) // Source describes one normalized artifact source identifier. type Source struct { ID string Kind SourceKind ConfiguredKey string } // ConfiguredSourceID converts a configured artifact key into source id form. func ConfiguredSourceID(key string) string { return configuredSourcePrefix + strings.TrimSpace(key) } // PreviousSessionSourceID converts a configured artifact key into previous-session source id form. func PreviousSessionSourceID(key string) string { return previousConfiguredSrcPrefix + strings.TrimSpace(key) } // ParseConfiguredSource extracts configured key from narratio.artifact.. func ParseConfiguredSource(source string) (string, bool) { matches := configuredSourceRE.FindStringSubmatch(strings.TrimSpace(source)) if len(matches) != 2 { return "", false } return matches[1], true } // ParsePreviousSessionSource extracts configured key from narratio.previous_session.artifact.. func ParsePreviousSessionSource(source string) (string, bool) { matches := previousSourceRE.FindStringSubmatch(strings.TrimSpace(source)) if len(matches) != 2 { return "", false } return matches[1], true } // ClassifySource classifies a source id as built-in, configured, or previous-session configured. func ClassifySource(source string) (Source, error) { trimmed := strings.TrimSpace(source) if trimmed == "" { return Source{}, fmt.Errorf("artifact source is required") } if _, ok := artifactmodel.LookupRuntimeTranscriptArtifact(trimmed); ok { return Source{ID: trimmed, Kind: SourceKindBuiltIn}, nil } if trimmed == SourceBoundsSession { return Source{ID: trimmed, Kind: SourceKindBuiltIn}, nil } if key, ok := ParseConfiguredSource(trimmed); ok { return Source{ID: trimmed, Kind: SourceKindConfiguredArtifact, ConfiguredKey: key}, nil } if key, ok := ParsePreviousSessionSource(trimmed); ok { return Source{ID: trimmed, Kind: SourceKindPreviousArtifact, ConfiguredKey: key}, nil } return Source{}, fmt.Errorf("unsupported artifact source %q", source) } // ValidatePublishSource validates that a source is publish-compatible and references a known configured artifact. func ValidatePublishSource(source string, configured map[string]string) (Source, error) { classified, err := ClassifySource(source) if err != nil { return Source{}, fmt.Errorf("must be a built-in source id or narratio.artifact.") } if classified.Kind == SourceKindPreviousArtifact { return Source{}, fmt.Errorf("must be a built-in source id or narratio.artifact.") } if classified.Kind == SourceKindConfiguredArtifact { if configured == nil { return Source{}, fmt.Errorf("configured artifact %q is not defined in pipeline.scriptorium.artifacts", classified.ConfiguredKey) } if _, ok := configured[classified.ConfiguredKey]; !ok { return Source{}, fmt.Errorf("configured artifact %q is not defined in pipeline.scriptorium.artifacts", classified.ConfiguredKey) } } return classified, nil } // DeriveDefaultPublishedDestination returns the default publish destination for one source. func DeriveDefaultPublishedDestination(source Source, configured map[string]string) (string, error) { switch source.Kind { case SourceKindBuiltIn: if spec, ok := artifactmodel.LookupRuntimeTranscriptArtifact(source.ID); ok { return pathsafe.NormalizeRelativeDestination(spec.CanonicalRelPath) } if source.ID == SourceBoundsSession { return pathsafe.NormalizeRelativeDestination("artifacts/session_bounds.json") } return "", fmt.Errorf("unsupported built-in source %q", source.ID) case SourceKindConfiguredArtifact: if configured == nil { return "", fmt.Errorf("configured artifact %q is not defined in pipeline.scriptorium.artifacts", source.ConfiguredKey) } outputPath, ok := configured[source.ConfiguredKey] if !ok { return "", fmt.Errorf("configured artifact %q is not defined in pipeline.scriptorium.artifacts", source.ConfiguredKey) } if strings.TrimSpace(outputPath) == "" { return "", fmt.Errorf("pipeline.scriptorium.artifacts.%s.output_path is empty", source.ConfiguredKey) } return pathsafe.NormalizeRelativeDestination(outputPath) default: return "", fmt.Errorf("publish destination cannot be derived from source %q", source.ID) } } // ResolvePublishedDestination validates and normalizes an explicit destination, // or derives one when omitted. func ResolvePublishedDestination(sourceID, explicitDest string, configured map[string]string) (string, error) { source, err := ValidatePublishSource(sourceID, configured) if err != nil { return "", err } if strings.TrimSpace(explicitDest) != "" { return pathsafe.NormalizeRelativeDestination(explicitDest) } return DeriveDefaultPublishedDestination(source, configured) }