Skip to main content

syster_cli/
lib.rs

1//! syster-cli library - Core analysis functionality
2//!
3//! This module provides the `run_analysis` function for parsing and analyzing
4//! SysML v2 and KerML files using the syster-base library.
5
6use serde::Serialize;
7use std::path::{Path, PathBuf};
8use syster::hir::{Severity, check_file};
9use syster::ide::AnalysisHost;
10use walkdir::WalkDir;
11
12/// Result of analyzing SysML/KerML files.
13#[derive(Debug, Serialize)]
14pub struct AnalysisResult {
15    /// Number of files analyzed.
16    pub file_count: usize,
17    /// Total number of symbols found.
18    pub symbol_count: usize,
19    /// Number of errors found.
20    pub error_count: usize,
21    /// Number of warnings found.
22    pub warning_count: usize,
23    /// All diagnostics collected.
24    pub diagnostics: Vec<DiagnosticInfo>,
25}
26
27/// A diagnostic message with location information.
28#[derive(Debug, Clone, Serialize)]
29pub struct DiagnosticInfo {
30    /// File path containing the diagnostic.
31    pub file: String,
32    /// Start line (1-indexed).
33    pub line: u32,
34    /// Start column (1-indexed).
35    pub col: u32,
36    /// End line (1-indexed).
37    pub end_line: u32,
38    /// End column (1-indexed).
39    pub end_col: u32,
40    /// The diagnostic message.
41    pub message: String,
42    /// Severity level.
43    #[serde(serialize_with = "serialize_severity")]
44    pub severity: Severity,
45    /// Optional error code.
46    pub code: Option<String>,
47}
48
49/// Serialize Severity as a string
50fn serialize_severity<S>(severity: &Severity, serializer: S) -> Result<S::Ok, S::Error>
51where
52    S: serde::Serializer,
53{
54    let s = match severity {
55        Severity::Error => "error",
56        Severity::Warning => "warning",
57        Severity::Info => "info",
58        Severity::Hint => "hint",
59    };
60    serializer.serialize_str(s)
61}
62
63/// Run analysis on input file or directory.
64///
65/// # Arguments
66/// * `input` - Path to a file or directory to analyze
67/// * `verbose` - Enable verbose output
68/// * `load_stdlib` - Whether to load the standard library
69/// * `stdlib_path` - Optional custom path to the standard library
70///
71/// # Returns
72/// An `AnalysisResult` with file count, symbol count, and diagnostics.
73pub fn run_analysis(
74    input: &Path,
75    verbose: bool,
76    load_stdlib: bool,
77    stdlib_path: Option<&Path>,
78) -> Result<AnalysisResult, String> {
79    let mut host = AnalysisHost::new();
80
81    // 1. Load stdlib if requested
82    if load_stdlib {
83        load_stdlib_files(&mut host, stdlib_path, verbose)?;
84    }
85
86    // 2. Load input file(s)
87    load_input(&mut host, input, verbose)?;
88
89    // 3. Trigger index rebuild and get analysis
90    let _analysis = host.analysis();
91
92    // 4. Collect diagnostics from all files
93    let diagnostics = collect_diagnostics(&host);
94
95    // 5. Build result
96    let error_count = diagnostics
97        .iter()
98        .filter(|d| matches!(d.severity, Severity::Error))
99        .count();
100    let warning_count = diagnostics
101        .iter()
102        .filter(|d| matches!(d.severity, Severity::Warning))
103        .count();
104
105    Ok(AnalysisResult {
106        file_count: host.file_count(),
107        symbol_count: host.symbol_index().all_symbols().count(),
108        error_count,
109        warning_count,
110        diagnostics,
111    })
112}
113
114/// Load input file or directory.
115fn load_input(host: &mut AnalysisHost, input: &Path, verbose: bool) -> Result<(), String> {
116    if input.is_file() {
117        load_file(host, input, verbose)
118    } else if input.is_dir() {
119        load_directory(host, input, verbose)
120    } else {
121        Err(format!("Path does not exist: {}", input.display()))
122    }
123}
124
125/// Load a single file into the analysis host.
126fn load_file(host: &mut AnalysisHost, path: &Path, verbose: bool) -> Result<(), String> {
127    if verbose {
128        println!("  Loading: {}", path.display());
129    }
130
131    let content = std::fs::read_to_string(path)
132        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
133
134    let path_str = path.to_string_lossy();
135    let parse_errors = host.set_file_content(&path_str, &content);
136
137    // Parse errors are reported but don't fail the load
138    for err in parse_errors {
139        eprintln!(
140            "parse error: {}:{}:{}: {}",
141            path.display(),
142            err.position.line,
143            err.position.column,
144            err.message
145        );
146    }
147
148    Ok(())
149}
150
151/// Load all SysML/KerML files from a directory.
152fn load_directory(host: &mut AnalysisHost, dir: &Path, verbose: bool) -> Result<(), String> {
153    if verbose {
154        println!("Scanning directory: {}", dir.display());
155    }
156
157    for entry in WalkDir::new(dir).follow_links(true) {
158        let entry = entry.map_err(|e| format!("Walk error: {}", e))?;
159        let path = entry.path();
160
161        if is_sysml_file(path) {
162            load_file(host, path, verbose)?;
163        }
164    }
165
166    Ok(())
167}
168
169/// Check if a path is a SysML or KerML file.
170fn is_sysml_file(path: &Path) -> bool {
171    path.is_file()
172        && matches!(
173            path.extension().and_then(|e| e.to_str()),
174            Some("sysml") | Some("kerml")
175        )
176}
177
178/// Load standard library files.
179fn load_stdlib_files(
180    host: &mut AnalysisHost,
181    custom_path: Option<&Path>,
182    verbose: bool,
183) -> Result<(), String> {
184    if verbose {
185        println!("Loading standard library...");
186    }
187
188    // Try custom path first
189    if let Some(path) = custom_path {
190        if path.exists() {
191            return load_directory(host, path, verbose);
192        } else {
193            return Err(format!("Stdlib path does not exist: {}", path.display()));
194        }
195    }
196
197    // Try default locations
198    let default_paths = [
199        PathBuf::from("sysml.library"),
200        PathBuf::from("../sysml.library"),
201        PathBuf::from("../base/sysml.library"),
202    ];
203
204    for path in &default_paths {
205        if path.exists() {
206            return load_directory(host, path, verbose);
207        }
208    }
209
210    if verbose {
211        println!("  Warning: Standard library not found");
212    }
213
214    Ok(())
215}
216
217/// Collect diagnostics from all files in the host.
218fn collect_diagnostics(host: &AnalysisHost) -> Vec<DiagnosticInfo> {
219    let mut all_diagnostics = Vec::new();
220
221    for path in host.files().keys() {
222        if let Some(file_id) = host.get_file_id_for_path(path) {
223            let file_path = path.to_string_lossy().to_string();
224            let diagnostics = check_file(host.symbol_index(), file_id);
225
226            for diag in diagnostics {
227                all_diagnostics.push(DiagnosticInfo {
228                    file: file_path.clone(),
229                    line: diag.start_line + 1, // 1-indexed for display
230                    col: diag.start_col + 1,
231                    end_line: diag.end_line + 1,
232                    end_col: diag.end_col + 1,
233                    message: diag.message.to_string(),
234                    severity: diag.severity,
235                    code: diag.code.map(|c| c.to_string()),
236                });
237            }
238        }
239    }
240
241    // Sort by file, then line, then column
242    all_diagnostics.sort_by(|a, b| (&a.file, a.line, a.col).cmp(&(&b.file, b.line, b.col)));
243
244    all_diagnostics
245}
246
247// ============================================================================
248// EXPORT FUNCTIONS
249// ============================================================================
250
251/// A symbol for JSON export (simplified from HirSymbol).
252#[derive(Debug, Serialize)]
253pub struct ExportSymbol {
254    pub name: String,
255    pub qualified_name: String,
256    pub kind: String,
257    pub file: String,
258    pub start_line: u32,
259    pub start_col: u32,
260    pub end_line: u32,
261    pub end_col: u32,
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub doc: Option<String>,
264    #[serde(skip_serializing_if = "Vec::is_empty")]
265    pub supertypes: Vec<String>,
266}
267
268/// AST export result.
269#[derive(Debug, Serialize)]
270pub struct AstExport {
271    pub files: Vec<FileAst>,
272}
273
274/// AST for a single file.
275#[derive(Debug, Serialize)]
276pub struct FileAst {
277    pub path: String,
278    pub symbols: Vec<ExportSymbol>,
279}
280
281/// Export AST (symbols) for all files.
282pub fn export_ast(
283    input: &Path,
284    verbose: bool,
285    load_stdlib: bool,
286    stdlib_path: Option<&Path>,
287) -> Result<String, String> {
288    let mut host = AnalysisHost::new();
289
290    if load_stdlib {
291        load_stdlib_files(&mut host, stdlib_path, verbose)?;
292    }
293
294    load_input(&mut host, input, verbose)?;
295    let _analysis = host.analysis();
296
297    let mut files = Vec::new();
298
299    // Only export user files, not stdlib
300    for path in host.files().keys() {
301        let path_str = path.to_string_lossy().to_string();
302
303        // Skip stdlib files
304        if path_str.contains("sysml.library") {
305            continue;
306        }
307
308        if let Some(file_id) = host.get_file_id_for_path(path) {
309            let symbols: Vec<ExportSymbol> = host
310                .symbol_index()
311                .symbols_in_file(file_id)
312                .into_iter()
313                .map(|sym| ExportSymbol {
314                    name: sym.name.to_string(),
315                    qualified_name: sym.qualified_name.to_string(),
316                    kind: format!("{:?}", sym.kind),
317                    file: path_str.clone(),
318                    start_line: sym.start_line + 1,
319                    start_col: sym.start_col + 1,
320                    end_line: sym.end_line + 1,
321                    end_col: sym.end_col + 1,
322                    doc: sym.doc.as_ref().map(|d| d.to_string()),
323                    supertypes: sym.supertypes.iter().map(|s| s.to_string()).collect(),
324                })
325                .collect();
326
327            files.push(FileAst {
328                path: path_str,
329                symbols,
330            });
331        }
332    }
333
334    // Sort files by path for consistent output
335    files.sort_by(|a, b| a.path.cmp(&b.path));
336
337    let export = AstExport { files };
338    serde_json::to_string_pretty(&export).map_err(|e| format!("Failed to serialize AST: {}", e))
339}
340
341/// Export analysis result as JSON.
342pub fn export_json(result: &AnalysisResult) -> Result<String, String> {
343    serde_json::to_string_pretty(result).map_err(|e| format!("Failed to serialize result: {}", e))
344}
345
346// ============================================================================
347// INTERCHANGE EXPORT
348// ============================================================================
349
350/// Export a model to an interchange format.
351///
352/// Supported formats:
353/// - `xmi` - XML Model Interchange
354/// - `kpar` - Kernel Package Archive (ZIP)
355/// - `jsonld` - JSON-LD
356///
357/// # Arguments
358/// * `input` - Path to a file or directory to analyze
359/// * `format` - Output format (xmi, kpar, jsonld)
360/// * `verbose` - Enable verbose output
361/// * `load_stdlib` - Whether to load the standard library
362/// * `stdlib_path` - Optional custom path to the standard library
363///
364/// # Returns
365/// The serialized model as bytes.
366#[cfg(feature = "interchange")]
367pub fn export_model(
368    input: &Path,
369    format: &str,
370    verbose: bool,
371    load_stdlib: bool,
372    stdlib_path: Option<&Path>,
373    self_contained: bool,
374) -> Result<Vec<u8>, String> {
375    use syster::interchange::{
376        JsonLd, Kpar, ModelFormat, Xmi, Yaml, model_from_symbols, restore_ids_from_symbols,
377    };
378
379    let mut host = AnalysisHost::new();
380
381    // 1. Load stdlib if requested
382    if load_stdlib {
383        load_stdlib_files(&mut host, stdlib_path, verbose)?;
384    }
385
386    // 2. Load input file(s)
387    load_input(&mut host, input, verbose)?;
388
389    // 2.5. Load metadata if present (for ID preservation on round-trip)
390    #[cfg(feature = "interchange")]
391    {
392        use syster::project::WorkspaceLoader;
393        let loader = WorkspaceLoader::new();
394
395        // If input is a file, check for companion metadata
396        if input.is_file() {
397            let parent_dir = input.parent().unwrap_or(input);
398            if let Err(e) = loader.load_metadata_from_directory(parent_dir, &mut host) {
399                if verbose {
400                    eprintln!("Note: Could not load metadata: {}", e);
401                }
402            } else if verbose {
403                println!("Loaded metadata from {}", parent_dir.display());
404            }
405        } else if input.is_dir() {
406            // For directories, load metadata from that directory
407            if let Err(e) = loader.load_metadata_from_directory(input, &mut host) {
408                if verbose {
409                    eprintln!("Note: Could not load metadata: {}", e);
410                }
411            } else if verbose {
412                println!("Loaded metadata from {}", input.display());
413            }
414        }
415    }
416
417    // 3. Trigger index rebuild
418    let analysis = host.analysis();
419
420    // 4. Get symbols from the index (filtered unless self_contained)
421    let symbols: Vec<_> = if self_contained {
422        // Include all symbols (user model + stdlib)
423        analysis.symbol_index().all_symbols().cloned().collect()
424    } else {
425        // Filter to only user files (exclude stdlib)
426        analysis
427            .symbol_index()
428            .all_symbols()
429            .filter(|sym| {
430                // Get the file path for this symbol and check if it's stdlib
431                if let Some(path) = analysis.get_file_path(sym.file) {
432                    !path.contains("sysml.library")
433                } else {
434                    true // Include if we can't determine the path
435                }
436            })
437            .cloned()
438            .collect()
439    };
440
441    if verbose {
442        println!(
443            "Collecting {} symbols (self_contained={})",
444            symbols.len(),
445            self_contained
446        );
447    }
448
449    // 5. Convert to interchange model
450    let mut model = model_from_symbols(&symbols);
451
452    // 6. Restore original element IDs from symbols (if they exist)
453    model = restore_ids_from_symbols(model, analysis.symbol_index());
454    if verbose {
455        println!("Restored element IDs from symbol database");
456    }
457
458    if verbose {
459        println!(
460            "Exported model: {} elements, {} relationships",
461            model.elements.len(),
462            model.relationships.len()
463        );
464    }
465
466    // 8. Serialize to requested format
467    match format.to_lowercase().as_str() {
468        "xmi" => Xmi.write(&model).map_err(|e| e.to_string()),
469        "kpar" => Kpar.write(&model).map_err(|e| e.to_string()),
470        "jsonld" | "json-ld" => JsonLd.write(&model).map_err(|e| e.to_string()),
471        "yaml" | "yml" => Yaml.write(&model).map_err(|e| e.to_string()),
472        _ => Err(format!(
473            "Unsupported format: {}. Use xmi, kpar, jsonld, or yaml.",
474            format
475        )),
476    }
477}
478
479/// Export model from an existing AnalysisHost to an interchange format.
480///
481/// This allows exporting from a pre-populated host (e.g., after import_model_into_host).
482/// Element IDs are preserved from the symbol database.
483///
484/// Set `self_contained` to true to include all symbols (including stdlib),
485/// or false to only include user model symbols.
486#[cfg(feature = "interchange")]
487pub fn export_from_host(
488    host: &mut AnalysisHost,
489    format: &str,
490    verbose: bool,
491    self_contained: bool,
492) -> Result<Vec<u8>, String> {
493    use syster::interchange::{
494        JsonLd, Kpar, ModelFormat, Xmi, Yaml, model_from_symbols, restore_ids_from_symbols,
495    };
496
497    let analysis = host.analysis();
498    let symbols: Vec<_> = if self_contained {
499        analysis.symbol_index().all_symbols().cloned().collect()
500    } else {
501        analysis
502            .symbol_index()
503            .all_symbols()
504            .filter(|sym| {
505                if let Some(path) = analysis.get_file_path(sym.file) {
506                    !path.contains("sysml.library")
507                } else {
508                    true
509                }
510            })
511            .cloned()
512            .collect()
513    };
514
515    if verbose {
516        println!(
517            "Collecting {} symbols (self_contained={})",
518            symbols.len(),
519            self_contained
520        );
521    }
522
523    let mut model = model_from_symbols(&symbols);
524    model = restore_ids_from_symbols(model, analysis.symbol_index());
525
526    if verbose {
527        println!(
528            "Exported model: {} elements, {} relationships",
529            model.elements.len(),
530            model.relationships.len()
531        );
532    }
533
534    match format.to_lowercase().as_str() {
535        "xmi" => Xmi.write(&model).map_err(|e| e.to_string()),
536        "kpar" => Kpar.write(&model).map_err(|e| e.to_string()),
537        "jsonld" | "json-ld" => JsonLd.write(&model).map_err(|e| e.to_string()),
538        "yaml" | "yml" => Yaml.write(&model).map_err(|e| e.to_string()),
539        _ => Err(format!(
540            "Unsupported format: {}. Use xmi, kpar, jsonld, or yaml.",
541            format
542        )),
543    }
544}
545
546/// Result of importing a model from an interchange format.
547#[cfg(feature = "interchange")]
548#[derive(Debug)]
549pub struct ImportResult {
550    /// Number of elements imported.
551    pub element_count: usize,
552    /// Number of relationships imported.
553    pub relationship_count: usize,
554    /// Number of validation errors.
555    pub error_count: usize,
556    /// Validation messages.
557    pub messages: Vec<String>,
558}
559
560/// Import a model from an interchange format file (validation only).
561///
562/// This validates the model but doesn't load it into a workspace.
563/// For importing into a workspace, use `import_model_into_host()`.
564///
565/// Supported formats are detected from file extension:
566/// - `.xmi` - XML Model Interchange
567/// - `.kpar` - Kernel Package Archive (ZIP)
568/// - `.jsonld`, `.json` - JSON-LD
569///
570/// # Arguments
571/// * `input` - Path to the interchange file
572/// * `format` - Optional format override (otherwise detected from extension)
573/// * `verbose` - Enable verbose output
574///
575/// # Returns
576/// An `ImportResult` with element count and symbol info.
577#[cfg(feature = "interchange")]
578pub fn import_model_into_host(
579    host: &mut AnalysisHost,
580    input: &Path,
581    format: Option<&str>,
582    verbose: bool,
583) -> Result<ImportResult, String> {
584    use syster::interchange::{JsonLd, Kpar, ModelFormat, Xmi, detect_format};
585
586    // Read the input file
587    let bytes =
588        std::fs::read(input).map_err(|e| format!("Failed to read {}: {}", input.display(), e))?;
589
590    // Determine format
591    let format_str = format.map(String::from).unwrap_or_else(|| {
592        input
593            .extension()
594            .and_then(|e| e.to_str())
595            .unwrap_or("xmi")
596            .to_string()
597    });
598
599    if verbose {
600        println!(
601            "Importing {} as {} into workspace",
602            input.display(),
603            format_str
604        );
605    }
606
607    // Parse the model
608    let model = match format_str.to_lowercase().as_str() {
609        "xmi" | "sysmlx" | "kermlx" => Xmi.read(&bytes).map_err(|e| e.to_string())?,
610        "kpar" => Kpar.read(&bytes).map_err(|e| e.to_string())?,
611        "jsonld" | "json-ld" | "json" => JsonLd.read(&bytes).map_err(|e| e.to_string())?,
612        _ => {
613            // Try to detect from file extension
614            if let Some(format_impl) = detect_format(input) {
615                format_impl.read(&bytes).map_err(|e| e.to_string())?
616            } else {
617                return Err(format!(
618                    "Unknown format: {}. Use xmi, sysmlx, kermlx, kpar, or jsonld.",
619                    format_str
620                ));
621            }
622        }
623    };
624
625    let element_count = model.elements.len();
626    let relationship_count = model.relationships.len();
627
628    if verbose {
629        println!(
630            "Parsed {} elements and {} relationships",
631            element_count, relationship_count
632        );
633    }
634
635    // Add model to host using the new add_model API
636    // This decompiles the model to SysML and parses it, preserving element IDs
637    let virtual_path = input.to_string_lossy().to_string() + ".sysml";
638    let errors = host.add_model(&model, &virtual_path);
639
640    if verbose {
641        if errors.is_empty() {
642            println!("Loaded model into workspace with preserved element IDs");
643        } else {
644            println!("Loaded model with {} parse warnings", errors.len());
645        }
646    }
647
648    Ok(ImportResult {
649        element_count,
650        relationship_count,
651        error_count: errors.len(),
652        messages: vec![format!("Successfully imported {} elements", element_count)],
653    })
654}
655
656/// Import and validate a model from an interchange format (legacy version).
657///
658/// This validates the model but doesn't load it into a workspace.
659/// For importing into a workspace, use `import_model_into_host()`.
660///
661/// # Arguments
662/// * `input` - Path to the model file
663/// * `format` - Optional format override (xmi, kpar, jsonld)
664/// * `verbose` - Enable verbose output
665///
666/// # Returns
667/// An `ImportResult` with element count and validation info.
668#[cfg(feature = "interchange")]
669pub fn import_model(
670    input: &Path,
671    format: Option<&str>,
672    verbose: bool,
673) -> Result<ImportResult, String> {
674    use syster::interchange::{JsonLd, Kpar, ModelFormat, Xmi, detect_format};
675
676    // Read the input file
677    let bytes =
678        std::fs::read(input).map_err(|e| format!("Failed to read {}: {}", input.display(), e))?;
679
680    // Determine format
681    let format_str = format.map(String::from).unwrap_or_else(|| {
682        input
683            .extension()
684            .and_then(|e| e.to_str())
685            .unwrap_or("xmi")
686            .to_string()
687    });
688
689    if verbose {
690        println!("Importing {} as {}", input.display(), format_str);
691    }
692
693    // Parse the model
694    let model = match format_str.to_lowercase().as_str() {
695        "xmi" | "sysmlx" | "kermlx" => Xmi.read(&bytes).map_err(|e| e.to_string())?,
696        "kpar" => Kpar.read(&bytes).map_err(|e| e.to_string())?,
697        "jsonld" | "json-ld" | "json" => JsonLd.read(&bytes).map_err(|e| e.to_string())?,
698        _ => {
699            // Try to detect from file extension
700            if let Some(format_impl) = detect_format(input) {
701                format_impl.read(&bytes).map_err(|e| e.to_string())?
702            } else {
703                return Err(format!(
704                    "Unknown format: {}. Use xmi, sysmlx, kermlx, kpar, or jsonld.",
705                    format_str
706                ));
707            }
708        }
709    };
710
711    // Basic validation
712    let mut messages = Vec::new();
713    let mut error_count = 0;
714
715    // Check for orphan relationships (references to non-existent elements)
716    for rel in &model.relationships {
717        if model.elements.get(&rel.source).is_none() {
718            messages.push(format!(
719                "Warning: Relationship source '{}' not found",
720                rel.source
721            ));
722            error_count += 1;
723        }
724        if model.elements.get(&rel.target).is_none() {
725            messages.push(format!(
726                "Warning: Relationship target '{}' not found",
727                rel.target
728            ));
729            error_count += 1;
730        }
731    }
732
733    if verbose {
734        println!(
735            "Imported: {} elements, {} relationships, {} validation issues",
736            model.elements.len(),
737            model.relationships.len(),
738            error_count
739        );
740        for msg in &messages {
741            println!("  {}", msg);
742        }
743    }
744
745    Ok(ImportResult {
746        element_count: model.elements.len(),
747        relationship_count: model.relationships.len(),
748        error_count,
749        messages,
750    })
751}
752
753/// Result of decompiling a model to SysML files.
754#[cfg(feature = "interchange")]
755#[derive(Debug)]
756pub struct DecompileResult {
757    /// Generated SysML text.
758    pub sysml_text: String,
759    /// Metadata JSON for preserving element IDs.
760    pub metadata_json: String,
761    /// Number of elements decompiled.
762    pub element_count: usize,
763    /// Source file path.
764    pub source_path: String,
765}
766
767/// Decompile an interchange file to SysML text with metadata.
768///
769/// This function converts an XMI/KPAR/JSON-LD file to SysML text plus
770/// a companion metadata JSON file that preserves element IDs for
771/// lossless round-tripping.
772///
773/// # Arguments
774/// * `input` - Path to the interchange file
775/// * `format` - Optional format override (otherwise detected from extension)
776/// * `verbose` - Enable verbose output
777///
778/// # Returns
779/// A `DecompileResult` with SysML text and metadata JSON.
780#[cfg(feature = "interchange")]
781pub fn decompile_model(
782    input: &Path,
783    format: Option<&str>,
784    verbose: bool,
785) -> Result<DecompileResult, String> {
786    use syster::interchange::{
787        JsonLd, Kpar, ModelFormat, SourceInfo, Xmi, decompile_with_source, detect_format,
788    };
789
790    // Read the input file
791    let bytes =
792        std::fs::read(input).map_err(|e| format!("Failed to read {}: {}", input.display(), e))?;
793
794    // Determine format
795    let format_str = format.map(String::from).unwrap_or_else(|| {
796        input
797            .extension()
798            .and_then(|e| e.to_str())
799            .unwrap_or("xmi")
800            .to_string()
801    });
802
803    if verbose {
804        println!("Decompiling {} as {}", input.display(), format_str);
805    }
806
807    // Parse the model
808    let model = match format_str.to_lowercase().as_str() {
809        "xmi" | "sysmlx" | "kermlx" => Xmi.read(&bytes).map_err(|e| e.to_string())?,
810        "kpar" => Kpar.read(&bytes).map_err(|e| e.to_string())?,
811        "jsonld" | "json-ld" | "json" => JsonLd.read(&bytes).map_err(|e| e.to_string())?,
812        _ => {
813            if let Some(format_impl) = detect_format(input) {
814                format_impl.read(&bytes).map_err(|e| e.to_string())?
815            } else {
816                return Err(format!(
817                    "Unknown format: {}. Use xmi, sysmlx, kermlx, kpar, or jsonld.",
818                    format_str
819                ));
820            }
821        }
822    };
823
824    let element_count = model.elements.len();
825
826    // Create source info
827    let source = SourceInfo::from_path(input.to_string_lossy()).with_format(&format_str);
828
829    // Decompile to SysML
830    let result = decompile_with_source(&model, source);
831
832    if verbose {
833        println!(
834            "Decompiled: {} elements -> {} chars of SysML, {} metadata entries",
835            element_count,
836            result.text.len(),
837            result.metadata.elements.len()
838        );
839    }
840
841    // Serialize metadata to JSON
842    let metadata_json = serde_json::to_string_pretty(&result.metadata)
843        .map_err(|e| format!("Failed to serialize metadata: {}", e))?;
844
845    Ok(DecompileResult {
846        sysml_text: result.text,
847        metadata_json,
848        element_count,
849        source_path: input.to_string_lossy().to_string(),
850    })
851}