1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub enum DiffStrategy {
22 Text,
24 Binary,
26 Structural,
28 Semantic,
30 Opaque,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36pub enum MergeStrategy {
37 TextThreeWay,
39 ManualResolve,
41 SchemaAware,
43 ComponentLevel,
45 AppendOnly,
47 LastWriterWins,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53pub enum StorageTier {
54 Standard,
56 Lfs,
58 Chunked,
60 External,
62}
63
64#[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#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct ContentType {
105 pub id: String,
107 pub name: String,
109 pub domain: ContentDomain,
111 pub mime_types: Vec<String>,
113 pub extensions: Vec<String>,
115 #[serde(default)]
117 pub magic_bytes: Vec<String>,
118 pub diff_strategy: DiffStrategy,
120 pub merge_strategy: MergeStrategy,
122 pub storage_tier: StorageTier,
124 pub lfs_threshold: Option<u64>,
126 #[serde(default)]
128 pub metadata_schema: Option<serde_json::Value>,
129 pub structural_diff: bool,
131 pub component_locking: bool,
133 pub description: String,
135}
136
137fn builtin_types() -> Vec<ContentType> {
140 vec![
141 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()], 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()], 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()], 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 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 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()], 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 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()], 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()], 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 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()], 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 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()], 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()], 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 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 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()], 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()], 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 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
772fn 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 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 types.retain(|t| t.id != ct.id);
809 types.push(ct);
810 }
811 }
812 }
813 }
814 Ok(types)
815}
816
817pub 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 let all = builtin_types();
827
828 if let Some(ct) = all.iter().find(|t| t.extensions.contains(&ext)) {
830 return Some(ct.clone());
831 }
832
833 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
851pub 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
871pub 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
889pub 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
971pub 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}