package itemregistry import ( "fmt" "sort" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" ) const incompatibleCurrencyGroup semanticreconcile.RejectionCategory = "incompatible_currency" func reconciliationInputs(records []normalizedRecord) ([]semanticreconcile.Candidate, []semanticreconcile.Record[dnd.Item], error) { candidates := make([]semanticreconcile.Candidate, len(records)) envelopes := make([]semanticreconcile.Record[dnd.Item], len(records)) for index, record := range records { candidates[index] = semanticreconcile.Candidate{ Label: record.item.Name, SourceRefs: cloneSourceRefs(record.item.SourceRefs), } envelope, err := semanticreconcile.NewRecord(record.item, record.inputIndexes, record.earliest, cloneItem) if err != nil { return nil, nil, fmt.Errorf("record %d: %w", index, err) } envelopes[index] = envelope } return candidates, envelopes, nil } func applyReconciliationPlan(plan semanticreconcile.Plan, records []normalizedRecord, envelopes []semanticreconcile.Record[dnd.Item], order shared.SourceRefOrder) ([]normalizedRecord, []contracts.Warning, int, error) { application, err := semanticreconcile.ApplyPlan(plan, envelopes, semanticreconcile.ApplicationPolicy[dnd.Item]{ CloneValue: cloneItem, RejectGroup: func(members []dnd.Item, _ dnd.Item) semanticreconcile.RejectionCategory { if !canConsolidate(members) { return incompatibleCurrencyGroup } return "" }, ConsolidateGroup: func(members []dnd.Item, canonical dnd.Item) (dnd.Item, error) { output := cloneItem(canonical) output.SourceRefs = nil for _, member := range members { output.SourceRefs = append(output.SourceRefs, member.SourceRefs...) } output.SourceRefs = order.Canonicalize(output.SourceRefs) output.ID = identity.DeriveID(output.Name) return output, nil }, }) if err != nil { return nil, nil, 0, err } applied := application.Records() output := make([]normalizedRecord, len(applied)) for index, record := range applied { output[index] = normalizedRecord{ item: record.Value(), inputIndexes: record.OriginalInputIndexes(), earliest: record.EarliestInputPosition(), } } type orderedWarning struct { position int warning contracts.Warning } orderedWarnings := make([]orderedWarning, 0, len(application.AppliedGroups())+len(application.RejectedGroups())) for _, event := range application.AppliedGroups() { provenance := event.Provenance() orderedWarnings = append(orderedWarnings, orderedWarning{ position: provenance.EarliestInputPosition(), warning: semanticDuplicateWarning(provenance, records[provenance.CanonicalPosition()]), }) } for _, event := range application.RejectedGroups() { provenance := event.Provenance() orderedWarnings = append(orderedWarnings, orderedWarning{ position: provenance.EarliestInputPosition(), warning: contracts.Warning{ Scope: itemScope(provenance.EarliestInputPosition()), ReasonCode: ReasonCodeItemSemanticProposalInvalid, Message: "proposal group preserved because currency may only be consolidated with aliases of one denomination", }, }) } sort.SliceStable(orderedWarnings, func(left, right int) bool { return orderedWarnings[left].position < orderedWarnings[right].position }) warnings := make([]contracts.Warning, len(orderedWarnings)) for index, entry := range orderedWarnings { warnings[index] = entry.warning } return output, warnings, len(application.RejectedGroups()), nil } func canConsolidate(items []dnd.Item) bool { denomination := "" hasCurrency := false hasNonCurrency := false hasConflictingDenominations := false for _, item := range items { current := currencyDenomination(item.Name) if current == "" { hasNonCurrency = true continue } hasCurrency = true if denomination != "" && denomination != current { hasConflictingDenominations = true continue } denomination = current } return !hasCurrency || (!hasNonCurrency && !hasConflictingDenominations) } func currencyDenomination(name string) string { switch identity.ComparisonKey(name) { case "copper piece", "copper pieces", "cp": return "copper" case "silver piece", "silver pieces", "sp": return "silver" case "electrum piece", "electrum pieces", "ep": return "electrum" case "gold piece", "gold pieces", "gp": return "gold" case "platinum piece", "platinum pieces", "pp": return "platinum" default: return "" } } func semanticDuplicateWarning(provenance semanticreconcile.GroupProvenance, canonical normalizedRecord) contracts.Warning { inputIndexes := provenance.OriginalInputIndexes() details := make([]string, 0, len(inputIndexes)+1) for _, inputIndex := range inputIndexes { details = append(details, fmt.Sprintf("input index %d", inputIndex)) } if canonical.earliest != provenance.EarliestInputPosition() { details = append(details, fmt.Sprintf("canonical display name from input index %d", canonical.earliest)) } return contracts.Warning{ Scope: itemScope(provenance.EarliestInputPosition()), ReasonCode: ReasonCodeDuplicateItemCollapsed, Message: diagnostics.Aggregate("semantic duplicate consolidation", details), } }