"""CLI interface for Config Converter.""" import glob import sys from pathlib import Path from typing import List, Optional import click from config_converter.converters import BaseConverter from config_converter.generators import TypeScriptGenerator from config_converter.validators import SchemaInferrer, SchemaValidator from config_converter.utils import OutputFormatter @click.group() @click.option("--color/--no-color", default=True, help="Enable/disable colorized output") @click.pass_context def main(ctx: click.Context, color: bool) -> None: """Config Converter CLI - Convert configuration files between formats.""" ctx.ensure_object(dict) ctx.obj["color"] = color ctx.obj["formatter"] = OutputFormatter(use_color=color) @main.command("convert") @click.argument("source", type=click.Path(exists=True)) @click.option("--from", "-f", "from_format", type=str, help="Source format (auto-detected if not specified)") @click.option("--to", "-t", "to_format", required=True, type=click.Choice(["json", "yaml", "toml", "ini"]), help="Target format") @click.option("--output", "-o", "output_path", type=click.Path(), help="Output file path (stdout if not specified)") @click.option("--overwrite", "-w", is_flag=True, help="Overwrite output file if it exists") @click.pass_context def convert( ctx: click.Context, source: str, from_format: Optional[str], to_format: str, output_path: Optional[str], overwrite: bool, ) -> None: """Convert a configuration file from one format to another.""" formatter: OutputFormatter = ctx.obj["formatter"] source_path = Path(source) if from_format is None: from_format = BaseConverter.guess_format(source_path) if from_format is None: formatter.print_error(f"Could not detect format from extension: {source_path.suffix}") sys.exit(1) try: source_converter = BaseConverter.get_converter(from_format) target_converter = BaseConverter.get_converter(to_format) except Exception as e: formatter.print_error(str(e)) sys.exit(1) try: data = source_converter.read(source_path) except Exception as e: formatter.print_error(f"Failed to read {source}: {e}") sys.exit(1) if output_path: output_file = Path(output_path) if output_file.exists() and not overwrite: formatter.print_error(f"Output file already exists: {output_path}") sys.exit(1) try: target_converter.write(data, output_file) formatter.print_success(f"Converted {source} ({from_format}) → {output_path} ({to_format})") except Exception as e: formatter.print_error(f"Failed to write {output_path}: {e}") sys.exit(1) else: try: output = target_converter.format(data) click.echo(output) except Exception as e: formatter.print_error(f"Failed to format output: {e}") sys.exit(1) @main.command("batch") @click.argument("pattern", type=str) @click.option("--from", "-f", "from_format", type=str, help="Source format (auto-detected if not specified)") @click.option("--to", "-t", "to_format", required=True, type=click.Choice(["json", "yaml", "toml", "ini"]), help="Target format") @click.option("--output-dir", "-o", "output_dir", type=click.Path(), help="Output directory for converted files") @click.option("--overwrite", "-w", is_flag=True, help="Overwrite output files if they exist") @click.pass_context def batch_convert( ctx: click.Context, pattern: str, from_format: Optional[str], to_format: str, output_dir: Optional[str], overwrite: bool, ) -> None: """Convert multiple files matching a glob pattern.""" formatter: OutputFormatter = ctx.obj["formatter"] files = glob.glob(pattern) if not files: formatter.print_error(f"No files found matching pattern: {pattern}") sys.exit(1) try: BaseConverter.get_converter(to_format) except Exception as e: formatter.print_error(str(e)) sys.exit(1) if output_dir: Path(output_dir).mkdir(parents=True, exist_ok=True) success_count = 0 failed_count = 0 failed_files: List[str] = [] for file_path in files: source_path = Path(file_path) try: if from_format is None: fmt = BaseConverter.guess_format(source_path) if fmt is None: failed_count += 1 failed_files.append(file_path) continue else: fmt = from_format converter = BaseConverter.get_converter(fmt) data = converter.read(source_path) if output_dir: output_path = Path(output_dir) / f"{source_path.stem}.{to_format}" if output_path.exists() and not overwrite: failed_count += 1 failed_files.append(file_path) continue converter.write(data, output_path) else: formatter.print_error("Output directory required for batch conversion") failed_count += 1 failed_files.append(file_path) continue success_count += 1 except Exception: failed_count += 1 failed_files.append(file_path) formatter.print_info(f"Batch conversion complete: {success_count} succeeded, {failed_count} failed") if failed_files: formatter.print_warning("Failed files:") for f in failed_files: formatter.print_warning(f" - {f}") @main.command("infer") @click.argument("source", type=click.Path(exists=True)) @click.option("--format", "-f", "fmt", type=str, help="Input format (auto-detected if not specified)") @click.option("--table", "-t", is_flag=True, help="Display as table") @click.option("--tree", is_flag=True, help="Display as tree") @click.pass_context def infer_schema( ctx: click.Context, source: str, fmt: Optional[str], table: bool, tree: bool, ) -> None: """Infer schema from a configuration file.""" formatter: OutputFormatter = ctx.obj["formatter"] source_path = Path(source) if fmt is None: fmt = BaseConverter.guess_format(source_path) if fmt is None: formatter.print_error(f"Could not detect format from extension: {source_path.suffix}") sys.exit(1) try: converter = BaseConverter.get_converter(fmt) data = converter.read(source_path) except Exception as e: formatter.print_error(f"Failed to read {source}: {e}") sys.exit(1) inferrer = SchemaInferrer() schema = inferrer.infer(data) schema_dict = schema.to_dict() if table: formatter.print_schema_table(schema_dict) elif tree: formatter.print_schema_tree(schema_dict) else: import json click.echo(json.dumps(schema_dict, indent=2)) @main.command("validate") @click.argument("source", type=click.Path(exists=True)) @click.option("--format", "-f", "fmt", type=str, help="Input format (auto-detected if not specified)") @click.pass_context def validate( ctx: click.Context, source: str, fmt: Optional[str], ) -> None: """Validate a configuration file against its inferred schema.""" formatter: OutputFormatter = ctx.obj["formatter"] source_path = Path(source) if fmt is None: fmt = BaseConverter.guess_format(source_path) if fmt is None: formatter.print_error(f"Could not detect format from extension: {source_path.suffix}") sys.exit(1) try: converter = BaseConverter.get_converter(fmt) data = converter.read(source_path) except Exception as e: formatter.print_error(f"Failed to read {source}: {e}") sys.exit(1) inferrer = SchemaInferrer() schema = inferrer.infer(data) validator = SchemaValidator(schema) is_valid, errors = validator.validate(data) if is_valid: formatter.print_success(f"Validation passed: {source}") else: formatter.print_error(f"Validation failed: {source}") for error in errors: formatter.print_error(f" - {error}") sys.exit(1) @main.command("generate-ts") @click.argument("source", type=click.Path(exists=True)) @click.option("--format", "-f", "fmt", type=str, help="Input format (auto-detected if not specified)") @click.option("--interface", "-i", "interface_name", default="Config", help="Name for the generated interface") @click.option("--output", "-o", "output_path", type=click.Path(), help="Output file path (stdout if not specified)") @click.pass_context def generate_typescript( ctx: click.Context, source: str, fmt: Optional[str], interface_name: str, output_path: Optional[str], ) -> None: """Generate TypeScript interfaces from a configuration file.""" formatter: OutputFormatter = ctx.obj["formatter"] source_path = Path(source) if fmt is None: fmt = BaseConverter.guess_format(source_path) if fmt is None: formatter.print_error(f"Could not detect format from extension: {source_path.suffix}") sys.exit(1) try: converter = BaseConverter.get_converter(fmt) data = converter.read(source_path) except Exception as e: formatter.print_error(f"Failed to read {source}: {e}") sys.exit(1) generator = TypeScriptGenerator(interface_name=interface_name) ts_output = generator.generate(data) if output_path: try: with open(output_path, "w", encoding="utf-8") as f: f.write(ts_output) formatter.print_success(f"Generated TypeScript interfaces: {output_path}") except Exception as e: formatter.print_error(f"Failed to write {output_path}: {e}") sys.exit(1) else: click.echo(ts_output) @main.command("interactive") @click.pass_context def interactive(ctx: click.Context) -> None: """Enter interactive mode for guided conversion.""" formatter: OutputFormatter = ctx.obj["formatter"] formatter.print_title("Config Converter - Interactive Mode") supported_formats = BaseConverter.get_supported_formats() formatter.print_info(f"Supported formats: {', '.join(supported_formats)}") source = click.prompt("Source file path", type=click.Path(exists=True)) source_format = click.prompt("Source format (or press Enter for auto-detect)", type=str, default="", show_default=False) if not source_format: source_format = BaseConverter.guess_format(source) if source_format is None: formatter.print_error("Could not detect format. Please specify manually.") return target_format = click.prompt("Target format", type=click.Choice(supported_formats)) output_path = click.prompt("Output file path (or press Enter for stdout)", type=str, default="", show_default=False) ctx.invoke( convert, source=source, from_format=source_format, to_format=target_format, output_path=output_path if output_path else None, overwrite=True, ) @main.command("formats") @click.pass_context def list_formats(ctx: click.Context) -> None: """List all supported formats.""" formatter: OutputFormatter = ctx.obj["formatter"] supported = BaseConverter.get_supported_formats() formatter.print_info("Supported formats:") for fmt in supported: formatter.print_info(f" - {fmt}") if __name__ == "__main__": main()