Skip to main content

lit/commands/
content_type.rs

1//! Content type system — extends Lit from SWE-only VCS to a universal
2//! versioning system for CAD, EDA, manuscripts, databases, scientific data,
3//! media assets, and arbitrary domain content.
4//!
5//! Each content type carries diff/merge strategy hints, metadata schemas,
6//! and size/storage policies so that Lit can handle domain-specific files
7//! without requiring external plugins.
8
9use crate::core::find_repo_root;
10use crate::errors::LitError;
11use crate::response::ContentTypeResponse;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::fs;
15use std::path::Path;
16
17// ── Data types ──────────────────────────────────────────────────────────────
18
19/// How a content type should be diffed
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub enum DiffStrategy {
22    /// Line-based textual diff (source code, markdown, config)
23    Text,
24    /// Binary diff (delta compression)
25    Binary,
26    /// Structural diff (JSON/XML/AST tree comparison)
27    Structural,
28    /// Semantic diff (schema-aware: databases, CAD feature trees)
29    Semantic,
30    /// No diff — treat as opaque blob, show only size/hash changes
31    Opaque,
32}
33
34/// How a content type should be merged
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36pub enum MergeStrategy {
37    /// Standard 3-way text merge
38    TextThreeWay,
39    /// Take ours or theirs — no automatic merge (binary, CAD)
40    ManualResolve,
41    /// Schema-aware merge (databases, structured data)
42    SchemaAware,
43    /// Component-level merge (EDA: merge at schematic block level)
44    ComponentLevel,
45    /// Append-only merge (logs, audit trails)
46    AppendOnly,
47    /// Last-writer-wins (media assets, compiled outputs)
48    LastWriterWins,
49}
50
51/// Storage tier hint
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53pub enum StorageTier {
54    /// Normal object store (small text files)
55    Standard,
56    /// LFS — large file storage (binary assets, datasets)
57    Lfs,
58    /// Chunked — content-defined chunking for large structured files
59    Chunked,
60    /// External — reference to external storage (S3, GCS, NFS)
61    External,
62}
63
64/// Domain classification
65#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
66pub enum ContentDomain {
67    Software,
68    Cad,
69    Eda,
70    Manuscript,
71    Database,
72    Scientific,
73    Media,
74    Geospatial,
75    Legal,
76    Financial,
77    Config,
78    Documentation,
79    Custom(String),
80}
81
82impl std::fmt::Display for ContentDomain {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        match self {
85            ContentDomain::Software => write!(f, "software"),
86            ContentDomain::Cad => write!(f, "cad"),
87            ContentDomain::Eda => write!(f, "eda"),
88            ContentDomain::Manuscript => write!(f, "manuscript"),
89            ContentDomain::Database => write!(f, "database"),
90            ContentDomain::Scientific => write!(f, "scientific"),
91            ContentDomain::Media => write!(f, "media"),
92            ContentDomain::Geospatial => write!(f, "geospatial"),
93            ContentDomain::Legal => write!(f, "legal"),
94            ContentDomain::Financial => write!(f, "financial"),
95            ContentDomain::Config => write!(f, "config"),
96            ContentDomain::Documentation => write!(f, "documentation"),
97            ContentDomain::Custom(s) => write!(f, "custom:{}", s),
98        }
99    }
100}
101
102/// A registered content type with domain-specific handling policies
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct ContentType {
105    /// Unique identifier (e.g. "cad/step", "eda/kicad-pcb", "db/sqlite")
106    pub id: String,
107    /// Human-readable name
108    pub name: String,
109    /// Domain classification
110    pub domain: ContentDomain,
111    /// MIME type(s) associated with this content type
112    pub mime_types: Vec<String>,
113    /// File extensions (without dot) that map to this type
114    pub extensions: Vec<String>,
115    /// Magic bytes for binary detection (hex-encoded prefixes)
116    #[serde(default)]
117    pub magic_bytes: Vec<String>,
118    /// Recommended diff strategy
119    pub diff_strategy: DiffStrategy,
120    /// Recommended merge strategy
121    pub merge_strategy: MergeStrategy,
122    /// Storage tier
123    pub storage_tier: StorageTier,
124    /// Maximum inline size (bytes) before promoting to LFS
125    pub lfs_threshold: Option<u64>,
126    /// Metadata schema — JSON Schema fragment describing domain-specific fields
127    #[serde(default)]
128    pub metadata_schema: Option<serde_json::Value>,
129    /// Whether this type supports structural diffing natively
130    pub structural_diff: bool,
131    /// Whether this type supports component-level locking
132    pub component_locking: bool,
133    /// Description
134    pub description: String,
135}
136
137// ── Built-in content types ──────────────────────────────────────────────────
138
139fn builtin_types() -> Vec<ContentType> {
140    vec![
141        // ── CAD ──
142        ContentType {
143            id: "cad/step".into(),
144            name: "STEP CAD Model".into(),
145            domain: ContentDomain::Cad,
146            mime_types: vec!["model/step".into()],
147            extensions: vec!["step".into(), "stp".into(), "p21".into()],
148            magic_bytes: vec!["49534F2D".into()], // "ISO-"
149            diff_strategy: DiffStrategy::Structural,
150            merge_strategy: MergeStrategy::ManualResolve,
151            storage_tier: StorageTier::Lfs,
152            lfs_threshold: Some(1024 * 1024),
153            metadata_schema: Some(serde_json::json!({
154                "type": "object",
155                "properties": {
156                    "units": {"type": "string", "enum": ["mm", "in", "m"]},
157                    "assembly_count": {"type": "integer"},
158                    "bounding_box": {"type": "array", "items": {"type": "number"}}
159                }
160            })),
161            structural_diff: true,
162            component_locking: true,
163            description: "ISO 10303 STEP geometry exchange format".into(),
164        },
165        ContentType {
166            id: "cad/stl".into(),
167            name: "STL Mesh".into(),
168            domain: ContentDomain::Cad,
169            mime_types: vec!["model/stl".into()],
170            extensions: vec!["stl".into()],
171            magic_bytes: vec!["736F6C6964".into()], // "solid" (ASCII STL)
172            diff_strategy: DiffStrategy::Opaque,
173            merge_strategy: MergeStrategy::LastWriterWins,
174            storage_tier: StorageTier::Lfs,
175            lfs_threshold: Some(512 * 1024),
176            metadata_schema: Some(serde_json::json!({
177                "type": "object",
178                "properties": {
179                    "triangle_count": {"type": "integer"},
180                    "format": {"type": "string", "enum": ["ascii", "binary"]}
181                }
182            })),
183            structural_diff: false,
184            component_locking: false,
185            description: "Stereolithography mesh format for 3D printing".into(),
186        },
187        ContentType {
188            id: "cad/iges".into(),
189            name: "IGES CAD Model".into(),
190            domain: ContentDomain::Cad,
191            mime_types: vec!["model/iges".into()],
192            extensions: vec!["igs".into(), "iges".into()],
193            magic_bytes: vec![],
194            diff_strategy: DiffStrategy::Structural,
195            merge_strategy: MergeStrategy::ManualResolve,
196            storage_tier: StorageTier::Lfs,
197            lfs_threshold: Some(1024 * 1024),
198            metadata_schema: None,
199            structural_diff: true,
200            component_locking: false,
201            description: "Initial Graphics Exchange Specification".into(),
202        },
203        ContentType {
204            id: "cad/3mf".into(),
205            name: "3MF Model".into(),
206            domain: ContentDomain::Cad,
207            mime_types: vec![
208                "model/3mf".into(),
209                "application/vnd.ms-package.3dmanufacturing-3dmodel+xml".into(),
210            ],
211            extensions: vec!["3mf".into()],
212            magic_bytes: vec!["504B0304".into()], // ZIP header
213            diff_strategy: DiffStrategy::Structural,
214            merge_strategy: MergeStrategy::ComponentLevel,
215            storage_tier: StorageTier::Lfs,
216            lfs_threshold: Some(1024 * 1024),
217            metadata_schema: None,
218            structural_diff: true,
219            component_locking: true,
220            description: "3D Manufacturing Format (ZIP-based XML)".into(),
221        },
222        // ── EDA ──
223        ContentType {
224            id: "eda/kicad-pcb".into(),
225            name: "KiCad PCB Layout".into(),
226            domain: ContentDomain::Eda,
227            mime_types: vec!["application/x-kicad-pcb".into()],
228            extensions: vec!["kicad_pcb".into()],
229            magic_bytes: vec![],
230            diff_strategy: DiffStrategy::Structural,
231            merge_strategy: MergeStrategy::ComponentLevel,
232            storage_tier: StorageTier::Standard,
233            lfs_threshold: Some(50 * 1024 * 1024),
234            metadata_schema: Some(serde_json::json!({
235                "type": "object",
236                "properties": {
237                    "layers": {"type": "integer"},
238                    "component_count": {"type": "integer"},
239                    "net_count": {"type": "integer"},
240                    "board_dimensions": {"type": "object", "properties": {
241                        "width_mm": {"type": "number"},
242                        "height_mm": {"type": "number"}
243                    }}
244                }
245            })),
246            structural_diff: true,
247            component_locking: true,
248            description: "KiCad PCB layout (S-expression format)".into(),
249        },
250        ContentType {
251            id: "eda/kicad-sch".into(),
252            name: "KiCad Schematic".into(),
253            domain: ContentDomain::Eda,
254            mime_types: vec!["application/x-kicad-schematic".into()],
255            extensions: vec!["kicad_sch".into()],
256            magic_bytes: vec![],
257            diff_strategy: DiffStrategy::Structural,
258            merge_strategy: MergeStrategy::ComponentLevel,
259            storage_tier: StorageTier::Standard,
260            lfs_threshold: None,
261            metadata_schema: None,
262            structural_diff: true,
263            component_locking: true,
264            description: "KiCad schematic (S-expression format)".into(),
265        },
266        ContentType {
267            id: "eda/gerber".into(),
268            name: "Gerber PCB Fabrication".into(),
269            domain: ContentDomain::Eda,
270            mime_types: vec!["application/x-gerber".into()],
271            extensions: vec![
272                "gbr".into(),
273                "ger".into(),
274                "gtl".into(),
275                "gbl".into(),
276                "gts".into(),
277                "gbs".into(),
278            ],
279            magic_bytes: vec![],
280            diff_strategy: DiffStrategy::Text,
281            merge_strategy: MergeStrategy::LastWriterWins,
282            storage_tier: StorageTier::Standard,
283            lfs_threshold: None,
284            metadata_schema: None,
285            structural_diff: false,
286            component_locking: false,
287            description: "Gerber RS-274X PCB fabrication data".into(),
288        },
289        ContentType {
290            id: "eda/spice".into(),
291            name: "SPICE Netlist".into(),
292            domain: ContentDomain::Eda,
293            mime_types: vec!["text/x-spice".into()],
294            extensions: vec!["spice".into(), "sp".into(), "cir".into()],
295            magic_bytes: vec![],
296            diff_strategy: DiffStrategy::Text,
297            merge_strategy: MergeStrategy::TextThreeWay,
298            storage_tier: StorageTier::Standard,
299            lfs_threshold: None,
300            metadata_schema: None,
301            structural_diff: false,
302            component_locking: false,
303            description: "SPICE circuit simulation netlist".into(),
304        },
305        // ── Manuscripts ──
306        ContentType {
307            id: "manuscript/latex".into(),
308            name: "LaTeX Document".into(),
309            domain: ContentDomain::Manuscript,
310            mime_types: vec!["application/x-latex".into(), "text/x-tex".into()],
311            extensions: vec!["tex".into(), "latex".into(), "ltx".into()],
312            magic_bytes: vec![],
313            diff_strategy: DiffStrategy::Text,
314            merge_strategy: MergeStrategy::TextThreeWay,
315            storage_tier: StorageTier::Standard,
316            lfs_threshold: None,
317            metadata_schema: Some(serde_json::json!({
318                "type": "object",
319                "properties": {
320                    "document_class": {"type": "string"},
321                    "word_count": {"type": "integer"},
322                    "bibliography_entries": {"type": "integer"}
323                }
324            })),
325            structural_diff: false,
326            component_locking: false,
327            description: "LaTeX typesetting source".into(),
328        },
329        ContentType {
330            id: "manuscript/docx".into(),
331            name: "Word Document".into(),
332            domain: ContentDomain::Manuscript,
333            mime_types: vec![
334                "application/vnd.openxmlformats-officedocument.wordprocessingml.document".into(),
335            ],
336            extensions: vec!["docx".into()],
337            magic_bytes: vec!["504B0304".into()], // ZIP
338            diff_strategy: DiffStrategy::Structural,
339            merge_strategy: MergeStrategy::ManualResolve,
340            storage_tier: StorageTier::Lfs,
341            lfs_threshold: Some(5 * 1024 * 1024),
342            metadata_schema: Some(serde_json::json!({
343                "type": "object",
344                "properties": {
345                    "page_count": {"type": "integer"},
346                    "word_count": {"type": "integer"},
347                    "author": {"type": "string"},
348                    "revision": {"type": "integer"}
349                }
350            })),
351            structural_diff: true,
352            component_locking: false,
353            description: "Microsoft Word OOXML document".into(),
354        },
355        ContentType {
356            id: "manuscript/typst".into(),
357            name: "Typst Document".into(),
358            domain: ContentDomain::Manuscript,
359            mime_types: vec!["text/x-typst".into()],
360            extensions: vec!["typ".into()],
361            magic_bytes: vec![],
362            diff_strategy: DiffStrategy::Text,
363            merge_strategy: MergeStrategy::TextThreeWay,
364            storage_tier: StorageTier::Standard,
365            lfs_threshold: None,
366            metadata_schema: None,
367            structural_diff: false,
368            component_locking: false,
369            description: "Typst markup language source".into(),
370        },
371        ContentType {
372            id: "manuscript/asciidoc".into(),
373            name: "AsciiDoc".into(),
374            domain: ContentDomain::Manuscript,
375            mime_types: vec!["text/asciidoc".into()],
376            extensions: vec!["adoc".into(), "asciidoc".into(), "asc".into()],
377            magic_bytes: vec![],
378            diff_strategy: DiffStrategy::Text,
379            merge_strategy: MergeStrategy::TextThreeWay,
380            storage_tier: StorageTier::Standard,
381            lfs_threshold: None,
382            metadata_schema: None,
383            structural_diff: false,
384            component_locking: false,
385            description: "AsciiDoc markup language".into(),
386        },
387        // ── Databases ──
388        ContentType {
389            id: "db/sqlite".into(),
390            name: "SQLite Database".into(),
391            domain: ContentDomain::Database,
392            mime_types: vec![
393                "application/vnd.sqlite3".into(),
394                "application/x-sqlite3".into(),
395            ],
396            extensions: vec!["sqlite".into(), "sqlite3".into(), "db".into()],
397            magic_bytes: vec!["53514C69746520666F726D6174".into()], // "SQLite format"
398            diff_strategy: DiffStrategy::Semantic,
399            merge_strategy: MergeStrategy::SchemaAware,
400            storage_tier: StorageTier::Chunked,
401            lfs_threshold: Some(10 * 1024 * 1024),
402            metadata_schema: Some(serde_json::json!({
403                "type": "object",
404                "properties": {
405                    "table_count": {"type": "integer"},
406                    "row_count": {"type": "integer"},
407                    "schema_version": {"type": "string"},
408                    "page_size": {"type": "integer"}
409                }
410            })),
411            structural_diff: true,
412            component_locking: true,
413            description: "SQLite embedded database file".into(),
414        },
415        ContentType {
416            id: "db/csv".into(),
417            name: "CSV Data".into(),
418            domain: ContentDomain::Database,
419            mime_types: vec!["text/csv".into()],
420            extensions: vec!["csv".into(), "tsv".into()],
421            magic_bytes: vec![],
422            diff_strategy: DiffStrategy::Structural,
423            merge_strategy: MergeStrategy::SchemaAware,
424            storage_tier: StorageTier::Standard,
425            lfs_threshold: Some(50 * 1024 * 1024),
426            metadata_schema: Some(serde_json::json!({
427                "type": "object",
428                "properties": {
429                    "column_count": {"type": "integer"},
430                    "row_count": {"type": "integer"},
431                    "delimiter": {"type": "string"},
432                    "has_header": {"type": "boolean"}
433                }
434            })),
435            structural_diff: true,
436            component_locking: false,
437            description: "Comma/tab-separated values".into(),
438        },
439        ContentType {
440            id: "db/parquet".into(),
441            name: "Apache Parquet".into(),
442            domain: ContentDomain::Database,
443            mime_types: vec!["application/x-parquet".into()],
444            extensions: vec!["parquet".into()],
445            magic_bytes: vec!["50415231".into()], // "PAR1"
446            diff_strategy: DiffStrategy::Semantic,
447            merge_strategy: MergeStrategy::SchemaAware,
448            storage_tier: StorageTier::Lfs,
449            lfs_threshold: Some(10 * 1024 * 1024),
450            metadata_schema: Some(serde_json::json!({
451                "type": "object",
452                "properties": {
453                    "row_groups": {"type": "integer"},
454                    "row_count": {"type": "integer"},
455                    "column_count": {"type": "integer"},
456                    "compression": {"type": "string"}
457                }
458            })),
459            structural_diff: true,
460            component_locking: false,
461            description: "Apache Parquet columnar storage".into(),
462        },
463        ContentType {
464            id: "db/sql-migration".into(),
465            name: "SQL Migration".into(),
466            domain: ContentDomain::Database,
467            mime_types: vec!["application/sql".into()],
468            extensions: vec!["sql".into()],
469            magic_bytes: vec![],
470            diff_strategy: DiffStrategy::Text,
471            merge_strategy: MergeStrategy::AppendOnly,
472            storage_tier: StorageTier::Standard,
473            lfs_threshold: None,
474            metadata_schema: Some(serde_json::json!({
475                "type": "object",
476                "properties": {
477                    "direction": {"type": "string", "enum": ["up", "down"]},
478                    "version": {"type": "string"},
479                    "idempotent": {"type": "boolean"}
480                }
481            })),
482            structural_diff: false,
483            component_locking: false,
484            description: "SQL database migration script".into(),
485        },
486        // ── Scientific ──
487        ContentType {
488            id: "scientific/hdf5".into(),
489            name: "HDF5 Dataset".into(),
490            domain: ContentDomain::Scientific,
491            mime_types: vec!["application/x-hdf5".into()],
492            extensions: vec!["h5".into(), "hdf5".into(), "he5".into()],
493            magic_bytes: vec!["894844460D0A1A0A".into()],
494            diff_strategy: DiffStrategy::Semantic,
495            merge_strategy: MergeStrategy::SchemaAware,
496            storage_tier: StorageTier::Chunked,
497            lfs_threshold: Some(10 * 1024 * 1024),
498            metadata_schema: Some(serde_json::json!({
499                "type": "object",
500                "properties": {
501                    "dataset_count": {"type": "integer"},
502                    "total_size": {"type": "integer"},
503                    "compression": {"type": "string"}
504                }
505            })),
506            structural_diff: true,
507            component_locking: true,
508            description: "Hierarchical Data Format 5 for scientific datasets".into(),
509        },
510        ContentType {
511            id: "scientific/fits".into(),
512            name: "FITS Astronomical Data".into(),
513            domain: ContentDomain::Scientific,
514            mime_types: vec!["application/fits".into()],
515            extensions: vec!["fits".into(), "fit".into()],
516            magic_bytes: vec!["53494D504C45".into()], // "SIMPLE"
517            diff_strategy: DiffStrategy::Opaque,
518            merge_strategy: MergeStrategy::LastWriterWins,
519            storage_tier: StorageTier::Lfs,
520            lfs_threshold: Some(5 * 1024 * 1024),
521            metadata_schema: None,
522            structural_diff: false,
523            component_locking: false,
524            description: "Flexible Image Transport System (astronomy)".into(),
525        },
526        ContentType {
527            id: "scientific/jupyter".into(),
528            name: "Jupyter Notebook".into(),
529            domain: ContentDomain::Scientific,
530            mime_types: vec!["application/x-ipynb+json".into()],
531            extensions: vec!["ipynb".into()],
532            magic_bytes: vec![],
533            diff_strategy: DiffStrategy::Structural,
534            merge_strategy: MergeStrategy::ComponentLevel,
535            storage_tier: StorageTier::Standard,
536            lfs_threshold: Some(50 * 1024 * 1024),
537            metadata_schema: Some(serde_json::json!({
538                "type": "object",
539                "properties": {
540                    "cell_count": {"type": "integer"},
541                    "kernel": {"type": "string"},
542                    "language": {"type": "string"}
543                }
544            })),
545            structural_diff: true,
546            component_locking: true,
547            description: "Jupyter/IPython notebook (cell-level versioning)".into(),
548        },
549        // ── Media ──
550        ContentType {
551            id: "media/image".into(),
552            name: "Image Asset".into(),
553            domain: ContentDomain::Media,
554            mime_types: vec![
555                "image/png".into(),
556                "image/jpeg".into(),
557                "image/webp".into(),
558                "image/tiff".into(),
559            ],
560            extensions: vec![
561                "png".into(),
562                "jpg".into(),
563                "jpeg".into(),
564                "webp".into(),
565                "tiff".into(),
566                "tif".into(),
567                "bmp".into(),
568            ],
569            magic_bytes: vec!["89504E47".into(), "FFD8FF".into()], // PNG, JPEG
570            diff_strategy: DiffStrategy::Opaque,
571            merge_strategy: MergeStrategy::LastWriterWins,
572            storage_tier: StorageTier::Lfs,
573            lfs_threshold: Some(256 * 1024),
574            metadata_schema: Some(serde_json::json!({
575                "type": "object",
576                "properties": {
577                    "width": {"type": "integer"},
578                    "height": {"type": "integer"},
579                    "format": {"type": "string"},
580                    "color_space": {"type": "string"}
581                }
582            })),
583            structural_diff: false,
584            component_locking: false,
585            description: "Raster image asset".into(),
586        },
587        ContentType {
588            id: "media/video".into(),
589            name: "Video Asset".into(),
590            domain: ContentDomain::Media,
591            mime_types: vec![
592                "video/mp4".into(),
593                "video/webm".into(),
594                "video/quicktime".into(),
595            ],
596            extensions: vec![
597                "mp4".into(),
598                "webm".into(),
599                "mov".into(),
600                "mkv".into(),
601                "avi".into(),
602            ],
603            magic_bytes: vec![],
604            diff_strategy: DiffStrategy::Opaque,
605            merge_strategy: MergeStrategy::LastWriterWins,
606            storage_tier: StorageTier::External,
607            lfs_threshold: Some(10 * 1024 * 1024),
608            metadata_schema: Some(serde_json::json!({
609                "type": "object",
610                "properties": {
611                    "duration_seconds": {"type": "number"},
612                    "resolution": {"type": "string"},
613                    "codec": {"type": "string"}
614                }
615            })),
616            structural_diff: false,
617            component_locking: false,
618            description: "Video media asset".into(),
619        },
620        ContentType {
621            id: "media/audio".into(),
622            name: "Audio Asset".into(),
623            domain: ContentDomain::Media,
624            mime_types: vec![
625                "audio/mpeg".into(),
626                "audio/wav".into(),
627                "audio/flac".into(),
628                "audio/ogg".into(),
629            ],
630            extensions: vec![
631                "mp3".into(),
632                "wav".into(),
633                "flac".into(),
634                "ogg".into(),
635                "aac".into(),
636            ],
637            magic_bytes: vec!["494433".into(), "52494646".into()], // "ID3", "RIFF"
638            diff_strategy: DiffStrategy::Opaque,
639            merge_strategy: MergeStrategy::LastWriterWins,
640            storage_tier: StorageTier::Lfs,
641            lfs_threshold: Some(1024 * 1024),
642            metadata_schema: None,
643            structural_diff: false,
644            component_locking: false,
645            description: "Audio media asset".into(),
646        },
647        // ── Geospatial ──
648        ContentType {
649            id: "geo/geojson".into(),
650            name: "GeoJSON".into(),
651            domain: ContentDomain::Geospatial,
652            mime_types: vec!["application/geo+json".into()],
653            extensions: vec!["geojson".into()],
654            magic_bytes: vec![],
655            diff_strategy: DiffStrategy::Structural,
656            merge_strategy: MergeStrategy::ComponentLevel,
657            storage_tier: StorageTier::Standard,
658            lfs_threshold: Some(50 * 1024 * 1024),
659            metadata_schema: Some(serde_json::json!({
660                "type": "object",
661                "properties": {
662                    "feature_count": {"type": "integer"},
663                    "geometry_types": {"type": "array", "items": {"type": "string"}},
664                    "crs": {"type": "string"}
665                }
666            })),
667            structural_diff: true,
668            component_locking: false,
669            description: "RFC 7946 GeoJSON geographic data".into(),
670        },
671        ContentType {
672            id: "geo/shapefile".into(),
673            name: "Shapefile".into(),
674            domain: ContentDomain::Geospatial,
675            mime_types: vec!["application/x-shapefile".into()],
676            extensions: vec!["shp".into(), "shx".into(), "dbf".into(), "prj".into()],
677            magic_bytes: vec![],
678            diff_strategy: DiffStrategy::Opaque,
679            merge_strategy: MergeStrategy::ManualResolve,
680            storage_tier: StorageTier::Lfs,
681            lfs_threshold: Some(5 * 1024 * 1024),
682            metadata_schema: None,
683            structural_diff: false,
684            component_locking: false,
685            description: "ESRI Shapefile geospatial vector data".into(),
686        },
687        // ── Legal / Financial ──
688        ContentType {
689            id: "legal/pdf".into(),
690            name: "PDF Document".into(),
691            domain: ContentDomain::Legal,
692            mime_types: vec!["application/pdf".into()],
693            extensions: vec!["pdf".into()],
694            magic_bytes: vec!["25504446".into()], // "%PDF"
695            diff_strategy: DiffStrategy::Opaque,
696            merge_strategy: MergeStrategy::ManualResolve,
697            storage_tier: StorageTier::Lfs,
698            lfs_threshold: Some(1024 * 1024),
699            metadata_schema: Some(serde_json::json!({
700                "type": "object",
701                "properties": {
702                    "page_count": {"type": "integer"},
703                    "signed": {"type": "boolean"},
704                    "version": {"type": "string"}
705                }
706            })),
707            structural_diff: false,
708            component_locking: false,
709            description: "Portable Document Format".into(),
710        },
711        ContentType {
712            id: "financial/xlsx".into(),
713            name: "Excel Spreadsheet".into(),
714            domain: ContentDomain::Financial,
715            mime_types: vec![
716                "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".into(),
717            ],
718            extensions: vec!["xlsx".into()],
719            magic_bytes: vec!["504B0304".into()], // ZIP
720            diff_strategy: DiffStrategy::Structural,
721            merge_strategy: MergeStrategy::SchemaAware,
722            storage_tier: StorageTier::Lfs,
723            lfs_threshold: Some(5 * 1024 * 1024),
724            metadata_schema: Some(serde_json::json!({
725                "type": "object",
726                "properties": {
727                    "sheet_count": {"type": "integer"},
728                    "row_count": {"type": "integer"},
729                    "has_macros": {"type": "boolean"}
730                }
731            })),
732            structural_diff: true,
733            component_locking: true,
734            description: "Microsoft Excel OOXML spreadsheet".into(),
735        },
736        // ── Config / Infrastructure ──
737        ContentType {
738            id: "config/terraform".into(),
739            name: "Terraform HCL".into(),
740            domain: ContentDomain::Config,
741            mime_types: vec!["text/x-hcl".into()],
742            extensions: vec!["tf".into(), "tfvars".into()],
743            magic_bytes: vec![],
744            diff_strategy: DiffStrategy::Text,
745            merge_strategy: MergeStrategy::TextThreeWay,
746            storage_tier: StorageTier::Standard,
747            lfs_threshold: None,
748            metadata_schema: None,
749            structural_diff: false,
750            component_locking: false,
751            description: "HashiCorp Terraform infrastructure-as-code".into(),
752        },
753        ContentType {
754            id: "config/kubernetes".into(),
755            name: "Kubernetes Manifest".into(),
756            domain: ContentDomain::Config,
757            mime_types: vec!["application/x-yaml".into()],
758            extensions: vec!["yaml".into(), "yml".into()],
759            magic_bytes: vec![],
760            diff_strategy: DiffStrategy::Structural,
761            merge_strategy: MergeStrategy::SchemaAware,
762            storage_tier: StorageTier::Standard,
763            lfs_threshold: None,
764            metadata_schema: None,
765            structural_diff: true,
766            component_locking: false,
767            description: "Kubernetes resource manifests (YAML)".into(),
768        },
769    ]
770}
771
772// ── Helpers ─────────────────────────────────────────────────────────────────
773
774fn types_dir(repo_root: &Path) -> std::path::PathBuf {
775    repo_root.join(".lit").join("content-types")
776}
777
778fn save_type(repo_root: &Path, ct: &ContentType) -> Result<(), LitError> {
779    let dir = types_dir(repo_root);
780    fs::create_dir_all(&dir)
781        .map_err(|e| LitError::io(format!("Create content-types dir: {}", e)))?;
782    let safe_id: String = ct.id.replace('/', "_");
783    let path = dir.join(format!("{}.json", safe_id));
784    let json = serde_json::to_string_pretty(ct)
785        .map_err(|e| LitError::general(format!("Serialize content type: {}", e)))?;
786    fs::write(&path, json).map_err(|e| LitError::io(format!("Write content type: {}", e)))?;
787    Ok(())
788}
789
790fn load_all_types(repo_root: &Path) -> Result<Vec<ContentType>, LitError> {
791    let dir = types_dir(repo_root);
792    let mut types = builtin_types();
793
794    // Overlay custom types from repo
795    if dir.exists() {
796        for entry in fs::read_dir(&dir).map_err(|e| LitError::io(e.to_string()))? {
797            let entry = entry.map_err(|e| LitError::io(e.to_string()))?;
798            if entry
799                .path()
800                .extension()
801                .map(|e| e == "json")
802                .unwrap_or(false)
803            {
804                let json =
805                    fs::read_to_string(entry.path()).map_err(|e| LitError::io(e.to_string()))?;
806                if let Ok(ct) = serde_json::from_str::<ContentType>(&json) {
807                    // Custom types override builtins with the same id
808                    types.retain(|t| t.id != ct.id);
809                    types.push(ct);
810                }
811            }
812        }
813    }
814    Ok(types)
815}
816
817/// Detect content type for a file by extension, then magic bytes
818pub fn detect(file_path: &str, first_bytes: Option<&[u8]>) -> Option<ContentType> {
819    let ext = file_path
820        .rsplit('.')
821        .next()
822        .map(|e| e.to_lowercase())
823        .unwrap_or_default();
824
825    // Try builtins first (avoid needing repo root for detection)
826    let all = builtin_types();
827
828    // Extension match
829    if let Some(ct) = all.iter().find(|t| t.extensions.contains(&ext)) {
830        return Some(ct.clone());
831    }
832
833    // Magic bytes match
834    if let Some(bytes) = first_bytes {
835        let hex: String = bytes
836            .iter()
837            .take(16)
838            .map(|b| format!("{:02X}", b))
839            .collect();
840        if let Some(ct) = all
841            .iter()
842            .find(|t| t.magic_bytes.iter().any(|mb| hex.starts_with(mb)))
843        {
844            return Some(ct.clone());
845        }
846    }
847
848    None
849}
850
851// ── Public API ──────────────────────────────────────────────────────────────
852
853/// List all registered content types, optionally filtered by domain
854pub fn execute_list(domain_filter: Option<String>) -> Result<ContentTypeResponse, LitError> {
855    let repo_root = find_repo_root().unwrap_or_else(|_| std::path::PathBuf::from("."));
856    let mut types = load_all_types(&repo_root)?;
857
858    if let Some(ref domain) = domain_filter {
859        types.retain(|t| t.domain.to_string() == *domain);
860    }
861
862    let count = types.len();
863    Ok(ContentTypeResponse {
864        action: "list".into(),
865        content_type_id: None,
866        message: format!("{} content type(s)", count),
867        details: Some(serde_json::to_value(&types).unwrap_or_default()),
868    })
869}
870
871/// Show a specific content type
872pub fn execute_show(type_id: String) -> Result<ContentTypeResponse, LitError> {
873    let repo_root = find_repo_root().unwrap_or_else(|_| std::path::PathBuf::from("."));
874    let types = load_all_types(&repo_root)?;
875
876    let ct = types
877        .iter()
878        .find(|t| t.id == type_id)
879        .ok_or_else(|| LitError::general(format!("Content type not found: {}", type_id)))?;
880
881    Ok(ContentTypeResponse {
882        action: "show".into(),
883        content_type_id: Some(ct.id.clone()),
884        message: format!("{} ({})", ct.name, ct.domain),
885        details: Some(serde_json::to_value(ct).unwrap_or_default()),
886    })
887}
888
889/// Register a custom content type
890pub fn execute_register(
891    id: String,
892    name: String,
893    domain: String,
894    extensions: Vec<String>,
895    diff_strategy: Option<String>,
896    merge_strategy: Option<String>,
897    storage_tier: Option<String>,
898) -> Result<ContentTypeResponse, LitError> {
899    let repo_root = find_repo_root()?;
900
901    let domain_enum = match domain.as_str() {
902        "software" => ContentDomain::Software,
903        "cad" => ContentDomain::Cad,
904        "eda" => ContentDomain::Eda,
905        "manuscript" => ContentDomain::Manuscript,
906        "database" => ContentDomain::Database,
907        "scientific" => ContentDomain::Scientific,
908        "media" => ContentDomain::Media,
909        "geospatial" => ContentDomain::Geospatial,
910        "legal" => ContentDomain::Legal,
911        "financial" => ContentDomain::Financial,
912        "config" => ContentDomain::Config,
913        "documentation" => ContentDomain::Documentation,
914        other => ContentDomain::Custom(other.to_string()),
915    };
916
917    let diff = match diff_strategy.as_deref() {
918        Some("text") => DiffStrategy::Text,
919        Some("binary") => DiffStrategy::Binary,
920        Some("structural") => DiffStrategy::Structural,
921        Some("semantic") => DiffStrategy::Semantic,
922        Some("opaque") => DiffStrategy::Opaque,
923        _ => DiffStrategy::Binary,
924    };
925
926    let merge = match merge_strategy.as_deref() {
927        Some("text-three-way") => MergeStrategy::TextThreeWay,
928        Some("manual-resolve") => MergeStrategy::ManualResolve,
929        Some("schema-aware") => MergeStrategy::SchemaAware,
930        Some("component-level") => MergeStrategy::ComponentLevel,
931        Some("append-only") => MergeStrategy::AppendOnly,
932        Some("last-writer-wins") => MergeStrategy::LastWriterWins,
933        _ => MergeStrategy::ManualResolve,
934    };
935
936    let tier = match storage_tier.as_deref() {
937        Some("standard") => StorageTier::Standard,
938        Some("lfs") => StorageTier::Lfs,
939        Some("chunked") => StorageTier::Chunked,
940        Some("external") => StorageTier::External,
941        _ => StorageTier::Lfs,
942    };
943
944    let ct = ContentType {
945        id: id.clone(),
946        name: name.clone(),
947        domain: domain_enum,
948        mime_types: vec![],
949        extensions,
950        magic_bytes: vec![],
951        diff_strategy: diff,
952        merge_strategy: merge,
953        storage_tier: tier,
954        lfs_threshold: None,
955        metadata_schema: None,
956        structural_diff: false,
957        component_locking: false,
958        description: format!("Custom content type: {}", name),
959    };
960
961    save_type(&repo_root, &ct)?;
962
963    Ok(ContentTypeResponse {
964        action: "register".into(),
965        content_type_id: Some(id),
966        message: format!("Content type '{}' registered", name),
967        details: Some(serde_json::to_value(&ct).unwrap_or_default()),
968    })
969}
970
971/// Detect the content type(s) of one or more files
972pub fn execute_detect(paths: Vec<String>) -> Result<ContentTypeResponse, LitError> {
973    let mut results: HashMap<String, serde_json::Value> = HashMap::new();
974
975    for path in &paths {
976        let first_bytes = fs::read(path).ok().map(|b| b[..b.len().min(16)].to_vec());
977        let detected = detect(path, first_bytes.as_deref());
978        results.insert(
979            path.clone(),
980            match detected {
981                Some(ct) => serde_json::to_value(&ct).unwrap_or_default(),
982                None => serde_json::json!({"detected": false}),
983            },
984        );
985    }
986
987    Ok(ContentTypeResponse {
988        action: "detect".into(),
989        content_type_id: None,
990        message: format!("Detected types for {} file(s)", paths.len()),
991        details: Some(serde_json::to_value(&results).unwrap_or_default()),
992    })
993}