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, 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        _ => Err(format!(
472            "Unsupported format: {}. Use xmi, kpar, or jsonld.",
473            format
474        )),
475    }
476}
477
478/// Export model from an existing AnalysisHost to an interchange format.
479///
480/// This allows exporting from a pre-populated host (e.g., after import_model_into_host).
481/// Element IDs are preserved from the symbol database.
482///
483/// Set `self_contained` to true to include all symbols (including stdlib),
484/// or false to only include user model symbols.
485#[cfg(feature = "interchange")]
486pub fn export_from_host(
487    host: &mut AnalysisHost,
488    format: &str,
489    verbose: bool,
490    self_contained: bool,
491) -> Result<Vec<u8>, String> {
492    use syster::interchange::{
493        JsonLd, Kpar, ModelFormat, Xmi, model_from_symbols, restore_ids_from_symbols,
494    };
495
496    let analysis = host.analysis();
497    let symbols: Vec<_> = if self_contained {
498        analysis.symbol_index().all_symbols().cloned().collect()
499    } else {
500        analysis
501            .symbol_index()
502            .all_symbols()
503            .filter(|sym| {
504                if let Some(path) = analysis.get_file_path(sym.file) {
505                    !path.contains("sysml.library")
506                } else {
507                    true
508                }
509            })
510            .cloned()
511            .collect()
512    };
513
514    if verbose {
515        println!(
516            "Collecting {} symbols (self_contained={})",
517            symbols.len(),
518            self_contained
519        );
520    }
521
522    let mut model = model_from_symbols(&symbols);
523    model = restore_ids_from_symbols(model, analysis.symbol_index());
524
525    if verbose {
526        println!(
527            "Exported model: {} elements, {} relationships",
528            model.elements.len(),
529            model.relationships.len()
530        );
531    }
532
533    match format.to_lowercase().as_str() {
534        "xmi" => Xmi.write(&model).map_err(|e| e.to_string()),
535        "kpar" => Kpar.write(&model).map_err(|e| e.to_string()),
536        "jsonld" | "json-ld" => JsonLd.write(&model).map_err(|e| e.to_string()),
537        _ => Err(format!(
538            "Unsupported format: {}. Use xmi, kpar, or jsonld.",
539            format
540        )),
541    }
542}
543
544/// Result of importing a model from an interchange format.
545#[cfg(feature = "interchange")]
546#[derive(Debug)]
547pub struct ImportResult {
548    /// Number of elements imported.
549    pub element_count: usize,
550    /// Number of relationships imported.
551    pub relationship_count: usize,
552    /// Number of validation errors.
553    pub error_count: usize,
554    /// Validation messages.
555    pub messages: Vec<String>,
556}
557
558/// Import a model from an interchange format file (validation only).
559///
560/// This validates the model but doesn't load it into a workspace.
561/// For importing into a workspace, use `import_model_into_host()`.
562///
563/// Supported formats are detected from file extension:
564/// - `.xmi` - XML Model Interchange
565/// - `.kpar` - Kernel Package Archive (ZIP)
566/// - `.jsonld`, `.json` - JSON-LD
567///
568/// # Arguments
569/// * `input` - Path to the interchange file
570/// * `format` - Optional format override (otherwise detected from extension)
571/// * `verbose` - Enable verbose output
572///
573/// # Returns
574/// An `ImportResult` with element count and symbol info.
575#[cfg(feature = "interchange")]
576pub fn import_model_into_host(
577    host: &mut AnalysisHost,
578    input: &Path,
579    format: Option<&str>,
580    verbose: bool,
581) -> Result<ImportResult, String> {
582    use syster::interchange::{JsonLd, Kpar, ModelFormat, Xmi, detect_format};
583
584    // Read the input file
585    let bytes =
586        std::fs::read(input).map_err(|e| format!("Failed to read {}: {}", input.display(), e))?;
587
588    // Determine format
589    let format_str = format.map(String::from).unwrap_or_else(|| {
590        input
591            .extension()
592            .and_then(|e| e.to_str())
593            .unwrap_or("xmi")
594            .to_string()
595    });
596
597    if verbose {
598        println!(
599            "Importing {} as {} into workspace",
600            input.display(),
601            format_str
602        );
603    }
604
605    // Parse the model
606    let model = match format_str.to_lowercase().as_str() {
607        "xmi" | "sysmlx" | "kermlx" => Xmi.read(&bytes).map_err(|e| e.to_string())?,
608        "kpar" => Kpar.read(&bytes).map_err(|e| e.to_string())?,
609        "jsonld" | "json-ld" | "json" => JsonLd.read(&bytes).map_err(|e| e.to_string())?,
610        _ => {
611            // Try to detect from file extension
612            if let Some(format_impl) = detect_format(input) {
613                format_impl.read(&bytes).map_err(|e| e.to_string())?
614            } else {
615                return Err(format!(
616                    "Unknown format: {}. Use xmi, sysmlx, kermlx, kpar, or jsonld.",
617                    format_str
618                ));
619            }
620        }
621    };
622
623    let element_count = model.elements.len();
624    let relationship_count = model.relationships.len();
625
626    if verbose {
627        println!(
628            "Parsed {} elements and {} relationships",
629            element_count,
630            relationship_count
631        );
632    }
633
634    // Add model to host using the new add_model API
635    // This decompiles the model to SysML and parses it, preserving element IDs
636    let virtual_path = input.to_string_lossy().to_string() + ".sysml";
637    let errors = host.add_model(&model, &virtual_path);
638
639    if verbose {
640        if errors.is_empty() {
641            println!("Loaded model into workspace with preserved element IDs");
642        } else {
643            println!("Loaded model with {} parse warnings", errors.len());
644        }
645    }
646
647    Ok(ImportResult {
648        element_count,
649        relationship_count,
650        error_count: errors.len(),
651        messages: vec![format!("Successfully imported {} elements", element_count)],
652    })
653}
654
655/// Import and validate a model from an interchange format (legacy version).
656///
657/// This validates the model but doesn't load it into a workspace.
658/// For importing into a workspace, use `import_model_into_host()`.
659///
660/// # Arguments
661/// * `input` - Path to the model file
662/// * `format` - Optional format override (xmi, kpar, jsonld)
663/// * `verbose` - Enable verbose output
664///
665/// # Returns
666/// An `ImportResult` with element count and validation info.
667#[cfg(feature = "interchange")]
668pub fn import_model(
669    input: &Path,
670    format: Option<&str>,
671    verbose: bool,
672) -> Result<ImportResult, String> {
673    use syster::interchange::{JsonLd, Kpar, ModelFormat, Xmi, detect_format};
674
675    // Read the input file
676    let bytes =
677        std::fs::read(input).map_err(|e| format!("Failed to read {}: {}", input.display(), e))?;
678
679    // Determine format
680    let format_str = format.map(String::from).unwrap_or_else(|| {
681        input
682            .extension()
683            .and_then(|e| e.to_str())
684            .unwrap_or("xmi")
685            .to_string()
686    });
687
688    if verbose {
689        println!("Importing {} as {}", input.display(), format_str);
690    }
691
692    // Parse the model
693    let model = match format_str.to_lowercase().as_str() {
694        "xmi" | "sysmlx" | "kermlx" => Xmi.read(&bytes).map_err(|e| e.to_string())?,
695        "kpar" => Kpar.read(&bytes).map_err(|e| e.to_string())?,
696        "jsonld" | "json-ld" | "json" => JsonLd.read(&bytes).map_err(|e| e.to_string())?,
697        _ => {
698            // Try to detect from file extension
699            if let Some(format_impl) = detect_format(input) {
700                format_impl.read(&bytes).map_err(|e| e.to_string())?
701            } else {
702                return Err(format!(
703                    "Unknown format: {}. Use xmi, sysmlx, kermlx, kpar, or jsonld.",
704                    format_str
705                ));
706            }
707        }
708    };
709
710    // Basic validation
711    let mut messages = Vec::new();
712    let mut error_count = 0;
713
714    // Check for orphan relationships (references to non-existent elements)
715    for rel in &model.relationships {
716        if model.elements.get(&rel.source).is_none() {
717            messages.push(format!(
718                "Warning: Relationship source '{}' not found",
719                rel.source
720            ));
721            error_count += 1;
722        }
723        if model.elements.get(&rel.target).is_none() {
724            messages.push(format!(
725                "Warning: Relationship target '{}' not found",
726                rel.target
727            ));
728            error_count += 1;
729        }
730    }
731
732    if verbose {
733        println!(
734            "Imported: {} elements, {} relationships, {} validation issues",
735            model.elements.len(),
736            model.relationships.len(),
737            error_count
738        );
739        for msg in &messages {
740            println!("  {}", msg);
741        }
742    }
743
744    Ok(ImportResult {
745        element_count: model.elements.len(),
746        relationship_count: model.relationships.len(),
747        error_count,
748        messages,
749    })
750}
751
752/// Result of decompiling a model to SysML files.
753#[cfg(feature = "interchange")]
754#[derive(Debug)]
755pub struct DecompileResult {
756    /// Generated SysML text.
757    pub sysml_text: String,
758    /// Metadata JSON for preserving element IDs.
759    pub metadata_json: String,
760    /// Number of elements decompiled.
761    pub element_count: usize,
762    /// Source file path.
763    pub source_path: String,
764}
765
766/// Decompile an interchange file to SysML text with metadata.
767///
768/// This function converts an XMI/KPAR/JSON-LD file to SysML text plus
769/// a companion metadata JSON file that preserves element IDs for
770/// lossless round-tripping.
771///
772/// # Arguments
773/// * `input` - Path to the interchange file
774/// * `format` - Optional format override (otherwise detected from extension)
775/// * `verbose` - Enable verbose output
776///
777/// # Returns
778/// A `DecompileResult` with SysML text and metadata JSON.
779#[cfg(feature = "interchange")]
780pub fn decompile_model(
781    input: &Path,
782    format: Option<&str>,
783    verbose: bool,
784) -> Result<DecompileResult, String> {
785    use syster::interchange::{
786        JsonLd, Kpar, ModelFormat, SourceInfo, Xmi, decompile_with_source, detect_format,
787    };
788
789    // Read the input file
790    let bytes =
791        std::fs::read(input).map_err(|e| format!("Failed to read {}: {}", input.display(), e))?;
792
793    // Determine format
794    let format_str = format.map(String::from).unwrap_or_else(|| {
795        input
796            .extension()
797            .and_then(|e| e.to_str())
798            .unwrap_or("xmi")
799            .to_string()
800    });
801
802    if verbose {
803        println!("Decompiling {} as {}", input.display(), format_str);
804    }
805
806    // Parse the model
807    let model = match format_str.to_lowercase().as_str() {
808        "xmi" | "sysmlx" | "kermlx" => Xmi.read(&bytes).map_err(|e| e.to_string())?,
809        "kpar" => Kpar.read(&bytes).map_err(|e| e.to_string())?,
810        "jsonld" | "json-ld" | "json" => JsonLd.read(&bytes).map_err(|e| e.to_string())?,
811        _ => {
812            if let Some(format_impl) = detect_format(input) {
813                format_impl.read(&bytes).map_err(|e| e.to_string())?
814            } else {
815                return Err(format!(
816                    "Unknown format: {}. Use xmi, sysmlx, kermlx, kpar, or jsonld.",
817                    format_str
818                ));
819            }
820        }
821    };
822
823    let element_count = model.elements.len();
824
825    // Create source info
826    let source = SourceInfo::from_path(input.to_string_lossy()).with_format(&format_str);
827
828    // Decompile to SysML
829    let result = decompile_with_source(&model, source);
830
831    if verbose {
832        println!(
833            "Decompiled: {} elements -> {} chars of SysML, {} metadata entries",
834            element_count,
835            result.text.len(),
836            result.metadata.elements.len()
837        );
838    }
839
840    // Serialize metadata to JSON
841    let metadata_json = serde_json::to_string_pretty(&result.metadata)
842        .map_err(|e| format!("Failed to serialize metadata: {}", e))?;
843
844    Ok(DecompileResult {
845        sysml_text: result.text,
846        metadata_json,
847        element_count,
848        source_path: input.to_string_lossy().to_string(),
849    })
850}