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/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 { checks := inspectStableInputs(cfg) out := make([]finding, 0, len(checks)) for _, check := range checks { if check.Err != nil { msg := check.Name + ": " + check.Err.Error() if strings.TrimSpace(check.Path) != "" { msg = fmt.Sprintf("%s missing: %v", check.Name, check.Err) } out = append(out, errorFinding("inputs", msg)) continue } out = append(out, okFinding("inputs", check.Name+": "+check.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 { check := inspectLocalAudioPresence(cfg) if !check.Checked { return nil } if check.Err != nil { return []finding{errorFinding("audio", check.Err.Error())} } return []finding{okFinding("audio", fmt.Sprintf("%d local audio file(s)", len(check.Paths)))} } func validateRemoteAudioFinding(ctx context.Context, cfg *config.Config, store storage.ObjectStore) finding { check := inspectRemoteAudioPresence(ctx, cfg, store) if check.Err != nil { return errorFinding("audio", check.Err.Error()) } return okFinding("audio", fmt.Sprintf("%d remote .flac object(s)", len(check.Keys))) } 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) } }