package quality import ( "crypto/sha256" "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "time" ) // StateManager manages quality analysis state with diff tracking type StateManager struct { dataDir string stateFile string historyDir string } // State represents the persisted quality state type State struct { Findings []Finding `json:"findings"` Scorecard *Scorecard `json:"scorecard"` LastScan time.Time `json:"last_scan"` ScanCount int `json:"scan_count"` ContentHash string `json:"content_hash"` History []StateSnapshot `json:"history,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` } // StateSnapshot represents a historical state snapshot type StateSnapshot struct { Timestamp time.Time `json:"timestamp"` Hash string `json:"hash"` Score int `json:"score"` StrictScore int `json:"strict_score"` Findings int `json:"findings"` File string `json:"file"` } // StateDiff represents the difference between two states type StateDiff struct { Added []Finding `json:"added"` Removed []Finding `json:"removed"` Changed []Finding `json:"changed"` Resolved []Finding `json:"resolved"` Regressions []Finding `json:"regressions"` } // NewStateManager creates a new state manager func NewStateManager(dataDir string) *StateManager { return &StateManager{ dataDir: dataDir, stateFile: filepath.Join(dataDir, "state.json"), historyDir: filepath.Join(dataDir, "history"), } } // Load loads the current state from disk func (sm *StateManager) Load() (*State, error) { data, err := os.ReadFile(sm.stateFile) if err != nil { if os.IsNotExist(err) { return &State{ Findings: []Finding{}, Metadata: make(map[string]string), }, nil } return nil, fmt.Errorf("failed to read state: %w", err) } var state State if err := json.Unmarshal(data, &state); err != nil { return nil, fmt.Errorf("failed to parse state: %w", err) } return &state, nil } // Save saves the state to disk func (sm *StateManager) Save(state *State) error { // Ensure directory exists if err := os.MkdirAll(sm.dataDir, 0755); err != nil { return fmt.Errorf("failed to create data directory: %w", err) } // Calculate content hash state.ContentHash = sm.calculateHash(state.Findings) // Save history snapshot if err := sm.saveHistory(state); err != nil { // Log but don't fail fmt.Fprintf(os.Stderr, "Warning: failed to save history: %v\n", err) } // Marshal state data, err := json.MarshalIndent(state, "", " ") if err != nil { return fmt.Errorf("failed to marshal state: %w", err) } // Write to temp file first tmpFile := sm.stateFile + ".tmp" if err := os.WriteFile(tmpFile, data, 0644); err != nil { return fmt.Errorf("failed to write state: %w", err) } // Rename to final location (atomic on most filesystems) if err := os.Rename(tmpFile, sm.stateFile); err != nil { return fmt.Errorf("failed to rename state file: %w", err) } return nil } // Merge merges new findings with existing state func (sm *StateManager) Merge(state *State, newFindings []Finding) *StateDiff { diff := &StateDiff{ Added: []Finding{}, Removed: []Finding{}, Changed: []Finding{}, Resolved: []Finding{}, } // Create lookup maps existingMap := make(map[string]Finding) for _, f := range state.Findings { existingMap[f.ID] = f } newMap := make(map[string]Finding) for _, f := range newFindings { newMap[f.ID] = f } // Find added and changed findings for _, new := range newFindings { if existing, ok := existingMap[new.ID]; ok { // Check if changed if !findingsEqual(existing, new) { diff.Changed = append(diff.Changed, new) } } else { // New finding diff.Added = append(diff.Added, new) } } // Find removed findings (these are resolved) for _, existing := range state.Findings { if _, ok := newMap[existing.ID]; !ok { if existing.Status == StatusOpen { diff.Resolved = append(diff.Resolved, existing) } } } // Update state state.Findings = newFindings state.LastScan = time.Now() state.ScanCount++ return diff } // Diff compares two states func (sm *StateManager) Diff(old, new *State) *StateDiff { diff := &StateDiff{ Added: []Finding{}, Removed: []Finding{}, Changed: []Finding{}, Resolved: []Finding{}, Regressions: []Finding{}, } oldMap := make(map[string]Finding) for _, f := range old.Findings { oldMap[f.ID] = f } newMap := make(map[string]Finding) for _, f := range new.Findings { newMap[f.ID] = f } for _, n := range new.Findings { if o, ok := oldMap[n.ID]; ok { if !findingsEqual(o, n) { diff.Changed = append(diff.Changed, n) // Check for regression (resolved -> open) if o.Status != StatusOpen && n.Status == StatusOpen { diff.Regressions = append(diff.Regressions, n) } } } else { diff.Added = append(diff.Added, n) } } for _, o := range old.Findings { if _, ok := newMap[o.ID]; !ok { diff.Removed = append(diff.Removed, o) } } return diff } // calculateHash calculates a content hash for findings func (sm *StateManager) calculateHash(findings []Finding) string { // Sort findings for consistent hashing sort.Slice(findings, func(i, j int) bool { return findings[i].ID < findings[j].ID }) // Create hash from findings data, _ := json.Marshal(findings) hash := sha256.Sum256(data) return fmt.Sprintf("%x", hash)[:16] } // saveHistory saves a historical snapshot func (sm *StateManager) saveHistory(state *State) error { if err := os.MkdirAll(sm.historyDir, 0755); err != nil { return err } // Create snapshot snapshot := StateSnapshot{ Timestamp: time.Now(), Hash: state.ContentHash, Score: state.Scorecard.TotalScore, StrictScore: state.Scorecard.StrictScore, Findings: len(state.Findings), File: fmt.Sprintf("%s.json", state.ContentHash), } // Save snapshot file snapshotFile := filepath.Join(sm.historyDir, snapshot.File) snapshotData, _ := json.MarshalIndent(state, "", " ") if err := os.WriteFile(snapshotFile, snapshotData, 0644); err != nil { return err } // Update history in state (keep last 50 snapshots) state.History = append(state.History, snapshot) if len(state.History) > 50 { // Remove old snapshots for _, old := range state.History[:len(state.History)-50] { oldFile := filepath.Join(sm.historyDir, old.File) os.Remove(oldFile) // Ignore errors } state.History = state.History[len(state.History)-50:] } return nil } // ResolveFinding updates a finding's status func (sm *StateManager) ResolveFinding(state *State, id string, status Status, note string) error { for i, f := range state.Findings { if f.ID == id { state.Findings[i].Status = status state.Findings[i].UpdatedAt = time.Now() if state.Findings[i].Metadata == nil { state.Findings[i].Metadata = make(map[string]string) } state.Findings[i].Metadata["resolution_note"] = note return nil } } return fmt.Errorf("finding not found: %s", id) } // GetFinding retrieves a finding by ID func (sm *StateManager) GetFinding(state *State, id string) *Finding { for _, f := range state.Findings { if f.ID == id { return &f } } return nil } // GetOpenFindings returns all open findings func (sm *StateManager) GetOpenFindings(state *State) []Finding { var open []Finding for _, f := range state.Findings { if f.Status == StatusOpen { open = append(open, f) } } return open } // GetFindingsByTier returns findings grouped by severity func (sm *StateManager) GetFindingsByTier(state *State) map[Severity][]Finding { result := make(map[Severity][]Finding) for _, f := range state.Findings { result[f.Severity] = append(result[f.Severity], f) } return result } // GetTrend returns the trend over the last N scans func (sm *StateManager) GetTrend(state *State, n int) []StateSnapshot { if len(state.History) < n { return state.History } return state.History[len(state.History)-n:] } // findingsEqual checks if two findings are equal (excluding timestamps) func findingsEqual(a, b Finding) bool { return a.ID == b.ID && a.Type == b.Type && a.Title == b.Title && a.File == b.File && a.Line == b.Line && a.Severity == b.Severity && a.Score == b.Score && a.Status == b.Status } // FormatDiff formats a state diff for display func FormatDiff(diff *StateDiff) string { var sb strings.Builder if len(diff.Added) > 0 { sb.WriteString(fmt.Sprintf("[+] Added: %d findings\n", len(diff.Added))) for _, f := range diff.Added { sb.WriteString(fmt.Sprintf(" - %s: %s\n", f.ID, f.Title)) } } if len(diff.Removed) > 0 { sb.WriteString(fmt.Sprintf("[-] Removed: %d findings\n", len(diff.Removed))) for _, f := range diff.Removed { sb.WriteString(fmt.Sprintf(" - %s: %s\n", f.ID, f.Title)) } } if len(diff.Changed) > 0 { sb.WriteString(fmt.Sprintf("[~] Changed: %d findings\n", len(diff.Changed))) for _, f := range diff.Changed { sb.WriteString(fmt.Sprintf(" - %s: %s\n", f.ID, f.Title)) } } if len(diff.Resolved) > 0 { sb.WriteString(fmt.Sprintf("[OK] Resolved: %d findings\n", len(diff.Resolved))) for _, f := range diff.Resolved { sb.WriteString(fmt.Sprintf(" - %s: %s\n", f.ID, f.Title)) } } if len(diff.Regressions) > 0 { sb.WriteString(fmt.Sprintf("[!] Regressions: %d findings\n", len(diff.Regressions))) for _, f := range diff.Regressions { sb.WriteString(fmt.Sprintf(" - %s: %s\n", f.ID, f.Title)) } } if sb.Len() == 0 { sb.WriteString("No changes detected\n") } return sb.String() }