zoko-cli 0.1.1

Command-line interface for the Zoko data format - parse, validate, and format .zo files
use clap::{Parser, Subcommand};
use indexmap::IndexMap;
use std::path::PathBuf;
use zoko_parser::{ZokoFile, parse_zoko, parse_zoko_to_json};

#[derive(Parser)]
#[command(name = "zoko")]
#[command(about = "Zoko - A JSON-like format for data storing", long_about = None)]
#[command(version)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Parse a .zo file and output as JSON
    Parse {
        /// Input .zo file path
        #[arg(value_name = "FILE")]
        file: PathBuf,

        /// Output file (stdout if not specified)
        #[arg(short, long, value_name = "FILE")]
        output: Option<PathBuf>,

        /// Pretty print JSON output
        #[arg(short, long, default_value_t = true)]
        pretty: bool,
    },

    /// Validate a .zo file without outputting
    Validate {
        /// Input .zo file path
        #[arg(value_name = "FILE")]
        file: PathBuf,
    },

    /// Format a .zo file (pretty print)
    Fmt {
        /// Input .zo file path
        #[arg(value_name = "FILE")]
        file: PathBuf,

        /// Output file (overwrites input if not specified)
        #[arg(short, long, value_name = "FILE")]
        output: Option<PathBuf>,

        /// Check if file is formatted correctly without writing
        #[arg(short, long)]
        check: bool,
    },

    /// Check a .zo file for syntax errors
    Check {
        /// Input .zo file path
        #[arg(value_name = "FILE")]
        file: PathBuf,
    },
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cli = Cli::parse();

    match cli.command {
        Commands::Parse {
            file,
            output,
            pretty,
        } => {
            let content = std::fs::read_to_string(&file)
                .map_err(|e| format!("Failed to read file {}: {}", file.display(), e))?;

            let json = parse_zoko_to_json(&content).map_err(|e| format!("Parse error: {}", e))?;

            let output_json = if pretty {
                json
            } else {
                let parsed: serde_json::Value = serde_json::from_str(&json)?;
                serde_json::to_string(&parsed)?
            };

            match output {
                Some(path) => std::fs::write(&path, output_json)
                    .map_err(|e| format!("Failed to write output: {}", e))?,
                None => println!("{}", output_json),
            }
        }

        Commands::Validate { file } => {
            let content = std::fs::read_to_string(&file)
                .map_err(|e| format!("Failed to read file {}: {}", file.display(), e))?;

            parse_zoko(&content).map_err(|e| format!("Validation failed: {}", e))?;

            println!("✓ File is valid: {}", file.display());
        }

        Commands::Fmt {
            file,
            output,
            check,
        } => {
            let content = std::fs::read_to_string(&file)
                .map_err(|e| format!("Failed to read file {}: {}", file.display(), e))?;

            let zoko: ZokoFile = parse_zoko(&content).map_err(|e| format!("Parse error: {}", e))?;

            let formatted = format_zoko(&zoko);

            if check {
                if content.trim() == formatted.trim() {
                    println!("✓ File is properly formatted: {}", file.display());
                } else {
                    eprintln!("✗ File is not properly formatted: {}", file.display());
                    std::process::exit(1);
                }
            } else {
                let output_path = output.unwrap_or(file.clone());
                std::fs::write(&output_path, formatted)
                    .map_err(|e| format!("Failed to write file: {}", e))?;
                println!("✓ Formatted: {}", output_path.display());
            }
        }

        Commands::Check { file } => {
            let content = std::fs::read_to_string(&file)
                .map_err(|e| format!("Failed to read file {}: {}", file.display(), e))?;

            parse_zoko(&content).map_err(|e| {
                eprintln!("✗ Check failed: {}", e);
                std::process::exit(1);
            })?;

            println!("✓ Check passed: {}", file.display());
        }
    }

    Ok(())
}

fn format_zoko(zoko: &ZokoFile) -> String {
    let mut output = String::new();
    format_entries(&zoko.entries, 0, &mut output);
    output
}

fn format_entries(
    entries: &IndexMap<String, zoko_parser::Value>,
    indent: usize,
    output: &mut String,
) {
    let indent_str = "  ".repeat(indent);
    for (key, value) in entries {
        output.push_str(&indent_str);
        output.push_str(key);
        output.push_str(": ");
        format_value(value, indent, output);
        output.push_str(",\n");
    }
}

fn format_value(value: &zoko_parser::Value, indent: usize, output: &mut String) {
    match value {
        zoko_parser::Value::String(s) => {
            if s.contains('\n') {
                output.push('`');
                output.push_str(s);
                output.push('`');
            } else if s.contains('"') {
                output.push('\'');
                output.push_str(s);
                output.push('\'');
            } else {
                output.push('"');
                output.push_str(s);
                output.push('"');
            }
        }
        zoko_parser::Value::Number(n) => {
            if n.fract() == 0.0 && *n >= i64::MIN as f64 && *n <= i64::MAX as f64 {
                output.push_str(&format!("{}", *n as i64));
            } else {
                output.push_str(&n.to_string());
            }
        }
        zoko_parser::Value::Boolean(b) => output.push_str(if *b { "true" } else { "false" }),
        zoko_parser::Value::Null => output.push_str("null"),
        zoko_parser::Value::Array(arr) => {
            if arr.is_empty() {
                output.push_str("[]");
            } else if arr.iter().all(|v| {
                matches!(
                    v,
                    zoko_parser::Value::String(_)
                        | zoko_parser::Value::Number(_)
                        | zoko_parser::Value::Boolean(_)
                        | zoko_parser::Value::Null
                )
            }) && arr.len() <= 3
            {
                output.push('[');
                for (i, v) in arr.iter().enumerate() {
                    if i > 0 {
                        output.push_str(", ");
                    }
                    format_value(v, indent, output);
                }
                output.push(']');
            } else {
                output.push_str("[\n");
                let next_indent = indent + 1;
                let indent_str = "  ".repeat(next_indent);
                for v in arr {
                    output.push_str(&indent_str);
                    format_value(v, next_indent, output);
                    output.push_str(",\n");
                }
                output.push_str(&"  ".repeat(indent));
                output.push(']');
            }
        }
        zoko_parser::Value::Object(obj) => {
            if obj.is_empty() {
                output.push_str("{}");
            } else {
                output.push_str("{\n");
                let next_indent = indent + 1;
                format_entries(obj, next_indent, output);
                output.push_str(&"  ".repeat(indent));
                output.push('}');
            }
        }
    }
}