File size: 15,723 Bytes
1dbc34b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
/**
 * Feature Export Service - Handles exporting and importing features in JSON/YAML formats
 *
 * Provides functionality to:
 * - Export single features to JSON or YAML format
 * - Export multiple features (bulk export)
 * - Import features from JSON or YAML data
 * - Validate import data for compatibility
 */

import { createLogger } from '@automaker/utils';
import { stringify as yamlStringify, parse as yamlParse } from 'yaml';
import type { Feature, FeatureExport, FeatureImport, FeatureImportResult } from '@automaker/types';
import { FeatureLoader } from './feature-loader.js';

const logger = createLogger('FeatureExportService');

/** Current export format version */
export const FEATURE_EXPORT_VERSION = '1.0.0';

/** Supported export formats */
export type ExportFormat = 'json' | 'yaml';

/** Options for exporting features */
export interface ExportOptions {
  /** Format to export in (default: 'json') */
  format?: ExportFormat;
  /** Whether to include description history (default: true) */
  includeHistory?: boolean;
  /** Whether to include plan spec (default: true) */
  includePlanSpec?: boolean;
  /** Optional metadata to include */
  metadata?: {
    projectName?: string;
    projectPath?: string;
    branch?: string;
    [key: string]: unknown;
  };
  /** Who/what is performing the export */
  exportedBy?: string;
  /** Pretty print output (default: true) */
  prettyPrint?: boolean;
}

/** Options for bulk export */
export interface BulkExportOptions extends ExportOptions {
  /** Filter by category */
  category?: string;
  /** Filter by status */
  status?: string;
  /** Feature IDs to include (if not specified, exports all) */
  featureIds?: string[];
}

/** Result of a bulk export */
export interface BulkExportResult {
  /** Export format version */
  version: string;
  /** ISO date string when the export was created */
  exportedAt: string;
  /** Number of features exported */
  count: number;
  /** The exported features */
  features: FeatureExport[];
  /** Export metadata */
  metadata?: {
    projectName?: string;
    projectPath?: string;
    branch?: string;
    [key: string]: unknown;
  };
}

/**
 * FeatureExportService - Manages feature export and import operations
 */
export class FeatureExportService {
  private featureLoader: FeatureLoader;

  constructor(featureLoader?: FeatureLoader) {
    this.featureLoader = featureLoader || new FeatureLoader();
  }

  /**
   * Export a single feature to the specified format
   *
   * @param projectPath - Path to the project
   * @param featureId - ID of the feature to export
   * @param options - Export options
   * @returns Promise resolving to the exported feature string
   */
  async exportFeature(
    projectPath: string,
    featureId: string,
    options: ExportOptions = {}
  ): Promise<string> {
    const feature = await this.featureLoader.get(projectPath, featureId);
    if (!feature) {
      throw new Error(`Feature ${featureId} not found`);
    }

    return this.exportFeatureData(feature, options);
  }

  /**
   * Export feature data to the specified format (without fetching from disk)
   *
   * @param feature - The feature to export
   * @param options - Export options
   * @returns The exported feature string
   */
  exportFeatureData(feature: Feature, options: ExportOptions = {}): string {
    const {
      format = 'json',
      includeHistory = true,
      includePlanSpec = true,
      metadata,
      exportedBy,
      prettyPrint = true,
    } = options;

    // Prepare feature data, optionally excluding some fields
    const featureData = this.prepareFeatureForExport(feature, {
      includeHistory,
      includePlanSpec,
    });

    const exportData: FeatureExport = {
      version: FEATURE_EXPORT_VERSION,
      feature: featureData,
      exportedAt: new Date().toISOString(),
      ...(exportedBy ? { exportedBy } : {}),
      ...(metadata ? { metadata } : {}),
    };

    return this.serialize(exportData, format, prettyPrint);
  }

  /**
   * Export multiple features to the specified format
   *
   * @param projectPath - Path to the project
   * @param options - Bulk export options
   * @returns Promise resolving to the exported features string
   */
  async exportFeatures(projectPath: string, options: BulkExportOptions = {}): Promise<string> {
    const {
      format = 'json',
      category,
      status,
      featureIds,
      includeHistory = true,
      includePlanSpec = true,
      metadata,
      prettyPrint = true,
    } = options;

    // Get all features
    let features = await this.featureLoader.getAll(projectPath);

    // Apply filters
    if (featureIds && featureIds.length > 0) {
      const idSet = new Set(featureIds);
      features = features.filter((f) => idSet.has(f.id));
    }
    if (category) {
      features = features.filter((f) => f.category === category);
    }
    if (status) {
      features = features.filter((f) => f.status === status);
    }

    // Generate timestamp once for consistent export time across all features
    const exportedAt = new Date().toISOString();

    // Prepare feature exports
    const featureExports: FeatureExport[] = features.map((feature) => ({
      version: FEATURE_EXPORT_VERSION,
      feature: this.prepareFeatureForExport(feature, { includeHistory, includePlanSpec }),
      exportedAt,
    }));

    const bulkExport: BulkExportResult = {
      version: FEATURE_EXPORT_VERSION,
      exportedAt,
      count: featureExports.length,
      features: featureExports,
      ...(metadata ? { metadata } : {}),
    };

    logger.info(`Exported ${featureExports.length} features from ${projectPath}`);

    return this.serialize(bulkExport, format, prettyPrint);
  }

  /**
   * Import a feature from JSON or YAML data
   *
   * @param projectPath - Path to the project
   * @param importData - Import configuration
   * @returns Promise resolving to the import result
   */
  async importFeature(
    projectPath: string,
    importData: FeatureImport
  ): Promise<FeatureImportResult> {
    const warnings: string[] = [];

    try {
      // Extract feature from data (handle both raw Feature and wrapped FeatureExport)
      const feature = this.extractFeatureFromImport(importData.data);
      if (!feature) {
        return {
          success: false,
          importedAt: new Date().toISOString(),
          errors: ['Invalid import data: could not extract feature'],
        };
      }

      // Validate required fields
      const validationErrors = this.validateFeature(feature);
      if (validationErrors.length > 0) {
        return {
          success: false,
          importedAt: new Date().toISOString(),
          errors: validationErrors,
        };
      }

      // Determine the feature ID to use
      const featureId = importData.newId || feature.id || this.featureLoader.generateFeatureId();

      // Check for existing feature
      const existingFeature = await this.featureLoader.get(projectPath, featureId);
      if (existingFeature && !importData.overwrite) {
        return {
          success: false,
          importedAt: new Date().toISOString(),
          errors: [`Feature with ID ${featureId} already exists. Set overwrite: true to replace.`],
        };
      }

      // Prepare feature for import
      const featureToImport: Feature = {
        ...feature,
        id: featureId,
        // Optionally override category
        ...(importData.targetCategory ? { category: importData.targetCategory } : {}),
        // Clear branch info if not preserving
        ...(importData.preserveBranchInfo ? {} : { branchName: undefined }),
      };

      // Clear runtime-specific fields that shouldn't be imported
      delete featureToImport.titleGenerating;
      delete featureToImport.error;

      // Handle image paths - they won't be valid after import
      if (featureToImport.imagePaths && featureToImport.imagePaths.length > 0) {
        warnings.push(
          `Feature had ${featureToImport.imagePaths.length} image path(s) that were cleared during import. Images must be re-attached.`
        );
        featureToImport.imagePaths = [];
      }

      // Handle text file paths - they won't be valid after import
      if (featureToImport.textFilePaths && featureToImport.textFilePaths.length > 0) {
        warnings.push(
          `Feature had ${featureToImport.textFilePaths.length} text file path(s) that were cleared during import. Files must be re-attached.`
        );
        featureToImport.textFilePaths = [];
      }

      // Create or update the feature
      if (existingFeature) {
        await this.featureLoader.update(projectPath, featureId, featureToImport);
        logger.info(`Updated feature ${featureId} via import`);
      } else {
        await this.featureLoader.create(projectPath, featureToImport);
        logger.info(`Created feature ${featureId} via import`);
      }

      return {
        success: true,
        featureId,
        importedAt: new Date().toISOString(),
        warnings: warnings.length > 0 ? warnings : undefined,
        wasOverwritten: !!existingFeature,
      };
    } catch (error) {
      logger.error('Failed to import feature:', error);
      return {
        success: false,
        importedAt: new Date().toISOString(),
        errors: [`Import failed: ${error instanceof Error ? error.message : String(error)}`],
      };
    }
  }

  /**
   * Import multiple features from JSON or YAML data
   *
   * @param projectPath - Path to the project
   * @param data - Raw JSON or YAML string, or parsed data
   * @param options - Import options applied to all features
   * @returns Promise resolving to array of import results
   */
  async importFeatures(
    projectPath: string,
    data: string | BulkExportResult,
    options: Omit<FeatureImport, 'data'> = {}
  ): Promise<FeatureImportResult[]> {
    let bulkData: BulkExportResult;

    // Parse if string
    if (typeof data === 'string') {
      const parsed = this.parseImportData(data);
      if (!parsed || !this.isBulkExport(parsed)) {
        return [
          {
            success: false,
            importedAt: new Date().toISOString(),
            errors: ['Invalid bulk import data: expected BulkExportResult format'],
          },
        ];
      }
      bulkData = parsed as BulkExportResult;
    } else {
      bulkData = data;
    }

    // Import each feature
    const results: FeatureImportResult[] = [];
    for (const featureExport of bulkData.features) {
      const result = await this.importFeature(projectPath, {
        data: featureExport,
        ...options,
      });
      results.push(result);
    }

    const successCount = results.filter((r) => r.success).length;
    logger.info(`Bulk import complete: ${successCount}/${results.length} features imported`);

    return results;
  }

  /**
   * Parse import data from JSON or YAML string
   *
   * @param data - Raw JSON or YAML string
   * @returns Parsed data or null if parsing fails
   */
  parseImportData(data: string): Feature | FeatureExport | BulkExportResult | null {
    const trimmed = data.trim();

    // Try JSON first
    if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
      try {
        return JSON.parse(trimmed);
      } catch {
        // Fall through to YAML
      }
    }

    // Try YAML
    try {
      return yamlParse(trimmed);
    } catch (error) {
      logger.error('Failed to parse import data:', error);
      return null;
    }
  }

  /**
   * Detect the format of import data
   *
   * @param data - Raw string data
   * @returns Detected format or null if unknown
   */
  detectFormat(data: string): ExportFormat | null {
    const trimmed = data.trim();

    // JSON detection
    if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
      try {
        JSON.parse(trimmed);
        return 'json';
      } catch {
        // Not valid JSON
      }
    }

    // YAML detection (if it parses and wasn't JSON)
    try {
      yamlParse(trimmed);
      return 'yaml';
    } catch {
      // Not valid YAML either
    }

    return null;
  }

  /**
   * Prepare a feature for export by optionally removing fields
   */
  private prepareFeatureForExport(
    feature: Feature,
    options: { includeHistory?: boolean; includePlanSpec?: boolean }
  ): Feature {
    const { includeHistory = true, includePlanSpec = true } = options;

    // Clone to avoid modifying original
    const exported: Feature = { ...feature };

    // Remove transient fields that shouldn't be exported
    delete exported.titleGenerating;
    delete exported.error;

    // Optionally exclude history
    if (!includeHistory) {
      delete exported.descriptionHistory;
    }

    // Optionally exclude plan spec
    if (!includePlanSpec) {
      delete exported.planSpec;
    }

    return exported;
  }

  /**
   * Extract a Feature from import data (handles both raw and wrapped formats)
   */
  private extractFeatureFromImport(data: Feature | FeatureExport): Feature | null {
    if (!data || typeof data !== 'object') {
      return null;
    }

    // Check if it's a FeatureExport wrapper
    if ('version' in data && 'feature' in data && 'exportedAt' in data) {
      const exportData = data as FeatureExport;
      return exportData.feature;
    }

    // Assume it's a raw Feature
    return data as Feature;
  }

  /**
   * Check if parsed data is a bulk export
   */
  isBulkExport(data: unknown): data is BulkExportResult {
    if (!data || typeof data !== 'object') {
      return false;
    }
    const obj = data as Record<string, unknown>;
    return 'version' in obj && 'features' in obj && Array.isArray(obj.features);
  }

  /**
   * Check if parsed data is a single FeatureExport
   */
  isFeatureExport(data: unknown): data is FeatureExport {
    if (!data || typeof data !== 'object') {
      return false;
    }
    const obj = data as Record<string, unknown>;
    return (
      'version' in obj &&
      'feature' in obj &&
      'exportedAt' in obj &&
      typeof obj.feature === 'object' &&
      obj.feature !== null &&
      'id' in (obj.feature as Record<string, unknown>)
    );
  }

  /**
   * Check if parsed data is a raw Feature
   */
  isRawFeature(data: unknown): data is Feature {
    if (!data || typeof data !== 'object') {
      return false;
    }
    const obj = data as Record<string, unknown>;
    // A raw feature has 'id' but not the 'version' + 'feature' wrapper of FeatureExport
    return 'id' in obj && !('feature' in obj && 'version' in obj);
  }

  /**
   * Validate a feature has required fields
   */
  private validateFeature(feature: Feature): string[] {
    const errors: string[] = [];

    if (!feature.description && !feature.title) {
      errors.push('Feature must have at least a title or description');
    }

    if (!feature.category) {
      errors.push('Feature must have a category');
    }

    return errors;
  }

  /**
   * Serialize export data to string (handles both single feature and bulk exports)
   */
  private serialize<T extends FeatureExport | BulkExportResult>(
    data: T,
    format: ExportFormat,
    prettyPrint: boolean
  ): string {
    if (format === 'yaml') {
      return yamlStringify(data, {
        indent: 2,
        lineWidth: 120,
      });
    }

    return prettyPrint ? JSON.stringify(data, null, 2) : JSON.stringify(data);
  }
}

// Singleton instance
let featureExportServiceInstance: FeatureExportService | null = null;

/**
 * Get the singleton feature export service instance
 */
export function getFeatureExportService(): FeatureExportService {
  if (!featureExportServiceInstance) {
    featureExportServiceInstance = new FeatureExportService();
  }
  return featureExportServiceInstance;
}