---
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<DiffPreview> GeneratePreviewAsync<TEntity>(
        Guid? entityId,
        TEntity afterData,
        string operation,
        CancellationToken cancellationToken) where TEntity : class;
}

public class DiffPreviewService : IDiffPreviewService
{
    private readonly IGenericRepository _repository;
    private readonly ILogger<DiffPreviewService> _logger;

    public async Task<DiffPreview> GeneratePreviewAsync<TEntity>(
        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<TEntity>(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<TEntity>(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>(TEntity afterData)
    {
        var changedFields = new List<DiffField>();
        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<TEntity>(
        Guid entityId,
        TEntity beforeData,
        TEntity afterData)
    {
        var changedFields = new List<DiffField>();
        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<TEntity>(Guid entityId, TEntity dataToDelete)
    {
        var changedFields = new List<DiffField>();
        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 $"<del>{oldStr}</del> <ins>{newStr}</ins>";
        }

        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>(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": "<del>Implement MCP Server</del> <ins>Implement MCP Server (updated)</ins>"
    },
    {
      "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<Story>(existingIssue.Id, It.IsAny<CancellationToken>()))
        .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)
