package app import ( "context" "fmt" "io" "os" "path/filepath" "sort" "strings" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) type finding struct { Severity string Category string Message string } type findingError struct { count int } func (e findingError) Error() string { return fmt.Sprintf("%d validation error(s)", e.count) } func renderFindings(out io.Writer, campaign, sessionID string, findings []finding) error { if campaign != "" || sessionID != "" { fmt.Fprintf(out, "Campaign: %s\n", campaign) fmt.Fprintf(out, "Session: %s\n\n", sessionID) } errorsCount := 0 for _, f := range findings { if f.Severity == "ERROR" { errorsCount++ } fmt.Fprintf(out, "%-5s %-10s %s\n", f.Severity, f.Category, f.Message) } if errorsCount > 0 { return findingError{count: errorsCount} } return nil } func okFinding(category, msg string) finding { return finding{"OK", category, msg} } func infoFinding(category, msg string) finding { return finding{"INFO", category, msg} } func warnFinding(category, msg string) finding { return finding{"WARN", category, msg} } func errorFinding(category, msg string) finding { return finding{"ERROR", category, msg} } func sessionSourceSummary(cfg *config.Config) string { source := cfg.SessionSource.Source if source == "" { source = "session_config" } if cfg.SessionSource.S3Key != "" { return source + " " + cfg.SessionSource.S3Key } return source + " " + cfg.SessionPath } func validateStableInputFindings(cfg *config.Config) []finding { items := []struct { name string in config.ResolvedInputFile }{ {"speakers", cfg.StableInputs.SpeakersFile}, {"autocorrect", cfg.StableInputs.AutocorrectFile}, {"glossary", cfg.StableInputs.GlossaryFile}, } out := make([]finding, 0, len(items)) for _, item := range items { path, err := resolveHelperConfigRelativePath(item.in) if err != nil { out = append(out, errorFinding("inputs", item.name+": "+err.Error())) continue } if _, err := os.Stat(path); err != nil { out = append(out, errorFinding("inputs", fmt.Sprintf("%s missing: %v", item.name, err))) } else { out = append(out, okFinding("inputs", item.name+": "+path)) } } return out } func resolveHelperConfigRelativePath(input config.ResolvedInputFile) (string, error) { if strings.TrimSpace(input.ConfigPath) == "" { return "", fmt.Errorf("source config path is required") } path := strings.TrimSpace(input.Path) if path == "" { return "", fmt.Errorf("path is required") } if filepath.IsAbs(path) { return filepath.Clean(path), nil } return filepath.Clean(filepath.Join(filepath.Dir(input.ConfigPath), path)), nil } func validateLocalAudioFindings(cfg *config.Config) []finding { if cfg.Session.Inputs.AudioS3 != nil { return nil } audioDir := strings.TrimSpace(cfg.Session.Inputs.AudioDir) if audioDir == "" && len(cfg.Session.Inputs.AudioFiles) == 0 { return []finding{errorFinding("audio", "audio_dir, audio_files, or audio_s3 is required")} } base := filepath.Dir(cfg.SessionPath) paths := []string{} if audioDir != "" { dir := audioDir if !filepath.IsAbs(dir) { dir = filepath.Join(base, dir) } matches, err := filepath.Glob(filepath.Join(dir, "*.flac")) if err != nil || len(matches) == 0 { return []finding{errorFinding("audio", "no .flac files found in "+dir)} } paths = append(paths, matches...) } for _, file := range cfg.Session.Inputs.AudioFiles { p := file if !filepath.IsAbs(p) { p = filepath.Join(base, p) } paths = append(paths, p) } for _, p := range paths { if _, err := os.Stat(p); err != nil { return []finding{errorFinding("audio", fmt.Sprintf("audio file missing: %v", err))} } } return []finding{okFinding("audio", fmt.Sprintf("%d local audio file(s)", len(paths)))} } func validateRemoteAudioFinding(ctx context.Context, cfg *config.Config, store storage.ObjectStore) finding { sessionPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.SessionID) audioPrefix := artifacts.S3AudioPrefix(sessionPrefix, cfg.Session.Inputs.AudioS3.Prefix) objects, err := store.List(ctx, audioPrefix) if err != nil { return errorFinding("audio", err.Error()) } count := 0 for _, obj := range objects { if strings.HasSuffix(strings.ToLower(obj.Key), ".flac") { count++ } } if count == 0 { return errorFinding("audio", "no remote .flac objects found under "+audioPrefix) } return okFinding("audio", fmt.Sprintf("%d remote .flac object(s)", count)) } func validatePreviousArtifactFindings(ctx context.Context, cfg *config.Config, store storage.ObjectStore, requirements []artifacts.PreviousArtifactRequirement) []finding { out := []finding{} prefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID) _, err := artifacts.LoadCurrentState(ctx, store, prefix, artifacts.CurrentStateValidation{ ExpectedSessionID: strings.TrimSpace(cfg.Session.PreviousSessionID), ExpectedCampaign: strings.TrimSpace(cfg.Session.Campaign), ValidateRunID: true, }) if err != nil { out = append(out, errorFinding("previous", fmt.Sprintf("remote %v", err))) return out } for _, req := range requirements { out = append(out, okFinding("previous", fmt.Sprintf("%s required=%t", req.Name, req.Required))) } return out } func loadLocalManifest(ctx context.Context, path string) (*manifest.Manifest, error) { if _, err := os.Stat(path); err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } store := &manifest.LocalStore{} return store.Load(ctx, path) } func writeStageStatuses(out io.Writer, m *manifest.Manifest) { if m == nil || len(m.Stages) == 0 { fmt.Fprintln(out, "stages: no stages recorded") return } fmt.Fprintln(out, "stages:") names := make([]string, 0, len(m.Stages)) for name := range m.Stages { names = append(names, name) } sort.Strings(names) for _, name := range names { fmt.Fprintf(out, "- %s: %s\n", name, m.Stages[name].Status) } }