--- story_id: story_5_9 sprint_id: sprint_5 phase: Phase 3 - Tools & Diff Preview status: not_started priority: P0 story_points: 5 assignee: backend estimated_days: 2 created_date: 2025-11-06 dependencies: [story_5_3] --- # Story 5.9: Diff Preview Service Implementation **Phase**: Phase 3 - Tools & Diff Preview (Week 5-6) **Priority**: P0 CRITICAL **Estimated Effort**: 5 Story Points (2 days) ## User Story **As a** User **I want** to see what changes AI will make before they are applied **So that** I can approve or reject AI operations safely ## Business Value Diff Preview is the **core safety mechanism** for M2. It enables: - **Transparency**: Users see exactly what will change - **Safety**: Prevents AI mistakes from affecting production data - **Compliance**: Audit trail for all AI operations - **Trust**: Users confident in AI automation **Without Diff Preview, AI write operations would be too risky for production.** ## Acceptance Criteria ### AC1: Diff Preview Generation - [ ] Generate before/after snapshots for CREATE, UPDATE, DELETE - [ ] Calculate field-level differences (changed fields only) - [ ] Support complex objects (nested, arrays, JSON) - [ ] Handle null values and type changes ### AC2: Diff Output Format - [ ] JSON format with before/after data - [ ] Array of changed fields (field name, old value, new value) - [ ] HTML diff for text fields (visual diff) - [ ] Entity metadata (type, ID, key) ### AC3: Supported Operations - [ ] CREATE: Show all new fields (before = null, after = new data) - [ ] UPDATE: Show only changed fields (before vs after) - [ ] DELETE: Show all deleted fields (before = data, after = null) ### AC4: Edge Cases - [ ] Nested object changes (e.g., assignee change) - [ ] Array/list changes (e.g., add/remove tags) - [ ] Date/time formatting - [ ] Large text fields (truncate in preview) ### AC5: Testing - [ ] Unit tests for all operations (CREATE, UPDATE, DELETE) - [ ] Test complex object changes - [ ] Test edge cases (null, arrays, nested) - [ ] Performance test (< 50ms for typical diff) ## Technical Design ### Service Interface ```csharp public interface IDiffPreviewService { Task GeneratePreviewAsync( Guid? entityId, TEntity afterData, string operation, CancellationToken cancellationToken) where TEntity : class; } public class DiffPreviewService : IDiffPreviewService { private readonly IGenericRepository _repository; private readonly ILogger _logger; public async Task GeneratePreviewAsync( Guid? entityId, TEntity afterData, string operation, CancellationToken ct) where TEntity : class { switch (operation) { case "CREATE": return GenerateCreatePreview(afterData); case "UPDATE": if (!entityId.HasValue) throw new ArgumentException("EntityId required for UPDATE"); var beforeData = await _repository.GetByIdAsync(entityId.Value, ct); if (beforeData == null) throw new McpNotFoundException(typeof(TEntity).Name, entityId.Value.ToString()); return GenerateUpdatePreview(entityId.Value, beforeData, afterData); case "DELETE": if (!entityId.HasValue) throw new ArgumentException("EntityId required for DELETE"); var dataToDelete = await _repository.GetByIdAsync(entityId.Value, ct); if (dataToDelete == null) throw new McpNotFoundException(typeof(TEntity).Name, entityId.Value.ToString()); return GenerateDeletePreview(entityId.Value, dataToDelete); default: throw new ArgumentException($"Unknown operation: {operation}"); } } private DiffPreview GenerateCreatePreview(TEntity afterData) { var changedFields = new List(); var properties = typeof(TEntity).GetProperties(); foreach (var prop in properties) { var newValue = prop.GetValue(afterData); if (newValue != null) // Skip null fields { changedFields.Add(new DiffField( fieldName: prop.Name, displayName: FormatFieldName(prop.Name), oldValue: null, newValue: newValue )); } } return new DiffPreview( operation: "CREATE", entityType: typeof(TEntity).Name, entityId: null, entityKey: null, beforeData: null, afterData: afterData, changedFields: changedFields.AsReadOnly() ); } private DiffPreview GenerateUpdatePreview( Guid entityId, TEntity beforeData, TEntity afterData) { var changedFields = new List(); var properties = typeof(TEntity).GetProperties(); foreach (var prop in properties) { var oldValue = prop.GetValue(beforeData); var newValue = prop.GetValue(afterData); if (!Equals(oldValue, newValue)) { var diffHtml = GenerateHtmlDiff(oldValue, newValue, prop.PropertyType); changedFields.Add(new DiffField( fieldName: prop.Name, displayName: FormatFieldName(prop.Name), oldValue: oldValue, newValue: newValue, diffHtml: diffHtml )); } } return new DiffPreview( operation: "UPDATE", entityType: typeof(TEntity).Name, entityId: entityId, entityKey: GetEntityKey(afterData), beforeData: beforeData, afterData: afterData, changedFields: changedFields.AsReadOnly() ); } private DiffPreview GenerateDeletePreview(Guid entityId, TEntity dataToDelete) { var changedFields = new List(); var properties = typeof(TEntity).GetProperties(); foreach (var prop in properties) { var oldValue = prop.GetValue(dataToDelete); if (oldValue != null) { changedFields.Add(new DiffField( fieldName: prop.Name, displayName: FormatFieldName(prop.Name), oldValue: oldValue, newValue: null )); } } return new DiffPreview( operation: "DELETE", entityType: typeof(TEntity).Name, entityId: entityId, entityKey: GetEntityKey(dataToDelete), beforeData: dataToDelete, afterData: null, changedFields: changedFields.AsReadOnly() ); } private string? GenerateHtmlDiff(object? oldValue, object? newValue, Type propertyType) { // For string properties, generate HTML diff if (propertyType == typeof(string) && oldValue != null && newValue != null) { var oldStr = oldValue.ToString(); var newStr = newValue.ToString(); // Use simple diff algorithm (can be improved with DiffPlex library) return $"{oldStr} {newStr}"; } return null; } private string FormatFieldName(string fieldName) { // Convert "EstimatedHours" to "Estimated Hours" return System.Text.RegularExpressions.Regex.Replace( fieldName, "([a-z])([A-Z])", "$1 $2"); } private string? GetEntityKey(TEntity entity) { // Try to get a human-readable key (e.g., "COLA-146") var keyProp = typeof(TEntity).GetProperty("Key"); return keyProp?.GetValue(entity)?.ToString(); } } ``` ### Example Diff Output (UPDATE Issue) ```json { "operation": "UPDATE", "entityType": "Story", "entityId": "12345678-1234-1234-1234-123456789012", "entityKey": "COLA-146", "beforeData": { "title": "Implement MCP Server", "priority": "High", "status": "InProgress", "assigneeId": "aaaa-bbbb-cccc-dddd" }, "afterData": { "title": "Implement MCP Server (updated)", "priority": "Critical", "status": "InProgress", "assigneeId": "aaaa-bbbb-cccc-dddd" }, "changedFields": [ { "fieldName": "title", "displayName": "Title", "oldValue": "Implement MCP Server", "newValue": "Implement MCP Server (updated)", "diffHtml": "Implement MCP Server Implement MCP Server (updated)" }, { "fieldName": "priority", "displayName": "Priority", "oldValue": "High", "newValue": "Critical" } ] } ``` ## Tasks ### Task 1: Create DiffPreviewService Interface (1 hour) - [ ] Define `IDiffPreviewService` interface - [ ] Define method signature (generic TEntity) **Files to Create**: - `ColaFlow.Modules.Mcp.Application/Contracts/IDiffPreviewService.cs` ### Task 2: Implement CREATE Preview (2 hours) - [ ] Extract all non-null properties from afterData - [ ] Format field names (camelCase → Title Case) - [ ] Return DiffPreview with changedFields **Files to Create**: - `ColaFlow.Modules.Mcp.Application/Services/DiffPreviewService.cs` ### Task 3: Implement UPDATE Preview (4 hours) - [ ] Fetch beforeData from repository - [ ] Compare all properties (old vs new) - [ ] Build changedFields list (only changed) - [ ] Generate HTML diff for text fields ### Task 4: Implement DELETE Preview (1 hour) - [ ] Fetch dataToDelete from repository - [ ] Extract all non-null properties - [ ] Return DiffPreview with oldValue set, newValue null ### Task 5: Handle Complex Types (3 hours) - [ ] Nested objects (e.g., assignee change) - [ ] Arrays/lists (e.g., tags) - [ ] Date/time formatting - [ ] Large text truncation ### Task 6: Unit Tests (5 hours) - [ ] Test CREATE preview (all fields) - [ ] Test UPDATE preview (only changed fields) - [ ] Test DELETE preview (all fields) - [ ] Test nested object changes - [ ] Test array changes - [ ] Test null handling **Files to Create**: - `ColaFlow.Modules.Mcp.Tests/Services/DiffPreviewServiceTests.cs` ### Task 7: Integration Tests (2 hours) - [ ] Test with real entities (Story, Epic, WorkTask) - [ ] Test performance (< 50ms) **Files to Create**: - `ColaFlow.Modules.Mcp.Tests/Integration/DiffPreviewIntegrationTests.cs` ## Testing Strategy ### Unit Tests (Target: > 90% coverage) - All operations (CREATE, UPDATE, DELETE) - All data types (string, int, enum, nested object, array) - Edge cases (null, empty, large text) ### Test Cases ```csharp [Fact] public async Task GeneratePreview_CreateOperation_AllFieldsIncluded() { // Arrange var newIssue = new Story { Title = "New Story", Priority = Priority.High, Status = IssueStatus.Todo }; // Act var diff = await _service.GeneratePreviewAsync( null, newIssue, "CREATE", CancellationToken.None); // Assert Assert.Equal("CREATE", diff.Operation); Assert.Null(diff.EntityId); Assert.Null(diff.BeforeData); Assert.NotNull(diff.AfterData); Assert.Equal(3, diff.ChangedFields.Count); // Title, Priority, Status } [Fact] public async Task GeneratePreview_UpdateOperation_OnlyChangedFields() { // Arrange var existingIssue = new Story { Id = Guid.NewGuid(), Title = "Original Title", Priority = Priority.Medium, Status = IssueStatus.InProgress }; var updatedIssue = new Story { Id = existingIssue.Id, Title = "Updated Title", Priority = Priority.High, // Changed Status = IssueStatus.InProgress // Unchanged }; _mockRepo.Setup(r => r.GetByIdAsync(existingIssue.Id, It.IsAny())) .ReturnsAsync(existingIssue); // Act var diff = await _service.GeneratePreviewAsync( existingIssue.Id, updatedIssue, "UPDATE", CancellationToken.None); // Assert Assert.Equal("UPDATE", diff.Operation); Assert.Equal(2, diff.ChangedFields.Count); // Title, Priority (Status unchanged) Assert.Contains(diff.ChangedFields, f => f.FieldName == "Title"); Assert.Contains(diff.ChangedFields, f => f.FieldName == "Priority"); } ``` ## Dependencies **Prerequisites**: - Story 5.3 (MCP Domain Layer) - Uses DiffPreview value object **Used By**: - Story 5.11 (Core MCP Tools) - Generates diff before creating PendingChange ## Risks & Mitigation | Risk | Impact | Probability | Mitigation | |------|--------|-------------|------------| | Complex object diff bugs | Medium | Medium | Comprehensive unit tests, code review | | Performance slow (> 50ms) | Medium | Low | Optimize reflection, cache property info | | Large text diff memory | Low | Low | Truncate long text fields | ## Definition of Done - [ ] All 3 operations (CREATE, UPDATE, DELETE) working - [ ] Unit test coverage > 90% - [ ] Integration tests passing - [ ] Performance < 50ms for typical diff - [ ] Code reviewed and approved - [ ] Handles complex types (nested, arrays) ## Notes ### Why This Story Matters - **Core Safety Mechanism**: Without diff preview, AI operations too risky - **User Trust**: Transparency builds confidence in AI - **Compliance**: Required audit trail - **M2 Critical Path**: Blocks all write operations ### Performance Optimization - Cache property reflection info - Use source generators for serialization (future) - Limit max diff size (truncate large fields)